@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.
- package/.claude/commands/adr.md +5 -4
- package/.claude/commands/init-method.md +27 -8
- package/.claude/commands/remember.md +4 -4
- package/.claude/commands/slice.md +5 -5
- package/.claude/commands/wrap-up.md +41 -4
- package/.claude/hooks/pre-commit +52 -2
- package/README.md +77 -11
- package/package.json +3 -1
- package/scripts/check-immutable.mjs +145 -0
- package/scripts/check-mutants.mjs +262 -0
- package/scripts/init-method.mjs +180 -21
- package/scripts/lint-docs.mjs +201 -11
- package/templates/CLAUDE.md +16 -2
package/.claude/commands/adr.md
CHANGED
|
@@ -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
|
|
14
|
-
digits.
|
|
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-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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.
|
|
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
|
-
|
|
66
|
+
5. Report what you found and did for each of the four, so the operator can confirm
|
|
30
67
|
nothing was silently skipped.
|
package/.claude/hooks/pre-commit
CHANGED
|
@@ -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
|
|
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
|
|
107
|
-
npm run index
|
|
108
|
-
npm
|
|
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.
|
|
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
|
|
159
|
-
(R1), id/filename agreement and uniqueness (R2), closed status/type
|
|
160
|
-
supersession graph (R4–R7), ledger citations resolve (R8), the
|
|
161
|
-
empty corpus (R10), verify scripts are wired (R11),
|
|
162
|
-
sections
|
|
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.
|
|
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
|
+
}
|