@isonimus/stele 0.1.2 → 0.3.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,16 +5,17 @@ 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:
12
12
 
13
- 1. Compute the next free id: the highest ordinal in `adr/` plus one, zero-padded to four
14
- digits. Do not reuse or renumber.
13
+ 1. Compute the next free id: the highest ordinal across **both** `adr/` and `slices/` plus
14
+ one, zero-padded to four digits. ADRs and slices share one id space — rule 2 fails on a
15
+ duplicate id whichever directory it sits in. Do not reuse or renumber.
15
16
  2. Choose the `type`: `architecture` for a durable decision, `slice` for a feature unit,
16
17
  `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:
18
+ 3. Write `adr/NNNN-<kebab-title>.md` opening with the stele:ADR-0002 frontmatter block:
18
19
 
19
20
  ```
20
21
  ---
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  description: Install the method kit (CLAUDE.md, LEDGER.md, linter, index, pre-commit hook) into a git repo
3
- argument-hint: [repo-root] [--check | --update]
3
+ argument-hint: [repo-root] [--check | --update [--force]]
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-0023): 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.
@@ -99,7 +99,26 @@ rather than summarising it as "installed".
99
99
  this repo adapted or declined one. Read them, mention them, do not "fix" them without
100
100
  asking; that is somebody's deliberate edit.
101
101
 
102
- `--update` re-copies the vendored scripts, the hook and the commands for a command it
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
105
- the price.
102
+ A `PROBLEM` on a command is different: it means that file is **behind the toolkit and
103
+ unmodified here**, so the repo is simply missing a fix. `--update` takes it.
104
+
105
+ ## 7. Updating an installed repo
106
+
107
+ ```
108
+ node scripts/init-method.mjs <target> --update --apply
109
+ ```
110
+
111
+ `--update` re-copies the vendored scripts and the hook unconditionally — a locally edited
112
+ linter is a defect, not an adaptation (stele:ADR-0006). Commands are gentler
113
+ (stele:ADR-0023): anything this repo has **not** touched takes the toolkit's version,
114
+ anything it **has** touched is kept and reported.
115
+
116
+ `.claude/.stele-vendored.json` is what makes that distinction possible — it records what
117
+ the toolkit last wrote, so a stale copy and a deliberate edit stop being the same
118
+ observation. **Commit it.** A repo without one (installed before the record existed) has
119
+ every differing command reported as unreconciled and overwrites none of them; reconcile
120
+ those by hand, once.
121
+
122
+ `--update --force` discards local adaptations. Never reach for it to make output tidy —
123
+ it is for when the operator has decided an adaptation should go. Say what will be lost
124
+ before running it.
@@ -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,44 @@ 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. **Mutation check** only if this repo has `scripts/check-mutants.mjs` *and* this change
16
+ touched a module its list covers. Otherwise skip it and say so; on most changes it has
17
+ nothing to look at.
18
+
19
+ - `npm run mutants`. Every non-exempt mutant must die.
20
+ - A **survivor is not a bug** — it is correct behaviour that no test is watching, so a
21
+ later refactor could reverse it in silence. Fix it by writing the missing regression
22
+ test, never by deleting the mutant.
23
+ - Mark a mutant `equivalent` only when the mutated code genuinely means the same thing,
24
+ and say why in the entry. That field is the one way a survivor passes, so it is also
25
+ the obvious place to bury an inconvenient gap.
26
+ - It measures whether tests *bite*, not whether code is right: it cannot see a missing
27
+ input or a rule that was wrong from the start. A green run is not a correctness claim,
28
+ and step 3 is what covers what it misses.
29
+
30
+ 3. **Adversarial pass** (stele:ADR-0017) — run it if this change touched a `CLAUDE.md` §4
31
+ standing invariant, an exported/public API, a data format or anything persisted, or a
32
+ Definition of Done scenario the slice flagged as risky. Otherwise skip it and say so.
33
+
34
+ - Spawn one subagent (two only if the second gets a *different* brief: **correctness** —
35
+ edge cases, error paths, boundaries, ordering and partial-failure hazards; **cost** —
36
+ complexity class, allocation, IO in loops). Sonnet 5 for correctness; announce the
37
+ delegation before it starts (`~/.claude/CLAUDE.md` §6).
38
+ - Brief it **blind to intent, aware of law**: give it the diff, this repo's `CLAUDE.md`,
39
+ and `adr/INDEX.md`. Do *not* give it the conversation, your rationale, or the slice's
40
+ claims about itself. A reviewer handed the reasoning returns the reasoning; one handed
41
+ nothing flags every deliberate deviation as a bug.
42
+ - Require a concrete failure scenario per finding — input or state → wrong output,
43
+ crash, or a counted cost. Reject "consider validating this" at intake, uninvestigated.
44
+ - **Reproduce before acting.** An accepted correctness finding becomes a test that fails
45
+ before the fix and passes after; one you cannot reproduce is rejected. The reviewer had
46
+ no intent context and is confidently wrong a predictable share of the time.
47
+ - Route what survives through the four questions below — fix, `LEDGER.md`, `/adr`, or
48
+ `/slice`. **And record the rejections**: a finding you correctly dismissed will be
49
+ raised again by every future fresh reader until the reason is written at the site
50
+ (stele:ADR-0012) or in the §4 table. That is what makes the next pass cheaper than this one.
51
+
52
+ 4. Then answer these four out loud, and act on each:
16
53
  - **Did this change a user- or dev-facing API or feature?** If so, update `README.md`
17
54
  (and any docs) in the same change — it is a live document.
18
55
  - **Did this make a decision later work must obey?** If so, write it with `/adr` now,
@@ -23,8 +60,8 @@ get recorded until they have gone stale (ADR-0003).
23
60
  deferrals live; a TODO in code or a note in your head is not tracked.
24
61
  - **Did this write code a later operator would plausibly try to "fix"?** — a deliberate
25
62
  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
63
+ ADR at that site in a comment (stele:ADR-0012), so the choice announces it is on purpose
27
64
  where the edit happens, not only in the §4 table nobody thinks to open.
28
65
 
29
- 3. Report what you found and did for each of the four, so the operator can confirm
66
+ 5. Report what you found and did for each of the four, so the operator can confirm
30
67
  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,18 @@ 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
+
84
+ It also **keeps any slash command this repo adapted**. `.claude/.stele-vendored.json`
85
+ records what the toolkit last handed over, which is what lets an update tell a stale copy
86
+ from a deliberate edit — before that record existed the two were the same observation, so
87
+ taking a fix meant discarding every adaptation
88
+ ([ADR-0023](adr/0023-vendored-commands-and-what-an-update-may-overwrite.md)).
89
+ Commit that file. `--update --force` discards adaptations, and has to be typed.
90
+
73
91
  From a clone of this repo, the same entry point runs directly:
74
92
 
75
93
  ```
@@ -83,7 +101,9 @@ owns the pre-commit slot, the doc checks join it rather than fight for the file
83
101
  ([ADR-0008](adr/0008-compose-with-an-existing-hook-framework.md)).
84
102
 
85
103
  The linter and slash commands are **vendored per repo** and a repo's local edits to the
86
- commands survive re-runs ([ADR-0007](adr/0007-commands-are-vendored-and-adaptable.md)).
104
+ commands survive re-runs — including across an `--update`
105
+ ([ADR-0023](adr/0023-vendored-commands-and-what-an-update-may-overwrite.md), superseding
106
+ [ADR-0007](adr/0007-commands-are-vendored-and-adaptable.md)).
87
107
 
88
108
  ---
89
109
 
@@ -96,16 +116,18 @@ Run in Claude Code as `/<name>`.
96
116
  | `/adr <title>` | Scaffold a new Architecture Decision Record, frontmatter pre-filled. |
97
117
  | `/slice <title>` | Scaffold a new slice (one feature work-unit). |
98
118
  | `/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. |
119
+ | `/wrap-up` | End-of-task gate — run the checks, run the mutation check and adversarial pass when they trigger, and ask the four questions that get forgotten. |
100
120
  | `/remember <fact>` | Route a fact to the destination that governs it (see [Where things live](#where-things-live)). |
101
121
  | `/init-method` | Install the kit into a git repo. |
102
122
 
103
123
  Under the hood, the npm scripts are the enforcement surface:
104
124
 
105
125
  ```
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)
126
+ npm run lint # node scripts/lint-docs.mjs . — the invariant checker
127
+ npm run index # regenerate adr/INDEX.md
128
+ npm run immutable # immutable bodies only gained lines since HEAD
129
+ npm run mutants # do the tests actually bite? (mutation check)
130
+ npm test # the regression suite (every rule has a fixture)
109
131
  ```
110
132
 
111
133
  ---
@@ -139,6 +161,16 @@ error**, and writes artifacts (screenshots, numbers) for human review. Name it i
139
161
  `## Verification` and **wire it into `package.json`** — an unwired verify script runs once and
140
162
  is dead thereafter, so the linter fails if any is unwired (R11). ([ADR-0004](adr/0004-verification-harness-and-in-repo-invariants.md))
141
163
 
164
+ **Cite a decision.** Write `ADR-NNNN` for one in *this* repo and `<repo>:ADR-NNNN` for one
165
+ in another — a bare citation always means "here", which is what lets the linter treat a
166
+ citation that doesn't resolve as a real defect rather than a maybe
167
+ ([ADR-0009](adr/0009-cross-repo-citations-are-qualified.md)). Text you expect to be copied
168
+ into another repo — a template, a slash command — must use the qualified form even for your
169
+ own decisions, or the copy silently names the *other* repo's decision of that number. Naming
170
+ your own repo costs nothing: the linter resolves `<this-repo>:ADR-NNNN` locally, so those
171
+ citations stay checked here and stay skipped downstream
172
+ ([ADR-0020](adr/0020-citations-are-checked-wherever-they-are-read.md)).
173
+
142
174
  **Adopt into an existing repo.** `/init-method <repo> --apply`. Migrate the corpus and hand-
143
175
  fix any red supersession pairs *before* the hook goes on. Legacy documents that predate a
144
176
  rule warn rather than error, so adoption is never blocked by history.
@@ -152,14 +184,25 @@ decision, and decisions are immutable records.
152
184
  ## Enforcement, in three honest layers
153
185
 
154
186
  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
187
+ and in CI. The hook checks the **commit**, not the files on disk — it materialises the staged
188
+ tree and runs the checks against that, so a fix you forgot to `git add` cannot green a commit
189
+ that lands red ([ADR-0018](adr/0018-the-hook-checks-the-commit-not-the-working-tree.md)). Rules
190
+ are graded by what can actually be mechanised
156
191
  ([ADR-0003](adr/0003-enforcement-by-hook.md)):
157
192
 
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).
193
+ 1. **Machine-checked (error — blocks the commit).** Frontmatter shape, completeness and a
194
+ real ISO date (R1), id/filename agreement and uniqueness (R2), closed status/type
195
+ vocabulary (R3), the supersession graph (R4–R7), ledger citations resolve (R8), the
196
+ linter isn't pointed at an empty corpus (R10), verify scripts are wired (R11), slices
197
+ carry their required sections in their own prose — a heading quoted inside a code fence
198
+ is not compliance (R12/R13) — and citations and links resolve in the prose read as
199
+ *instruction*: `CLAUDE.md`, `README.md`, `docs/`, the slash commands (R14/R15,
200
+ [ADR-0020](adr/0020-citations-are-checked-wherever-they-are-read.md)).
201
+
202
+ Every rule reads inside one declared scope, which is exactly what the hook copies out of
203
+ the staged tree — so a rule cannot pass in CI and do nothing in the hook, and a link
204
+ check cannot depend on where it ran
205
+ ([ADR-0021](adr/0021-the-checked-scope-is-one-list-and-no-rule-reads-outside-it.md)).
163
206
  2. **Legacy-aware (warning, not error).** Bare prose cross-references (R9) and slice-section
164
207
  rules on documents that predate them warn instead of failing, so a repo's history never
165
208
  blocks its next commit — while *new* work is held to the full bar.
@@ -169,6 +212,29 @@ and in CI. Its rules are graded by what can actually be mechanised
169
212
  a human read-through, never claimed as guaranteed. A linter that pretended to check them
170
213
  would be a false green, the exact failure this project exists to prevent.
171
214
 
215
+ This layer has two instruments, and neither is a linter. The first is the **mutation
216
+ check** (`npm run mutants`), which answers one narrow question mechanically: *do the
217
+ tests bite?* It applies a curated list of small behaviour changes to the linter's pure
218
+ predicates and requires each to break a test. A survivor is not a bug — it is correct
219
+ behaviour nothing is watching, which a later refactor can reverse in silence. Measured on
220
+ adoption: 3 of 10 mutants died, and the seven survivors were all correct-but-unpinned
221
+ behaviour, now covered. It is deliberately not a framework — that would cost the
222
+ zero-dependency property, and curating the list is the work anyway.
223
+
224
+ Its limit is stated rather than discovered: it perturbs code and asks whether tests
225
+ notice, so it is blind to an input nobody wrote and to a rule that was wrong from the
226
+ start. **None of the three defects this project has actually suffered would have been
227
+ caught by it.** It buys regression durability, not correctness.
228
+
229
+ The second instrument is the one that covers exactly that gap: the **adversarial pass**
230
+ ([ADR-0017](adr/0017-adversarial-review-is-the-coverage-mechanism.md)). Every coverage
231
+ question has the form "is what you wrote sufficient?" and is addressed to the person who
232
+ just wrote it — who already believes it is, which is why the code looks that way. So
233
+ `/wrap-up` spawns a reviewer that is **blind to intent but aware of law**: it gets the
234
+ diff, `CLAUDE.md` and the ADR index, never the rationale. Findings must state a concrete
235
+ failure, get reproduced before they are acted on, and — when correctly *rejected* — get
236
+ written at the code site (ADR-0012) so the next fresh reader does not raise them again.
237
+
172
238
  The dividing line is the whole point: **a rule is enforced, legacy-tolerated, or explicitly
173
239
  declared uncheckable — never silently trusted.**
174
240
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isonimus/stele",
3
- "version": "0.1.2",
3
+ "version": "0.3.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,8 @@
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",
45
+ "mutants": "node scripts/check-mutants.mjs",
44
46
  "scan": "node scripts/scan-legacy.mjs",
45
47
  "migrate": "node scripts/migrate-adrs.mjs",
46
48
  "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
+ }