@isonimus/stele 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@ argument-hint: <short title of the decision>
5
5
 
6
6
  Create a new ADR recording a decision that later work must obey — a mechanism, data
7
7
  format, or boundary. An ADR asserts *"on date X we chose Y because Z"*: a historical
8
- claim that stays true forever (ADR-0001). If you are recording a **feature work-unit**
8
+ claim that stays true forever (stele:ADR-0001). If you are recording a **feature work-unit**
9
9
  rather than a durable decision, use `/slice` instead.
10
10
 
11
11
  Steps:
@@ -14,7 +14,7 @@ Steps:
14
14
  digits. Do not reuse or renumber.
15
15
  2. Choose the `type`: `architecture` for a durable decision, `slice` for a feature unit,
16
16
  `batch` only if it genuinely bundles several unrelated decisions that cannot map 1:1.
17
- 3. Write `adr/NNNN-<kebab-title>.md` opening with the ADR-0002 frontmatter block:
17
+ 3. Write `adr/NNNN-<kebab-title>.md` opening with the stele:ADR-0002 frontmatter block:
18
18
 
19
19
  ```
20
20
  ---
@@ -3,7 +3,7 @@ description: Install the method kit (CLAUDE.md, LEDGER.md, linter, index, pre-co
3
3
  argument-hint: [repo-root] [--check | --update]
4
4
  ---
5
5
 
6
- Install this kit into a repo, or verify an existing install (ADR-0006). The script does
6
+ Install this kit into a repo, or verify an existing install (stele:ADR-0006). The script does
7
7
  the mechanical half; you do the two halves that need judgement — filling the scaffolded
8
8
  `CLAUDE.md`, and wiring verify scripts.
9
9
 
@@ -36,7 +36,7 @@ This scaffolds `adr/`, `CLAUDE.md` and `LEDGER.md` (never overwriting), vendors
36
36
  `adr/INDEX.md`, links `~/.claude/CLAUDE.md`, and installs the pre-commit hook **only if
37
37
  the corpus lints clean**.
38
38
 
39
- The commands are vendored under softer rules than the machinery (ADR-0007): a repo may
39
+ The commands are vendored under softer rules than the machinery (stele:ADR-0007): a repo may
40
40
  edit its own copy of `/slice` or `/wrap-up` to say something repo-specific, and an
41
41
  install keeps that edit rather than overwriting it. If you edit one, say so — an edit
42
42
  made in an installed repo does not travel back to the toolkit.
@@ -44,7 +44,7 @@ made in an installed repo does not travel back to the toolkit.
44
44
  ## 3. If the repo already has a hook framework
45
45
 
46
46
  A target with a `.pre-commit-config.yaml` gets the doc checks **composed into it** as a
47
- `repo: local` block rather than a symlink (ADR-0008) — the framework owns
47
+ `repo: local` block rather than a symlink (stele:ADR-0008) — the framework owns
48
48
  `.git/hooks/pre-commit`, and a symlink there is silently erased by the next
49
49
  `pre-commit install`. The append is idempotent and additive; the existing config is
50
50
  never reordered or rewritten, and `--update` leaves it alone.
@@ -101,5 +101,5 @@ asking; that is somebody's deliberate edit.
101
101
 
102
102
  `--update` re-copies the vendored scripts, the hook and the commands — for a command it
103
103
  discards a local edit, which is exactly what it is for. It is the only way a toolkit fix
104
- reaches an installed repo: the copies are deliberate (ADR-0006, ADR-0007), and drift is
104
+ reaches an installed repo: the copies are deliberate (stele:ADR-0006, stele:ADR-0007), and drift is
105
105
  the price.
@@ -3,7 +3,7 @@ description: Route a fact to the destination that governs it — repo CLAUDE.md,
3
3
  argument-hint: <the thing to remember>
4
4
  ---
5
5
 
6
- Record a durable fact. **This is a router, not a store** (ADR-0005). Your job is to decide
6
+ Record a durable fact. **This is a router, not a store** (stele:ADR-0005). Your job is to decide
7
7
  *what the fact governs* and write it where that is enforceable — not to write it wherever
8
8
  the operator's phrasing points.
9
9
 
@@ -29,7 +29,7 @@ Two tests resolve most cases:
29
29
  zod for validation" is a fact. "Migrate the remaining validators to zod" is a task.
30
30
 
31
31
  Memory is the **residual**, not the default. If you are about to write a rule that
32
- constrains code into memory, you have misrouted — that is the exact failure ADR-0005
32
+ constrains code into memory, you have misrouted — that is the exact failure stele:ADR-0005
33
33
  records, which cost an amend and force-push after the same rule was independently rewritten
34
34
  into 12 files across 8 projects.
35
35
 
@@ -38,7 +38,7 @@ into 12 files across 8 projects.
38
38
  - **Repo `CLAUDE.md`** — add to the section it belongs to. If it is a standing invariant,
39
39
  add a row to the §5 invariants table citing the ADR that created it. If this repo has no
40
40
  `CLAUDE.md`, scaffold one from `templates/CLAUDE.md` first; without it there is no
41
- destination and the fact will silently fall back to memory (ADR-0005).
41
+ destination and the fact will silently fall back to memory (stele:ADR-0005).
42
42
  - **Global `CLAUDE.md`** — edit your global `~/.claude/CLAUDE.md`, wherever your global
43
43
  conventions live. Keep it dense: it loads into every session in every repo.
44
44
  - **`LEDGER.md`** — one line, `- [type] description (ADR-NNNN)`, citing the source ADR if
@@ -54,7 +54,7 @@ instead of adding a duplicate.
54
54
  State which destination it went to and why in one line — e.g. *"→ repo `CLAUDE.md` §3: it
55
55
  constrains code, so it needs to be greppable and reviewable."*
56
56
 
57
- This step is not optional. Routing cannot be linted (ADR-0005), so saying the decision out
57
+ This step is not optional. Routing cannot be linted (stele:ADR-0005), so saying the decision out
58
58
  loud is the only thing that makes a misroute correctable in the moment rather than
59
59
  discoverable in a survey weeks later.
60
60
 
@@ -4,7 +4,7 @@ argument-hint: <short title of the feature>
4
4
  ---
5
5
 
6
6
  Create a new **slice**: one feature work-unit, written *before* implementation and frozen
7
- at merge (ADR-0001). A slice is a current-state claim while open, so it goes stale by
7
+ at merge (stele:ADR-0001). A slice is a current-state claim while open, so it goes stale by
8
8
  nature — freezing it to past tense at merge ("this is what shipped") converts it into a
9
9
  historical claim that never goes stale. If you are recording a durable decision rather
10
10
  than a unit of feature work, use `/adr` instead.
@@ -19,7 +19,7 @@ Steps:
19
19
  ledger for any item this slice closes** — closing an item means deleting its line (see
20
20
  the ledger's own header). If nothing relates, note nothing; this is a read, not a
21
21
  requirement to invent links.
22
- 3. Write the file opening with the ADR-0002 frontmatter block, `type: slice`:
22
+ 3. Write the file opening with the stele:ADR-0002 frontmatter block, `type: slice`:
23
23
 
24
24
  ```
25
25
  ---
@@ -36,7 +36,7 @@ Steps:
36
36
  4. Body sections, written before you code:
37
37
  - `## Goal` — what ships and why, in one paragraph. Fold in any ledger items from step 2.
38
38
  - `## Definition of Done` — **required.** The acceptance criteria as one or more
39
- **Given / When / Then** scenarios, written before you code (ADR-0011). This is the
39
+ **Given / When / Then** scenarios, written before you code (stele:ADR-0011). This is the
40
40
  grammar, not a toolchain: plain markdown steps, no Cucumber. State the observable
41
41
  outcome — a criterion with no observable "Then" is not done-able. Each scenario's
42
42
  proof is named below in `## Verification`; a scenario with no proof is an unmet
@@ -53,7 +53,7 @@ Steps:
53
53
  - `## Design` — the approach; cite measured numbers from a probe where a design
54
54
  question has a measurable answer, not estimates. State **what existing code was
55
55
  considered**: what you searched for, what you reused, and what you deliberately did
56
- *not* reuse and why (ADR-0013). This is not a mandate to reuse — the wrong abstraction
56
+ *not* reuse and why (stele:ADR-0013). This is not a mandate to reuse — the wrong abstraction
57
57
  costs more than a duplication (global §3, rule of three); it makes the
58
58
  reuse-or-duplicate choice *visible*, so an un-considered duplication is caught at
59
59
  review. It is checked by human read-through, not the linter: whether you truly searched
@@ -61,7 +61,7 @@ Steps:
61
61
  - `## Verification` — **required.** Name the unit tests, and if the behaviour cannot be
62
62
  asserted in a unit test (rendering, worldgen, physics, timing), name the
63
63
  `scripts/<slice>-verify.mjs` script and confirm it is wired into `package.json`
64
- (ADR-0004). A slice with no `## Verification` section is incomplete. The linter
64
+ (stele:ADR-0004). A slice with no `## Verification` section is incomplete. The linter
65
65
  enforces both required sections (R12/R13): a `type: slice` must carry `## Verification`
66
66
  and a `## Definition of Done` holding at least one full Given/When/Then triad.
67
67
  - `## As built` — filled in at merge: what actually shipped, in past tense, confirming
@@ -4,7 +4,7 @@ description: End-of-task gate — run the checks and ask the four questions that
4
4
 
5
5
  Run before finishing a task. The point is to make mechanical what memory keeps dropping:
6
6
  the linter catches structural drift, and four questions catch the follow-ups that never
7
- get recorded until they have gone stale (ADR-0003).
7
+ get recorded until they have gone stale (stele:ADR-0003).
8
8
 
9
9
  1. Run the linter and the index check:
10
10
  - `npm run lint` — must be green (rule 9 warnings are acceptable on a legacy corpus).
@@ -12,7 +12,29 @@ get recorded until they have gone stale (ADR-0003).
12
12
  generated index is drift by another name.
13
13
  - `npm test` — the test suite must pass; docs changes must not disturb code.
14
14
 
15
- 2. Then answer these four out loud, and act on each:
15
+ 2. **Adversarial pass** (stele:ADR-0017) run it if this change touched a `CLAUDE.md` §4
16
+ standing invariant, an exported/public API, a data format or anything persisted, or a
17
+ Definition of Done scenario the slice flagged as risky. Otherwise skip it and say so.
18
+
19
+ - Spawn one subagent (two only if the second gets a *different* brief: **correctness** —
20
+ edge cases, error paths, boundaries, ordering and partial-failure hazards; **cost** —
21
+ complexity class, allocation, IO in loops). Sonnet 5 for correctness; announce the
22
+ delegation before it starts (`~/.claude/CLAUDE.md` §6).
23
+ - Brief it **blind to intent, aware of law**: give it the diff, this repo's `CLAUDE.md`,
24
+ and `adr/INDEX.md`. Do *not* give it the conversation, your rationale, or the slice's
25
+ claims about itself. A reviewer handed the reasoning returns the reasoning; one handed
26
+ nothing flags every deliberate deviation as a bug.
27
+ - Require a concrete failure scenario per finding — input or state → wrong output,
28
+ crash, or a counted cost. Reject "consider validating this" at intake, uninvestigated.
29
+ - **Reproduce before acting.** An accepted correctness finding becomes a test that fails
30
+ before the fix and passes after; one you cannot reproduce is rejected. The reviewer had
31
+ no intent context and is confidently wrong a predictable share of the time.
32
+ - Route what survives through the four questions below — fix, `LEDGER.md`, `/adr`, or
33
+ `/slice`. **And record the rejections**: a finding you correctly dismissed will be
34
+ raised again by every future fresh reader until the reason is written at the site
35
+ (stele:ADR-0012) or in the §4 table. That is what makes the next pass cheaper than this one.
36
+
37
+ 3. Then answer these four out loud, and act on each:
16
38
  - **Did this change a user- or dev-facing API or feature?** If so, update `README.md`
17
39
  (and any docs) in the same change — it is a live document.
18
40
  - **Did this make a decision later work must obey?** If so, write it with `/adr` now,
@@ -23,8 +45,8 @@ get recorded until they have gone stale (ADR-0003).
23
45
  deferrals live; a TODO in code or a note in your head is not tracked.
24
46
  - **Did this write code a later operator would plausibly try to "fix"?** — a deliberate
25
47
  deviation, a non-obvious constraint, a hard-won exception. If so, cite the governing
26
- ADR at that site in a comment (ADR-0012), so the choice announces it is on purpose
48
+ ADR at that site in a comment (stele:ADR-0012), so the choice announces it is on purpose
27
49
  where the edit happens, not only in the §4 table nobody thinks to open.
28
50
 
29
- 3. Report what you found and did for each of the four, so the operator can confirm
51
+ 4. Report what you found and did for each of the four, so the operator can confirm
30
52
  nothing was silently skipped.
@@ -21,9 +21,59 @@ if ! command -v node >/dev/null 2>&1; then
21
21
  exit 1
22
22
  fi
23
23
 
24
+ # The checks run against the COMMIT, not the working tree (ADR-0018). Reading the working
25
+ # tree passes on a state no commit will ever contain: stage a broken ADR, fix it without
26
+ # staging the fix, and the hook is green while HEAD lands red. `git write-tree` is used
27
+ # rather than `git stash --keep-index` because it touches nothing the operator owns.
28
+ tree=$(git write-tree)
29
+
30
+ staged=$(mktemp -d)
31
+ trap 'rm -rf "$staged"' EXIT
32
+
33
+ # Only what the two checks actually read. The full tree costs ~1.1ms per tracked file —
34
+ # ~11s on a 10k-file repo, which is a hook people delete (ADR-0018 has the numbers).
35
+ # ADR-0018: a rule that starts reading a file outside this list must extend it here, or
36
+ # the hook checks a file it never extracted. That obligation was missed exactly once, by
37
+ # the very next feature: rules 14 and 15 read the prose files and ran nowhere in the hook
38
+ # for a whole release. `test/read-set.test.mjs` now compares this list against the scope
39
+ # the linter exports, so the next omission fails a test instead of going quiet. Paths
40
+ # absent from the tree are dropped, because `git archive` fails on a pathspec that matches
41
+ # nothing.
42
+ paths=''
43
+ for candidate in adr slices LEDGER.md CLAUDE.md README.md docs .claude/commands scripts package.json; do
44
+ if git cat-file -e "$tree:$candidate" 2>/dev/null; then
45
+ paths="$paths $candidate"
46
+ fi
47
+ done
48
+
49
+ # No doc path in the tree at all is not a reason to skip: the checks then run against an
50
+ # empty directory and rule 10 reports "no adr/ or slices/ directory here", which is the
51
+ # right diagnosis rather than a silent pass.
52
+ if [ -n "$paths" ]; then
53
+ # shellcheck disable=SC2086 # word splitting is the point: $paths is a pathspec list.
54
+ git archive "$tree" -- $paths | tar -x -C "$staged"
55
+ fi
56
+
57
+ # The commit's own checkers, not the working tree's (ADR-0018) — grading a commit with a
58
+ # linter it does not contain is the same defect one turn removed. No fallback to the
59
+ # working copy: a silent fallback is the thing this hook exists to remove.
60
+ for checker in lint-docs.mjs build-index.mjs check-immutable.mjs; do
61
+ if [ ! -f "$staged/scripts/$checker" ]; then
62
+ echo "pre-commit: scripts/$checker is not in the commit — cannot check it against itself." >&2
63
+ echo " Stage the vendored checker, or run /init-method --update." >&2
64
+ exit 1
65
+ fi
66
+ done
67
+
24
68
  # 1. Invariants must hold (rules 1–8 block; rule 9 warns). Zero-dependency, so no install.
25
- node scripts/lint-docs.mjs .
69
+ node "$staged/scripts/lint-docs.mjs" "$staged"
26
70
 
27
71
  # 2. The generated index must match the corpus. A stale INDEX.md is drift by another name;
28
72
  # regenerate and stage it rather than letting it fall behind.
29
- node scripts/build-index.mjs --check .
73
+ node "$staged/scripts/build-index.mjs" --check "$staged"
74
+
75
+ # 3. Immutable documents may only grow (ADR-0019). Skipped on an unborn branch, where the
76
+ # first commit has nothing to have edited.
77
+ if git rev-parse --verify --quiet HEAD >/dev/null; then
78
+ node "$staged/scripts/check-immutable.mjs" HEAD "$tree"
79
+ fi
package/README.md CHANGED
@@ -55,6 +55,12 @@ means deleting its line from the ledger.** Changing your mind means a **new** AD
55
55
  supersedes the old one and says *why the old reasoning was wrong* — that record is the most
56
56
  valuable thing this workflow produces, and an in-place edit destroys it.
57
57
 
58
+ This one is enforced, not merely stated: a committed immutable document's body may **gain**
59
+ lines — an appended `## Amendment`, a correction marker placed at the claim it corrects — and
60
+ may never lose or rewrite one
61
+ ([ADR-0019](adr/0019-immutable-bodies-may-gain-lines-never-lose-them.md)). Frontmatter stays
62
+ free to change, because status and supersession are how a record announces it was superseded.
63
+
58
64
  ---
59
65
 
60
66
  ## Quickstart
@@ -70,6 +76,11 @@ npx @isonimus/stele <repo-root> --check # verify an install is intact
70
76
  npx @isonimus/stele <repo-root> --update # re-sync vendored machinery
71
77
  ```
72
78
 
79
+ `--update` lints the corpus once the new machinery is in place and reports anything the
80
+ updated linter calls red. A release that adds a rule can turn a green repo red behind a
81
+ hook that is already installed, and the failure otherwise shows up as an unexplained
82
+ blocked commit some time later (ADR-0021).
83
+
73
84
  From a clone of this repo, the same entry point runs directly:
74
85
 
75
86
  ```
@@ -96,16 +107,17 @@ Run in Claude Code as `/<name>`.
96
107
  | `/adr <title>` | Scaffold a new Architecture Decision Record, frontmatter pre-filled. |
97
108
  | `/slice <title>` | Scaffold a new slice (one feature work-unit). |
98
109
  | `/audit` | Full-corpus health check — run every invariant, surface warnings and drift. |
99
- | `/wrap-up` | End-of-task gate — run the checks and ask the four questions that get forgotten. |
110
+ | `/wrap-up` | End-of-task gate — run the checks, run the adversarial pass when it triggers, and ask the four questions that get forgotten. |
100
111
  | `/remember <fact>` | Route a fact to the destination that governs it (see [Where things live](#where-things-live)). |
101
112
  | `/init-method` | Install the kit into a git repo. |
102
113
 
103
114
  Under the hood, the npm scripts are the enforcement surface:
104
115
 
105
116
  ```
106
- npm run lint # node scripts/lint-docs.mjs . — the invariant checker
107
- npm run index # regenerate adr/INDEX.md
108
- npm test # the regression suite (every rule has a fixture)
117
+ npm run lint # node scripts/lint-docs.mjs . — the invariant checker
118
+ npm run index # regenerate adr/INDEX.md
119
+ npm run immutable # immutable bodies only gained lines since HEAD
120
+ npm test # the regression suite (every rule has a fixture)
109
121
  ```
110
122
 
111
123
  ---
@@ -139,6 +151,16 @@ error**, and writes artifacts (screenshots, numbers) for human review. Name it i
139
151
  `## Verification` and **wire it into `package.json`** — an unwired verify script runs once and
140
152
  is dead thereafter, so the linter fails if any is unwired (R11). ([ADR-0004](adr/0004-verification-harness-and-in-repo-invariants.md))
141
153
 
154
+ **Cite a decision.** Write `ADR-NNNN` for one in *this* repo and `<repo>:ADR-NNNN` for one
155
+ in another — a bare citation always means "here", which is what lets the linter treat a
156
+ citation that doesn't resolve as a real defect rather than a maybe
157
+ ([ADR-0009](adr/0009-cross-repo-citations-are-qualified.md)). Text you expect to be copied
158
+ into another repo — a template, a slash command — must use the qualified form even for your
159
+ own decisions, or the copy silently names the *other* repo's decision of that number. Naming
160
+ your own repo costs nothing: the linter resolves `<this-repo>:ADR-NNNN` locally, so those
161
+ citations stay checked here and stay skipped downstream
162
+ ([ADR-0020](adr/0020-citations-are-checked-wherever-they-are-read.md)).
163
+
142
164
  **Adopt into an existing repo.** `/init-method <repo> --apply`. Migrate the corpus and hand-
143
165
  fix any red supersession pairs *before* the hook goes on. Legacy documents that predate a
144
166
  rule warn rather than error, so adoption is never blocked by history.
@@ -152,14 +174,25 @@ decision, and decisions are immutable records.
152
174
  ## Enforcement, in three honest layers
153
175
 
154
176
  The linter ([`scripts/lint-docs.mjs`](scripts/lint-docs.mjs)) runs from the pre-commit hook
155
- and in CI. Its rules are graded by what can actually be mechanised
177
+ and in CI. The hook checks the **commit**, not the files on disk — it materialises the staged
178
+ tree and runs the checks against that, so a fix you forgot to `git add` cannot green a commit
179
+ that lands red ([ADR-0018](adr/0018-the-hook-checks-the-commit-not-the-working-tree.md)). Rules
180
+ are graded by what can actually be mechanised
156
181
  ([ADR-0003](adr/0003-enforcement-by-hook.md)):
157
182
 
158
- 1. **Machine-checked (error — blocks the commit).** Frontmatter shape and completeness
159
- (R1), id/filename agreement and uniqueness (R2), closed status/type vocabulary (R3), the
160
- supersession graph (R4–R7), ledger citations resolve (R8), the linter isn't pointed at an
161
- empty corpus (R10), verify scripts are wired (R11), and slices carry their required
162
- sections (R12/R13).
183
+ 1. **Machine-checked (error — blocks the commit).** Frontmatter shape, completeness and a
184
+ real ISO date (R1), id/filename agreement and uniqueness (R2), closed status/type
185
+ vocabulary (R3), the supersession graph (R4–R7), ledger citations resolve (R8), the
186
+ linter isn't pointed at an empty corpus (R10), verify scripts are wired (R11), slices
187
+ carry their required sections in their own prose — a heading quoted inside a code fence
188
+ is not compliance (R12/R13) — and citations and links resolve in the prose read as
189
+ *instruction*: `CLAUDE.md`, `README.md`, `docs/`, the slash commands (R14/R15,
190
+ [ADR-0020](adr/0020-citations-are-checked-wherever-they-are-read.md)).
191
+
192
+ Every rule reads inside one declared scope, which is exactly what the hook copies out of
193
+ the staged tree — so a rule cannot pass in CI and do nothing in the hook, and a link
194
+ check cannot depend on where it ran
195
+ ([ADR-0021](adr/0021-the-checked-scope-is-one-list-and-no-rule-reads-outside-it.md)).
163
196
  2. **Legacy-aware (warning, not error).** Bare prose cross-references (R9) and slice-section
164
197
  rules on documents that predate them warn instead of failing, so a repo's history never
165
198
  blocks its next commit — while *new* work is held to the full bar.
@@ -169,6 +202,15 @@ and in CI. Its rules are graded by what can actually be mechanised
169
202
  a human read-through, never claimed as guaranteed. A linter that pretended to check them
170
203
  would be a false green, the exact failure this project exists to prevent.
171
204
 
205
+ This layer has one instrument, and it is not a linter: the **adversarial pass**
206
+ ([ADR-0017](adr/0017-adversarial-review-is-the-coverage-mechanism.md)). Every coverage
207
+ question has the form "is what you wrote sufficient?" and is addressed to the person who
208
+ just wrote it — who already believes it is, which is why the code looks that way. So
209
+ `/wrap-up` spawns a reviewer that is **blind to intent but aware of law**: it gets the
210
+ diff, `CLAUDE.md` and the ADR index, never the rationale. Findings must state a concrete
211
+ failure, get reproduced before they are acted on, and — when correctly *rejected* — get
212
+ written at the code site (ADR-0012) so the next fresh reader does not raise them again.
213
+
172
214
  The dividing line is the whole point: **a rule is enforced, legacy-tolerated, or explicitly
173
215
  declared uncheckable — never silently trusted.**
174
216
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isonimus/stele",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "A linted, installable ADR workflow for git projects using Claude as an assistant",
6
6
  "keywords": [
@@ -41,6 +41,7 @@
41
41
  "test": "node --test test/*.test.mjs",
42
42
  "lint": "node scripts/lint-docs.mjs .",
43
43
  "index": "node scripts/build-index.mjs .",
44
+ "immutable": "node scripts/check-immutable.mjs",
44
45
  "scan": "node scripts/scan-legacy.mjs",
45
46
  "migrate": "node scripts/migrate-adrs.mjs",
46
47
  "init": "node scripts/init-method.mjs"
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ // Checks that immutable documents stayed immutable (ADR-0019).
3
+ //
4
+ // node scripts/check-immutable.mjs <base> <head>
5
+ //
6
+ // Compares two tree-ish revisions. Fails when a document under adr/ or slices/ that exists
7
+ // in <base> has had its body changed other than by appending, or has been removed.
8
+ //
9
+ // This is a sibling of `build-index --check`, not a lint rule, because it needs git — and
10
+ // lint(root) is a pure function over one directory, which is what makes it testable and
11
+ // reusable from init-method (ADR-0019). Frontmatter is deliberately out of scope: status
12
+ // and supersession fields are the mutable surface by design (ADR-0002).
13
+
14
+ import { execFileSync } from 'node:child_process';
15
+ import { realpathSync } from 'node:fs';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { parseFrontmatter } from './lint-docs.mjs';
18
+
19
+ const DOC_DIRS = ['adr', 'slices'];
20
+
21
+ /** The generated index lives among the documents but is not one (ADR-0010). */
22
+ const GENERATED = 'INDEX.md';
23
+
24
+ const git = (...args) => execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
25
+
26
+ /** Immutable documents in a tree. A pathspec matching nothing is not an error for
27
+ * ls-tree, so a repo without slices/ needs no special case. */
28
+ function docsIn(rev) {
29
+ const out = git('ls-tree', '-r', '--name-only', rev, '--', ...DOC_DIRS);
30
+ return out.split('\n').filter((p) => p.endsWith('.md') && !p.endsWith(`/${GENERATED}`));
31
+ }
32
+
33
+ /**
34
+ * The document's body as lines, with trailing blanks dropped so that a file gaining or
35
+ * losing a final newline does not read as an edit.
36
+ *
37
+ * Returns null when the frontmatter does not parse: the body boundary is then unknown, and
38
+ * comparing whole files instead would silently change what this check means. Rule 1 already
39
+ * errors on those files, so nothing escapes between the two (ADR-0019).
40
+ */
41
+ function bodyLines(text) {
42
+ const parsed = parseFrontmatter(text);
43
+ if (!parsed.ok) return null;
44
+ const lines = parsed.body.split('\n');
45
+ while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
46
+ return lines;
47
+ }
48
+
49
+ const show = (rev, path) => git('show', `${rev}:${path}`);
50
+
51
+ /**
52
+ * Index of the first line of `was` that is not still present, in order, in `now` — or -1
53
+ * when the older body survives intact.
54
+ *
55
+ * A subsequence test rather than a prefix test: insertions anywhere are legitimate here,
56
+ * because this repo's sanctioned way to correct a wrong claim is a marker placed *at* the
57
+ * claim (ADR-0001's 2026-07-21 amendment, used in ADR-0003), not a note at the end. What
58
+ * it forbids is a line disappearing or changing, which is what "immutable" means.
59
+ *
60
+ * The known limit: deleting a line that recurs verbatim later in the same body reads as
61
+ * intact. Blank lines and bare headings are the realistic instances, and both are prose
62
+ * scaffolding rather than claims (ADR-0019).
63
+ */
64
+ function firstLostLine(was, now) {
65
+ let cursor = 0;
66
+ for (let i = 0; i < was.length; i++) {
67
+ while (cursor < now.length && now[cursor] !== was[i]) cursor++;
68
+ if (cursor === now.length) return i;
69
+ cursor++;
70
+ }
71
+ return -1;
72
+ }
73
+
74
+ /** Whether a revision resolves. Used only to tell an unborn branch from a real base —
75
+ * every other git failure here is a genuine fault and is left to throw. */
76
+ function revExists(rev) {
77
+ try {
78
+ git('rev-parse', '--verify', '--quiet', `${rev}^{commit}`);
79
+ return true;
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * @returns {string[]} one message per violation; empty means the bodies only grew.
87
+ */
88
+ export function checkImmutable(base, head) {
89
+ const problems = [];
90
+ const present = new Set(docsIn(head));
91
+
92
+ for (const path of docsIn(base)) {
93
+ if (!present.has(path)) {
94
+ problems.push(`${path}: removed. Immutable documents are never deleted — supersede it instead (ADR-0010).`);
95
+ continue;
96
+ }
97
+
98
+ const was = bodyLines(show(base, path));
99
+ const now = bodyLines(show(head, path));
100
+ if (was === null || now === null) continue; // rule 1 owns unparseable frontmatter
101
+
102
+ const lost = firstLostLine(was, now);
103
+ if (lost !== -1) {
104
+ problems.push(
105
+ `${path}: body line ${lost + 1} was deleted or rewritten. Immutable prose may gain lines,\n` +
106
+ ` never lose or change them (ADR-0019).\n` +
107
+ ` was: ${was[lost]}`,
108
+ );
109
+ }
110
+ }
111
+ return problems;
112
+ }
113
+
114
+ /**
115
+ * Defaults are what let the pre-commit *framework* call this with no arguments: its `entry`
116
+ * is argv, never a shell, so `$(git write-tree)` in a config would be passed literally. The
117
+ * hook still names both revisions explicitly, having already computed the tree (ADR-0018).
118
+ */
119
+ function main(argv) {
120
+ const [base = 'HEAD', head] = argv;
121
+
122
+ if (base === 'HEAD' && !revExists('HEAD')) {
123
+ console.log('check-immutable: no commits yet — nothing to have edited.');
124
+ return 0;
125
+ }
126
+
127
+ const problems = checkImmutable(base, head ?? git('write-tree').trim());
128
+ for (const problem of problems) console.error(` IMMUTABLE ${problem}`);
129
+ if (problems.length) {
130
+ console.error(
131
+ `\n${problems.length} immutable document(s) changed below the frontmatter.\n` +
132
+ 'Record the change as an appended `## Amendment — <date>: …` block, or as a new ADR that\n' +
133
+ 'supersedes this one and says why the old reasoning was wrong (ADR-0010).',
134
+ );
135
+ return 1;
136
+ }
137
+ return 0;
138
+ }
139
+
140
+ // Resolved on both sides before comparing: the hook invokes this through the vendored copy
141
+ // and a raw `file://${argv[1]}` compare is false whenever a symlink is in the path, which
142
+ // silently no-ops main() (ADR-0015 amendment).
143
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
144
+ process.exit(main(process.argv.slice(2)));
145
+ }
@@ -28,6 +28,7 @@ const TOOLKIT = dirname(dirname(fileURLToPath(import.meta.url)));
28
28
  const VENDORED = [
29
29
  ['scripts/lint-docs.mjs', 'scripts/lint-docs.mjs'],
30
30
  ['scripts/build-index.mjs', 'scripts/build-index.mjs'],
31
+ ['scripts/check-immutable.mjs', 'scripts/check-immutable.mjs'],
31
32
  ['.claude/hooks/pre-commit', '.claude/hooks/pre-commit'],
32
33
  ];
33
34
 
@@ -61,7 +62,16 @@ const FRAMEWORK_CONFIG = '.pre-commit-config.yaml';
61
62
  /** Identifies our block on re-runs, so composing is idempotent. */
62
63
  const FRAMEWORK_HOOK_ID = 'stele-docs';
63
64
 
64
- /** Appended verbatim. Mirrors .claude/hooks/pre-commit — the same two commands. */
65
+ /**
66
+ * Appended verbatim. Mirrors .claude/hooks/pre-commit — the same two checks.
67
+ *
68
+ * These deliberately run against the working tree (`.`), where the hook materialises the
69
+ * staged tree first (ADR-0018). It is not an oversight and must not be "fixed" into a
70
+ * copy of that machinery: the framework stashes unstaged changes before dispatching, so
71
+ * by the time these entries run the working tree already *is* the index. Reproducing the
72
+ * archive dance here would duplicate what the framework provides — which is the whole
73
+ * reason ADR-0008 chose to compose with it rather than fight it for the file.
74
+ */
65
75
  const FRAMEWORK_BLOCK = `
66
76
  # Doc invariants (stele:ADR-0003, composed by /init-method per stele:ADR-0008).
67
77
  # Zero-dependency and language: system, so there is nothing to install but node.
@@ -79,6 +89,12 @@ const FRAMEWORK_BLOCK = `
79
89
  language: system
80
90
  pass_filenames: false
81
91
  always_run: true
92
+ - id: stele-immutable
93
+ name: immutable documents only grow
94
+ entry: node scripts/check-immutable.mjs
95
+ language: system
96
+ pass_filenames: false
97
+ always_run: true
82
98
  `;
83
99
 
84
100
  const read = (path) => readFileSync(path, 'utf8');
@@ -256,6 +272,28 @@ function frameworkInstalled({ target, report }) {
256
272
  else report('problem', hook, 'the pre-commit framework is configured but never installed — no hook runs at all, including its own. Run `pre-commit install`.');
257
273
  }
258
274
 
275
+ /**
276
+ * Reports a corpus the freshly vendored linter calls red.
277
+ *
278
+ * `--apply` refuses to *install* a hook on a red corpus, because a hook that blocks every
279
+ * commit is the tool bricking the repo it protects. `--update` reaches the same state by
280
+ * the other door and had no equivalent guard: a release that adds an error-severity rule
281
+ * lands a stricter linter behind a hook that is already live, and the next commit fails
282
+ * with no hint that an update caused it.
283
+ *
284
+ * It reports rather than refuses. By the time the copy is written the old linter is gone,
285
+ * so there is nothing to decline into — and rolling back would leave the repo on machinery
286
+ * the operator explicitly asked to replace. Loud and accurate beats a silent half-update.
287
+ */
288
+ function lintAfterUpdate({ target, report }) {
289
+ const errors = lintErrors(target);
290
+ if (errors.length === 0) {
291
+ return report('ok', target, 'corpus still clean under the updated linter');
292
+ }
293
+ for (const f of errors) report('problem', f.path, f.message);
294
+ report('problem', target, `${errors.length} lint error(s) under the updated linter — the hook is live, so every commit is blocked until these are fixed. \`git commit --no-verify\` is the escape hatch while you do.`);
295
+ }
296
+
259
297
  function check({ target, toolkit, report }) {
260
298
  for (const [dest, src] of VENDORED) {
261
299
  const to = join(target, dest);
@@ -318,6 +356,7 @@ export function initMethod({ target, toolkit = TOOLKIT, mode = 'install', apply
318
356
  } else if (mode === 'update') {
319
357
  vendor({ target, toolkit, apply, report });
320
358
  vendorCommands({ target, toolkit, apply, force: true, report });
359
+ if (apply) lintAfterUpdate({ target, report });
321
360
  } else {
322
361
  scaffold({ target, toolkit, apply, report });
323
362
  vendor({ target, toolkit, apply, report });
@@ -10,14 +10,44 @@
10
10
  //
11
11
  // Exit 1 if any error-severity rule fails. Warnings never fail the build.
12
12
 
13
- import { readFileSync, readdirSync, existsSync } from 'node:fs';
14
- import { join, basename } from 'node:path';
13
+ import { readFileSync, readdirSync, existsSync, realpathSync } from 'node:fs';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { join, basename, dirname, relative } from 'node:path';
15
16
 
16
17
  const STATUSES = ['accepted', 'proposed', 'superseded', 'amended'];
17
18
  const TYPES = ['architecture', 'slice', 'batch'];
18
19
  const REQUIRED = ['id', 'title', 'type', 'status', 'date'];
19
20
  const DOC_DIRS = ['adr', 'slices'];
20
21
 
22
+ // Prose that is read as instruction rather than as a record (ADR-0020). `CLAUDE.md` is
23
+ // loaded at the start of every session and the command files are the assistant's own
24
+ // procedures, so a citation rotting in either misroutes work silently — the corpus itself
25
+ // stays green because rules 8 and 9 never open these files. Directories are scanned one
26
+ // level deep; anything else here is a plain file path.
27
+ const PROSE_FILES = ['CLAUDE.md', 'README.md'];
28
+ const PROSE_DIRS = ['docs', '.claude/commands'];
29
+
30
+ /**
31
+ * Every path this linter reads, relative to the repo root — the hook's archive list
32
+ * (ADR-0018) in machine-readable form.
33
+ *
34
+ * The hook materialises the staged tree and copies only what the checks read, so a rule
35
+ * reading outside this set runs against a file that was never extracted. That happened:
36
+ * rules 14 and 15 shipped reading four paths the hook did not copy, and were dead there
37
+ * for a release while passing in CI. `test/read-set.test.mjs` holds the two lists equal.
38
+ */
39
+ export const READ_SCOPE = [
40
+ ...DOC_DIRS, 'LEDGER.md', ...PROSE_FILES, ...PROSE_DIRS, 'scripts', 'package.json',
41
+ ];
42
+
43
+ /** Whether a root-relative path lies inside the checked scope. Outside it, the hook and a
44
+ * working-tree run would disagree, and a check that depends on where it runs is worse
45
+ * than no check. */
46
+ function inReadScope(rootRelative) {
47
+ if (rootRelative.startsWith('..')) return false;
48
+ return READ_SCOPE.some((entry) => rootRelative === entry || rootRelative.startsWith(`${entry}/`));
49
+ }
50
+
21
51
  // The date the required-slice-section rules (R12/R13) shipped (ADR-0004, ADR-0011). A
22
52
  // slice dated before this predates the rules and only warns; one dated on or after must
23
53
  // comply. Without the split, a repo adopting this linter would go red on its whole legacy
@@ -34,16 +64,70 @@ const normId = (v) => String(v).trim().padStart(4, '0');
34
64
 
35
65
  const isId = (v) => /^\d{1,4}$/.test(String(v).trim());
36
66
 
67
+ /**
68
+ * ISO 8601 `YYYY-MM-DD`, and a day that exists — `2026-02-31` parses but round-trips wrong.
69
+ *
70
+ * `date` is not decoration: R12/R13 pick their severity by string-comparing it against
71
+ * SLICE_SECTIONS_SINCE, so anything sorting above `2026-07-22` grades as current and
72
+ * anything below grades as legacy. Unvalidated, `date: sometime last tuesday` graded as
73
+ * *current* purely because 's' > '2', and a copy-pasted earlier date graded as legacy —
74
+ * shipping a slice with no Definition of Done on a green build. ADR-0002 already specifies
75
+ * ISO 8601; this is that specification made executable.
76
+ */
77
+ function isCalendarDate(v) {
78
+ const text = String(v).trim();
79
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) return false;
80
+ return new Date(`${text}T00:00:00Z`).toISOString().startsWith(text);
81
+ }
82
+
37
83
  // Citations, bare or qualified (ADR-0009). A leading `<repo>:` says the decision lives in
38
84
  // another repo's corpus, which this linter cannot open and so must skip. The colon has to
39
85
  // be adjacent, leaving an ordinary sentence ending in a colon ("see also: ADR-0004")
40
86
  // resolving locally as before.
41
87
  const CITATION = /(?:([A-Za-z][\w.-]*):)?ADR[-\s](\d{1,4})/g;
42
88
 
43
- /** Ids cited in `text` that this repo is expected to own — cross-repo refs skipped. */
44
- function* localCitations(text) {
89
+ /**
90
+ * Ids cited in `text` that this repo is expected to own — cross-repo refs skipped.
91
+ *
92
+ * A qualifier naming *this* repo resolves locally (ADR-0020). Text that is vendored into
93
+ * other repos must qualify its citations, or a bare `ADR-0005` copied into gamatar reads
94
+ * as gamatar's ADR-0005; but qualifying it would also put it permanently beyond checking,
95
+ * since ADR-0009 skips every qualified reference. Recognising our own name is what keeps
96
+ * `stele:ADR-0005` verified in the one corpus that can verify it.
97
+ */
98
+ function* localCitations(text, selfRepo = null) {
45
99
  for (const [, repo, id] of text.matchAll(CITATION)) {
46
- if (repo === undefined) yield normId(id);
100
+ if (repo === undefined || (selfRepo !== null && repo === selfRepo)) yield normId(id);
101
+ }
102
+ }
103
+
104
+ /** This repo's own name for citation purposes: the unscoped half of package.json `name`.
105
+ * Null when there is no readable name — rule 11 owns malformed package.json, and without
106
+ * a name there is simply no self-qualifier to recognise. */
107
+ function repoName(root) {
108
+ const path = join(root, 'package.json');
109
+ if (!existsSync(path)) return null;
110
+ let pkg;
111
+ try {
112
+ pkg = JSON.parse(readFileSync(path, 'utf8'));
113
+ } catch {
114
+ return null;
115
+ }
116
+ const name = typeof pkg.name === 'string' ? pkg.name : '';
117
+ const unscoped = name.startsWith('@') ? name.slice(name.indexOf('/') + 1) : name;
118
+ return unscoped === '' ? null : unscoped;
119
+ }
120
+
121
+ // Inline markdown links, with the optional title form `[x](path "title")`.
122
+ const LINK = /\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/g;
123
+
124
+ /** Link targets in `line` that name a file in this repo. External URLs, mail links,
125
+ * in-page anchors and absolute paths are all outside what a file check can decide. */
126
+ function* relativeLinks(line) {
127
+ for (const [, target] of line.matchAll(LINK)) {
128
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/|#)/i.test(target)) continue;
129
+ const path = target.split('#')[0].split('?')[0];
130
+ if (path !== '') yield path;
47
131
  }
48
132
  }
49
133
 
@@ -127,16 +211,53 @@ export function loadDocs(root) {
127
211
  return docs;
128
212
  }
129
213
 
214
+ /** The prose files present under `root`, as { path, text }. Missing ones are simply
215
+ * absent — not every repo has docs/ or slash commands. */
216
+ function loadProse(root) {
217
+ const paths = PROSE_FILES.map((f) => join(root, f));
218
+ for (const dir of PROSE_DIRS) {
219
+ const full = join(root, dir);
220
+ if (!existsSync(full)) continue;
221
+ paths.push(...readdirSync(full).sort().filter((f) => f.endsWith('.md')).map((f) => join(full, f)));
222
+ }
223
+ return paths.filter(existsSync).map((path) => ({ path, text: readFileSync(path, 'utf8') }));
224
+ }
225
+
130
226
  // --- section helpers --------------------------------------------------------
131
227
  // Slice rules (R12/R13) assert the presence and shape of `## Sections` in the body prose.
132
228
  // This is the only place the linter reads body text structurally; ADR-0002 keeps
133
229
  // frontmatter the machine-readable surface, and a markdown heading is not frontmatter.
134
230
 
231
+ /**
232
+ * Body lines with fenced code blocks blanked out, positions preserved.
233
+ *
234
+ * A `## Verification` inside a fence is a *quotation* of the rule, not compliance with it.
235
+ * Reproduced 2026-07-27: a slice whose only two required sections sat in a ```markdown
236
+ * sample — exactly what a document explaining the slice template contains — lints clean,
237
+ * which is a false green on the two rules that define "done".
238
+ */
239
+ function withoutFences(body) {
240
+ const fence = /^\s*(```|~~~)/;
241
+ let open = null;
242
+ return (body ?? '').split('\n').map((line) => {
243
+ const marker = line.match(fence);
244
+ if (marker && open === null) {
245
+ open = marker[1];
246
+ return '';
247
+ }
248
+ if (marker && line.trim().startsWith(open)) {
249
+ open = null;
250
+ return '';
251
+ }
252
+ return open === null ? line : '';
253
+ });
254
+ }
255
+
135
256
  /** The text under a `## Heading`, up to the next `#`/`##` heading or end of body.
136
257
  * Returns null when the heading is absent — distinct from a present-but-empty section. */
137
258
  function sectionText(body, name) {
138
259
  const heading = new RegExp(`^##\\s+${name}\\s*$`, 'i');
139
- const lines = (body ?? '').split('\n');
260
+ const lines = withoutFences(body);
140
261
  const start = lines.findIndex((l) => heading.test(l.trim()));
141
262
  if (start === -1) return null;
142
263
  const rest = lines.slice(start + 1);
@@ -201,6 +322,9 @@ const rules = {
201
322
  report('error', d.path, `R1 missing required field "${field}"`);
202
323
  }
203
324
  }
325
+ if (d.data.date !== undefined && !isCalendarDate(d.data.date)) {
326
+ report('error', d.path, `R1 date "${d.data.date}" is not a calendar date in YYYY-MM-DD form`);
327
+ }
204
328
  }
205
329
  },
206
330
 
@@ -261,6 +385,14 @@ const rules = {
261
385
  const supersededBy = listOf(d, 'superseded_by');
262
386
  const supersedes = listOf(d, 'supersedes');
263
387
 
388
+ // R4 — a document cannot supersede itself. Self-reference satisfies every other
389
+ // check in this rule vacuously: the bidirectionality test finds the id in its own
390
+ // list, R6 sees a superseded status with a non-empty superseded_by, and R7 sees a
391
+ // target that is not "accepted". The whole graph agrees, about nothing.
392
+ if (supersedes.includes(id) || supersededBy.includes(id)) {
393
+ report('error', d.path, `R4 ADR ${id} supersedes itself — a decision is replaced by a later one, never by itself`);
394
+ }
395
+
264
396
  // R5 — dangling references. Legacy 0051 and 0061 claimed supersession with no
265
397
  // resolvable target at all.
266
398
  for (const key of ['supersedes', 'superseded_by']) {
@@ -315,8 +447,9 @@ const rules = {
315
447
  const ids = new Set(docs.filter((d) => d.ok && d.data.id !== undefined).map((d) => normId(d.data.id)));
316
448
 
317
449
  const text = readFileSync(path, 'utf8');
450
+ const self = repoName(root);
318
451
  text.split('\n').forEach((line, i) => {
319
- for (const id of localCitations(line)) {
452
+ for (const id of localCitations(line, self)) {
320
453
  if (!ids.has(id)) {
321
454
  report('error', path, `R8 line ${i + 1} cites ADR ${id}, which does not exist. Another repo's decision is cited as \`<repo>:ADR-${id}\` (ADR-0009).`);
322
455
  }
@@ -324,6 +457,41 @@ const rules = {
324
457
  });
325
458
  },
326
459
 
460
+ // R14/R15 — the prose that is read as instruction (ADR-0020). Rules 8 and 9 open
461
+ // `LEDGER.md` and the corpus and nothing else, so `CLAUDE.md`, `README.md`, `docs/` and
462
+ // the slash commands were never checked at all. That is not hypothetical: gamatar's
463
+ // vendored `/remember` said "the exact failure ADR-0005 exists to prevent", and
464
+ // gamatar's ADR-0005 is a superseded decision about canvas face textures.
465
+ //
466
+ // Error, not a warning like R9: measured across boxel's prose (43 bare references) and
467
+ // gamatar's, every reference that is meant to be local resolves, so the severity that
468
+ // forced R9 to warn — legacy volume — is absent here.
469
+ //
470
+ // The two run together because they share the file set. R15 checks relative link
471
+ // targets, and only here: a broken link inside an immutable document cannot be fixed
472
+ // without the rewrite ADR-0019 forbids, so it is not something to fail a build on.
473
+ prose(docs, root, report) {
474
+ const ids = new Set(docs.filter((d) => d.ok && d.data.id !== undefined).map((d) => normId(d.data.id)));
475
+ const self = repoName(root);
476
+
477
+ for (const { path, text } of loadProse(root)) {
478
+ text.split('\n').forEach((line, i) => {
479
+ for (const id of localCitations(line, self)) {
480
+ if (!ids.has(id)) {
481
+ report('error', path, `R14 line ${i + 1} cites ADR ${id}, which does not exist. Another repo's decision is cited as \`<repo>:ADR-${id}\` (ADR-0009).`);
482
+ }
483
+ }
484
+ for (const target of relativeLinks(line)) {
485
+ const resolved = relative(root, join(dirname(path), target));
486
+ if (!inReadScope(resolved)) continue;
487
+ if (!existsSync(join(root, resolved))) {
488
+ report('error', path, `R15 line ${i + 1} links to ${target}, which does not exist`);
489
+ }
490
+ }
491
+ });
492
+ }
493
+ },
494
+
327
495
  // R11 — every verify script is wired into package.json (ADR-0004). The harness's
328
496
  // load-bearing half: an unwired `*-verify.mjs` ran once on the day it was written and
329
497
  // never again — ADR-0004 Finding 2 found eleven of twelve boxel scripts in exactly that
@@ -400,12 +568,13 @@ const rules = {
400
568
  // Since ADR-0009 a bare reference means unambiguously "in this repo" — the other-repo
401
569
  // case has its own syntax — so the remaining obstacle to erroring here is boxel's
402
570
  // legacy volume alone, not the mechanism.
403
- proseRefs(docs, _root, report) {
571
+ proseRefs(docs, root, report) {
404
572
  const ids = new Set(docs.filter((d) => d.ok && d.data.id !== undefined).map((d) => normId(d.data.id)));
573
+ const self = repoName(root);
405
574
  for (const d of docs) {
406
575
  if (!d.ok || !d.body) continue;
407
576
  const unresolved = new Set();
408
- for (const id of localCitations(d.body)) {
577
+ for (const id of localCitations(d.body, self)) {
409
578
  if (!ids.has(id)) unresolved.add(id);
410
579
  }
411
580
  for (const ref of [...unresolved].sort()) {
@@ -458,6 +627,8 @@ function main(argv) {
458
627
  return errors > 0 ? 1 : 0;
459
628
  }
460
629
 
461
- if (import.meta.url === `file://${process.argv[1]}`) {
630
+ // realpath, not a string compare on argv[1]: invoked through a bin symlink the naive form
631
+ // silently does nothing, which is how `npx stele` shipped as a no-op (ADR-0015).
632
+ if (realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
462
633
  process.exit(main(process.argv.slice(2)));
463
634
  }
@@ -36,13 +36,27 @@ Changing our minds means writing a **new** ADR that supersedes the old one, neve
36
36
  it. The superseding note must say *why the old reasoning was wrong* — that record is the
37
37
  most valuable thing this workflow produces, and an in-place edit destroys it.
38
38
 
39
+ A committed document's body may **gain** lines — an appended `## Amendment — <date>: …`, or a
40
+ correction marker placed at the claim it corrects — and may never lose or rewrite one. The
41
+ hook enforces this (stele:ADR-0019); frontmatter is exempt, because status and supersession
42
+ fields are how a record announces it was superseded.
43
+
39
44
  This is also how a justified rule-violation gets recorded. `~/.claude/CLAUDE.md` §2 says a
40
45
  justified violation is written down as a decision rather than taken as a silent exception;
41
46
  in this repo, that decision is a new or superseding ADR.
42
47
 
43
48
  ## 2. Enforcement — invariants are executable
44
49
 
45
- `node scripts/lint-docs.mjs` runs from a pre-commit hook and in CI.
50
+ `node scripts/lint-docs.mjs` runs from a pre-commit hook and in CI. The hook checks the
51
+ **commit**, not the working tree, so a fix you forgot to stage cannot green a red commit
52
+ (stele:ADR-0018); alongside the linter it verifies that `adr/INDEX.md` matches the corpus and
53
+ that immutable bodies have only gained lines (stele:ADR-0019).
54
+
55
+ A citation is bare (`ADR-NNNN`) only when it means *this* repo, and qualified
56
+ (`<repo>:ADR-NNNN`) otherwise (stele:ADR-0009). The linter resolves citations in `LEDGER.md`,
57
+ in the corpus, and in the prose that is read as instruction — this file, `README.md`,
58
+ `docs/`, `.claude/commands/` — so a citation that rots there fails the build rather than
59
+ quietly misrouting the next session (stele:ADR-0020).
46
60
 
47
61
  A rule enforced by memory is a rule that holds until the first busy afternoon. If a
48
62
  convention matters, it gets a rule; if it genuinely can't be checked, say so out loud
@@ -76,7 +90,7 @@ are exempt: a probe answers its question once and the number lands in an ADR.
76
90
 
77
91
  Every slice carries two required sections, both rule-checked: `## Verification` names the
78
92
  proof (R12), and `## Definition of Done` states the acceptance criteria as Given/When/Then
79
- scenarios written before the code (R13, ADR-0011). Each scenario names its proof in
93
+ scenarios written before the code (R13, stele:ADR-0011). Each scenario names its proof in
80
94
  `## Verification`; the linter checks the sections exist and that the Definition of Done
81
95
  holds a full triad — it cannot check that a scenario is *right*, which is what `/wrap-up`
82
96
  is for.