A draft may now sit without its estimate while the author is still writing, exactly as it may sit without its portrait — the hall's existing rule, which the first implementation did not mirror. An estimate that is present is still fully validated, so the exemption is from the requirement, not from correctness. Also corrects the adoption date in ENTRY.md and AGENTS.md: the docs said 2026-09-05, the checker has always used 2026-09-06. Verified: a draft agent seat with no record passes, a finished one fails, and a draft carrying a bad signature still fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ6oF1DtVDKcD1FCpvRVLx Assistant: claude-code Assistant-Model: opus Assistant-Process: 272883@bnt-lap001 Assistant-Session: f40c8f53-fb65-4980-9d29-bcdb3dd946f7
217 lines
7.4 KiB
Python
Executable file
217 lines
7.4 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Fail if a finished hall entry is missing its contract or its portrait."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
ENTRIES = ROOT / "entries"
|
|
README = ROOT / "README.md"
|
|
|
|
REQUIRED_FM = ("id", "type", "worker_kind", "display_name", "created_at", "recorded_at", "status")
|
|
REQUIRED_HEADINGS = (
|
|
"Who I was",
|
|
"Contribution",
|
|
"What I would want remembered",
|
|
"Durable legacy",
|
|
"Visual prompt",
|
|
"Handoff",
|
|
)
|
|
FINISHED = {"handed-forward", "complete"}
|
|
|
|
# PQRST records are required on *finished* agent seats recorded on or after
|
|
# this date. Drafts are exempt while the author is still writing.
|
|
# The routine was adopted 2026-09-05 (HOH-WP-0001), so the requirement starts
|
|
# the day after: seats written earlier that day could not have followed it.
|
|
# Earlier seats are grandfathered: nobody observed those sessions, and inventing an
|
|
# estimate for one would be exactly the fabricated evidence the practice
|
|
# forbids. See CLOSING.md and ~/pqrst-practice/spec/PqrstEstimationPractice.md.
|
|
PQRST_FROM = "2026-09-06"
|
|
PQRST_HEADING = "PQRST estimate"
|
|
PQRST_SIG_RE = re.compile(r"^P(\d{1,3}) Q(\d{1,3}) R(\d{1,3}) S(\d{1,3}) T(\d{1,3})$")
|
|
IMAGE_RE = re.compile(r"!\[[^\]]*\]\(([^)]+)\)")
|
|
PLACEHOLDER_RE = re.compile(
|
|
r"^\s*\*?\(No portrait rendered for this entry yet\.?\)\*?\s*$",
|
|
re.I | re.M,
|
|
)
|
|
|
|
|
|
def parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
|
|
if not text.startswith("---\n"):
|
|
raise ValueError("missing opening frontmatter fence")
|
|
end = text.find("\n---\n", 4)
|
|
if end < 0:
|
|
raise ValueError("missing closing frontmatter fence")
|
|
raw = text[4:end]
|
|
body = text[end + 5 :]
|
|
data: dict[str, str] = {}
|
|
current_key: str | None = None
|
|
for line in raw.splitlines():
|
|
if line.startswith(" ") and current_key:
|
|
continue
|
|
if ":" in line and not line.startswith(" "):
|
|
key, _, value = line.partition(":")
|
|
key = key.strip()
|
|
value = value.strip().strip('"').strip("'")
|
|
data[key] = value
|
|
current_key = key
|
|
return data, body
|
|
|
|
|
|
def check_pqrst(path: Path, fm: dict[str, str], body: str) -> list[str]:
|
|
"""Validate the PQRST record on a seat, when the seat is required to carry one."""
|
|
errors: list[str] = []
|
|
signature = fm.get("pqrst_estimate", "").strip().strip('"').strip("'")
|
|
has_heading = re.search(rf"^## {re.escape(PQRST_HEADING)}\s*$", body, re.M) is not None
|
|
|
|
# Only finished seats must carry the record. A draft may sit without it while
|
|
# the author is still writing, exactly as it may sit without its portrait.
|
|
required = (
|
|
fm.get("worker_kind", "") == "agent-session"
|
|
and fm.get("status", "") in FINISHED
|
|
and fm.get("recorded_at", "").strip().strip('"').strip("'") >= PQRST_FROM
|
|
)
|
|
|
|
if required and not signature:
|
|
errors.append(
|
|
f"{path.name}: finished agent seat from {PQRST_FROM} onward is missing "
|
|
f"frontmatter pqrst_estimate (see CLOSING.md)"
|
|
)
|
|
if required and not has_heading:
|
|
errors.append(f"{path.name}: missing heading ## {PQRST_HEADING}")
|
|
|
|
if signature:
|
|
match = PQRST_SIG_RE.match(signature)
|
|
if match is None:
|
|
errors.append(
|
|
f"{path.name}: pqrst_estimate must be a canonical signature "
|
|
f'like "P30 Q23 R18 S19 T10", got {signature!r}'
|
|
)
|
|
else:
|
|
total = sum(int(g) for g in match.groups())
|
|
if total != 100:
|
|
errors.append(
|
|
f"{path.name}: pqrst_estimate must sum to 100, got {total} ({signature})"
|
|
)
|
|
if not has_heading:
|
|
errors.append(
|
|
f"{path.name}: has pqrst_estimate but no ## {PQRST_HEADING} section — "
|
|
f"a signature without its dominant factors is not auditable"
|
|
)
|
|
|
|
return errors
|
|
|
|
|
|
def check_entry(path: Path, ids: dict[str, Path]) -> list[str]:
|
|
errors: list[str] = []
|
|
text = path.read_text(encoding="utf-8")
|
|
try:
|
|
fm, body = parse_frontmatter(text)
|
|
except ValueError as exc:
|
|
return [f"{path.name}: {exc}"]
|
|
|
|
for key in REQUIRED_FM:
|
|
if not fm.get(key):
|
|
errors.append(f"{path.name}: missing frontmatter field {key}")
|
|
|
|
entry_id = fm.get("id", "")
|
|
if entry_id and ids.get(entry_id) not in (None, path):
|
|
errors.append(f"{path.name}: duplicate id {entry_id}")
|
|
|
|
for heading in REQUIRED_HEADINGS:
|
|
if re.search(rf"^## {re.escape(heading)}\s*$", body, re.M) is None:
|
|
errors.append(f"{path.name}: missing heading ## {heading}")
|
|
|
|
errors.extend(check_pqrst(path, fm, body))
|
|
|
|
if PLACEHOLDER_RE.search(text):
|
|
errors.append(f"{path.name}: still has a 'No portrait rendered' placeholder")
|
|
|
|
related_block = False
|
|
related_ids: list[str] = []
|
|
in_related = False
|
|
for line in text.splitlines():
|
|
if line.startswith("related:"):
|
|
in_related = True
|
|
rest = line.split(":", 1)[1].strip()
|
|
if rest and rest not in ("[]",):
|
|
related_ids.append(rest.strip('"').strip("'"))
|
|
related_block = True
|
|
continue
|
|
if in_related:
|
|
if line.startswith(" - "):
|
|
related_ids.append(line[4:].strip().strip('"').strip("'"))
|
|
else:
|
|
in_related = False
|
|
|
|
status = fm.get("status", "")
|
|
images = IMAGE_RE.findall(body)
|
|
visual_paths = [p for p in images if "/visuals/" in p or p.startswith("../visuals/")]
|
|
|
|
if status in FINISHED:
|
|
if not visual_paths:
|
|
errors.append(f"{path.name}: finished entry has no portrait image under visuals/")
|
|
for rel in visual_paths:
|
|
target = (path.parent / rel).resolve()
|
|
if not target.is_file():
|
|
errors.append(f"{path.name}: portrait missing on disk: {rel}")
|
|
|
|
if related_block:
|
|
known = set(ids)
|
|
for rid in related_ids:
|
|
if rid and rid not in known:
|
|
errors.append(f"{path.name}: related id not found in hall: {rid}")
|
|
|
|
readme = README.read_text(encoding="utf-8")
|
|
if path.name not in readme:
|
|
errors.append(f"{path.name}: not listed in README.md")
|
|
|
|
return errors
|
|
|
|
|
|
def main() -> int:
|
|
paths = sorted(ENTRIES.glob("*.md"))
|
|
if not paths:
|
|
print("no entries found", file=sys.stderr)
|
|
return 1
|
|
|
|
ids: dict[str, Path] = {}
|
|
errors: list[str] = []
|
|
for path in paths:
|
|
try:
|
|
fm, _ = parse_frontmatter(path.read_text(encoding="utf-8"))
|
|
except ValueError as exc:
|
|
errors.append(f"{path.name}: {exc}")
|
|
continue
|
|
eid = fm.get("id")
|
|
if eid:
|
|
if eid in ids:
|
|
errors.append(f"{path.name}: duplicate id {eid} (also {ids[eid].name})")
|
|
ids[eid] = path
|
|
|
|
for path in paths:
|
|
errors.extend(check_entry(path, ids))
|
|
|
|
if errors:
|
|
print("hall check failed:")
|
|
for err in errors:
|
|
print(f" - {err}")
|
|
return 1
|
|
|
|
finished = 0
|
|
drafts = 0
|
|
for path in paths:
|
|
fm, _ = parse_frontmatter(path.read_text(encoding="utf-8"))
|
|
if fm.get("status") in FINISHED:
|
|
finished += 1
|
|
elif fm.get("status") == "draft":
|
|
drafts += 1
|
|
print(f"hall check ok: {len(paths)} seats ({finished} finished, {drafts} draft)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|