Files
FabledScribe/src/scribe/mcp/tools/shapes.py
T
bvandeusenandClaude Fable 5 1ab614bfbe
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
feat(ledger): the scoped bucket — by-construction one-offs are stamped by the sync, not judged by a person (#2869, milestone 294)
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>
2026-08-21 15:06:52 -04:00

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)