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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user