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>
133 lines
5.6 KiB
Python
133 lines
5.6 KiB
Python
"""The report-shape check: does a reply that closed a task carry the completion
|
|
sections, and what is it sent back with when it does not.
|
|
|
|
WHY THIS EXISTS (milestone 409 step 5)
|
|
|
|
Everything that helps an agent write a readable completion report arrives
|
|
BEFORE the reply is written. The Stop hook is the one moment the finished reply
|
|
exists, so a reply that closed a task is checked for the completion sections
|
|
(where the work sits, what needs the operator, what comes next). Two jobs:
|
|
|
|
- RECORDING the outcome, so the rate of `blocked` among checked replies is a
|
|
number rather than an impression (`category=plugin, action=report_check`).
|
|
- OWNING THE WORDS the agent is sent back with, so every client's hook says
|
|
the same thing (plugin/PACKAGING.md: hooks carry timing and transport).
|
|
|
|
ONE END-OF-TURN REQUEST (milestone 500 step 4). This used to be a Stop hook of
|
|
its own that checked the sections in shell and reported here; the reply-moment
|
|
check sent the same reply to `/reply-rules` a moment later. The hook now sends
|
|
the reply once, with the ids of the tasks the turn closed, and the section
|
|
check runs here beside the reply hold. The patterns are the ones the shell
|
|
used, matched on the words that carry the meaning rather than exact headings,
|
|
so a shape's wording can change without breaking this.
|
|
|
|
app_logs rather than a table of its own: one small event with a JSON detail is
|
|
what that table holds, it already has retention and an admin viewer, and
|
|
nothing here needs a join.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
|
|
from scribe.models import async_session
|
|
from scribe.models.app_log import AppLog
|
|
from scribe.services import reply_shapes
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
OUTCOMES = ("passed", "blocked", "passed_after_rewrite", "missing_after_rewrite")
|
|
|
|
# The sections, in the order the reason lists them, each with what counts as
|
|
# having it. A bare id ("closed #41") does not place the work: naming a record
|
|
# by id AND title, or a step position, is the shape — the title is what spares
|
|
# the reader a lookup. "Needs you: nothing" counts; it is an answer.
|
|
SECTION_PATTERNS: dict[str, re.Pattern] = {
|
|
"where it sits": re.compile(
|
|
r'(#[0-9]+|milestone [0-9]+|task [0-9]+)[*_`]*\s*[*_`]*["“]|step [0-9]+ of [0-9]+', re.I),
|
|
"needs you": re.compile(
|
|
r"needs? (from )?you|nothing (is )?needed from you|your (call|decision)", re.I),
|
|
"next": re.compile(r"\bnext\b", re.I),
|
|
}
|
|
SECTIONS = tuple(SECTION_PATTERNS)
|
|
|
|
|
|
def missing_sections(reply: str) -> list[str]:
|
|
return [name for name, pat in SECTION_PATTERNS.items() if not pat.search(reply or "")]
|
|
|
|
|
|
def known_sections(missing: list[str]) -> list[str]:
|
|
wanted = {m.strip().lower() for m in missing}
|
|
return [s for s in SECTIONS if s in wanted]
|
|
|
|
|
|
def block_reason(missing: list[str]) -> str:
|
|
"""What the agent is told when its completion report is sent back.
|
|
|
|
Names what is missing and carries the completion shape's one line rather
|
|
than the whole shape: the shape itself was delivered when the task closed,
|
|
and `list_reply_shapes` has it in full.
|
|
"""
|
|
listed = ", ".join(known_sections(missing)) or "the completion sections"
|
|
shape = reply_shapes.SHAPES["completion"]
|
|
return (
|
|
f"This turn closed a Scribe task, and the reply that ends it is missing: {listed}. "
|
|
"The person the work is for reads this reply to find out where it stands. Rewrite it "
|
|
f"as a completion report — {shape.reminder} Take where it sits from `placement`, the "
|
|
"task or plan by id and title (`list_reply_shapes` has the shape in full)."
|
|
)
|
|
|
|
|
|
async def record_report_check(
|
|
user_id: int | None,
|
|
outcome: str,
|
|
*,
|
|
missing: list[str] | None = None,
|
|
task_ids: list[int] | None = None,
|
|
project_id: int | None = None,
|
|
) -> None:
|
|
if outcome not in OUTCOMES:
|
|
raise ValueError(f"unknown report-check outcome {outcome!r}")
|
|
details: dict = {"outcome": outcome, "missing": known_sections(missing or []),
|
|
"task_ids": list(task_ids or [])}
|
|
if project_id:
|
|
details["project_id"] = project_id
|
|
async with async_session() as session:
|
|
session.add(AppLog(
|
|
category="plugin",
|
|
user_id=user_id,
|
|
action="report_check",
|
|
details=json.dumps(details),
|
|
))
|
|
await session.commit()
|
|
|
|
|
|
async def check_reply(
|
|
user_id: int, reply: str, *, task_ids: list[int], rewrite: bool,
|
|
project_id: int | None = None,
|
|
) -> dict:
|
|
"""Check a reply that closed tasks, record the outcome, and say whether it is held.
|
|
|
|
`rewrite` is the reply written after an earlier hold: it is recorded and
|
|
never held again, so a hold costs one turn at most.
|
|
|
|
Returns {"outcome", "reason"} — `reason` empty unless the reply is held.
|
|
A hold happens only when its record was written: an outcome the numbers
|
|
cannot see is not one this check may act on, so a recording failure
|
|
degrades to no hold rather than to an unmeasured one.
|
|
"""
|
|
missing = missing_sections(reply)
|
|
if rewrite:
|
|
outcome = "missing_after_rewrite" if missing else "passed_after_rewrite"
|
|
else:
|
|
outcome = "blocked" if missing else "passed"
|
|
try:
|
|
await record_report_check(user_id, outcome, missing=missing, task_ids=task_ids,
|
|
project_id=project_id)
|
|
except Exception: # noqa: BLE001 - an unrecorded check never holds a reply
|
|
logger.warning("report check not recorded", exc_info=True)
|
|
return {"outcome": "", "reason": ""}
|
|
return {"outcome": outcome,
|
|
"reason": block_reason(missing) if outcome == "blocked" else ""}
|