feat(500): one end-of-turn request - the completion-section check folds into the reply check
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m7s
CI & Build / Python tests (push) Failing after 1m25s
CI & Build / Build & push image (push) Skipped

The Stop hook sent the finished reply twice: scribe_report_check.sh checked a
task-closing reply for the completion sections in shell and reported to
/report-check, and scribe_reply_check.sh sent the same reply to /reply-rules
for the rule hold. Now the reply goes once. When the turn closed a task the
hook adds the close count and ids, and the server runs the section check
(services/report_check, the same three patterns) beside the reply hold,
folding both into one reason.

The report_check adherence log is still written for every checked reply
(milestone 409's number). A section hold marks the session, so its rewrite is
sent back once with rewrite:true to record how it came out, and is never held.
The block reason carries the completion shape's one line and points at
list_reply_shapes rather than at the skill. #5496 (step 4 of milestone 500).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 15:38:17 -04:00
co-authored by Claude Opus 5.5
parent ae66d83213
commit 6071007fe2
15 changed files with 348 additions and 433 deletions
+40 -49
View File
@@ -367,28 +367,58 @@ async def reply_rules():
the reply asks something), and the reply text against every rule's
trigger — the backstop for whatever the earlier arms missed.
Body: `{"reply": "<text>"}`. Query: `repo` / `project_id` for the scope;
ONE END-OF-TURN REQUEST (milestone 500 step 4): a reply that closed tasks
is also checked here for the completion sections (services/report_check),
which used to be a Stop hook and an endpoint of its own. Both holds fold
into one `reason`; `report_check` says what the section check recorded, so
the hook knows the rewrite is one to report.
Body: `{"reply": "<text>", "closed": 1, "closed_task_ids": [41],
"rewrite": false}` — the last three only when the turn closed a task.
`closed` is the count and decides whether the check runs (a task created
already done has no id to list); `rewrite` marks the reply written after a
section hold, which is recorded and never held.
Query: `repo` / `project_id` for the scope;
`held_rule_ids` (opened this session — exempt, as at the act checkpoint);
`exclude_rule_ids` (named this session — not re-counted as surfaced);
`stopped_rule_ids` (already held a reply or an act this session — a rule
holds once, and the per-session cap counts these).
Returns `reason` — the words the hook blocks with, empty when nothing
holds — plus `rule_ids` for the hook's ledger and `moments` reached.
holds — plus `rule_ids` for the hook's ledger, `moments` reached and
`report_check` (the recorded outcome, or "" when nothing was checked).
"""
data = await request.get_json(silent=True) or {}
if not isinstance(data, dict):
data = {}
reply = str(data.get("reply") or "")
# `type(...) is int`, not isinstance: JSON `true` would otherwise read as 1.
closed = data.get("closed") if type(data.get("closed")) is int else 0
ids = data.get("closed_task_ids")
task_ids = [i for i in ids if type(i) is int and i > 0][:20] if isinstance(ids, list) else []
rewrite = data.get("rewrite") is True
project_id, _repo, _unbound = await _project_scope()
held = await moment_delivery_svc.reply_hold(
g.user.id, reply, project_id=project_id,
exclude=frozenset(_int_list(request.args.get("exclude_rule_ids"))),
held=frozenset(_int_list(request.args.get("held_rule_ids"))),
stopped=frozenset(_int_list(request.args.get("stopped_rule_ids"))),
)
checked: dict = {}
if closed > 0:
checked = await report_check_svc.check_reply(
g.user.id, reply, task_ids=task_ids, rewrite=rewrite, project_id=project_id or None,
)
# The rewrite after a hold goes out as written: recorded above, held by nothing.
held: dict = {}
if not rewrite:
held = await moment_delivery_svc.reply_hold(
g.user.id, reply, project_id=project_id,
exclude=frozenset(_int_list(request.args.get("exclude_rule_ids"))),
held=frozenset(_int_list(request.args.get("held_rule_ids"))),
stopped=frozenset(_int_list(request.args.get("stopped_rule_ids"))),
)
reasons = [r for r in (checked.get("reason", ""), held.get("reason", "")) if r]
return jsonify({
"reason": held.get("reason", ""),
"reason": "\n\n".join(reasons),
"rule_ids": held.get("rule_ids", []),
"moments": held.get("moments", []),
"report_check": checked.get("outcome", ""),
})
@@ -504,45 +534,6 @@ def _parse_shapes(raw: str) -> list[tuple[str, str]]:
return out
@plugin_bp.get("/report-check")
@login_required
async def report_check():
"""Record what a Stop hook found in a reply that closed a task (milestone 409 step 5).
The hook decides which completion sections the reply lacks — a local check
of text it can read — and reports the outcome here. For `blocked` the
response carries the `reason` to send the agent back with: the words are
the server's, so every client's hook says the same thing (plugin/PACKAGING.md).
A hook blocks only on a `reason` it received, which means only on a block
that was recorded.
A GET for the reason every plugin endpoint is one: a read-scoped key must
be enough to run the plugin, and this records telemetry the way /retrieve
records a retrieval log.
Query:
outcome (str) — passed | blocked | passed_after_rewrite |
missing_after_rewrite. Anything else is a 400.
missing (opt) — comma-separated sections the reply lacked:
"where it sits", "needs you", "next".
task_ids (opt) — comma-separated ids of the tasks the turn closed.
repo (opt) — working repo remote, resolved like the other arms.
"""
outcome = (request.args.get("outcome") or "").strip()
if outcome not in report_check_svc.OUTCOMES:
return jsonify({"error": f"outcome must be one of {list(report_check_svc.OUTCOMES)}"}), 400
missing = [m for m in (request.args.get("missing") or "").split(",") if m.strip()]
task_ids = _int_list(request.args.get("task_ids"))[:20]
project_id, _repo, _unbound = await _project_scope()
await report_check_svc.record_report_check(
g.user.id, outcome, missing=missing, task_ids=task_ids, project_id=project_id or None,
)
body: dict = {"status": "ok"}
if outcome == "blocked":
body["reason"] = report_check_svc.block_reason(missing)
return jsonify(body)
@plugin_bp.get("/shape-check")
@login_required
async def shape_check():
@@ -557,7 +548,7 @@ async def shape_check():
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
this records telemetry the way /reply-rules records its section check. It changes no ledger
row — the agent's own classify_shapes call does that.
Query: