"""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 ""}