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
+78
View File
@@ -1138,3 +1138,81 @@ async def test_history_records_what_was_used_when_and_drift_asks_for_a_recheck(s
# reads nothing.
assert len((await shape_history(owner, pid, "src"))["events"]) == 6
assert await shape_history(other, pid, "src/app.py") == {}
# --- milestone 439: the writer judges what it wrote, before the sync has seen it
@pytest.mark.integration
async def test_a_shape_written_this_turn_is_judged_before_the_sync_sees_it(seeded):
"""The end-of-turn verdict lands on a shape no sync has read yet: with a
bound `repo` it becomes a provisional row and is judged, the sync that
then reads the shape keeps the judgment, and a sync that does NOT have it
yet (the code has not reached the bound ref) keeps it through the vanish
and the reappearance. Without `repo` the old contract holds."""
from scribe.services import repo_bindings as repo_bindings_svc
owner, pid = seeded["owner"], seeded["pid"]
await repo_bindings_svc.set_binding(owner, REPO, pid)
verdict = [{"path": "web/src/lib/StatusChip.svelte", "symbol": "tone",
"kind": "sym", "status": "exempt",
"reason": "the chip's own colour switch", "reason_code": "pure-helper"}]
out = await classify_shapes(owner, pid, verdict)
assert out["unmatched"] == [{"path": "web/src/lib/StatusChip.svelte", "symbol": "tone"}]
with pytest.raises(ValueError, match="not bound"):
await classify_shapes(owner, pid, verdict, repo="git.example.com/alice/elsewhere")
out = await classify_shapes(owner, pid, verdict, repo=REPO)
assert out == {"classified": 1, "unmatched": [], "provisional": 1}
# A sync that has not got the code yet: the row vanishes, judgment intact…
await sync_repo_shapes(pid, REPO, SHAPES, seen_marker="c1")
# …and the sync that has it brings the row back with the verdict.
await sync_repo_shapes(
pid, REPO, SHAPES + [("web/src/lib/StatusChip.svelte", "sym", "tone")], seen_marker="c2",
)
rows, _ = await list_project_shapes(owner, pid, path="web/src/lib/StatusChip.svelte")
assert len(rows) == 1
row = rows[0]
assert (row.status, row.classified_by, row.reason_code) == ("exempt", "agent", "pure-helper")
assert row.vanished_at is None and row.last_seen_commit == "c2"
@pytest.mark.integration
async def test_unjudged_is_what_nobody_read(seeded):
"""The end-of-turn question: a shape with no row, an unclassified one, a
sync-scoped one and a hook stamp are all asked about; an agent's or an
audit's verdict is not. Evidence travels; a verdict is never inferred."""
from sqlalchemy import update
from scribe.services.shape_ledger import _RESEMBLE_REASON, unjudged_shapes
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
await classify_shapes(owner, pid, [
{"path": "src/app.py", "symbol": "make_app", "status": "instance", "snippet_id": sid},
{"path": "src/util.py", "symbol": "helper", "status": "exempt", "reason": "x"},
])
async with async_session() as session:
await session.execute(
update(CodeShape)
.where(CodeShape.project_id == pid, CodeShape.symbol == "helper")
.values(classified_by="hook", status="instance", snippet_id=sid,
reason=_RESEMBLE_REASON.format(sid=sid, score=0.7))
)
await session.commit()
written = [
("src/app.py", "sym", "make_app"), # judged by an agent → not asked
("src/app.py", "sym", "Config"), # unclassified → asked
("src/util.py", "sym", "helper"), # a hook stamp → asked, with its evidence
("src/new.py", "sym", "fresh"), # no row → asked as new
("src/new.py", "sym", "fresh"), # duplicates collapse
]
got = await unjudged_shapes(pid, written, repo_key=REPO)
assert [(g["symbol"], g["status"]) for g in got] == [
("Config", "unclassified"), ("helper", "instance"), ("fresh", "new"),
]
assert got[1]["evidence"]["stamped"] == {"snippet_id": sid, "score": 0.7}
assert got[2]["evidence"] == {}
assert await unjudged_shapes(pid, []) == []