Every 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 and trips
none of them. That gap already has two rules pointing at it -- `WORK_CYCLE.md`
opens with *"Done" is not a close*, and the tracker convention refuses a close
under fifteen characters of evidence -- and a rule is a thing a reader can skip.
This is the same rule with an exit code.
`scripts/verify-before-done.sh` is a Claude Code `TaskCompleted` hook. Verified
against Claude Code 2.1.251: the event exists and the binary carries the string
`TaskCompleted hook prevented continuation`.
WIRED TO `verify.sh`, NOT TO THE TEST COMMAND. `npm test` in a repo with no
tests exits 0, so a gate built on it reports green having verified nothing --
`GUARDS.md` §4 and §8's whole subject. `verify.sh` already distinguishes the
three answers, and this treats exit 2 (nothing was verified) as a failure with
its own message rather than a pass. A repository with no checks yet fails this
gate loudly, which is the intended answer.
IT EXITS 2 TO BLOCK, AND THAT IS NOT THE USUAL CONVENTION. Claude Code reads a
`1` from a hook as "the hook broke" and continues, so a gate written the
ordinary way fails OPEN and lets through exactly what it was installed to catch,
looking identical from the outside. `docs/TOOLS.md` now says this beside the
exit-2-is-never-a-pass rule it inverts.
THE LIMITATION IS IN THE HEADER IN CAPITALS, because it decides whether this is
worth installing: it gates Claude Code and nothing else. A Codex session or a
human in the same checkout writes past it. It is a second layer and never the
layer -- `.githooks/pre-commit` is what git runs whoever is driving, and if a
project's real suite is not wired into that hook, wiring it there is worth more
than installing this.
Proved rather than assumed, all four outcomes, per §1: no `verify.sh` -> 2 (fails
closed); pass -> 0 with empty stderr; fail -> 2 naming the code; verified-nothing
-> 2 with the distinct message.
Docs in the same commit, per the triggers `doc-triggers.py` reported:
`architecture/README.md` gains the row its own "Adding one" rule requires,
`TOOLS.md` gains the paragraph under "Which ones can stop you", and
`DOC_TRUST_MAP.md` gains the question it now answers. `doc-claims.sh` re-run:
51 claimed paths across the three, all present.
Prompted by an XDA piece on Claude Code shipping unverified work. The idea is
theirs; the exit-2 contract, the `verify.sh` wiring and the Codex caveat are
what it needed to be true here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collection started at DOCS.rglob("*.md"), so nothing at the root was read.
README.md, project-readme-template.md and both START-HERE documents carry a full
status header, and Governs lines nothing ever looked at. Changing a file they
govern fired nothing, and the run said "No document's Governs matched these
paths" -- true of the tool, false of the repository.
Third instance of one shape in two days, each a level further out. Documents
whose Governs carried a gloss were handed a glob no file could satisfy; before
that a root resolved by depth pointed the whole tool outside the repository; here
four documents were never collected at all. Every one of them printed something
reassuring while checking less than it claimed.
Root documents are now collected alongside the tree. The root walk is glob, not
rglob -- deliberately one level deep, so a vendored copy of this template, a
scratch checkout or somebody's directory of notes cannot enrol its documents as
governing the project that holds it. Verified: a vendor/Template/README.md
declaring Governs: src/** is not consulted.
## Status is what separates a document from a template for one
project-readme-template.md carries `Status: <Current | Draft | Superseded |
Archived>` and a Governs describing the README of whichever project copies it.
Collecting the root without a guard would trade a document that never fires for a
template that always does, which is the pair of failures this script has spent
two days on.
DOC_TRUST_MAP.md already makes the status vocabulary a rule with a checker behind
it, so that is the test: a document whose Status is not one of the four words is
not treated as governing anything here. It is **named, not dropped** -- when such
a document governs a path in the change it is listed with its status, because a
silent exclusion is the failure being fixed, not a smaller version of it. A
document that governs a subject rather than paths can never fire mechanically, so
an unfilled one is left out of the judge-these-yourself list entirely rather than
sitting in it permanently.
Both branches were exercised: a root template governing src/** is named and not
fired; the same file with Status: Current fires normally.
The no-match message now covers this case too, rather than claiming nothing
matched when something did and was set aside for a stated reason.
## Documents
architecture/README.md's row says where doc-triggers reads from, which has
changed. DOC_TRUST_MAP.md owns the status header: it now says root documents
carry one and are read, that the root is one level deep and why, and what the
four status words separate.
Verified from a clean clone: touching START-HERE-New-Project.md fires README.md;
docs/ behaviour is unchanged across a modified script, a modified document, a
modified githook, a branding asset and a staged deletion; the scripts/ copy still
resolves its own root. doc-claims reads 116 claimed paths across 23 files, all
present.
closes#22
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DOC_TRUST_MAP.md declares `Governs: docs/**`, the broadest glob in the tree,
while its Review trigger is one of the narrowest -- any doc added, deleted or
moved. Matching on the glob alone fired it on every edit to every document,
forever, and correctly by the only rule the tool had. Touching one script fired
three documents and exactly one of them applied.
A prompt that always fires is one people stop reading, and it takes the true
positives with it. This tool exits 0 by design -- it is a prompt, not a gate --
which makes it more vulnerable to that, not less, because nothing forces the
reading.
Documents now declare the kinds of change their trigger names, in an optional
`Fires on:` header field, read against git's own status letter. Absent, empty or
unparseable means every kind, so nothing changes for the six other
path-governing documents and a document is only ever quietened by somebody
writing the line deliberately.
## Why declared rather than read out of the trigger prose
The obvious first cut is to look for added/deleted/moved with no changed/change
to. Tried against the seven path-governing documents here, it misclassifies the
one it exists to fix: DOC_TRUST_MAP.md's trigger ends "any change to which doc
owns a subject", so it reads as a change-verb. That clause is about which
document owns a subject, not about a file being edited, and nothing lexical
separates it from architecture/README.md's "any change to a module boundary or a
data shape", which genuinely does mean modification.
Guessing at English is silent in the expensive direction: a document wrongly
read as existence-only stops being prompted for and goes quietly stale, which is
the failure this whole tool exists to prevent. So the narrowing is declared or it
does not happen.
## Also
changed_paths now carries a status letter per path, from --name-status for
--staged and --range and from the porcelain columns for the working tree. Paths
named on the command line have no diff to read, so the kind is inferred: absent
from disk is a deletion, present but untracked is an addition, otherwise a
modification.
Documents that govern a path in the change but do not fire on its kind are named
in their own short block rather than dropped, because a reader who saw nothing
would have to guess whether they had been considered. The no-match message now
distinguishes "nothing governs these paths" from "governed, but not this kind of
change" -- the second is a declaration somebody wrote, not an unclaimed area.
Verified: modifying a script fires TOOLS.md and architecture/README.md and not
DOC_TRUST_MAP.md; adding, deleting and moving a document under docs/ each still
fire it; modifying a document fires nothing; an unknown word warns and fires on
everything; an empty or absent field fires on everything.
closes#20
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four things auditors of applications of this kind report seeing over and over,
each of them mechanical: a header that is absent, a scheme that is plain, a
login that answers a thousand guesses, a reset form that confirms which
addresses have accounts. None needs understanding to be checked, which is why
they belong in a script rather than a page somebody re-reads before a release
and then does not.
*(precautionary)* -- none of it has bitten a project here. The checks are cheap
and the evidence is somebody else's.
**Passive by default.** A bare run sends two GETs and could not be mistaken for
anything. Rate limiting and enumeration are behind --auth, because one of them
deliberately generates a dozen failed authentications.
**It refuses any host but its configured origin.** There is no URL argument that
can point it elsewhere: the target is PREFLIGHT_ORIGIN, and a URL on the command
line must match it. status.sh makes this argument for having no --host flag;
here there is more at stake, since a mistake there reads the wrong machine and a
mistake here hammers somebody else's login form from your address. The login and
reset paths are configured too, never guessed -- a POST to an assumed /login on
the wrong app posts to whatever is actually there.
Verified: exit 2 unconfigured, for a foreign host, and for an unreachable one;
exit 0 for --dry-run; exit 1 with the finding named. Run passively against
privacyllc.dev it correctly reported a strong CSP, a framing policy and HSTS,
and found that plain http answers 200 with the full page rather than
redirecting -- which is the class of finding this exists for, on its first real
target.
closes#9
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The question that decides whether a system can be depended on is not "does it
work" -- a demo answers that -- but which controls are present, asked before
something goes wrong. verify.sh reports which checks ran just now; backup.sh
proves one dump; the Command Center reports documents and tracker labels. None
of them says whether this project has backups AND has ever restored one AND has
somewhere errors go AND has an environment that is not production.
Four states, because flattening them is how a report starts lying:
measured observed here -- a file with a date, a variable that is set, a
command that answered
declared asserted in configuration, checked for shape and not for truth
n/a the project said the control does not apply. A library has no
uptime; saying so is an answer, not an omission
unknown expected and undeterminable. Never rendered as absent, because
"I could not tell" and "it is not there" send people to different
places
Reads BACKUP_DIR, BACKUP_NAME, HEALTHCHECK_BASE_URL, STATUS_HOST and
STATUS_CONTAINER from the scripts that own them, so the two cannot disagree
about which project this is. Writes nothing: a committed CONTROLS.md saying
"backups: ok" is a description of current state in a document, which is what the
batch ledger was and why it was archived.
Verified: exit 2 when nothing is declared and when a control name is unknown;
exit 0 for a library that declares only what applies, with seven n/a rows; exit
1 with three absent and one unknown; --quiet showing only rows needing
attention.
One bug that testing found and reading would not. GNU date parses relative
English, so `CONTROLS_LAST_RESTORE="last tuesday"` returned a real timestamp and
a plausible age -- a restore date the script invented. The shape is now required
before date sees it, and prose becomes unknown rather than a number.
closes#10
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
backup.sh says it outright -- it verifies the artefact, only a restore verifies
the backup -- and its header names this script as the missing half, with the
pg_restore command it should run. This is that command with the parts that stop
it being dangerous.
A dump pg_restore --list can read is a file with a table of contents, not a
database. Between those sit every reason a restore fails on the day it is
needed: a missing extension, an owner that does not exist, version skew, a dump
of the wrong database that reads perfectly. And the number nobody has and will
want badly: how long it takes. During an incident that decides whether you
restore or fail over, and it is unknowable from the file size. Printed every run.
**The dangerous part.** pg_restore --clean issues DROPs, and pointed at
production it obeys immediately and irreversibly. Handled by never accepting a
target: there is no --database flag, because naming the database is the mistake.
The script creates `restorecheck_<epoch>_<pid>`, restores into that, and drops it
from a trap so an interrupted run leaves no copy of production data behind.
Same argument status.sh makes for having no --host flag.
Shares BACKUP_DIR, BACKUP_NAME and BACKUP_MIN_TABLES with backup.sh rather than
taking its own, so the two cannot disagree about which series belongs to this
project.
Proved against a real PostgreSQL, not asserted -- GUARDS.md section 1:
exit 0 a real 3-table dump, minimum 1
exit 1 minimum raised to 99; a dump truncated to 2000 bytes; a zero-byte dump
exit 2 unconfigured (naming the missing value one at a time); server
unreachable
exit 0 --dry-run, always, contacting nothing
Two things that testing found and assertion would not. Every scratch database
was dropped, confirmed by querying pg_database afterwards. And --dry-run could
exit 1 on an empty dump, because the emptiness check ran before it; a mode whose
exit code depends on the state of the data is not a dry run, so the check moved
below and the dry run now notes the emptiness in its plan instead.
closes#7
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The basis for every project here was itself unversioned: no .git, no remote,
no history. Changes to it had no diff and no revert, and two of its own guards
could not run at all -- doc-claims.sh and doc-triggers.py both read git
history, so the script written to catch documentation drift could not be run
against the documents that define drift.
This is the tree as it stands, including work that until now existed only as
loose files on disk: WORK_CYCLE.md, TOOLS.md, the Portainer image-line fix in
deploy.py, the status vocabulary corrected to the four words the conformance
checker actually enforces, the Exempt: mechanism documented, and the Forgejo
instance named in README.md.
secrets.sh --tracked reports one candidate, migrate.sh:480. It is the comment
documenting the three Postgres credential shapes that script redacts, with
literal placeholders, and it is left alone deliberately: GUARDS.md section 2
is that a source-grep guard must tell code from the comment about code, and
deleting an explanation to quiet a scanner is the failure it names.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>