diff --git a/docs/DOC_TRUST_MAP.md b/docs/DOC_TRUST_MAP.md index 698ea44..1998108 100644 --- a/docs/DOC_TRUST_MAP.md +++ b/docs/DOC_TRUST_MAP.md @@ -42,6 +42,7 @@ disagree, and nothing will say which one is right. | How do I release, back up, or check this? | `scripts/` | | Which script do I run, and can it stop me? | `docs/TOOLS.md` — the signpost; `docs/architecture/README.md` has the table | | What runs before a commit? | `.githooks/` | +| What stops an agent reporting work it did not verify? | `docs/architecture/scripts/verify-before-done.sh`, and `docs/TOOLS.md` for why it is a second layer rather than the layer | | How do I write a check that will actually catch something? | `docs/architecture/GUARDS.md` | **Next action and blockers are recorded at the end of every piece of work, not diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 54f99f1..b0f7d0e 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -51,6 +51,26 @@ The hooks are the other place work gets stopped: [`architecture/githooks/`](architecture/githooks/README.md) has the one install command and the table of what each hook runs. +**And a third place, which stops something the other two cannot: the claim.** +`scripts/verify-before-done.sh` is a Claude Code `TaskCompleted` hook that runs +`verify.sh` and refuses to let a task be reported as finished when it fails. +Every other gate here fires on an **artifact** — a commit, a build, a tag — so +an agent that says "done" without committing trips none of them, which is the +gap `WORK_CYCLE.md` is describing when it says *"Done" is not a close*. + +Two things to know before installing it, both in the script's header at length: + +- **It exits `2` to block, and `1` fails open.** Claude Code reads a `1` from a + hook as "the hook broke" and continues, so a gate written the ordinary way + lets through precisely what it was installed to catch, and looks identical + doing it. This is the exit-code rule above with the numbers swapped, and it is + the one place in this template where that is true. +- **It gates Claude Code and nothing else.** A Codex session, a human, or any + other agent in the same checkout writes past it without knowing it is there. + It is a second layer; `.githooks/pre-commit` is the layer, because git runs + that whoever is driving. If this project's real suite is not wired into that + hook, wiring it there is worth more than installing this. + ## Where to start in a fresh clone 1. `bash docs/architecture/scripts/check-env.sh` — what is configured and what diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 70b7cb7..7e827a9 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -54,6 +54,7 @@ can stop you, and where to start in a fresh clone. | `scripts/scaffold.sh` | lay out a new project in this shape | | `scripts/doc-triggers.py` | which documents a **pending** change fires, read from the `Governs:` headers of everything under `docs/` and of the documents at the repository root, narrowed by the optional `Fires on:` — the kinds of change (added, deleted, moved, changed) a document's trigger actually names, so one governing `docs/**` for existence changes alone does not fire on every edit. The `Review trigger` on each document names the change that should send somebody back to it; this is the check that asks before the commit rather than after | | `scripts/prove-guard.sh` | breaks the thing a guard protects, requires the guard to go red, restores the file from a trap. `GUARDS.md` §1 written out as a command, including the count — one failing test reported on six lines is not six failures | +| `scripts/verify-before-done.sh` | a Claude Code `TaskCompleted` hook: runs `verify.sh` and **blocks the claim of being finished** when it fails. One step earlier than `GUARDS.md` §6 — every other guard here fires on an artifact, and an agent that says "done" without committing produces none. Wired to `verify.sh` rather than the test command because `npm test` on a repo with no tests exits 0, and exit **2** (verified nothing) is treated as a failure, not a pass. Exits 2 to block: a hook exiting 1 fails **open**. Gates Claude Code only — a Codex session in the same checkout writes past it, so `.githooks/pre-commit` remains the gate that catches every writer | | `scripts/commit-mine.sh` | commits only the paths you name, by pathspec, after the secret scan. For a tree something else is also writing: what anyone else has staged is reported and left exactly as it was | | `scripts/doc-claims.sh` | every file a document names must exist, and (`--covers`) every file that exists is named — the second is the one that catches a list missing rows | | `scripts/duplication.py` | code that exists twice, tuned so what it reports is worth reading | diff --git a/docs/architecture/scripts/verify-before-done.sh b/docs/architecture/scripts/verify-before-done.sh new file mode 100755 index 0000000..caeaf7e --- /dev/null +++ b/docs/architecture/scripts/verify-before-done.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# +# Gate the CLAIM of being finished, not the artifact. A Claude Code +# `TaskCompleted` hook. +# +# ## The hole this fills +# +# Every other guard in this template fires on an artifact: `githooks/pre-commit` +# on a commit, `audit-gate.mjs` and `preflight.sh` on a release, `verify.sh` +# when somebody runs it. `GUARDS.md` §6 is the rule they follow -- guards belong +# before the artifact exists. +# +# An agent that says "done" and does not commit produces no artifact, so it +# trips none of them. That is not a hypothetical: `WORK_CYCLE.md` opens by +# saying "Done" is not a close, the tracker convention refuses a `close` under +# fifteen characters of evidence, and both exist because the claim arrives +# without the work behind it often enough to need a rule. A rule is a thing a +# reader can skip. This is the same rule with an exit code. +# +# So: one step earlier than §6. Before the artifact exists, and before the +# sentence claiming it does. +# +# ## Why it calls verify.sh and not the test command +# +# `npm test` in a repo with no tests exits 0. A gate wired to it passes +# vacuously and reports green on a repository that verified nothing, which is +# `GUARDS.md` §4 and §8's entire subject -- the guard that cannot fail is worse +# than the guard nobody wrote, because it is believed. +# +# `verify.sh` already answers the right question. It runs every check the +# project actually has, in one command, and its exit codes are built for this: +# +# 0 everything that ran passed +# 1 something failed, or the run could not start +# 2 NOTHING WAS VERIFIED -- no checks detected, or every step skipped +# +# Two is not a pass and this script does not treat it as one. A repository that +# has not got as far as having checks fails this gate, loudly, with the reason. +# That is the intended answer: wire the gate up when there is something for it +# to run, and until then know that there is not. +# +# ## Install +# +# cp docs/architecture/scripts/verify-before-done.sh scripts/ +# chmod +x scripts/verify-before-done.sh +# +# and in `.claude/settings.json` at the project root: +# +# { +# "hooks": { +# "TaskCompleted": [ +# { "hooks": [ { "type": "command", +# "command": "${CLAUDE_PROJECT_DIR}/scripts/verify-before-done.sh", +# "timeout": 900 } ] } +# ] +# } +# } +# +# **Set `timeout` from this repository's own suite time, not from a number +# copied out of a blog post.** Time `bash scripts/verify.sh` once and give it +# headroom. A hook killed by its own timeout reports as a failure, so a budget +# that is too small turns a passing suite into a blocked task and teaches +# everybody to remove the hook -- `GUARDS.md` §5, a guard that is often wrong is +# worse than none. +# +# ## What it costs, said plainly +# +# It runs the suite every time a task completes, not once at the end of the +# session, so a long session runs it many times. On a repository whose suite +# takes minutes that is real time and real usage. `VERIFY_BEFORE_DONE_ARGS` +# exists for that: set it to `--quick` and the gate runs the checks that are +# cheap and names the test step as skipped rather than pretending it ran. +# +# A skipped step is visible in verify.sh's table. A gate that quietly stopped +# running is not. That is the whole difference. +# +# ## THE LIMITATION, AND IT IS NOT SMALL +# +# This is a Claude Code harness feature. It gates Claude Code and nothing else. +# A Codex session, a human, or any other agent working in the same checkout +# writes past it without noticing it exists -- and at least one repository here +# shares its checkout with Codex. +# +# So this is a second layer and never the layer. The gate that catches every +# writer is `.githooks/pre-commit`, because git runs it whoever is driving. If +# this repository's real suite is not wired into that hook, wiring it there is +# worth more than installing this, and neither one replaces the other. +# +# ## Exit codes -- the hook contract, which is not the usual one +# +# 0 allow the task to complete +# 2 BLOCK it, and feed stderr back to the model as the reason +# +# Two, specifically. A hook exiting 1 is a hook that broke, and Claude Code +# treats it as non-blocking -- so a gate that returns 1 on failure fails OPEN +# and lets exactly the thing it was installed to catch straight through. It +# looks identical from the outside. Prove this one blocks before trusting it: +# break something the suite covers and watch a task refuse to finish. +set -uo pipefail + +cd "${CLAUDE_PROJECT_DIR:-.}" || { + echo "verify-before-done: cannot enter ${CLAUDE_PROJECT_DIR:-.}" >&2 + exit 2 +} + +if [ ! -f scripts/verify.sh ]; then + # Fail closed. "There is no verify.sh" is a fact about the repository worth + # blocking on -- the alternative is a gate that silently allows everything on + # exactly the projects with no other checks. + echo "verify-before-done: scripts/verify.sh not found in $PWD." >&2 + echo "Install it from the template, or remove this hook from .claude/settings.json." >&2 + exit 2 +fi + +# Word-split on purpose: this is a small set of flags, not a path. +# shellcheck disable=SC2086 +out="$(bash scripts/verify.sh ${VERIFY_BEFORE_DONE_ARGS:-} 2>&1)" +code=$? + +if [ "$code" -eq 0 ]; then + exit 0 +fi + +# The tail, because the model reads this and the table verify.sh prints last is +# the part that says which step failed. A full log of a large suite buries it. +{ + if [ "$code" -eq 2 ]; then + echo "verify.sh verified NOTHING (exit 2): no checks were detected, or every" + echo "step skipped. That is not a pass. Do not report this task as finished." + else + echo "verify.sh FAILED (exit $code). Do not report this task as finished." + fi + echo "Fix what it names, or say plainly that it is failing and why." + echo + printf '%s\n' "$out" | tail -n 200 +} >&2 +exit 2