diff --git a/.claude/commands/refresh-docs.md b/.claude/commands/refresh-docs.md index 85a2010..2c3ec02 100644 --- a/.claude/commands/refresh-docs.md +++ b/.claude/commands/refresh-docs.md @@ -5,7 +5,7 @@ description: Reconcile the docs against the actual code and report drift Reconcile documentation with reality. The code is the truth; the docs are the suspects. 1. Inventory the actual Go files, their line counts, and the non-stdlib dependencies in `go.mod`. -2. Compare against `docs/state.md`: inventory rows, counters, latent items, `verified against`. +2. Compare against `docs/state.md`: inventory rows, counters, latent items. Recount the counters **from the code** — number of render transforms, routing cases, views, output formats, extensions — rather than trusting the recorded numbers. 3. Check `docs/architecture.md` STATUS lines: has a primitive become real, or is one described diff --git a/.claude/skills/feature-loop/SKILL.md b/.claude/skills/feature-loop/SKILL.md index 53d4c2c..595270d 100644 --- a/.claude/skills/feature-loop/SKILL.md +++ b/.claude/skills/feature-loop/SKILL.md @@ -120,7 +120,8 @@ not run something, say which and why. Same change, not later. Triggers: `docs/README.md`. Walk the propagation table above — every surface done or explicitly n/a. Minimum: -- `docs/state.md`: inventory rows, counters, `verified against` line, latent items added/removed. +- `docs/state.md`: inventory rows, counters, latent items added/removed — in *this* commit, never a + trailing one, which is what `verify.sh` compares (ADR-0057). - `docs/decisions.md`: an ADR if the choice is expensive to reverse. Six lines. First open the doc that owns the topic — if it already carries the rule, amend it there; an ADR restating an existing doc is a duplicate, not a decision. diff --git a/HARNESS.md b/HARNESS.md index d132968..5a95831 100644 --- a/HARNESS.md +++ b/HARNESS.md @@ -118,8 +118,9 @@ In order, cheapest first: 1. `./scripts/verify.sh` — one command, tells you whether the thing is still coherent and whether `docs/state.md` has fallen behind the code. -2. `docs/state.md` — what exists, the earn-it counters, the latent list, the commit it was last verified - against. This is the only doc that describes the present. Its companion `docs/surface.md` is generated: +2. `docs/state.md` — what exists, the earn-it counters, the latent list. This is the only doc that + describes the present, and `verify.sh` compares its last commit against the last `.go` one rather than + trusting a sha written by hand (ADR-0057). Its companion `docs/surface.md` is generated: every declaration and its line, so you can find your way around without opening anything. 3. `git log --oneline` — one revertible unit per commit, each body saying *why* (`conventions.md`). This is the real map of how the code got here. @@ -211,7 +212,9 @@ that describe it, in the same change.** changing — except `.claude/settings.json`, which is Claude Code's permission list rather than anything about khosra. That exemption is one path: `scripts/budgets.env` and every other file under `scripts/` and `.claude/` stays gated, and khosra's own settings are not exempt from anything. This file is the current description of the machine, not a snapshot of its design. -- `verify.sh` fails when a `.go` file changes without `docs/state.md` changing, and when `cmd/` or +- `verify.sh` fails when a `.go` file changes without `docs/state.md` changing, warns when `state.md`'s last + commit is older than the last `.go` one — the two together mean the doc ships inside the change, never in a + commit trailing it (ADR-0057) — and fails when `cmd/` or `internal/` code changes without a `_test.go` changing — behaviour ships with a test. A comment-only or `gofmt`-only diff is exempt: it ships no behaviour, and failing it would only teach you `--no-verify`. - The gate is not optional. `scripts/hooks/pre-commit` runs it on every commit; enable once per clone diff --git a/docs/README.md b/docs/README.md index 2a4cb54..7239050 100644 --- a/docs/README.md +++ b/docs/README.md @@ -78,7 +78,7 @@ Update in the Document step of the change that caused them. Not later. | Change | Update | |---|---| -| Any code change at all | `state.md` inventory + `verified against` line | +| Any code change at all | `state.md` inventory — in the same commit, which is what `verify.sh` compares (ADR-0057) | | New transform, route, view, extension, or dependency | the counters table in `state.md` | | A choice expensive to reverse | new ADR in `decisions.md` | | A primitive becomes real (earned) | STATUS line in `architecture.md` + counters | diff --git a/docs/conventions.md b/docs/conventions.md index 305e5d2..e0f86c7 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -109,6 +109,14 @@ already wrong beforehand is a separate unit even when it lands in the same sitti Imperative subject under 72 characters; body says *why*, and carries the numbers the change earned. Committing happens by itself and needs no request (`CLAUDE.md` §4); pushing never does. +**Never a `state:` commit.** `state.md` is part of the change, not a follow-up to it — nothing about a +change is knowable only after committing it, now that currency is compared rather than declared +(ADR-0057). A doc commit of its own is for a correction to something that was already wrong. + +**Amend rather than accumulate.** A minor change the human asks for just after a commit — a comment +removed, a wording fixed, a line they decided against — is amended into it while it is still unpushed. +History records units of work, not the order in which someone noticed things. + **Authorship names who wrote the bytes, not who asked for them.** Three cases, no fourth: | Who wrote the change | `Author` | Trailer | diff --git a/docs/decisions.md b/docs/decisions.md index ee82375..89ed3d5 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -882,3 +882,25 @@ previous index. Both halves are internally coherent and the gap is microseconds, of the disk, and closing it means one pointer holding both, which is `web.Handler`'s signature. Revisit if: that microsecond gap ever matters — then the index and the theme become one snapshot behind one pointer, and `web.Handler` takes an accessor for it instead of two arguments. + +## ADR-0057 — `state.md`'s currency is compared, not declared +Date: 2026-08-01 · Status: accepted (retires the `verified against` sha the harness carried from day one) +Decision: `verify.sh` decides whether `docs/state.md` is current by comparing the last commit that touched +it against the last commit that touched a `.go` file — current when the doc's commit is the same or newer. +The hand-written `**Verified against:** ` line is deleted, and no commit exists solely to write one. +Why: the sha could only ever be wrong. A commit cannot name itself, so the line had to be written *after* +the commit it described, which forced a trailing `state:` commit every time — and `conventions.md` has +always said code, test, `state.md` row and ADR belong in one commit, so the gate was pushing against the +convention it was meant to protect. Folding the trailing commits away then left the sha naming a commit +that no longer existed, which is how this session reproduced the same mess the last history rewrite +cleaned up. Two commits in `backup/pre-fold` per feature, and `8905686` needed afterwards to name the +survivor, are the evidence. Git already knows when each file last changed; asking it is free and cannot +drift. +Consequence: cheap — one fewer thing to write, one fewer commit per feature, and a rewrite of history no +longer invalidates a doc. The gate's intent is unchanged, so `state.md` still cannot lag the code silently. +Expensive — the doc no longer records *which* commit a human last reconciled it against, so a deliberate +`/refresh-docs` pass leaves no mark beyond its own commit; if that turns out to matter, it is a line in the +commit body, not a value in the file. The check is also weaker on a squashed or rebased history, where both +files move in the same commit by construction. +Revisit if: someone wants the reconciliation *moment* recorded rather than the currency, which is a +different fact and belongs in the arc retro log. diff --git a/docs/state.md b/docs/state.md index fbcd9fb..1c47a8a 100644 --- a/docs/state.md +++ b/docs/state.md @@ -1,6 +1,8 @@ # State -**Verified against:** `cf3edd7` on 2026-08-01 — update this line every change. +**Currency is compared, not declared.** `verify.sh` warns unless this file's last commit is at least as new +as the last commit touching `.go`, so it ships *inside* the change it describes — no sha to write, and no +trailing commit to write one (ADR-0057). If this file disagrees with the code, the code is right and this file is a bug. ## Inventory diff --git a/scripts/verify.sh b/scripts/verify.sh index 0f7c5ef..c51a7c9 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -52,7 +52,7 @@ if [ -d .git ] && command -v git >/dev/null 2>&1; then pass "working tree clean — nothing to couple" else if echo "$changed" | grep -qE '\.go$' && ! echo "$changed" | grep -qx 'docs/state.md'; then - bad "*.go changed but docs/state.md did not — inventory, counters and the verified-against line move with the code" + bad "*.go changed but docs/state.md did not — inventory, counters and latent items move with the code" else pass "code/state.md coupling" fi @@ -171,18 +171,18 @@ if [ -d .git ] && command -v git >/dev/null 2>&1; then fi done - # True staleness: has any Go file changed since the commit state.md claims to describe? - recorded=$(awk -F'`' '/^\*\*Verified against:\*\*/{print $2; exit}' docs/state.md 2>/dev/null) - if git rev-parse --verify -q HEAD >/dev/null 2>&1; then - if git rev-parse --verify -q "${recorded:-nonexistent}^{commit}" >/dev/null 2>&1; then - behind=$(git rev-list "$recorded..HEAD" -- '*.go' 2>/dev/null | wc -l | tr -d ' ') - if [ "${behind:-0}" -gt 0 ]; then - note "docs/state.md describes $recorded; $behind commit(s) have touched .go since" - else - pass "docs/state.md is current with HEAD" - fi + # True staleness: was state.md updated no earlier than the last commit that touched Go? + # + # Compared, never declared. A hand-written sha cannot do this job: a commit cannot name itself, so the + # doc update had to trail the code in a commit of its own, and folding the two together then left the + # sha naming a commit that no longer existed (ADR-0057). + lastgo=$(git log -1 --format=%H -- '*.go' 2>/dev/null) + laststate=$(git log -1 --format=%H -- docs/state.md 2>/dev/null) + if [ -n "$lastgo" ]; then + if [ -n "$laststate" ] && git merge-base --is-ancestor "$lastgo" "$laststate" 2>/dev/null; then + pass "docs/state.md is current with the code" else - note "docs/state.md 'verified against' does not name a commit this repo knows ($recorded)" + note "docs/state.md was last updated in ${laststate:-no commit}, older than the .go change in $(git log -1 --format=%h -- '*.go')" fi fi # The generated surface is only worth reading if it cannot be wrong. Regenerate and compare rather