CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Failing after 33s
CI & Build / Build & push image (push) Skipped
The UI Refresh button must not be the only seed path (operator directive, hit live: the P7 backfill stalled waiting for a click). Three parts: - enter_project fire-and-forgets refresh_if_stale on the project OWNER — absent or day-old readouts recompute in the background (same spawn the webhook path uses), the enter stays fast, forge-less owners exit quietly (rule #115 baseline), and an in-flight guard keeps concurrent enters from fetching the same tarball N times. - refresh_pattern_coverage(project_id): the synchronous agent-facing form — write-gated, owner-keyring resolution, and ValueError messages that name the fix (add a connection / bind_repo) instead of measuring nothing silently. - plugin 0.1.33 ships the shape-accounting skill: the five statuses, the seed/todo/judge loop, and the derive-first rule, triggered by the coverage line or any proved code-to-canon relationship. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
125 lines
5.6 KiB
Python
125 lines
5.6 KiB
Python
"""Shape-ledger MCP tools — the classification write/read surface (#2789).
|
|
|
|
The accounting model (note 2786): the snippet library records CANON (small);
|
|
the ledger accounts for EVERY extracted shape (total). These tools are how
|
|
agents move shapes out of `unclassified` — the todo state — and how they read
|
|
what still needs judgment. The ledger rows themselves are fed by the coverage
|
|
refresh; these tools only ever judge what the sync has seen.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from scribe.mcp._context import current_user_id
|
|
from scribe.services import coverage as coverage_svc
|
|
from scribe.services import shape_ledger as shape_ledger_svc
|
|
|
|
|
|
async def classify_shapes(
|
|
project_id: int, classifications: list[dict], via: str = "agent"
|
|
) -> dict:
|
|
"""Record judgments for a project's code shapes — in batch, as rows.
|
|
|
|
EVERY shape in a bound repo should end up classified (note 2786):
|
|
- `instance` of snippet N — it conforms to recorded canon (family-level
|
|
canon in another project counts; that fully accounts for the shape).
|
|
- `variant` of snippet N — a deliberate, named departure. `reason`
|
|
(the why) is REQUIRED; it is the record.
|
|
- `exempt` — judged genuinely one-off. `reason` REQUIRED.
|
|
- `canonical` of snippet N — this row IS the snippet's reference
|
|
(rarely set by hand; the coverage sync stamps these mechanically).
|
|
- `unclassified` — withdraw a judgment; the shape rejoins the todo.
|
|
|
|
Consumer maps belong HERE, not in prose: when an audit enumerates call
|
|
sites of a canonical helper, each call site's defining shape is an
|
|
`instance` row — a sentence in a verification detail cannot be sorted,
|
|
queried, or diffed.
|
|
|
|
Args:
|
|
project_id: The project whose ledger is being judged.
|
|
classifications: Objects of {path, symbol, status, kind?, snippet_id?,
|
|
reason?}. path+symbol name the shape exactly as list_shapes shows
|
|
it; kind ("sym"/"css") narrows when one file defines both.
|
|
snippet_id is required for canonical/instance/variant; reason is
|
|
required for variant/exempt.
|
|
via: Who is judging — "agent" (default), "audit" (a sweep), or
|
|
"import" (carrying maps recorded elsewhere).
|
|
|
|
All-or-nothing: a structural error, a missing snippet target, or no write
|
|
access applies NOTHING. Returns {"classified": N, "unmatched": [...]} —
|
|
unmatched names shapes no live ledger row matches (the tree may have
|
|
moved since you listed; re-run the project's coverage refresh to re-sync).
|
|
"""
|
|
uid = current_user_id()
|
|
return await shape_ledger_svc.classify_shapes(
|
|
uid, project_id, classifications, via=via
|
|
)
|
|
|
|
|
|
async def list_shapes(
|
|
project_id: int,
|
|
status: str = "",
|
|
path: str = "",
|
|
snippet_id: int = 0,
|
|
include_vanished: bool = False,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
) -> dict:
|
|
"""Read a project's shape ledger — `status="unclassified"` IS the todo.
|
|
|
|
Every extracted definition in the project's bound repos has a row here
|
|
(fed by the coverage refresh). Filters compose:
|
|
|
|
Args:
|
|
status: canonical | instance | variant | exempt | unclassified.
|
|
path: exact file, or a directory — matches everything beneath it
|
|
(the coverage line's "largest" dirs go straight in here).
|
|
snippet_id: rows classified against this snippet — a consumer map.
|
|
include_vanished: include shapes no longer in the tree (history).
|
|
limit/offset: page through big ledgers (limit caps at 500).
|
|
|
|
Returns {"shapes": [...], "total": N} — total counts every match, not
|
|
just this page. Classify what you can judge with classify_shapes; a
|
|
repeating shape with NO recorded canon is a derive-one-first moment
|
|
(consolidate onto a reference, create_snippet it, then classify the
|
|
rest against it), never N loose classifications.
|
|
"""
|
|
uid = current_user_id()
|
|
rows, total = await shape_ledger_svc.list_project_shapes(
|
|
uid, project_id,
|
|
status=status, path=path, snippet_id=snippet_id,
|
|
include_vanished=include_vanished, limit=limit, offset=offset,
|
|
)
|
|
return {"shapes": [r.to_dict() for r in rows], "total": total}
|
|
|
|
|
|
async def refresh_pattern_coverage(project_id: int) -> dict:
|
|
"""Seed or refresh the project's shape ledger NOW, and return the readout.
|
|
|
|
The synchronous form of the arrival-moment background seed (#2802):
|
|
downloads the bound repos through the OWNER's forge connections, upserts
|
|
every extracted shape into the ledger (new shapes arrive `unclassified`),
|
|
re-stamps snippet reference locations as canonical, and recomputes the
|
|
accounting. Reach for it when the ledger must be current before you act —
|
|
a classification batch about to run, a coverage question asked directly —
|
|
rather than waiting on the background seed an enter_project triggers.
|
|
|
|
Takes seconds, not milliseconds (it moves repo archives). Requires write
|
|
access to the project. Errors name the fix: no forge connection → the
|
|
owner adds one (Settings → Integrations → Git Forges); no served repo →
|
|
bind_repo on a host a connection serves.
|
|
|
|
Returns the accounting payload — total, accounted, counts by status,
|
|
unclassified, repos, largest_gaps — plus `pattern_coverage`, the same
|
|
one-line summary enter_project carries.
|
|
"""
|
|
uid = current_user_id()
|
|
coverage = await coverage_svc.refresh_for_caller(uid, project_id)
|
|
return {
|
|
"pattern_coverage": coverage_svc.coverage_line(coverage),
|
|
**coverage,
|
|
}
|
|
|
|
|
|
def register(mcp) -> None:
|
|
for fn in (classify_shapes, list_shapes, refresh_pattern_coverage):
|
|
mcp.tool(name=fn.__name__)(fn)
|