"""The report-shape check: what the plugin's Stop hook found, and what it says. WHY THIS EXISTS (milestone 409 step 5) Everything that helps an agent write a readable completion report arrives BEFORE the reply is written. A client's Stop hook is the one moment the finished reply exists, so it checks that a reply closing a task carries the completion sections (where the work sits, what needs the operator, what comes next), and reports what it found here. Two jobs live on this side: - RECORDING the outcome, so the rate of `blocked` among checked replies is a number milestone 409's last step can read rather than an impression. - OWNING THE WORDS the agent is sent back with. A hook carries timing and transport only (plugin/PACKAGING.md); guidance text comes from the server, so a second client's hook gets the same instruction by calling the same endpoint, and the wording changes in one place. 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. If the numbers earn a readout, that is the moment to decide whether they earn a table. """ from __future__ import annotations import json from scribe.models import async_session from scribe.models.app_log import AppLog OUTCOMES = ("passed", "blocked", "passed_after_rewrite", "missing_after_rewrite") # The sections a hook may name as missing, in the order the reason lists them. # Anything else a client sends is dropped rather than echoed into an # instruction the agent will follow. SECTIONS = ("where it sits", "needs you", "next") 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 points at the reporting-back skill for the shape rather than restating it — the skill owns the shape (decision #4027). """ listed = ", ".join(known_sections(missing)) or "the completion sections" return ( f"This turn closed a Scribe task, and the reply that ends it is missing: {listed}. " "The operator reads this reply to find out where the work stands. Rewrite it as a " "completion report (the reporting-back skill has the shape): where it sits — the task " "or milestone by id and title, from `placement` — what now works, what needs them " "(or \"nothing\"), and what comes next." ) 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()