@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.
- package/.claude/commands/adr.md +2 -2
- package/.claude/commands/init-method.md +4 -4
- package/.claude/commands/remember.md +4 -4
- package/.claude/commands/slice.md +5 -5
- package/.claude/commands/wrap-up.md +26 -4
- package/.claude/hooks/pre-commit +52 -2
- package/README.md +52 -10
- package/package.json +2 -1
- package/scripts/check-immutable.mjs +145 -0
- package/scripts/init-method.mjs +40 -1
- package/scripts/lint-docs.mjs +181 -10
- package/templates/CLAUDE.md +16 -2
package/.claude/commands/adr.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
51
|
+
4. Report what you found and did for each of the four, so the operator can confirm
|
|
30
52
|
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,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
|
|
107
|
-
npm run index
|
|
108
|
-
npm
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
}
|
package/scripts/init-method.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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 });
|
package/scripts/lint-docs.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
44
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
}
|
package/templates/CLAUDE.md
CHANGED
|
@@ -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.
|