Files
FabledGrimoire/src/scribe/services/report_check.py
T
bvandeusenandClaude Opus 5.5 fa4f88747e
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m46s
CI & Build / Python tests (push) Successful in 2m36s
CI & Build / Build & push image (push) Successful in 31s
docs(6205): the remaining services files state the reason, not the instance that taught it
The 1-2 hit tail: access, background, backup, backup_job, canonical_systems,
design_stylesheet, forge_connections, moment_actions, notes, notifications,
placement, planning, platforms, projects, record_batch, recurrence,
report_check, retrieval_review, rule_scope, rule_versions, scheduler,
shape_check, supersession, system_rulings, system_usage, text, trash,
verification, version_pinning.

Also corrected while reading:
- backup.py said restore step 16 fills the System map; step 15 does.
- version_pinning.py pointed at a design doc that is not in the repo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:34:24 -04:00

130 lines
5.3 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
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. The Stop hook 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 match 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 ""}