CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / integration (push) Failing after 25s
CI & Build / Python tests (push) Canceled after 51s
CI & Build / Build & push image (push) Canceled after 0s
The 2026-08 audit left 77% of Scribe's ledger `exempt`, most of it a Vue component's scoped <style> rules and <script setup> functions — one-offs by construction (unreachable from any other file) that add nothing when judged one by one and bury the rows a person should look at. - coverage: Definition carries its line; scoped_definitions() names, per .vue file, every sym and every css rule inside <style scoped>; ArchiveShape carries the flag. - sync: such rows are stamped status=scoped / classified_by=mechanical with the by-construction reason (history event recorded); un-stamped back to unclassified if a later tree makes them ordinary; a judgment overrides. - The machine still sees them: proposer, derive grouping, divergence, hook evidence, canonical stamping and classify_shapes_by_rule's default all treat unclassified + scoped as the unjudged set (_MECHANICAL_TODO). Only the human todo (status=unclassified) and largest_gaps exclude them. - accounting counts `scoped`; coverage line and the project card legend show it; SHAPE_STATUSES gains it (no DB CHECK on status — no migration). - shape-accounting skill documents the bucket; plugin 0.1.37. Operator decision on #2869 (2026-08-21): keep extracting everything, stamp mechanically, keep `exempt` a human judgment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
292 lines
14 KiB
Python
292 lines
14 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 = "",
|
|
compact: bool = False,
|
|
) -> 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 | scoped | unclassified.
|
|
`scoped` (#2869) is the sync's mechanical stamp on one-offs by
|
|
construction (a Vue component's scoped <style> rules and its
|
|
<script setup> functions): accounted for, not judged, still
|
|
proposed against / grouped / flagged, and overridable by any
|
|
classify_shapes judgment. The human todo is `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).
|
|
compact: rows as `path · symbol · kind · status · signature` plus
|
|
snippet_id / by / proposal / diverges_from / recheck only when
|
|
set — no commits, shas or timestamps. THE form for an audit:
|
|
a full 500-row page fits the tool budget. The default rows carry
|
|
everything (shape_history-grade bookkeeping).
|
|
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_compact() if compact else r.to_dict() for r in rows],
|
|
"total": total,
|
|
}
|
|
|
|
|
|
async def classify_shapes_by_rule(
|
|
project_id: int,
|
|
path: str,
|
|
status: str,
|
|
pattern: str = "",
|
|
kind: str = "",
|
|
snippet_id: int = 0,
|
|
reason: str = "",
|
|
via: str = "agent",
|
|
include_judged: bool = False,
|
|
) -> dict:
|
|
"""The sweep form of classify_shapes: ONE judgment applied to every
|
|
unclassified shape under a directory whose symbol matches a glob.
|
|
|
|
For the long tail an audit judges by family, not by row — "every scoped
|
|
rule under frontend/src/views is exempt: styles one element of its view",
|
|
"every `*_scheduler.py` symbol is an instance of ScheduledJob" — where
|
|
listing 900 rows and sending them back is the whole cost. The row form
|
|
stays the precise tool; reach for it when each row gets its own reason.
|
|
|
|
Args:
|
|
project_id: The project whose ledger is being judged.
|
|
path: A file, or a directory and everything beneath it. Required —
|
|
a sweep names what it judges.
|
|
status: instance | variant | exempt | unclassified (canonical is the
|
|
sync's stamp, not a sweep's).
|
|
pattern: Shell glob on the symbol (`*_rows`, `_*`, `modal-*`, `*`);
|
|
"" = every symbol under path.
|
|
kind: "sym" or "css" to narrow; "" = both.
|
|
snippet_id: Required for instance/variant — the canon judged against.
|
|
reason: Required for variant/exempt — the why, recorded on every row.
|
|
via: "agent" (default) | "audit" | "import".
|
|
include_judged: By default only unjudged rows are touched —
|
|
`unclassified` and the sync's mechanical `scoped` stamp — a
|
|
sweep never silently overwrites a judgment. True re-judges every
|
|
matching live row (use to re-confirm after a recheck, or to
|
|
revise a family you judged earlier).
|
|
|
|
One transaction: applies whole or not at all. Returns
|
|
{"classified": N, "sample": ["path::symbol", ...]} (first 12, sorted)
|
|
so you can see what the rule reached; N = 0 means the rule matched
|
|
nothing live and unclassified — widen the pattern or refresh coverage.
|
|
"""
|
|
uid = current_user_id()
|
|
try:
|
|
return await shape_ledger_svc.classify_shapes_where(
|
|
uid, project_id, path=path, status=status, pattern=pattern,
|
|
kind=kind, snippet_id=snippet_id or None, reason=reason or None,
|
|
via=via, include_judged=include_judged,
|
|
)
|
|
except ValueError as exc:
|
|
return {"error": str(exc)}
|
|
|
|
|
|
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, classify_shapes_by_rule, list_shapes,
|
|
refresh_pattern_coverage, confirm_shape_proposals, shape_history,
|
|
):
|
|
mcp.tool(name=fn.__name__)(fn)
|