feat(shapes): the agent judges what it wrote, at the end of the turn (milestone 439 steps 1-3)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 52s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / Build & push image (push) Successful in 33s

Recording used to be decided by machinery — the only "record it" prompt
fired when a same-named copy already existed (#2664), so a first instance of
a reusable piece was never asked about, and judgment arrived only through
audits. Now the question is asked where the knowledge is: the end of the
turn that wrote the code, of the agent that wrote it.

- Write hooks keep `<sid>.written.ids` (path, kind, name) for every
  definition a write names; a new file adds a `file` line for its stem — a
  candidate in any language without a framework rule (scribe_written_append).
- Stop hook scribe_shape_check.sh sends the ledger to GET
  /api/plugin/shape-check and blocks once, in the server's words, when
  anything is unjudged. Same discipline as the report check: never twice,
  never without a recorded check, another hook's loop left alone; the ledger
  is kept when the instance cannot be reached.
- shape_ledger.unjudged_shapes: no row, unclassified, scoped and hook stamps
  are unjudged; an agent/audit/import verdict is not. A snippet recorded at
  the shape answers for it until the refresh stamps it canonical.
- services/shape_check owns the reason text and records every outcome in
  app_logs (passed / blocked / judged_after_block / left_after_block).
- classify_shapes(repo=…) judges a shape the ledger has not synced yet via a
  provisional row under a bound repo; the sync confirms it, or vanishes and
  revives it with the verdict intact. An unbound repo is refused.

Plugin version minted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 08:39:36 -04:00
co-authored by Claude Opus 5.5
parent eb5cc6d3a7
commit f1fbdf746a
17 changed files with 854 additions and 9 deletions
+114 -1
View File
@@ -535,6 +535,7 @@ async def classify_shapes(
classifications: list[dict],
*,
via: str = "agent",
repo: str = "",
) -> dict:
"""Apply a batch of judgments to a project's live ledger rows.
@@ -544,7 +545,19 @@ async def classify_shapes(
item carries one — and a target no live row matches is reported in
``unmatched``, not an error: the tree may simply have moved since the
caller listed. Idempotent by construction.
``repo`` — a repo bound to the project — lets a judgment land on a shape
the ledger has not synced yet (milestone 439): the shape the agent wrote
this turn. Such an item becomes a PROVISIONAL row under that repo, the
same row the write-path hook has always created, with no seen commit;
the next sync confirms it, or marks it vanished until the code reaches
the bound ref, and either way the judgment rides along (a vanished row
that reappears keeps its status). Without ``repo`` an unknown shape is
`unmatched`, exactly as before. An unbound ``repo`` is refused rather
than ignored: a verdict silently dropped reads as one recorded.
"""
from scribe.services import repo_bindings as repo_bindings_svc
from scribe.services import access
from scribe.services import snippets as snippets_svc
@@ -569,9 +582,18 @@ async def classify_shapes(
for sid in sorted(target_ids):
if await snippets_svc.get_snippet(user_id, sid) is None:
raise ValueError(f"snippet {sid} not found (or not readable)")
repo_key = ""
if (repo or "").strip():
repo_key = await repo_bindings_svc.is_bound(project_id, repo)
if not repo_key:
raise ValueError(
f"repo {repo!r} is not bound to project {project_id} — "
"bind_repo it, or omit repo to judge synced shapes only"
)
now = datetime.now(timezone.utc)
classified = 0
provisional = 0
unmatched: list[dict] = []
async with async_session() as session:
rows = (
@@ -592,6 +614,17 @@ async def classify_shapes(
kind = (item.get("kind") or "").strip()
if kind:
matches = [r for r in matches if r.kind == kind]
if not matches and repo_key and item.get("status") != "unclassified":
path = (item.get("path") or "").strip()
symbol = (item.get("symbol") or "").strip()
row = CodeShape(
project_id=project_id, repo_key=repo_key, path=path,
symbol=symbol, kind=kind or snippet_kind(symbol, ""),
)
session.add(row)
by_key.setdefault((path, symbol), []).append(row)
matches = [row]
provisional += 1
if not matches:
unmatched.append({
"path": item.get("path"), "symbol": item.get("symbol"),
@@ -610,7 +643,10 @@ async def classify_shapes(
evidence=item.get("reason"))
classified += 1
await session.commit()
return {"classified": classified, "unmatched": unmatched}
out = {"classified": classified, "unmatched": unmatched}
if provisional:
out["provisional"] = provisional
return out
def rule_matches(row: CodeShape, *, path: str, pattern: str, kind: str) -> bool:
@@ -2550,6 +2586,83 @@ async def flag_divergence(project_id: int, *, since: datetime | None) -> int:
return flagged
# --- the end-of-turn question (milestone 439): what did this turn write that
# nobody has judged? ---------------------------------------------------------
#
# The write hooks keep a session ledger of the definitions each turn wrote; at
# the end of the turn the client asks this, and the agent that wrote them
# answers. "Judged" means a verdict SOMEONE READ: an agent's, an audit's, an
# import's. The sync's `scoped` stamp, `unclassified`, a hook stamp and no row
# at all are all unjudged — each is a machine's guess or nothing, and the
# point of the question is that the writer, who knows, says.
def _is_judged(row) -> bool:
return (
row.status not in _MECHANICAL_TODO
and row.classified_by not in _UNATTENDED_BY
)
async def unjudged_shapes(
project_id: int, written: list[tuple[str, str, str]], *, repo_key: str = "",
) -> list[dict]:
"""The (path, kind, name) shapes in ``written`` that carry no judgment.
Each comes back as {path, kind, symbol, status, evidence} — status is the
row's, or "new" when the ledger has none yet; evidence is what the
machinery holds that the writer may want to weigh: the proposer's
candidate canon, a hook's stamp, a divergence flag. Evidence, never a
verdict — the writer is asked because the machine's guesses were the
thing that poisoned two ledgers. Order follows ``written``; duplicates
collapse. The caller has already resolved the project and checked access.
"""
wanted: list[tuple[str, str, str]] = []
for path, kind, name in written:
key = ((path or "").strip(), (kind or "").strip(), (name or "").strip())
if all(key) and key not in wanted:
wanted.append(key)
if not project_id or not wanted:
return []
paths = sorted({p for p, _k, _n in wanted})
query = select(CodeShape).where(
CodeShape.project_id == project_id,
CodeShape.vanished_at.is_(None),
CodeShape.path.in_(paths),
)
if repo_key:
query = query.where(CodeShape.repo_key == repo_key)
async with async_session() as session:
rows = (await session.execute(query)).scalars().all()
by_key = {(r.path, r.kind, r.symbol): r for r in rows}
out: list[dict] = []
for path, kind, name in wanted:
row = by_key.get((path, kind, name))
if row is not None and _is_judged(row):
continue
evidence: dict = {}
if row is not None:
if row.proposed_snippet_id:
evidence["looks_like"] = {
"snippet_id": row.proposed_snippet_id,
"basis": row.proposal_basis,
"score": row.proposal_score,
}
if row.classified_by in _UNATTENDED_BY and row.snippet_id:
evidence["stamped"] = {
"snippet_id": row.snippet_id, "score": stamp_score(row.reason),
}
if row.proposal_group:
evidence["copies"] = row.proposal_group
if row.diverges_from:
evidence["diverges_from"] = row.diverges_from
out.append({
"path": path, "kind": kind, "symbol": name,
"status": row.status if row is not None else "new",
"evidence": evidence,
})
return out
# How many of a canon's rows a review listing shows before "…" — enough to
# judge from, not the whole table.
_REVIEW_ROWS_SHOWN = 12