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
+66
View File
@@ -15,6 +15,7 @@ from scribe.config import Config
from scribe.services import plugin_context as plugin_ctx_svc
from scribe.services import repo_bindings as repo_bindings_svc
from scribe.services import report_check as report_check_svc
from scribe.services import shape_check as shape_check_svc
from scribe.services import task_claims as task_claims_svc
from scribe.services.settings import get_admin_setting, set_setting
@@ -359,6 +360,71 @@ async def report_check():
return jsonify(body)
@plugin_bp.get("/shape-check")
@login_required
async def shape_check():
"""The end-of-turn question (milestone 439): what did this turn write that
nobody has judged?
The client's write hooks keep a ledger of the definitions each turn wrote;
its Stop hook sends them here. The server decides which carry no judgment
— against the ledger and the project's recorded snippets — records the
outcome, and for a block returns the `reason` the agent is sent back with.
A hook blocks only on a reason it received, so only on a block that was
recorded, and never on the stop that follows one (`phase=after`).
A GET like every plugin endpoint: a read-scoped key runs the plugin, and
this records telemetry the way /report-check does. It changes no ledger
row — the agent's own classify_shapes call does that.
Query:
written (str) — newline-separated `path<TAB>kind<TAB>name` lines,
repo-relative, kind css|sym|file.
phase (str) — check | after. Anything else is a 400.
repo (opt) — working repo remote, resolved like the other arms.
project_id (opt) — explicit scope override.
"""
phase = (request.args.get("phase") or "check").strip()
if phase not in shape_check_svc.PHASES:
return jsonify({"error": f"phase must be one of {list(shape_check_svc.PHASES)}"}), 400
written = shape_check_svc.parse_written(request.args.get("written") or "")
project_id, repo, _unbound = await _project_scope()
body: dict = {"status": "ok", "unjudged": []}
if not project_id or not written:
return jsonify(body)
from scribe.services import access
from scribe.services import coverage as coverage_svc
from scribe.services import shape_ledger as shape_ledger_svc
if not await access.can_read_project(g.user.id, project_id):
return jsonify(body)
repo_key = repo_bindings_svc.normalize_repo_key(repo) if repo else ""
unjudged = await shape_ledger_svc.unjudged_shapes(project_id, written, repo_key=repo_key)
# A snippet recorded AT a shape is the strongest verdict there is — the
# next refresh stamps that row canonical. Until then the ledger cannot
# know, so the recorded locations answer for it: an agent who just
# recorded what it built is not asked again.
if unjudged:
recorded = {
(path, symbol)
for _sid, path, symbol in await coverage_svc._recorded_locations(g.user.id, project_id)
}
unjudged = [u for u in unjudged if (u["path"], u["symbol"]) not in recorded
and not (u["kind"] == "file" and (u["path"], "") in recorded)]
outcome = shape_check_svc.outcome_for(phase, unjudged)
await shape_check_svc.record_shape_check(
g.user.id, outcome, written=len(written), unjudged=len(unjudged),
project_id=project_id,
)
body["outcome"] = outcome
body["unjudged"] = unjudged
if outcome == "blocked":
body["reason"] = shape_check_svc.block_reason(
unjudged, project_id=project_id, repo=repo_key or repo,
)
return jsonify(body)
@plugin_bp.get("/claim-session")
@login_required
async def claim_session():