Project-Template/docs/architecture/scripts/verify-before-done.sh

138 lines
5.7 KiB
Bash
Executable File

#!/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