CI & Build / TypeScript typecheck (push) Failing after 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 28s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Every judgment now goes through one helper that remembers the fingerprint judged (classified_sha) and writes a code_shape_events row; the sync writes vanished / reappeared / drifted events and flags recheck_at when a body moves under an instance/variant. The refresh flags diverges_from on shapes new since the previous computation that sit where one canon dominates the judged siblings of their directory+kind and were not proposed as that canon (a first seed flags nothing); the write-path hint asks the same question in-band for the shapes the hook names. list_shapes(flag=divergence|recheck), shape_history(project_id, path, symbol) (read-only), coverage line/payload/ card carry divergent + recheck. Backup v8 carries the history. Plugin 0.1.36. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
224 lines
11 KiB
Python
224 lines
11 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,
|
|
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 <first classified at>,
|
|
re-judged variant of #M at <at> because <reason>, vanished at <commit>".
|
|
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)
|