HookPostToolUse
plan-card
PostToolUse validator: enforce the Review Card contract on plan files.
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()