"""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, proposal: str = "", flag: str = "", ) -> 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). proposal: the proposer's queue (#2792) — "any", "canon" (rows the machine thinks are an instance of a snippet: `proposal` carries snippet_id, basis, score), "derive" (rows that repeat with NO canon: `proposal.group` names the family), or one basis (symbol/text/reference/signature/semantic). flag: the divergence readout (#2793) — "divergence": shapes new since the previous refresh in a directory where one canon dominates the judged siblings and NOT proposed as that canon (`diverges_from` names it: button B where button A is canon — classify it: instance if it should use the canon, variant with the why if deliberate); "recheck": judged instances/variants whose body changed since judged (the judgment stands; confirm it again with classify_shapes, or re-judge). Returns {"shapes": [...], "total": N} — total counts every match, not just this page. Each row's `classified_by` says who judged: agent / audit / import are judgments; `mechanical` is the canonical stamp the sync applies; `hook` is write-path EVIDENCE (#2791) — the session pulled a snippet and then wrote code referencing/resembling it, so the shape was stamped an instance with the evidence in `reason`. A hook row is overridable by any classify_shapes call; it never overrides yours. THE FAST PATH through a big todo is the proposer's queue: every coverage refresh matches unclassified shapes against canon (strongest basis first: same symbol elsewhere → textual containment → body references the canon → signature resemblance → semantic) and attaches a `proposal` to each row it can speak for. Review `proposal="canon"` by snippet or directory, then confirm_shape_proposals the ones that hold — hundreds at a time — and classify_shapes the rest (variant/exempt, or instance of a different snippet). `proposal="derive"` lists the derive-first candidates: a repeating shape with NO recorded canon is never N loose classifications — consolidate onto a reference, create_snippet it, then classify the group against it. """ 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, proposal=proposal, flag=flag, ) return {"shapes": [r.to_dict() for r in rows], "total": total} async def shape_history( project_id: int, path: str, symbol: str = "", limit: int = 200 ) -> dict: """What was used here, when, and why — a shape's (or a directory's) history from the ledger (#2793). `shapes` are the current rows at `path` (a file, or a directory and everything beneath it; `symbol` narrows to one definition) with first/last-seen commits, vanished_at, and the standing judgment; `events` are the state changes, oldest first: `classified` (status, snippet_id, who, why — one per judgment, so a shape that was an instance of #N and later a variant of #M shows both), `vanished`, `reappeared`, `drifted` (the body moved under a judgment; see list_shapes flag= "recheck"). Each event carries the commit the tree was read at. Read it as a timeline: "instance of #N from , re-judged variant of #M at because , vanished at ". Read-only; requires read access to the project. """ uid = current_user_id() return await shape_ledger_svc.shape_history( uid, project_id, path, symbol=symbol, limit=limit ) async def confirm_shape_proposals( project_id: int, snippet_id: int = 0, path: str = "", basis: str = "", min_score: float = 0.0, ) -> dict: """Confirm the proposer's canon proposals you have reviewed, in batch. The machine proposes, judgment classifies (#2792): each matching row — live, unclassified, carrying a `proposal` with a snippet_id — becomes `instance` of that snippet, classified_by="agent", reason naming the basis and score. Narrow to what you actually looked at: at least one of snippet_id (confirm one canon's whole queue after reading its `list_shapes(proposal="canon", ...)` page), path (a directory you audited), or basis (e.g. "symbol" and "reference" are near-certain; "semantic" deserves a look first) is required — a bare confirm-all is not a judgment. min_score trims a basis's tail. Proposals you do NOT confirm are judged with classify_shapes (variant, exempt, or instance of a different snippet) — any judgment retires the proposal. Requires write access. Returns {"confirmed": N}. """ uid = current_user_id() try: return await shape_ledger_svc.confirm_proposals( uid, project_id, snippet_id=snippet_id, path=path, basis=basis, min_score=min_score, ) except ValueError as exc: return {"error": str(exc)} 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. The refresh is also when the mechanical proposer runs (#2792): with the repo bodies in hand it matches every changed unclassified shape against canon and records proposals (see list_shapes proposal=), then regroups the derive-first candidates. Semantic matching is capped per refresh, so a large ledger's queue grows across refreshes rather than in one. Returns the accounting payload — total, accounted, counts by status, unclassified, repos, largest_gaps, `proposed` (canon proposals awaiting confirmation), `derive_groups` (the biggest repeats-with-no-canon families), `proposer` (what this refresh examined) — 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, confirm_shape_proposals, shape_history, ): mcp.tool(name=fn.__name__)(fn)