Add activity-core scope context API

This commit is contained in:
2026-05-15 01:40:49 +02:00
parent 8ea0b18ac3
commit 1990f75b73
6 changed files with 542 additions and 113 deletions

View File

@@ -0,0 +1,59 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://repo-scoping.local/schemas/repo-scope-context-response.json",
"title": "Repository Scope Context Response",
"description": "Provider-side context contract returned by GET /repos/{repo_slug}/scope/context for activity-core.",
"type": "object",
"additionalProperties": false,
"required": [
"repo_slug",
"capabilities",
"tags",
"scope_md_exists",
"scope_summary"
],
"properties": {
"repo_slug": {
"type": "string",
"description": "Slug requested by the caller after normal slugification.",
"examples": ["repo-scoping"]
},
"capabilities": {
"type": "array",
"description": "Approved capability names from the repository ability map.",
"items": {
"type": "string"
},
"examples": [["Generate SCOPE.md", "Preview generated SCOPE.md"]]
},
"tags": {
"type": "array",
"description": "Stable, sorted union of approved ability and capability primary_class values plus their attributes.",
"items": {
"type": "string"
},
"examples": [["api", "generation", "scope"]]
},
"scope_md_exists": {
"type": "boolean",
"description": "True when repo-scoping can inspect a local or cached checkout and root SCOPE.md exists; false when absent or unknown.",
"examples": [true]
},
"scope_summary": {
"description": "First non-empty paragraph of root SCOPE.md when inspectable; otherwise approved scope description, repository description, or null.",
"type": ["string", "null"],
"examples": [
"Repository Scoping maps repositories into reviewable scope graphs."
]
}
},
"examples": [
{
"repo_slug": "repo-scoping",
"capabilities": ["Generate SCOPE.md"],
"tags": ["api", "generation", "scope"],
"scope_md_exists": true,
"scope_summary": "Repository Scoping maps repositories into reviewable scope graphs."
}
]
}

View File

@@ -62,6 +62,7 @@ from repo_registry.web_api.schemas import (
RepositoryComparisonResponse,
RepositoryCreate,
RepositoryResponse,
RepositoryScopeContextResponse,
RepositoryUpdate,
ReviewDecisionResponse,
ScanSummaryResponse,
@@ -1346,6 +1347,45 @@ def export_repository_registry_entry(
return PlainTextResponse(content, media_type="application/x-yaml")
@app.get(
"/repos/{repo_slug}/scope/context",
tags=["scope"],
response_model=RepositoryScopeContextResponse,
)
def get_repository_scope_context(
repo_slug: str,
service: RegistryService = Depends(get_service),
settings: Settings = Depends(get_settings),
) -> dict[str, object]:
try:
repository = repository_by_slug(service, repo_slug)
ability_map = service.ability_map(repository.id)
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
scope_path = inspectable_scope_file_path(service, repository, repo_slug, settings)
scope_md_exists = bool(scope_path and scope_path.is_file())
scope_summary = (
scope_summary_from_file(scope_path)
if scope_md_exists and scope_path is not None
else None
)
if not scope_summary:
scope_summary = (
ability_map.scope.description.strip()
or (repository.description.strip() if repository.description else None)
or None
)
return {
"repo_slug": slugify(repo_slug),
"capabilities": scope_context_capabilities(ability_map),
"tags": scope_context_tags(ability_map),
"scope_md_exists": scope_md_exists,
"scope_summary": scope_summary,
}
@app.get(
"/repos/{repo_slug}/scope",
tags=["scope"],
@@ -1413,6 +1453,97 @@ def write_repository_scope(
return {"written": True, "path": str(scope_path)}
def scope_context_capabilities(ability_map) -> list[str]:
return unique_preserving_order(
[
capability.name
for ability in ability_map.abilities
for capability in ability.capabilities
]
)
def scope_context_tags(ability_map) -> list[str]:
tags = []
for ability in ability_map.abilities:
tags.append(ability.primary_class)
tags.extend(ability.attributes)
for capability in ability.capabilities:
tags.append(capability.primary_class)
tags.extend(capability.attributes)
return sorted(unique_preserving_order(tags), key=str.lower)
def unique_preserving_order(values: list[str]) -> list[str]:
result: list[str] = []
seen: set[str] = set()
for value in values:
item = str(value).strip()
key = item.lower()
if not item or key in seen:
continue
seen.add(key)
result.append(item)
return result
def inspectable_scope_file_path(
service: RegistryService,
repository,
repo_slug: str,
settings: Settings,
) -> Path | None:
try:
state_hub_path = state_hub_scope_file_path(repo_slug, settings)
except ValueError:
state_hub_path = None
if state_hub_path is not None:
return state_hub_path
source_path = Path(repository.url)
if source_path.exists() and source_path.is_dir():
return source_path / "SCOPE.md"
checkout = service.ingestion.cached_checkout(repository.url)
if checkout is not None and checkout.source_path.exists():
return checkout.source_path / "SCOPE.md"
return None
def scope_summary_from_file(scope_path: Path) -> str | None:
try:
content = scope_path.read_text(encoding="utf-8")
except OSError:
return None
paragraph: list[str] = []
in_frontmatter = False
frontmatter_checked = False
for line in content.splitlines():
stripped = line.strip()
if not frontmatter_checked:
frontmatter_checked = True
if stripped == "---":
in_frontmatter = True
continue
if in_frontmatter:
if stripped == "---":
in_frontmatter = False
continue
if not stripped:
if paragraph:
return " ".join(paragraph)
continue
if stripped == "---" or stripped.startswith("#") or stripped.startswith(">"):
if paragraph:
return " ".join(paragraph)
continue
paragraph.append(stripped)
if paragraph:
return " ".join(paragraph)
return None
def ensure_scope_generation_ready(
service: RegistryService,
repo_slug: str,

View File

@@ -862,6 +862,51 @@ class RepositoryAbilityMapResponse(BaseModel):
}
class RepositoryScopeContextResponse(BaseModel):
repo_slug: str = Field(
description="Slug requested by the caller after normal slugification."
)
capabilities: list[str] = Field(
description="Approved capability names from the repository ability map."
)
tags: list[str] = Field(
description=(
"Sorted union of approved ability and capability primary classes "
"plus their attributes."
)
)
scope_md_exists: bool = Field(
description=(
"True when repo-scoping can inspect a local or cached checkout and "
"root SCOPE.md exists."
)
)
scope_summary: str | None = Field(
description=(
"First non-empty paragraph of root SCOPE.md when available, "
"otherwise registry metadata."
)
)
model_config = {
"extra": "forbid",
"json_schema_extra": {
"examples": [
{
"repo_slug": "repo-scoping",
"capabilities": ["Generate SCOPE.md"],
"tags": ["api", "generation", "scope"],
"scope_md_exists": True,
"scope_summary": (
"Repository Scoping maps repositories into reviewable "
"scope graphs."
),
}
]
},
}
class IdResponse(BaseModel):
id: int

View File

@@ -0,0 +1,162 @@
import json
from pathlib import Path
from fastapi.testclient import TestClient
from repo_registry.web_api.app import Settings, app, get_settings
def override_settings(tmp_path):
def _override():
return Settings(
database_path=str(tmp_path / "scope-context.sqlite3"),
checkout_root=str(tmp_path / "checkouts"),
state_hub_base_url="",
)
return _override
def test_scope_context_endpoint_returns_activity_core_contract(tmp_path):
scoped_source = tmp_path / "scoped-repo"
scoped_source.mkdir()
(scoped_source / "SCOPE.md").write_text(
"# SCOPE\n\n"
"> Generated context note.\n\n"
"---\n\n"
"## One-liner\n\n"
"Maps repositories into concise operating context.\n\n"
"## Notes\n\n"
"Further details stay out of the summary.\n",
encoding="utf-8",
)
plain_source = tmp_path / "plain-repo"
plain_source.mkdir()
app.dependency_overrides[get_settings] = override_settings(tmp_path)
client = TestClient(app)
try:
scoped = client.post(
"/repos",
json={
"name": "Scoped Repo",
"url": str(scoped_source),
"description": "Registry fallback for scoped repo.",
},
).json()
ability_id = client.post(
f"/repos/{scoped['id']}/abilities",
json={
"name": "Context Publishing",
"description": "Expose repository context to callers.",
},
).json()["id"]
client.post(
f"/repos/{scoped['id']}/capabilities",
json={
"ability_id": ability_id,
"name": "Generate SCOPE.md",
"description": "Render repository scope from approved entries.",
},
)
client.post(
"/repos",
json={
"name": "Plain Repo",
"url": str(plain_source),
"description": "Plain repository metadata summary.",
},
)
client.post(
"/repos",
json={
"name": "Remote Repo",
"url": "https://example.test/remote-repo.git",
"description": "Remote repository metadata summary.",
},
)
scoped_context = client.get("/repos/scoped-repo/scope/context")
assert scoped_context.status_code == 200
assert scoped_context.json() == {
"repo_slug": "scoped-repo",
"capabilities": ["Generate SCOPE.md"],
"tags": ["ability", "capability"],
"scope_md_exists": True,
"scope_summary": "Maps repositories into concise operating context.",
}
plain_context = client.get("/repos/plain-repo/scope/context")
assert plain_context.status_code == 200
assert plain_context.json() == {
"repo_slug": "plain-repo",
"capabilities": [],
"tags": [],
"scope_md_exists": False,
"scope_summary": "Plain repository metadata summary.",
}
remote_context = client.get("/repos/remote-repo/scope/context")
assert remote_context.status_code == 200
assert remote_context.json() == {
"repo_slug": "remote-repo",
"capabilities": [],
"tags": [],
"scope_md_exists": False,
"scope_summary": "Remote repository metadata summary.",
}
missing_context = client.get("/repos/missing-repo/scope/context")
assert missing_context.status_code == 404
assert missing_context.json()["detail"]
markdown_scope = client.get("/repos/scoped-repo/scope")
assert markdown_scope.status_code == 200
assert markdown_scope.headers["content-type"].startswith("text/markdown")
assert "# SCOPE" in markdown_scope.text
finally:
app.dependency_overrides.clear()
def test_scope_context_schema_artifact_matches_contract_shape():
schema = json.loads(
(
Path(__file__).parents[1]
/ "docs"
/ "schemas"
/ "repo-scope-context-response.json"
).read_text(encoding="utf-8")
)
assert schema["additionalProperties"] is False
assert schema["required"] == [
"repo_slug",
"capabilities",
"tags",
"scope_md_exists",
"scope_summary",
]
assert schema["properties"]["scope_md_exists"]["type"] == "boolean"
assert schema["properties"]["scope_summary"]["type"] == ["string", "null"]
def test_scope_context_openapi_contract_is_exposed(tmp_path):
app.dependency_overrides[get_settings] = override_settings(tmp_path)
client = TestClient(app)
try:
schema = client.get("/openapi.json").json()
finally:
app.dependency_overrides.clear()
operation = schema["paths"]["/repos/{repo_slug}/scope/context"]["get"]
assert operation["tags"] == ["scope"]
response_schema = operation["responses"]["200"]["content"]["application/json"][
"schema"
]
assert response_schema["$ref"].endswith("/RepositoryScopeContextResponse")
assert (
"RepositoryScopeContextResponse"
in schema["components"]["schemas"]
)

View File

@@ -333,6 +333,12 @@ def test_openapi_contract_snapshot_for_stable_agent_paths():
"/repos/{repo_slug}/scope": {
"get": {"tags": ["scope"], "success_schema": None}
},
"/repos/{repo_slug}/scope/context": {
"get": {
"tags": ["scope"],
"success_schema": "RepositoryScopeContextResponse",
}
},
"/repos/{repo_slug}/scope/diff": {
"get": {"tags": ["scope"], "success_schema": "object"}
},

View File

@@ -1,152 +1,178 @@
---
id: RREG-WP-0012
type: workplan
domain: custodian
title: "Activity-Core Scope Context API"
domain: capabilities
repo: repo-scoping
status: active
state_hub_workstream_id: 83a43ab6-d566-41e0-afd7-f2833812564f
tasks:
- id: T01
title: Define context query response schema for activity-core
state_hub_task_id: 83482f1d-4f44-440d-a27f-0c375786b87f
status: todo
- id: T02
title: Verify/implement GET /repos/{slug}/scope endpoint
state_hub_task_id: 75f6123a-2703-42ec-9564-dd665e16c5ef
status: todo
- id: T03
title: Add scope_md_exists boolean to scope response
state_hub_task_id: cb0b17f4-30c4-4d68-b2ff-9a3fa9ef73a3
status: todo
- id: T04
title: Write integration test for activity-core repo-scoping adapter
state_hub_task_id: f70c1c58-b23f-4b61-adca-a02644b6ee7e
status: todo
owner: codex
topic_slug: foerster-capabilities
created: "2026-05-14"
updated: "2026-05-15"
state_hub_workstream_id: "83a43ab6-d566-41e0-afd7-f2833812564f"
---
# RREG-WP-0012: Activity-Core Context API
# Activity-Core Scope Context API
## Purpose
activity-core's `RunActivityWorkflow` resolves repository context before
evaluating rules and instructions. One of its context sources is repo-scoping:
given a repo slug, activity-core needs a compact capability profile with
capability names, classification tags, whether a `SCOPE.md` is known to exist,
and a short scope summary when available.
activity-core's `RunActivityWorkflow` resolves context before evaluating rules
and instructions. One of its context sources is repo-scoping: given a repo slug,
it queries repo-scoping for the repo's capability profile (tags, capabilities,
whether a SCOPE.md exists, etc.).
This is a contract workplan. The goal is a stable JSON interface for
activity-core, not new scope analysis functionality.
This workplan ensures repo-scoping exposes a stable, documented context API
endpoint that activity-core can call reliably. It is a **contract workplan**
the goal is a stable interface, not new functionality.
## Contract Decision
## Context
Do not change `GET /repos/{repo_slug}/scope`. That endpoint already exists as
the generated `SCOPE.md` Markdown preview endpoint, with sibling diff/write
routes. Changing its success media type or response shape would break existing
repo-scoping API clients and the OpenAPI contract policy.
- **activity-core WP-0003** (in progress): implements `RepoScopingContextResolver`
in `src/activity_core/context_resolvers/repo_scoping.py` which calls
`GET /repos/{slug}/scope` on the repo-scoping API.
- **repo-scoping** already has a scope API; the question is whether it returns
the exact fields activity-core needs, and whether those fields are stable.
Add a new provider-side context endpoint instead:
See: `docs/adr/adr-001-event-bridge-architecture.md` in activity-core, section
on context resolution adapters.
```text
GET /repos/{repo_slug}/scope/context
```
## Scope
**In scope:**
- Documenting and stabilizing the `/repos/{slug}/scope` endpoint response schema
- Adding `scope_md_exists` if not present
- Contract/integration test
**Out of scope:**
- Dynamic operational state (that is the state hub's domain, not repo-scoping's)
- NATS-based context queries (REST only for now)
- Any new scope analysis features
## Contract
The endpoint `GET /repos/{slug}/scope` must return:
The endpoint returns `application/json`:
```json
{
"repo_slug": "string",
"capabilities": ["string"],
"tags": ["string"],
"repo_slug": "repo-scoping",
"capabilities": ["Generate SCOPE.md"],
"tags": ["api", "generation", "scope"],
"scope_md_exists": true,
"scope_summary": "string or null"
"scope_summary": "Repository Scoping maps repositories into reviewable scope graphs."
}
```
| Field | Type | Required | Notes |
| Field | Type | Required | Source |
|---|---|---|---|
| `repo_slug` | string | yes | URL-safe identifier |
| `capabilities` | string[] | yes | Declared capability types |
| `tags` | string[] | yes | Free-form classification tags |
| `scope_md_exists` | boolean | yes | True if SCOPE.md present in repo root |
| `scope_summary` | string or null | no | First paragraph of SCOPE.md if present |
| `repo_slug` | string | yes | Slug requested by the caller after normal slugification |
| `capabilities` | string[] | yes | Approved capability names from the repository ability map |
| `tags` | string[] | yes | Stable, sorted union of approved ability and capability `primary_class` values plus their `attributes` |
| `scope_md_exists` | boolean | yes | True when repo-scoping can inspect a local or cached checkout and root `SCOPE.md` exists; false when absent or unknown |
| `scope_summary` | string or null | yes | First non-empty paragraph of root `SCOPE.md` when inspectable; otherwise approved scope description, repository description, or null |
This schema is the contract. Changes must be versioned and communicated to the
activity-core agent before deployment.
The `scope_md_exists` field is intentionally conservative. The provider must
not clone or fetch remote repositories merely to answer this lightweight context
query. If no local or cached checkout is available, the value is `false` and
`scope_summary` falls back to registry metadata.
## Tasks
activity-core should call this new endpoint, not the Markdown preview endpoint.
If a future contract needs an unknown/absent distinction for `SCOPE.md`, add a
new optional field such as `scope_md_status` rather than changing the boolean.
### T01 Define context query response schema for activity-core
Write a JSON Schema file (`docs/schemas/repo-scope-context-response.json`) that
formalizes the response shape above. Include field descriptions and examples.
This becomes the machine-readable contract between repo-scoping and activity-core.
### T02 — Verify/implement GET /repos/{slug}/scope endpoint
Check the existing API for `GET /repos/{slug}/scope`. Verify it returns all
required fields. If missing fields, implement them. If the endpoint does not
exist, implement it.
The endpoint is called by activity-core's `RepoScopingContextResolver` and
must be stable — no breaking changes without coordinating with activity-core.
### T03 — Add scope_md_exists boolean to scope response
Ensure `scope_md_exists` is present and accurate: `true` if a `SCOPE.md` file
exists in the repo's root directory on the default branch, `false` otherwise.
This field is used by activity-core rules to decide whether to emit a
generate-SCOPE.md task for a repo.
### T04 — Write integration test for activity-core repo-scoping adapter
Write a contract test that:
1. Calls `GET /repos/{slug}/scope` with a known test repo slug
2. Asserts all required fields are present
3. Asserts types match the schema (scope_md_exists is bool, capabilities is list, etc.)
This test lives in repo-scoping as the provider-side contract test. It should
run in CI so contract drift is caught before it breaks activity-core.
**Test file**: `tests/test_scope_context_api.py` (or alongside existing API tests)
## Build Order
## T01: Define Context Query Response Schema
```task
id: RREG-WP-0012-T01
status: done
priority: high
state_hub_task_id: "83482f1d-4f44-440d-a27f-0c375786b87f"
```
T01 (schema) → T02 (implement) → T03 (add field) → T04 (test)
Write `docs/schemas/repo-scope-context-response.json` to formalize the response
shape above. Include field descriptions, required fields, `additionalProperties:
false`, and at least one complete example.
Acceptance criteria:
- The schema describes `repo_slug`, `capabilities`, `tags`, `scope_md_exists`,
and `scope_summary`.
- Required fields match the contract table.
- The schema is easy for activity-core to vendor or validate against.
## T02: Implement Scope Context Endpoint
```task
id: RREG-WP-0012-T02
status: done
priority: high
state_hub_task_id: "75f6123a-2703-42ec-9564-dd665e16c5ef"
```
Implement `GET /repos/{repo_slug}/scope/context` in repo-scoping. The endpoint
must return the JSON contract above and must preserve the existing
`GET /repos/{repo_slug}/scope` Markdown behavior.
Acceptance criteria:
- The endpoint resolves repositories with the same slug rules as the existing
scope endpoints.
- Repositories without approved capabilities still return a valid context
response with empty `capabilities` and `tags`.
- Missing repository slugs return the existing `404` error shape.
- The OpenAPI snapshot includes the new endpoint and response model.
## T03: Resolve SCOPE.md Presence And Summary
```task
id: RREG-WP-0012-T03
status: done
priority: high
state_hub_task_id: "cb0b17f4-30c4-4d68-b2ff-9a3fa9ef73a3"
```
Populate `scope_md_exists` and `scope_summary` without performing network
operations. Reuse local source paths, cached checkouts, and State Hub local path
lookup where available.
Acceptance criteria:
- A local registered repo with root `SCOPE.md` returns `scope_md_exists: true`.
- A local registered repo without root `SCOPE.md` returns `scope_md_exists:
false`.
- A remote repo with no cached checkout returns `scope_md_exists: false` and a
metadata fallback summary when available.
- `scope_summary` extracts the first non-empty paragraph after headings or
frontmatter noise when a `SCOPE.md` file is inspectable.
## T04: Add Provider-Side Contract Tests
```task
id: RREG-WP-0012-T04
status: done
priority: high
state_hub_task_id: "f70c1c58-b23f-4b61-adca-a02644b6ee7e"
```
Add `tests/test_scope_context_api.py` or equivalent focused API coverage for
the new context endpoint.
Acceptance criteria:
- Tests assert all required fields and types.
- Tests cover at least three repository cases: local with `SCOPE.md`, local
without `SCOPE.md`, and remote/uncached.
- Tests assert the existing Markdown `/scope` endpoint still returns Markdown.
- Tests assert the OpenAPI contract exposes the new response model.
## Completion Criteria
1. `GET /repos/{slug}/scope` returns all five required fields
2. `scope_md_exists` is accurate for at least three test repos
3. JSON Schema file committed to `docs/schemas/`
4. Contract test passes in CI
5. activity-core agent confirmed the `RepoScopingContextResolver` integration works
- `GET /repos/{repo_slug}/scope/context` returns the five-field JSON contract.
- Existing `GET /repos/{repo_slug}/scope` Markdown preview behavior is unchanged.
- `docs/schemas/repo-scope-context-response.json` exists.
- Provider-side contract tests pass in CI.
- activity-core updates `RepoScopingContextResolver` to call `/scope/context`.
Implementation note 2026-05-15: repo-scoping provider work is complete. Added
`GET /repos/{repo_slug}/scope/context`, the Pydantic response model, the JSON
Schema artifact, focused provider-side contract tests, and OpenAPI snapshot
coverage. Full repo test suite passed locally with `.venv/bin/python -m pytest`
(`140 passed`). Workplan remains active until activity-core updates and confirms
its resolver.
## Notes
- The local agent implementing this should coordinate with the activity-core agent
to confirm the exact field names and types before implementing T02.
- Do **not** add dynamic operational state to this endpoint (e.g., last_commit_at,
open_issues_count) — that belongs to the state hub context adapter, not repo-scoping.
- `scope_summary` is optional (null OK) — do not block on extracting it if the
SCOPE.md parsing is complex.
- Dynamic operational state such as last commit time, open issues, active tasks,
or runtime health belongs to the State Hub context adapter, not this endpoint.
- This endpoint should be cheap and deterministic. It must not trigger scans,
clones, LLM calls, or SCOPE.md generation.
- Backward-compatible additions can be optional fields. Required field changes
need coordination with activity-core.
## Change History
- v0.1 (2026-05-14): Stub created by activity-core agent during WP-0003 planning.
Local agent to flesh out and implement.
- v0.2 (2026-05-15): Revised by repo-scoping agent to avoid breaking the
existing Markdown `/scope` endpoint, define `/scope/context`, and normalize
ADR-001 task blocks.