CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m41s
CI & Build / Build & push image (push) Successful in 28s
The review surface shipped yesterday reported two canons on its first live day and both were sound. Coherence was "do all judged rows share a form", which #2844 failed at 37/62 = 0.597 for containing a model class and the to_dict the canon's own text says the class must carry, and #2849 failed at 4/7 for pairing sync loop-starters with the async ticks they schedule. A review surface whose whole output is noise is one that stops being read. The obvious repair is a trap, and there is now a test standing in front of it. Grouping by family and keeping a majority test makes the check BLIND: before #2844 was cleaned by hand it held 37 classes and 56 callables, which as families is 56/93 = 0.602 — a clean pass, and the 31 rows that had no business being there (Vue functions, route handlers, a dozen tests) would never have been reported at all. A looser bar in the same shape is worse than the bug. So the verdict is inverted. Instead of asking whether most rows agree, it asks how many rows the canon CANNOT ACCOUNT FOR: a row in the majority family is accounted for; a callable defined in a file that also holds a majority-family `type` row is a method of a member, not a foreign body; and strangers above a fifth of the readable rows make the canon incoherent. The majority vote abstains those methods, so a class's own serialisers cannot outvote the classes and turn the members into the strangers. Measured on the real ledger before it was written, which is why it is this rule and not a nudge to the share: clean #2844 has 0 strangers in 62, #2849 has 0 in 7, and polluted #2844 had 31 in 93 — the same 31 withdrawn by hand this morning, named exactly. The entry now carries `families`, the majority `family`, `attached`, `stranger_count`, `unattended`, and `strangers` — THE ROWS THAT DO NOT FIT, replacing a sample of the first twelve members. The reader's question is which rows are wrong, and a sample of the agreeing majority cannot answer it. `unattended` is the discriminator between a check that is too strict and a ledger full of junk: both canons flagged on day one were entirely audit-judged, and nothing showed that without opening each one. Scoped to the review surface. `canon_form` still answers at the precise form level for stamping and divergence, where a sync helper beside an async canon is a fair question; nothing here changes what the ledger writes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
378 lines
19 KiB
Python
378 lines
19 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?, reason_code?}. 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. reason_code is
|
|
an OPTIONAL index beside the prose (one of: scoped-css,
|
|
one-off-handler, test-helper, convention-plumbing, pure-helper,
|
|
generated, script, typed-record) so the ledger can be filtered
|
|
and aggregated by kind of one-off — the prose stays the record.
|
|
uses is an OPTIONAL list of snippet ids this shape CALLS (#2870):
|
|
conformance (status + snippet_id) says what shape it is, uses
|
|
says which canonical helpers it consumes — a service function
|
|
can be an instance of the service-function convention AND use
|
|
hash_token. Consumer maps are uses edges; list_shapes(uses=N)
|
|
and get_snippet's `uses` read them.
|
|
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,
|
|
uses: 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 | 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 (instance/variant
|
|
of it — conformance).
|
|
uses: rows that CALL this snippet (#2870) — the consumer map proper,
|
|
whatever shape each row is itself; edges come from judgments
|
|
(classify_shapes uses=), the write-path hook, and the proposer's
|
|
by-name reference hits.
|
|
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); "unused-css"
|
|
(milestone 302): live css rules no file's markup names — a
|
|
deletion candidate to look at, never auto-deleted. Transition
|
|
classes and concatenated names are read (#2970), so the list is
|
|
worth acting on; a name assembled in a script still is not.
|
|
|
|
Returns {"shapes": [...], "total": N} — total counts every match, not
|
|
just this page. Every css row carries `used_by` {count, paths} — the
|
|
files whose markup names its class (milestone 302, the CSS consumer
|
|
map: a scoped rule is used by its own template; a shared recipe by
|
|
many; a count of 0 is "no template names it"). 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, uses=uses,
|
|
)
|
|
shapes = [r.to_compact() if compact else r.to_dict() for r in rows]
|
|
used_by = await shape_ledger_svc.used_by_map(rows)
|
|
for row, out in zip(rows, shapes):
|
|
if row.id in used_by:
|
|
out["used_by"] = used_by[row.id]
|
|
return {"shapes": shapes, "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,
|
|
reason_code: str = "",
|
|
uses: list[int] | None = None,
|
|
) -> 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".
|
|
reason_code: Optional catalogue code beside the reason (see
|
|
classify_shapes) — a sweep is exactly where one applies.
|
|
uses: Optional snippet ids every matched shape CALLS (#2870) — e.g.
|
|
"every *_scheduler.py symbol uses ScheduledJob".
|
|
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,
|
|
reason_code=reason_code or None, uses=uses or None,
|
|
)
|
|
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 stamps_to_review(project_id: int, top: int = 10) -> dict:
|
|
"""Judged rows whose evidence no longer meets the ledger's bar, and canons
|
|
whose own rows no longer agree what they are. **Read-only — you decide.**
|
|
|
|
This tool deliberately cannot fix anything. Every row it lists was written
|
|
unattended by the write-path hook on a similarity score, and what made
|
|
that harmful was not one bad score but a machine recording a permanent
|
|
classification with nobody reading it. Un-asserting them automatically
|
|
would be the same mistake with a wider blast radius. So: read the
|
|
evidence, judge, and record the judgment yourself with `classify_shapes`
|
|
— under your own name, with a reason.
|
|
|
|
`weak` — rows the hook stamped on a resemblance below the current floor
|
|
(`floor` in the response). The floor arrived after they did, and a guard
|
|
at the point of classification never undoes what is already stored. Rows
|
|
an agent or an audit judged are NOT listed at any age: a judgment is not
|
|
weak evidence, it is a different kind of evidence. Each row carries its
|
|
score, signature and derived form so you can judge rather than trust the
|
|
threshold.
|
|
|
|
`incoherent` — canons whose membership no longer agrees what the canon
|
|
is. A canon asserts that some shapes are the same sort of thing; when its
|
|
members are a class, three getters and a dozen tests, that assertion has
|
|
stopped being true and every base-rate reading built on it — the
|
|
divergence prompt included — is reading noise.
|
|
|
|
Disagreement is judged by FAMILY (a sync helper and an async one are one
|
|
family), and a method defined beside a member class counts as part of the
|
|
shape rather than against it — so a model convention that covers both the
|
|
class and its `to_dict` reads as coherent, which it is (#4220). Each
|
|
entry carries `families` and `forms`, the majority `family`, `attached`
|
|
(minority rows excused as methods of a member), `strangers` — THE ROWS
|
|
THAT DO NOT FIT, which is what you judge — and `unattended` / `weak_rows`,
|
|
the count written by the hook with nobody reading. An incoherence whose
|
|
rows are all judged is likelier this check being strict than a bad
|
|
ledger; one made of unattended rows is the real thing.
|
|
|
|
A row listed here is a question, not a verdict. Some will be correct.
|
|
|
|
Read-only; requires read access to the project.
|
|
"""
|
|
uid = current_user_id()
|
|
rows = await shape_ledger_svc.live_rows_for(uid, project_id)
|
|
return shape_ledger_svc.stamps_to_review(rows, top=top)
|
|
|
|
|
|
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, each css one with `consumers` — the files whose markup
|
|
render it, milestone 302), `unused_css` (css rules no template names —
|
|
counting a `<Transition name=>`'s generated classes and concatenated
|
|
names as named, #2970; None where the map has no evidence of templates), `derive_new` (copies
|
|
that joined a family since the previous
|
|
refresh — the drift to act on now: derive the canon, don't queue an
|
|
audit), `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,
|
|
stamps_to_review,
|
|
):
|
|
mcp.tool(name=fn.__name__)(fn)
|