hall-of-helix/scripts/check-entries.py
tegwick 5b7d9d90b9 Require the PQRST record only on finished seats
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
2026-09-05 22:33:04 +02:00

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())