Model Citizen by Jake Selby v0.13.1
GitHub
HookPostToolUse

plan-card

PostToolUse validator: enforce the Review Card contract on plan files.

Event
PostToolUse
Runs
when plan-ceremony = review-card
Dispatcher
adapters/claude-code/hook.py
Script
policy/hooks/validate-plan-card.py

What it does

Contract lives in the plan-authoring skill (~/.claude/skills/plan-authoring/SKILL.md). Advisory only — this never fails a write; it feeds a correction back to the agent.

When it runs

The policy answers on PostToolUse. It runs only when the plan-ceremony stance is review-card. It has no settings entry of its own: harness sync registers one dispatcher command per event, adapters/claude-code/hook.py, marked harness:runtime-posttooluse in your settings.json. That dispatcher routes each call to the policies its event and tool need and composes their answers; plan-card is the policy's id in OWNERSHIP.json.

The script

policy/hooks/validate-plan-card.pypython
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""PostToolUse validator: enforce the Review Card contract on plan files.

Contract lives in the plan-authoring skill (~/.claude/skills/plan-authoring/SKILL.md).
Advisory only — this never fails a write; it feeds a correction back to the agent.
"""
import json
import os
import sys
from pathlib import Path

CARD_CAP = 85
REQUIRED = ["## At a glance", "## Steps", "## Decisions for the reviewer"]
FORBIDDEN = ["## Context"]
SKILL_DIR = Path.home() / ".claude" / "skills" / "plan-authoring"


def emit(problems, path):
    body = "\n".join("- " + p for p in problems)
    msg = (
        f"The plan file {os.path.basename(path)} does not meet the Review Card contract:\n"
        f"{body}\n\n"
        "Fix the file before showing it to the reviewer. The Review Card is everything above "
        "the first `---`: title, two-sentence verdict blockquote, `## At a glance` bullets, "
        "`## System design` diagram, `## Steps` with exit tests, "
        "`## Decisions for the reviewer`, `## Risks`. Background and detail belong below the "
        "rule under `# Addendum`. Full contract and template: "
        f"{SKILL_DIR / 'SKILL.md'} and {SKILL_DIR / 'TEMPLATE.md'}."
    )
    print(json.dumps({
        "systemMessage": f"Plan card check failed ({len(problems)}): {os.path.basename(path)}",
        "hookSpecificOutput": {
            "hookEventName": "PostToolUse",
            "additionalContext": msg,
        },
    }))


def main():
    try:
        payload = json.load(sys.stdin)
    except Exception:
        return
    ti = payload.get("tool_input") or {}
    tr = payload.get("tool_response") or {}
    path = tr.get("filePath") or ti.get("file_path") or ""
    if not path.endswith(".md"):
        return
    norm = path.replace(os.sep, "/")
    if "/.claude/plans/" not in norm and "/.agent-harness/plans/" not in norm:
        return
    try:
        with open(path, encoding="utf-8", errors="replace") as fh:
            lines = fh.read().splitlines()
    except OSError:
        return
    if not lines:
        return

    start = 0
    if lines[0].strip() == "---":  # tolerate YAML frontmatter
        for i in range(1, len(lines)):
            if lines[i].strip() == "---":
                start = i + 1
                break

    end = None
    for i in range(start, len(lines)):
        if lines[i].strip() == "---":
            end = i
            break

    problems = []
    if end is None:
        problems.append(
            f"No `---` separator, so the whole {len(lines)}-line file is the card. "
            "Put the detail below a `---` under `# Addendum`."
        )
        card = lines[start:]
    else:
        card = lines[start:end]
        if len(card) > CARD_CAP:
            problems.append(
                f"Card is {len(card)} lines, cap is {CARD_CAP} "
                "(70, plus up to five 3-line decisions). Move detail into the addendum."
            )

    fence = False
    table_lines = []
    for idx, raw in enumerate(lines[start:], start=start + 1):
        if raw.lstrip().startswith("```"):
            fence = not fence
        elif not fence and raw.lstrip().startswith("|"):
            table_lines.append(idx)
    if table_lines:
        where = ", ".join(str(n) for n in table_lines[:6])
        more = "" if len(table_lines) <= 6 else f" (+{len(table_lines) - 6} more)"
        problems.append(
            f"{len(table_lines)} markdown table lines at {where}{more}. A plan carries no "
            "tables, above or below the rule — one bullet per row instead."
        )

    text = "\n".join(card)
    for want in REQUIRED:
        if want not in text:
            problems.append(f"Card is missing `{want}`.")
    for bad in FORBIDDEN:
        if bad in text:
            problems.append(
                f"Card contains `{bad}`. Background goes in the addendum — the "
                "two-sentence verdict carries the why."
            )

    if problems:
        emit(problems, path)


if __name__ == "__main__":
    main()
↑↓ move↵ openPagefind · indexed at build