"""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)