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
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>
130 lines
5.3 KiB
Python
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 ""}
|