@isonimus/stele 0.3.0 → 0.4.1

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.
@@ -33,13 +33,19 @@ node scripts/init-method.mjs <target> --apply
33
33
 
34
34
  This scaffolds `adr/`, `CLAUDE.md` and `LEDGER.md` (never overwriting), vendors
35
35
  `lint-docs.mjs` / `build-index.mjs` / the hook and the slash commands, generates
36
- `adr/INDEX.md`, links `~/.claude/CLAUDE.md`, and installs the pre-commit hook **only if
37
- the corpus lints clean**.
36
+ `adr/INDEX.md`, and installs the pre-commit hook **only if the corpus lints clean**.
38
37
 
39
- The commands are vendored under softer rules than the machinery (stele:ADR-0023): a repo may
40
- edit its own copy of `/slice` or `/wrap-up` to say something repo-specific, and an
41
- install keeps that edit rather than overwriting it. If you edit one, say so — an edit
42
- made in an installed repo does not travel back to the toolkit.
38
+ Nothing outside the target repo is written never `$HOME`, never `~/.claude/CLAUDE.md`
39
+ (stele:ADR-0016). Wiring the operator's machine-level conventions is a personal choice, not
40
+ an install step; do not add it back as a convenience.
41
+
42
+ The commands and `docs/quality-bar.md` are vendored under softer rules than the machinery
43
+ (stele:ADR-0023): a repo may edit its own copy of `/slice`, `/wrap-up` or the bar to say
44
+ something repo-specific, and an install keeps that edit rather than overwriting it. If you
45
+ edit one, say so — an edit made in an installed repo does not travel back to the toolkit.
46
+
47
+ The quality bar is the standard the slice `## Definition of Done` is measured against
48
+ (stele:ADR-0024). Cite it from the scaffolded `CLAUDE.md`; do not restate its rules there.
43
49
 
44
50
  ## 3. If the repo already has a hook framework
45
51
 
@@ -92,8 +98,8 @@ node scripts/init-method.mjs <target> --check
92
98
  ```
93
99
 
94
100
  Writes nothing; fails on a missing or broken hook, a drifted vendored **script**, a stale
95
- index, a red corpus, or a broken `~/.claude/CLAUDE.md` link. Report its output verbatim
96
- rather than summarising it as "installed".
101
+ index, or a red corpus. Report its output verbatim rather than summarising it as
102
+ "installed".
97
103
 
98
104
  `LOCAL` and `MISSING` lines are about commands only and are **not** failures — they say
99
105
  this repo adapted or declined one. Read them, mention them, do not "fix" them without
@@ -35,6 +35,13 @@ get recorded until they have gone stale (stele:ADR-0003).
35
35
  edge cases, error paths, boundaries, ordering and partial-failure hazards; **cost** —
36
36
  complexity class, allocation, IO in loops). Sonnet 5 for correctness; announce the
37
37
  delegation before it starts (`~/.claude/CLAUDE.md` §6).
38
+ - **Tell the reviewer to read the files itself and not to spawn subagents of its own.** A
39
+ brief that fans out and does not forbid recursion has unbounded cost: a 12-reviewer sweep
40
+ on 2026-07-30 became an estimated 40–50 agents, exhausted a month's API budget, and lost
41
+ eight batches mid-run — while the one reviewer that read its own files returned the best
42
+ report of the twelve. One reviewer deciding to parallelise is enough to reproduce that at
43
+ smaller scale, so the sentence belongs in every brief, not only the wide ones
44
+ (stele:ADR-0017).
38
45
  - Brief it **blind to intent, aware of law**: give it the diff, this repo's `CLAUDE.md`,
39
46
  and `adr/INDEX.md`. Do *not* give it the conversation, your rationale, or the slice's
40
47
  claims about itself. A reviewer handed the reasoning returns the reasoning; one handed
package/CHANGELOG.md ADDED
@@ -0,0 +1,86 @@
1
+ # Changelog
2
+
3
+ Generated by `scripts/build-changelog.mjs` from annotated git tags — never hand-edited
4
+ (ADR-0026). Run `npm run changelog` after tagging a release.
5
+
6
+ Every entry is a commit subject verbatim. Work committed after the newest tag is absent by
7
+ design and appears when the next release is tagged.
8
+
9
+ ## v0.4.1 — 2026-08-01
10
+
11
+ - docs(releasing): a change after the tag costs a version, not a moved tag (`5b3c621`)
12
+ - docs(readme): show the formats the linter enforces, and what the kit will not install (`f307d2e`)
13
+ - chore: 0.4.1 — README documents 0.4.0's behaviour, so it ships with it (`23ab57a`)
14
+ - chore: changelog for v0.4.0 (`c9f535e`)
15
+
16
+ ## v0.4.0 — 2026-08-01
17
+
18
+ - feat: generate the changelog from tags, and stop there (ADR-0026) (`b242d33`)
19
+ - feat: reinvention must name a cost and measure the build (ADR-0025) (`d54711f`)
20
+ - feat: ship the quality bar with the method (ADR-0024) (`7ae3ca8`)
21
+ - fix: ship the changelog to the people it was written for (`6847262`)
22
+ - fix: refuse a managed path that is not a readable file, by name (0.4.0) (`9eed537`)
23
+ - fix: the vendor report says "copied", not "copyd" (`e7af0d5`)
24
+ - docs(adr-0024): the import-the-constant check is worth running after all (`f358cbf`)
25
+ - docs(ledger): a fan-out brief that permits recursion has unbounded cost (`5383f97`)
26
+ - docs(ledger): log the rule that would have caught a ledger losing a section (`5b19a6f`)
27
+ - docs(init-method): stop describing the global link ADR-0016 withdrew (`82d51bd`)
28
+ - chore: stop tracking npm pack output (`7915705`)
29
+
30
+ ## v0.3.0 — 2026-07-28
31
+
32
+ - feat: an update overwrites only what the repo has not touched (ADR-0023) (`8bcba5d`)
33
+ - feat: make the mutation check standing, wired into /wrap-up (ADR-0022) (`f269c6a`)
34
+ - fix: two defects the adversarial pass found in the published machinery (`97bedd6`)
35
+ - fix: /adr computes the next id across both document directories (`75d36ae`)
36
+ - test: pin six behaviours a mutation probe found unpinned (`19a45f9`)
37
+ - chore: 0.3.0 — an update no longer discards local command adaptations (`bdbaf86`)
38
+ - chore: 0.2.1 — patch the two defects found after publishing 0.2.0 (`7d2086e`)
39
+
40
+ ## v0.2.0 — 2026-07-27
41
+
42
+ - feat: check citations wherever they are read (ADR-0020) (`68e56ac`)
43
+ - feat: enforce document immutability with a git-aware check (ADR-0019) (`a165fa1`)
44
+ - feat: give the coverage layer an instrument — the adversarial pass (ADR-0017) (`6e2fa6d`)
45
+ - fix: the hook checks every path a rule reads (ADR-0021) (`dec6967`)
46
+ - fix: close the three remaining false greens found by the audit (`b3efdcb`)
47
+ - fix: check the commit rather than the working tree in the pre-commit hook (ADR-0018) (`5144718`)
48
+ - docs: log six enforcement gaps found by auditing the machinery (`562f838`)
49
+ - chore: 0.2.0 — new error-severity rules change installed behaviour (`4fc52f6`)
50
+ - chore: close npm-cleanup ledger item — 0.1.2 published (ADR-0016) (`e8f8812`)
51
+
52
+ ## v0.1.2 — 2026-07-23
53
+
54
+ - feat: surface the reuse decision (ADR-0013); slice records existing code considered (`c0a9034`)
55
+ - feat: cite the governing ADR at the code site (ADR-0012); /slice folds in ledger (`47a22eb`)
56
+ - feat(lint): rules 12/13 — slices carry Verification and a Gherkin Definition of Done (`8a8a4c0`)
57
+ - feat(init-method): compose with an existing pre-commit framework instead of taking the file (`719178b`)
58
+ - feat(init-method): vendor the slash commands, and let a repo adapt its copy (`c3ae30d`)
59
+ - feat(init): /init-method installs the kit, and refuses to brick the repo (ADR-0006) (`206c629`)
60
+ - feat(lint): rule 11 — verify scripts must be wired (ADR-0004) (`7d81dab`)
61
+ - feat(lint): rule 10 — an empty corpus is not a pass (`3a4c18e`)
62
+ - feat(method): route writes by what they govern, bounding memory (ADR-0005) (`2abf31c`)
63
+ - feat(method): add global CLAUDE.md for cross-repo working conventions (`6575f00`)
64
+ - feat(method): make the architectural type set per-repo (--arch) (`e98a039`)
65
+ - feat(method): enforcement layer — hook, index, commands, CI (Phase 4) (`6a21f42`)
66
+ - feat(method): migrate legacy ADRs to frontmatter (Phase 2) (`b8476de`)
67
+ - feat(method): document invariants, linter, and legacy scanner (`e26d709`)
68
+ - fix: stop the installer touching ~/.claude; drop the global-conventions link (ADR-0016) (`f6199b5`)
69
+ - fix: run the CLI when invoked via a bin symlink (npx no-op) (`53c61c8`)
70
+ - fix(lint): qualify cross-repo citations, unbreaking install into a fresh repo (`04b7afd`)
71
+ - fix(test): read rule codes by pattern, not by fixed width (`f6d709d`)
72
+ - fix(templates): stop the scaffold from duplicating global conventions (`a01c22c`)
73
+ - fix(migrate): don't read a supersession date as the decision date (`35e8792`)
74
+ - refactor: rename to Stele and prepare npm distribution as @isonimus/stele (`ef294ae`)
75
+ - docs: add contributing, security, code of conduct, and PR template (`3ea62f9`)
76
+ - docs: add README — the front door, mental model, and usage scenarios (`108502c`)
77
+ - docs(ledger): close the taxonomy question and the l33t migration (`7c0969d`)
78
+ - docs(adr): name the fourth kind — the live doc — superseding ADR-0001 (`42d7d61`)
79
+ - docs(ledger): record the commands gap and the decision to vendor them (`127dcd8`)
80
+ - docs(ledger): reconcile with boxel, which has now run the whole flow by hand (`78d9baf`)
81
+ - docs(global): expand §3 quality bar with structure, error-handling and test-quality rules (`684954c`)
82
+ - docs(adr): sanction dated corrections on immutable bodies (ADR-0001) (`efc4d29`)
83
+ - docs(ledger): record that an empty corpus lints green (`2902686`)
84
+ - docs(method): record that boxel has no CLAUDE.md (`6fcaa2b`)
85
+ - docs(method): correction covers ADR violations and subpar proposals (`64d0d54`)
86
+ - Add verification harness and standing-invariants conventions (ADR-0004) (`4a995a9`)
package/README.md CHANGED
@@ -39,7 +39,7 @@ Every document is exactly one kind. There is no fifth. ([ADR-0010](adr/0010-live
39
39
  | Kind | Files | Rule |
40
40
  |---|---|---|
41
41
  | **Immutable** | `adr/*.md`, `slices/*.md` | Written once. Body prose is never edited — only status/supersession fields change. |
42
- | **Generated** | `adr/INDEX.md` | Built from frontmatter by script. Never hand-edited. |
42
+ | **Generated** | `adr/INDEX.md`, and this repo's `CHANGELOG.md` | Built by script — from frontmatter, or from git tags. Never hand-edited. |
43
43
  | **Mutable** | `LEDGER.md` | Exactly one per repo. The only file maintained by hand. |
44
44
  | **Live** | `README.md`, `docs/*` | Describes how something behaves *now*; updated in the same change as the code it describes. |
45
45
 
@@ -63,6 +63,60 @@ free to change, because status and supersession are how a record announces it wa
63
63
 
64
64
  ---
65
65
 
66
+ ## The three formats
67
+
68
+ Everything the linter enforces is one of these. They are small on purpose: a schema you can
69
+ parse by hand is one you can also fix by hand at 6pm.
70
+
71
+ **An ADR's frontmatter.** Eight fields, all required, closed vocabularies for `type` and
72
+ `status` (R1–R3):
73
+
74
+ ```markdown
75
+ ---
76
+ id: '0007'
77
+ title: "Sessions are signed, not stored"
78
+ type: architecture # architecture | slice | batch
79
+ status: accepted # accepted | proposed | superseded | amended
80
+ date: 2026-03-14 # a real calendar date; 2026-02-30 is rejected
81
+ supersedes: [] # [0003] — and 0003 must point back (R4–R7)
82
+ superseded_by: []
83
+ ---
84
+
85
+ # ADR-0007 — Sessions are signed, not stored
86
+
87
+ ## Context
88
+ ## Decision
89
+ ## Consequences
90
+ ```
91
+
92
+ **A ledger line.** One per item, and closing it means deleting the line (R8 checks the
93
+ citation resolves):
94
+
95
+ ```markdown
96
+ - [bug] Token refresh races when two tabs renew at once; the loser gets a 401 and no retry.
97
+ Reproduced on staging 2026-03-02 (ADR-0007).
98
+ ```
99
+
100
+ `type` is `bug` | `feature` | `deferred` | `audit`.
101
+
102
+ **A Definition of Done.** Acceptance criteria as Given/When/Then, written before the code
103
+ (R13 requires a full triad, R12 requires `## Verification` alongside it):
104
+
105
+ ```markdown
106
+ ## Definition of Done
107
+
108
+ - **Given** a session signed 25 hours ago
109
+ **When** the client calls any authenticated endpoint
110
+ **Then** the response is 401 and the body names `token_expired`
111
+
112
+ ## Verification
113
+
114
+ `test/session-expiry.test.mjs`, plus `scripts/session-verify.mjs` for the clock-skew case a
115
+ unit test cannot assert — wired into `package.json`, or R11 fails the commit.
116
+ ```
117
+
118
+ ---
119
+
66
120
  ## Quickstart
67
121
 
68
122
  Install into a git repo — dry-run first, always ([ADR-0006](adr/0006-init-method-bootstrap.md)).
@@ -94,17 +148,43 @@ From a clone of this repo, the same entry point runs directly:
94
148
  node scripts/init-method.mjs <repo-root> --apply
95
149
  ```
96
150
 
97
- It installs `CLAUDE.md`, `LEDGER.md`, the linter, the index builder, and a pre-commit hook —
151
+ It installs `CLAUDE.md`, `LEDGER.md`, `docs/quality-bar.md`, the linter, the index builder,
152
+ and a pre-commit hook —
98
153
  and **refuses to install the hook on a linter-red corpus**, because a hook that blocks every
99
154
  commit is the tool bricking the repo it was meant to protect. If a hook framework already
100
155
  owns the pre-commit slot, the doc checks join it rather than fight for the file
101
156
  ([ADR-0008](adr/0008-compose-with-an-existing-hook-framework.md)).
102
157
 
158
+ It also refuses, by name, when a path it manages is not a file it can read and write — a
159
+ directory where `CLAUDE.md` should be, or a symlink that does not resolve. Nothing is written
160
+ in that state, in any mode: the alternative was an `EISDIR` stack trace naming `readFileSync`,
161
+ or, for an unresolvable link, silently writing through it.
162
+
103
163
  The linter and slash commands are **vendored per repo** and a repo's local edits to the
104
164
  commands survive re-runs — including across an `--update`
105
165
  ([ADR-0023](adr/0023-vendored-commands-and-what-an-update-may-overwrite.md), superseding
106
166
  [ADR-0007](adr/0007-commands-are-vendored-and-adaptable.md)).
107
167
 
168
+ `docs/quality-bar.md` rides the same rules. It is the standard a slice's
169
+ `## Definition of Done` is measured against — no `any`, fail loud, no magic values, the
170
+ testing rule that a test derives from the spec and never from the code
171
+ ([ADR-0024](adr/0024-the-quality-bar-ships-with-the-method.md)), and the rule that where a
172
+ well-tested library or standard already covers the need it is proposed **by name** before any
173
+ bespoke design, with a departure argued from a property it would cost *and* a measured build
174
+ cost ([ADR-0025](adr/0025-the-established-solution-is-the-default.md)). Shipping the demand for a
175
+ Definition of Done without the standard it is judged by left every consumer to supply the
176
+ standard themselves. Adapt it freely: a repo whose stack has no `any` should cut that rule,
177
+ and an `--update` keeps the cut.
178
+
179
+ Almost all of it is **`review-only`** and says so in the file. No linter here can tell
180
+ whether a test was derived from a specification or from the implementation it tests; that is
181
+ what the adversarial pass in `/wrap-up` is for, and claiming otherwise would be the failure
182
+ this kit exists to remove. One subset *is* mechanical and the bar says so: a test asserting a
183
+ constant it imported from the module under test is the assertion `K === K`, it passes whatever
184
+ `K` is, and an AST linter can find it. Catching some instances of a defect beats catching
185
+ none — the original claim that this was not worth checking was withdrawn by amendment after a
186
+ wider count found six sites in one suite.
187
+
108
188
  ---
109
189
 
110
190
  ## Commands
@@ -127,6 +207,7 @@ npm run lint # node scripts/lint-docs.mjs . — the invariant checker
127
207
  npm run index # regenerate adr/INDEX.md
128
208
  npm run immutable # immutable bodies only gained lines since HEAD
129
209
  npm run mutants # do the tests actually bite? (mutation check)
210
+ npm run changelog # regenerate CHANGELOG.md from git tags (this repo only)
130
211
  npm test # the regression suite (every rule has a fixture)
131
212
  ```
132
213
 
@@ -255,6 +336,31 @@ happened ([ADR-0005](adr/0005-write-routing-and-the-bounds-of-memory.md)):
255
336
  A rule that governs a codebase never belongs in assistant memory: memory is invisible to
256
337
  every other reader of the repo, unversioned, and lost on a change of machine.
257
338
 
339
+ One artifact moved off row two. General *working practice* — the quality bar — now ships
340
+ with the method as `docs/quality-bar.md` rather than living only in a personal global file
341
+ ([ADR-0024](adr/0024-the-quality-bar-ships-with-the-method.md)). ADR-0005 rejected
342
+ per-repo copies for having no reconciliation point; the vendoring record built in ADR-0023
343
+ is that point, so the rejection is answered rather than overruled. The table itself still
344
+ governs everything else.
345
+
346
+ ---
347
+
348
+ ## What this kit does *not* install
349
+
350
+ Stele publishes itself to npm, so it carries release machinery: annotated tags as release
351
+ boundaries, a `CHANGELOG.md` generated from them, and [`docs/releasing.md`](docs/releasing.md)
352
+ for the order of the steps. **None of it is installed into your repo, and no rule requires any
353
+ of it.** Nor are `CONTRIBUTING.md`, `SECURITY.md`, a code of conduct or a PR template
354
+ scaffolded ([ADR-0026](adr/0026-the-changelog-is-generated-and-release-engineering-stops-at-this-repo.md)).
355
+
356
+ Three reasons, and the third is the one that decides it. Most repos using this kit publish
357
+ nothing, so a release convention there is ceremony against nobody. `CLAUDE.md` and `LEDGER.md`
358
+ *are* scaffolded because the method reads and writes them — community-health files are
359
+ load-bearing for nothing it does, and adding them would make this a repo scaffolder, which
360
+ GitHub already ships and tests better than we would. And `SECURITY.md` is a **promise** — a
361
+ disclosure address, a response window. A missing one says "no stated policy", which is true;
362
+ a templated one says something false to whoever reads it in the moment they rely on it.
363
+
258
364
  ---
259
365
 
260
366
  ## Design principles
@@ -266,7 +372,10 @@ every other reader of the repo, unversioned, and lost on a change of machine.
266
372
  - **Grammar over toolchain.** Given/When/Then is adopted as *writing discipline*, not a test
267
373
  framework — the verify scripts are the executable layer.
268
374
  - **The diff is the audit trail.** Immutable records, generated indexes, and single-writer
269
- ledgers mean the git log *is* the history no hand-maintained changelog to drift.
375
+ ledgers mean the git log *is* the history. This repo's own `CHANGELOG.md` follows from that
376
+ rather than sitting beside it: it is generated from annotated tags, never hand-written, so
377
+ there is no second copy of the facts to drift
378
+ ([ADR-0026](adr/0026-the-changelog-is-generated-and-release-engineering-stops-at-this-repo.md)).
270
379
 
271
380
  For the reasoning behind any of these, read the ADR it links to. That is what the ADRs are
272
381
  for.
@@ -0,0 +1,169 @@
1
+ # Quality bar
2
+
3
+ The standard a slice's `## Definition of Done` is measured against (stele:ADR-0024). This
4
+ file ships with the method and is vendored into this repo; it is a **live doc**
5
+ (stele:ADR-0010) — update it in the same change as the practice it describes.
6
+
7
+ It is yours to adapt. An `--update` keeps an edit you made here and reports it, and
8
+ overwrites this file only if the repo never touched it (stele:ADR-0023). A rule that does
9
+ not apply to this stack should be cut rather than ignored.
10
+
11
+ Each section says how it is **enforced**, in the vocabulary of `CLAUDE.md` §4 —
12
+ `verified_by: <script>`, `pending (LEDGER)`, `review-only`. Most of this is `review-only`:
13
+ it is enforced by `/wrap-up` and by review, not by a script. That is stated rather than
14
+ glossed, because a standard that overstates its own enforcement is the failure
15
+ stele:ADR-0003 exists to prevent, committed one level up.
16
+
17
+ ## 1. Correctness — `review-only`
18
+
19
+ - **Never use `any`.** No exceptions. `unknown` plus narrowing, generics, or proper types.
20
+ In a language without `any`, the rule is the same one: no escape hatch that turns off the
21
+ checker for a value you did not want to describe.
22
+ - **Fail loud.** No swallowed errors — no empty `catch`, no catch-log-and-continue past a
23
+ broken invariant, no default or fallback that masks a real failure. Validate at boundaries
24
+ and stop at the first sign something is wrong. A silenced error is a bug debugged twice:
25
+ once now, blind, and once later for real.
26
+ - **No hacks, no quick fixes, no workarounds.** Find the root cause and fix it for good. A
27
+ symptom silenced is a bug rescheduled.
28
+ - **No magic values.** A bare `86400` or `0.15` in a branch is a latent bug — name it as a
29
+ constant whose name explains what it is and why.
30
+
31
+ ## 2. Design — `review-only`
32
+
33
+ - **Lean, purposeful code — KISS.** The simplest thing that works. Complexity must earn its
34
+ place, and unexplained complexity is a defect. No speculative generality, no abstraction
35
+ without a present need. Fight cyclomatic complexity by extracting composable, well-named
36
+ functions **for readability and reuse — never to hit a number**; decomposition that adds
37
+ indirection without adding clarity is its own smell.
38
+ - **Cohesion and DRY, by the rule of three.** Modules stay cohesive; a piece of logic lives
39
+ in one place. Reach for reuse or composition on the **third** instance of the same logic —
40
+ not the first, which is the speculative generality forbidden above. Apply SOLID only where
41
+ it earns its keep; misapplied, it produces exactly the over-abstraction this bar exists to
42
+ prevent.
43
+ - **Names are accurate descriptors.** No `x`, `tmp`, `data2`, `handle2`. A name states the
44
+ thing's intent and its meaning in the domain. When a good name is hard to find, the design
45
+ is usually the problem, not the vocabulary.
46
+ - **Comments explain WHY, not WHAT** — the code already shows what. No commented-out code and
47
+ no unused exports left behind; dead code is deleted, not parked. Version control remembers
48
+ it.
49
+ - **Never hardcode absolute paths** in config files, scripts, or commands. Always relative.
50
+ - **Prefer the established solution; argue any departure in writing** (stele:ADR-0025). Where a
51
+ well-tested library, pattern, protocol, or industry standard already covers the need, propose
52
+ it first and **by name**, before drawing a bespoke design — including when the operator asked
53
+ for the bespoke build, since asking for one is not evidence that none exists. Hand-rolling is
54
+ the exception and it is a legitimate one, under two conditions together: it names the property
55
+ the standard would cost (a hard constraint, a dependency budget, a platform or licence limit)
56
+ **and** it states the build cost as a measurement, not an estimate. What is not admissible is
57
+ reinventing by default, or meeting the standard after the code is written — at that point the
58
+ sunk build argues for itself and the comparison is a design against a rewrite. Both cases are
59
+ on the record in the repo that ships this file: `stele:ADR-0022` declines a mutation-testing
60
+ framework, naming the zero-dependency property it would cost and the twenty lines its
61
+ replacement takes; `stele:ADR-0008` is the bill for the omission — a design that assumed the
62
+ pre-commit slot was free met the widely-deployed tool that owns it three ADRs later, and pays
63
+ for a second install shape permanently. Inside a single codebase the same rule is
64
+ `stele:ADR-0013`.
65
+
66
+ ## 3. Design first — measure twice, cut once — `verified_by: lint-docs.mjs` (partly)
67
+
68
+ Design before implementation. The decision is written down *before* the code, and the doc
69
+ and the code ship in the same commit. R12 and R13 check that a slice carries its
70
+ `## Verification` and its `## Definition of Done`; they cannot check that a scenario is
71
+ *right*, which is what `/wrap-up` is for.
72
+
73
+ **Decisions cite measured data, not estimates.** If a design question has a measurable
74
+ answer, measure it — that is what `scripts/<topic>-probe.mjs` is for, and the number goes in
75
+ the ADR.
76
+
77
+ For anything non-trivial, **present the options and their tradeoffs before building.**
78
+
79
+ ## 4. Tests — `review-only`
80
+
81
+ - **Every relevant piece of logic gets a regression test — a good one.** No excuses, and no
82
+ irrelevant, duplicate, or fragile tests either. Test observable behaviour, not
83
+ implementation internals, and cover the error and edge paths, not just the happy one. Tests
84
+ are codebase: same standards, same strict typing.
85
+ - **A regression test must fail before the fix and pass after.** One that passes before
86
+ proves nothing.
87
+ - **Tests assert intended behaviour, not observed behaviour** (stele:ADR-0024). A test
88
+ derives from the spec — the slice's `## Definition of Done`, the API contract, the
89
+ reference implementation, the issue — never from reading the code and recording what it
90
+ currently returns. A test written from the code cannot fail when the code is wrong: it
91
+ detects change, and it will pin a bug in place and defend it against the fix.
92
+ - Where there is no prior behaviour to fail against, **write the expected value down before
93
+ running it**, and treat a first-run pass as unverified rather than as proof.
94
+ - Where no specification exists, **say so at the test site** and name what the expectation
95
+ is derived from instead. An invented citation is worse than an admitted gap.
96
+ - When the spec is ambiguous, **settle the spec.** Never let the implementation cast the
97
+ deciding vote.
98
+ - **Citing the source is not the same as opening it.** The finding behind this rule was a
99
+ test whose comment cited the reference implementation by class name and then stated the
100
+ wrong arithmetic. Read the source, not the writeup — including your own. An aggregated
101
+ report of somebody else's findings is a writeup too.
102
+ - **Never assert a value the test imported from the module under test.** `expect(f(x)).toBe(K)`
103
+ where `K` comes from the module that produced it is the assertion `K === K`; it passes
104
+ whatever `K` is, including wrong. Write the number, and put the derivation in a comment.
105
+ This is the one part of this rule a machine can find, and in the suite that motivated it
106
+ the form appeared six times — more often than any other test-basis defect
107
+ (stele:ADR-0024). If this repo has an AST linter, it should carry the rule; scope it to
108
+ **scalar constants**, since asserting an imported *enum member* (`toBe(ItemId.Apple)`) is
109
+ correct and was 594 of the hits when the scope was widened.
110
+
111
+ Neither instrument in the coverage layer detects a test derived from the implementation.
112
+ `npm run mutants` cannot: such a test kills its mutant perfectly well, because mutating the
113
+ constant breaks the test that asserts the constant (stele:ADR-0022). The adversarial pass in
114
+ `/wrap-up` is the enforcement (stele:ADR-0017) — except for the imported-constant form above,
115
+ which is the rule's one mechanically checkable subset, and catching some instances of a defect
116
+ beats catching none.
117
+
118
+ ## 5. Finishing — `review-only`
119
+
120
+ - **Boyscout rule:** leave every touched file better than found. A noticed bug is ours even
121
+ if we did not introduce it — fix it, or log it in `LEDGER.md` if deferred.
122
+ - **Verify before declaring done.** Never report a task complete without running this repo's
123
+ own checks — typecheck, linter, tests. "Done" means the checks ran and passed in this
124
+ conversation, and that what ran was said out loud — not that the change looks right.
125
+
126
+ ## 6. Commits — `review-only`
127
+
128
+ - **Explain WHY, not WHAT.** The diff already shows what changed.
129
+ - **Atomic and coherent** — one logical change per commit.
130
+ - **Ship the doc and the code in the same commit.**
131
+ - **Commit only when asked.** Never commit unprompted.
132
+
133
+ ## 7. The operator may be wrong — `review-only`
134
+
135
+ The operator is not infallible. If a proposal, observation, or assumption is incorrect, say
136
+ so directly, with data or a clear explanation. Deferring to a wrong idea to be agreeable
137
+ builds on a bad premise; being corrected early is cheaper for everyone.
138
+
139
+ Correction is not only for factual errors. When the operator asks for something that violates
140
+ a recorded decision without justifiable reason, or proposes a subpar fix, feature, or plan,
141
+ push back the same way — with evidence and a concrete better option — and where a standard
142
+ already solves the problem, the better option is that standard, named (§2). An operator can lack
143
+ context a decision record already settled, so citing it *is* the correction. If a violation
144
+ turns out to be justified, that justification is written down as a new or superseding ADR —
145
+ never a silent exception.
146
+
147
+ This cuts both ways: when the evidence contradicts a convention stated here, report that too.
148
+
149
+ **Explain the reasoning behind operating choices**, do not just execute. An unexplained
150
+ choice teaches nothing.
151
+
152
+ ## 8. Token economy — `review-only`
153
+
154
+ - The main model plans, reviews, corrects, and writes tests. Delegate rock-mining
155
+ (mechanical refactors, boilerplate, broad surveys) to cheaper subagents — a small fast
156
+ model for mechanical work, a mid-tier one for work needing judgement — instructed to return
157
+ minimal, structured output so the main context stays clean.
158
+ - **Say what was delegated, to which agent and model, and why** — one line, before the work
159
+ starts. Delegation is otherwise invisible: a subagent's reasoning never reaches the main
160
+ conversation, so an unannounced handoff means a result arrives with no way to judge how much
161
+ to trust it, and no chance to object that the task needed judgement rather than a cheaper
162
+ model.
163
+ - When context is deep and the task is clearly switching, **say it is a good moment to
164
+ `/compact`** — it gets forgotten, and it causes context rot.
165
+
166
+ ## 9. Language — `review-only`
167
+
168
+ **Everything written is in English** — code, comments, commits, docs, and user-facing or
169
+ creative copy alike — regardless of the language of the conversation.
@@ -0,0 +1,84 @@
1
+ # Releasing
2
+
3
+ How a version of `@isonimus/stele` reaches npm. A **live doc** (ADR-0010): update it in the
4
+ same change as the procedure it describes.
5
+
6
+ It exists because the steps below were previously done from memory, in the repo whose thesis
7
+ is that a rule enforced by memory holds until the first busy afternoon (ADR-0003). Two things
8
+ had already gone wrong by the time it was written: three published versions had **no git tags
9
+ at all**, and a build tarball had been sitting in version control since 2026-07-27.
10
+
11
+ ## The order, and why it is this order
12
+
13
+ ```
14
+ 1. npm run lint && npm test the corpus is green and the suite passes
15
+ 2. npm run index regenerate adr/INDEX.md; commit if it moved
16
+ 3. edit package.json bump the version
17
+ 4. commit the last commit before the tag
18
+ 5. git tag -a vX.Y.Z -m "…" on the commit being published (ADR-0026)
19
+ 6. npm run changelog regenerate CHANGELOG.md from the new tag
20
+ 7. commit "chore: changelog for vX.Y.Z"
21
+ 8. git push && git push --tags tag and history reach the remote together
22
+ 9. npm publish irreversible: a version number is never reusable
23
+ 10. /init-method --update in every installed repo, so the fix actually lands
24
+ ```
25
+
26
+ **Why the tag comes before the changelog.** `scripts/build-changelog.mjs` reads tags and
27
+ nothing else, so a tag that does not exist yet produces a changelog missing its own release.
28
+ Tagging first is not a preference; it is the data dependency.
29
+
30
+ **Why the changelog is a second commit, and why publish is one commit past the tag.** The
31
+ changelog describes commits up to and including the tagged one, so it cannot live inside the
32
+ object it is naming. `npm publish` therefore packs a tree one commit ahead of `vX.Y.Z` —
33
+ identical to it but for `CHANGELOG.md`, which is the file that could not have been there. That
34
+ skew is deliberate and bounded; anything else in the diff at step 9 means the release is not
35
+ what the tag says it is.
36
+
37
+ **Step 3 bumps the version, but step 5 tags the commit you are about to publish** — and on
38
+ 0.4.0 those were three commits apart, because work continued after the bump. `npm publish`
39
+ packs the working tree, not the version-bump commit, so a tag left behind on the bump would
40
+ name bytes nobody received. Bump last where you can; tag what ships where you cannot.
41
+
42
+ **Why publish is last and by hand.** `npm publish` cannot be undone: a version number is burned
43
+ even if the release is unpublished within the 72-hour window. Everything reversible happens
44
+ first, so the irreversible step runs against a state already checked.
45
+
46
+ **Why step 10 is part of the release.** The linter, the hook and the commands are *vendored*
47
+ per repo (ADR-0006, ADR-0023). Publishing changes nothing in boxel or gamatar until
48
+ `/init-method --update` runs there, so a release that stops at step 9 has shipped to the
49
+ registry and to nobody.
50
+
51
+ ## Version numbers
52
+
53
+ Pre-1.0, so the minor slot carries breaking changes:
54
+
55
+ - **patch** — a fix that changes no vendored file's behaviour.
56
+ - **minor** — a new linter rule, a change to a vendored command or to `docs/quality-bar.md`,
57
+ or any change to the installer's behaviour. A new rule makes commits fail that were green in
58
+ the previous version, which is breaking however small the diff.
59
+ - **major** — reserved for 1.0.
60
+
61
+ ## What the changelog does and does not say
62
+
63
+ It is generated from commit subjects between tags (ADR-0026). It therefore inherits exactly
64
+ the quality of the commit messages, which is the argument for writing them properly rather
65
+ than an argument for editing the changelog afterwards — **never hand-edit it**; the CI check
66
+ will fail and the edit is lost at the next regeneration.
67
+
68
+ It cannot say "you must run `--update` for this one". If a release needs that, say so in the
69
+ release commit's subject, where the changelog will pick it up verbatim.
70
+
71
+ ## If something goes wrong
72
+
73
+ - **Anything changed after tagging, including docs:** do not move the tag. Bump a patch
74
+ version, tag that, and publish it instead — the working tree is what `npm publish` packs, so
75
+ a tree that has moved past the tag is no longer the release the tag names. This happened on
76
+ the first run of this procedure: `v0.4.0` was tagged, a README update followed, and `0.4.1`
77
+ was published in its place. `v0.4.0` stays in the history as a tagged release that was never
78
+ published, which the changelog shows and which is the honest record (`0.2.1` is the same).
79
+ - **Tagged the wrong commit, not yet pushed:** `git tag -d vX.Y.Z`, retag, regenerate.
80
+ - **Tagged and pushed the wrong commit:** do not move the tag. Tag the correction as the next
81
+ patch version — a moved tag means two clones disagree about what a release was.
82
+ - **Published a broken version:** publish the fix as a new version. Unpublishing is available
83
+ for 72 hours and is worse than a superseding release: it breaks every lockfile that already
84
+ pinned it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isonimus/stele",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "A linted, installable ADR workflow for git projects using Claude as an assistant",
6
6
  "keywords": [
@@ -29,9 +29,11 @@
29
29
  "files": [
30
30
  "scripts/",
31
31
  "templates/",
32
+ "docs/",
32
33
  ".claude/commands/",
33
34
  ".claude/hooks/",
34
35
  "README.md",
36
+ "CHANGELOG.md",
35
37
  "LICENSE"
36
38
  ],
37
39
  "publishConfig": {
@@ -41,6 +43,7 @@
41
43
  "test": "node --test test/*.test.mjs",
42
44
  "lint": "node scripts/lint-docs.mjs .",
43
45
  "index": "node scripts/build-index.mjs .",
46
+ "changelog": "node scripts/build-changelog.mjs .",
44
47
  "immutable": "node scripts/check-immutable.mjs",
45
48
  "mutants": "node scripts/check-mutants.mjs",
46
49
  "scan": "node scripts/scan-legacy.mjs",
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+ // Builds CHANGELOG.md from annotated git tags (ADR-0026). Generated, never hand-edited.
3
+ //
4
+ // node scripts/build-changelog.mjs [repo-root] write CHANGELOG.md
5
+ // node scripts/build-changelog.mjs --check [repo-root] fail if it is stale
6
+ //
7
+ // Tags are the only input, so the file changes when a release is tagged and at no other
8
+ // time — which is what makes --check affordable in CI. Commits made after the newest tag
9
+ // are deliberately absent until the next tag exists (ADR-0026).
10
+ //
11
+ // --check runs in CI only, and cannot move into the pre-commit hook: the hook checks the
12
+ // commit by extracting a `git archive` of the staged tree into a temp directory (ADR-0018),
13
+ // which has no `.git` and therefore no tags. Reading tags from the real repo instead would
14
+ // check the working tree while claiming to check the commit, which is the exact confusion
15
+ // ADR-0018 exists to prevent.
16
+
17
+ import { writeFileSync, readFileSync, existsSync, realpathSync } from 'node:fs';
18
+ import { execFileSync } from 'node:child_process';
19
+ import { join } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const CHANGELOG = 'CHANGELOG.md';
23
+
24
+ /** Conventional-commit types, in the order a reader cares about them. Anything else sorts
25
+ * last under its own literal prefix rather than being dropped: a commit that escaped the
26
+ * convention is still a change that shipped, and hiding it would make the file a claim
27
+ * about the corpus instead of a rendering of it. */
28
+ const TYPE_ORDER = ['feat', 'fix', 'perf', 'refactor', 'test', 'docs', 'chore'];
29
+
30
+ const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
31
+
32
+ /** The type prefix of a conventional subject (`feat(x): …` → `feat`), or null. */
33
+ export function commitType(subject) {
34
+ const match = /^([a-z]+)(\([^)]*\))?!?:/.exec(subject);
35
+ return match ? match[1] : null;
36
+ }
37
+
38
+ const typeRank = (subject) => {
39
+ const index = TYPE_ORDER.indexOf(commitType(subject));
40
+ return index === -1 ? TYPE_ORDER.length : index;
41
+ };
42
+
43
+ /**
44
+ * The tags, split into releases and everything this tool refuses to call one.
45
+ *
46
+ * A release is an **annotated** tag that dereferences to a **commit**, and both halves of
47
+ * that are load-bearing (ADR-0026, found by the ADR-0017 pass on this file):
48
+ *
49
+ * - `refs/tags` holds lightweight tags too — local bookmarks, bisect markers, CI leftovers —
50
+ * and counting one as a release publishes a section for something nobody released.
51
+ * - An annotated tag may point at a blob or a tree. `git rev-list -n 1` then yields the empty
52
+ * string, and the next git call inherits it as a ref and dies with a raw `fatal: ambiguous
53
+ * argument ''` plus a stack trace.
54
+ *
55
+ * Both are read straight off `for-each-ref` rather than probed afterwards: `%(*objecttype)`
56
+ * and `%(*objectname)` are the peeled object, so one call answers both questions and there is
57
+ * no window where a tag is assumed to be something it is not.
58
+ *
59
+ * Releases come back oldest first, ordered by the tagged **commit's** date rather than the
60
+ * tag's own — this repo's first three tags were written retroactively in one sitting, so
61
+ * creation order carries no release information whatever.
62
+ */
63
+ export function readTags(cwd) {
64
+ const lines = git(cwd, [
65
+ 'for-each-ref',
66
+ '--format=%(refname:short)\t%(objecttype)\t%(*objecttype)\t%(*objectname)',
67
+ 'refs/tags',
68
+ ]);
69
+ if (lines === '') return { releases: [], skipped: [] };
70
+
71
+ const releases = [];
72
+ const skipped = [];
73
+
74
+ for (const line of lines.split('\n')) {
75
+ const [name, objectType, peeledType, peeledCommit] = line.split('\t');
76
+ if (objectType !== 'tag') {
77
+ skipped.push({ name, why: 'lightweight — a release tag is annotated (git tag -a)' });
78
+ continue;
79
+ }
80
+ if (peeledType !== 'commit') {
81
+ skipped.push({ name, why: `annotated but points at a ${peeledType || 'non-commit object'}` });
82
+ continue;
83
+ }
84
+ // Full timestamp for ordering, short date for display: two releases on one day are
85
+ // ordinary, and a date-only sort would fall back to alphabetical refname order, which
86
+ // puts v0.10.0 before v0.9.0.
87
+ const [orderedBy, date] = git(cwd, ['log', '-1', '--format=%aI\t%ad', '--date=short', peeledCommit]).split('\t');
88
+ releases.push({ name, commit: peeledCommit, orderedBy, date });
89
+ }
90
+
91
+ releases.sort((a, b) => (a.orderedBy < b.orderedBy ? -1 : a.orderedBy > b.orderedBy ? 1 : 0));
92
+ return { releases, skipped };
93
+ }
94
+
95
+ /**
96
+ * Subject and short SHA of every commit reachable from `to` and from none of `alreadyShipped`,
97
+ * newest first.
98
+ *
99
+ * Excluding *every* earlier release rather than only the previous one is what keeps a commit
100
+ * in one section. A tagged branch that merges in later is reachable from both its own tag and
101
+ * the merge's tag, so a plain `previous..this` range reports it twice — once under the release
102
+ * that shipped it and again under the release that merged it (ADR-0017 pass on this file).
103
+ *
104
+ * Merges themselves are excluded: a merge commit's subject records an integration, not a
105
+ * change, and the commits it brings in are listed individually.
106
+ */
107
+ export function commitsSince(cwd, alreadyShipped, to) {
108
+ const lines = git(cwd, ['log', '--no-merges', '--format=%h\t%s', to, '--not', ...alreadyShipped]);
109
+ if (lines === '') return [];
110
+
111
+ return lines.split('\n').map((line) => {
112
+ const separator = line.indexOf('\t');
113
+ return { sha: line.slice(0, separator), subject: line.slice(separator + 1) };
114
+ });
115
+ }
116
+
117
+ /** The document. Pure, so the shape is testable without a repo. */
118
+ export function renderChangelog(releases) {
119
+ const head = `# Changelog
120
+
121
+ Generated by \`scripts/build-changelog.mjs\` from annotated git tags — never hand-edited
122
+ (ADR-0026). Run \`npm run changelog\` after tagging a release.
123
+
124
+ Every entry is a commit subject verbatim. Work committed after the newest tag is absent by
125
+ design and appears when the next release is tagged.
126
+ `;
127
+
128
+ if (releases.length === 0) {
129
+ return `${head}\nNo tags yet, so no releases to describe.\n`;
130
+ }
131
+
132
+ const sections = [...releases].reverse().map(({ name, date, commits }) => {
133
+ const body = commits.length === 0
134
+ ? '\nNo commits between this tag and the previous one.\n'
135
+ : `\n${[...commits]
136
+ .sort((a, b) => typeRank(a.subject) - typeRank(b.subject))
137
+ .map(({ sha, subject }) => `- ${subject} (\`${sha}\`)`)
138
+ .join('\n')}\n`;
139
+ return `## ${name} — ${date}\n${body}`;
140
+ });
141
+
142
+ return `${head}\n${sections.join('\n')}`;
143
+ }
144
+
145
+ /** The document this repo's tags describe, and the tags that were not treated as releases. */
146
+ export function buildChangelog(cwd) {
147
+ const { releases, skipped } = readTags(cwd);
148
+ return {
149
+ text: renderChangelog(
150
+ releases.map((release, index) => ({
151
+ ...release,
152
+ commits: commitsSince(cwd, releases.slice(0, index).map((earlier) => earlier.commit), release.commit),
153
+ })),
154
+ ),
155
+ skipped,
156
+ };
157
+ }
158
+
159
+ function main(argv) {
160
+ const check = argv.includes('--check');
161
+ const cwd = argv.find((arg) => !arg.startsWith('--')) ?? process.cwd();
162
+ const path = join(cwd, CHANGELOG);
163
+ const { text: wanted, skipped } = buildChangelog(cwd);
164
+
165
+ // Named rather than dropped in silence: a release tagged with a bare `git tag` produces a
166
+ // changelog missing that release, and the only symptom is an absence nobody looks for.
167
+ for (const { name, why } of skipped) {
168
+ console.error(`note: tag ${name} is not treated as a release — ${why}`);
169
+ }
170
+
171
+ if (!check) {
172
+ writeFileSync(path, wanted);
173
+ console.log(`wrote ${CHANGELOG}`);
174
+ return 0;
175
+ }
176
+
177
+ if (!existsSync(path)) {
178
+ console.error(`${CHANGELOG} is missing — run \`npm run changelog\`.`);
179
+ return 1;
180
+ }
181
+ if (readFileSync(path, 'utf8') !== wanted) {
182
+ console.error(`${CHANGELOG} does not match the tags — a release was tagged without regenerating it. Run \`npm run changelog\`.`);
183
+ return 1;
184
+ }
185
+ console.log(`${CHANGELOG} matches the tags`);
186
+ return 0;
187
+ }
188
+
189
+ // Resolved on both sides before comparing: a raw `file://${argv[1]}` check is false whenever
190
+ // the script is reached through a symlink, and main() then silently never runs (ADR-0015
191
+ // amendment, where that cost an install that exited 0 and did nothing).
192
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
193
+ process.exit(main(process.argv.slice(2)));
194
+ }
@@ -31,11 +31,15 @@ const LINTER = 'scripts/lint-docs.mjs';
31
31
  const IMMUTABLE = 'scripts/check-immutable.mjs';
32
32
 
33
33
  /** ADR-0022 excluded init-method.mjs wholesale, reasoning that a mutant there mostly proves
34
- * the filesystem still works. `classifyCommand` (ADR-0023) is the first total, IO-free
34
+ * the filesystem still works. `classifyVendored` (ADR-0023) is the first total, IO-free
35
35
  * predicate in that file, so it falls inside the scope ADR-0022 actually described rather
36
36
  * than the file it named. Recorded as a refinement, never a silent exception. */
37
37
  const INIT = 'scripts/init-method.mjs';
38
38
 
39
+ /** The changelog's type classifier and its ordering are total and IO-free, so they fall in
40
+ * the same scope as the linter's predicates — the git-reading half does not (ADR-0026). */
41
+ const CHANGELOG = 'scripts/build-changelog.mjs';
42
+
39
43
  /**
40
44
  * The curated mutant list. Each entry names one behaviour and the smallest edit that
41
45
  * reverses it.
@@ -98,13 +102,13 @@ export const MUTANTS = [
98
102
  replace: 'if (!isSuperseded && supersededBy.length > 1) {',
99
103
  },
100
104
  {
101
- label: 'classifyCommand: treat an unrecorded command as stale rather than unknown',
105
+ label: 'classifyVendored: treat an unrecorded command as stale rather than unknown',
102
106
  file: INIT,
103
107
  find: "if (recordedDigest === undefined) return 'unknown';",
104
108
  replace: "if (recordedDigest === undefined) return 'stale';",
105
109
  },
106
110
  {
107
- label: 'classifyCommand: swap the stale/adapted verdict',
111
+ label: 'classifyVendored: swap the stale/adapted verdict',
108
112
  file: INIT,
109
113
  find: "return digest(targetText) === recordedDigest ? 'stale' : 'adapted';",
110
114
  replace: "return digest(targetText) === recordedDigest ? 'adapted' : 'stale';",
@@ -115,6 +119,18 @@ export const MUTANTS = [
115
119
  find: 'if (cursor === now.length) return i;',
116
120
  replace: 'if (cursor > now.length) return i;',
117
121
  },
122
+ {
123
+ label: 'commitType: accept a subject with no colon as conventional',
124
+ file: CHANGELOG,
125
+ find: "/^([a-z]+)(\\([^)]*\\))?!?:/",
126
+ replace: "/^([a-z]+)(\\([^)]*\\))?!?:?/",
127
+ },
128
+ {
129
+ label: 'typeRank: sort an unrecognised type first rather than last',
130
+ file: CHANGELOG,
131
+ find: 'return index === -1 ? TYPE_ORDER.length : index;',
132
+ replace: 'return index === -1 ? 0 : index;',
133
+ },
118
134
  {
119
135
  label: 'firstLostLine: compare body lines loosely',
120
136
  file: IMMUTABLE,
@@ -12,7 +12,7 @@
12
12
  // documents it would report "0 document(s) — ok" (the reason rule 10 exists), certifying
13
13
  // an install that checks nothing.
14
14
 
15
- import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, lstatSync, readlinkSync, symlinkSync, unlinkSync, readdirSync, realpathSync } from 'node:fs';
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, lstatSync, statSync, accessSync, constants as fsConstants, readlinkSync, symlinkSync, unlinkSync, readdirSync, realpathSync } from 'node:fs';
16
16
  import { createHash } from 'node:crypto';
17
17
  import { join, dirname, relative } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
@@ -35,21 +35,36 @@ const VENDORED = [
35
35
 
36
36
  const COMMANDS_DIR = '.claude/commands';
37
37
 
38
+ /** Prose vendored alongside the commands and under the same rules: adaptable, kept on
39
+ * update unless untouched. The quality bar is the standard a slice's Definition of Done
40
+ * is measured against, so it has to reach the repo the Definition of Done lives in
41
+ * (ADR-0024). A Python repo cutting the rule about `any` is use, not drift. */
42
+ export const ADAPTABLE_DOCS = ['docs/quality-bar.md'];
43
+
38
44
  /**
39
- * The slash commands, vendored too (ADR-0023) — same target path as toolkit path.
45
+ * Everything vendored under the adaptable rules (ADR-0023) — same target path as toolkit
46
+ * path, which is what lets one classification serve both `--update` and `--check`.
40
47
  *
41
- * Read from disk rather than listed, so a new command reaches installed repos without
42
- * anyone remembering to extend an array here.
48
+ * Commands are read from disk rather than listed, so a new command reaches installed repos
49
+ * without anyone remembering to extend an array here.
43
50
  */
44
- const commandFiles = (toolkit) =>
45
- readdirSync(join(toolkit, COMMANDS_DIR))
51
+ const adaptableFiles = (toolkit) => [
52
+ ...readdirSync(join(toolkit, COMMANDS_DIR))
46
53
  .filter((name) => name.endsWith('.md'))
47
54
  .sort()
48
- .map((name) => `${COMMANDS_DIR}/${name}`);
55
+ .map((name) => `${COMMANDS_DIR}/${name}`),
56
+ ...ADAPTABLE_DOCS,
57
+ ];
49
58
 
50
59
  /**
51
- * What the toolkit last handed this repo: command path → SHA-256 of the content written
52
- * there (ADR-0023).
60
+ * What the toolkit last handed this repo: vendored adaptable path → SHA-256 of the content
61
+ * written there (ADR-0023).
62
+ *
63
+ * The JSON key is still `commands`, though the set now includes `docs/quality-bar.md`
64
+ * (ADR-0024). Renaming it would mean bumping PROVENANCE_VERSION, and an unrecognised
65
+ * version is deliberately read as *no record at all* — so a cosmetic rename would
66
+ * reclassify every command in every installed repo as `unknown` on the next run. The
67
+ * inaccuracy is cheaper than that, and this comment is the fix.
53
68
  *
54
69
  * Without it an update sees two states where three are needed — a stale copy of an older
55
70
  * release and a deliberate local adaptation are the same observation, "differs from the
@@ -127,6 +142,76 @@ function isSymlink(path) {
127
142
  }
128
143
  }
129
144
 
145
+ /**
146
+ * Managed paths in the target that exist but are not a file this tool can read and write.
147
+ *
148
+ * `existsSync` answers the wrong question twice, and every managed path lives in a repo we
149
+ * do not control. It is **true** for a directory, so the read crashes with an `EISDIR` that
150
+ * names `readFileSync` rather than the offending path; it is **false** for a symlink that
151
+ * does not resolve, so the path reads as *absent* — `--check` calls it missing and `--apply`
152
+ * writes straight through the link. Refusing by name is the fix, for the reason the hook
153
+ * install refuses rather than warns (ADR-0006): a stack trace tells the operator nothing
154
+ * about which file is wrong.
155
+ *
156
+ * `.git/hooks/pre-commit` is deliberately a symlink and is judged by installHook(), not here.
157
+ */
158
+ function unusablePaths({ target, toolkit }) {
159
+ const managed = [
160
+ ...SCAFFOLD.map(([dest]) => dest),
161
+ ...VENDORED.map(([dest]) => dest),
162
+ ...adaptableFiles(toolkit),
163
+ join('adr', 'INDEX.md'),
164
+ PROVENANCE,
165
+ FRAMEWORK_CONFIG,
166
+ ];
167
+
168
+ return managed.flatMap((dest) => {
169
+ const path = join(target, dest);
170
+ let link;
171
+ try {
172
+ link = lstatSync(path, { throwIfNoEntry: false });
173
+ } catch (error) {
174
+ // throwIfNoEntry suppresses ENOENT and nothing else. ENOTDIR here means an ancestor
175
+ // exists as a file, so nothing at this path can be read, written or created — and
176
+ // pointing at the leaf would send the operator to the wrong file.
177
+ return [{ path, kind: error.code === 'ENOTDIR'
178
+ ? 'unreachable — a parent of it exists as a file rather than a directory'
179
+ : `cannot be inspected (${error.code})` }];
180
+ }
181
+ if (link === undefined) return []; // absent: the ordinary case
182
+ if (link.isFile()) return whenUnreadable(path);
183
+ if (!link.isSymbolicLink()) {
184
+ return [{ path, kind: link.isDirectory() ? 'a directory' : 'not a regular file' }];
185
+ }
186
+ // A symlink to a real file reads and writes fine, so it stays supported. statSync
187
+ // throws ELOOP on a cycle, which throwIfNoEntry does not suppress — the code it
188
+ // carries goes into the report rather than being discarded.
189
+ try {
190
+ if (statSync(path).isFile()) return whenUnreadable(path);
191
+ return [{ path, kind: `a symlink to ${readlinkSync(path)}, which is not a file` }];
192
+ } catch (error) {
193
+ return [{ path, kind: `a symlink to ${readlinkSync(path)} that cannot be resolved (${error.code})` }];
194
+ }
195
+ });
196
+ }
197
+
198
+ /**
199
+ * A file whose type is fine but whose permissions are not: `lstat` succeeds on a `chmod 000`
200
+ * file, and the read that follows throws a bare EACCES.
201
+ *
202
+ * Only readability is checked. Every managed path that exists is read unconditionally, while
203
+ * writes are conditional — so refusing on write permission too would reject a read-only
204
+ * vendored file that already matches the toolkit and needs no write at all.
205
+ */
206
+ function whenUnreadable(path) {
207
+ try {
208
+ accessSync(path, fsConstants.R_OK);
209
+ return [];
210
+ } catch (error) {
211
+ return [{ path, kind: `unreadable (${error.code})` }];
212
+ }
213
+ }
214
+
130
215
  /** The linter's error findings for a repo.
131
216
  * Always the repo ROOT: pointed at a subdirectory holding no documents the linter
132
217
  * reports "0 document(s) — ok", certifying an install that checks nothing (rule 10). */
@@ -151,7 +236,7 @@ function vendor({ target, toolkit, apply, report }) {
151
236
  }
152
237
  mkdirSync(dirname(to), { recursive: true });
153
238
  copyFileSync(from, to);
154
- report('wrote', to, `${verb}d from toolkit`);
239
+ report('wrote', to, `${verb === 'copy' ? 'copied' : 'updated'} from toolkit`);
155
240
  }
156
241
  }
157
242
 
@@ -199,7 +284,7 @@ function writeProvenance(target, commands) {
199
284
  *
200
285
  * @returns {'absent'|'current'|'stale'|'adapted'|'unknown'}
201
286
  */
202
- export function classifyCommand({ targetText, toolkitText, recordedDigest }) {
287
+ export function classifyVendored({ targetText, toolkitText, recordedDigest }) {
203
288
  if (targetText === null) return 'absent';
204
289
  if (targetText === toolkitText) return 'current';
205
290
  if (recordedDigest === undefined) return 'unknown';
@@ -213,21 +298,21 @@ const KEPT_REASON = {
213
298
  };
214
299
 
215
300
  /**
216
- * Slash commands, which are prose and therefore adaptable (ADR-0023).
301
+ * Slash commands and the quality bar — prose, and therefore adaptable (ADR-0023, ADR-0024).
217
302
  *
218
303
  * Copy-if-absent, unlike vendor(): a repo that has tailored `/slice` to its own workflow
219
304
  * must not have that overwritten. `--update` additionally refreshes anything the repo has
220
305
  * not touched, and only `--force` discards an adaptation (ADR-0023).
221
306
  */
222
- function vendorCommands({ target, toolkit, apply, update, force, report }) {
307
+ function vendorAdaptable({ target, toolkit, apply, update, force, report }) {
223
308
  const recorded = readProvenance(target, report);
224
309
  const learned = { ...recorded };
225
310
  let changed = false;
226
311
 
227
- for (const path of commandFiles(toolkit)) {
312
+ for (const path of adaptableFiles(toolkit)) {
228
313
  const to = join(target, path);
229
314
  const toolkitText = read(join(toolkit, path));
230
- const state = classifyCommand({
315
+ const state = classifyVendored({
231
316
  targetText: existsSync(to) ? read(to) : null,
232
317
  toolkitText,
233
318
  recordedDigest: recorded[path],
@@ -411,9 +496,9 @@ function check({ target, toolkit, report }) {
411
496
  // under which no command difference could be counted and so a shipped defect in one was
412
497
  // invisible in every installed repo).
413
498
  const recorded = readProvenance(target, report);
414
- for (const path of commandFiles(toolkit)) {
499
+ for (const path of adaptableFiles(toolkit)) {
415
500
  const to = join(target, path);
416
- const state = classifyCommand({
501
+ const state = classifyVendored({
417
502
  targetText: existsSync(to) ? read(to) : null,
418
503
  toolkitText: read(join(toolkit, path)),
419
504
  recordedDigest: recorded[path],
@@ -465,16 +550,27 @@ export function initMethod({ target, toolkit = TOOLKIT, mode = 'install', apply
465
550
  return { actions, problems: 1 };
466
551
  }
467
552
 
553
+ // Before anything reads or writes, and in every mode: a partial install is worse than no
554
+ // install, and --check cannot judge a state it would crash on. Every offending path is
555
+ // named, not just the first, so one re-run is enough to clear them.
556
+ const unusable = unusablePaths({ target, toolkit });
557
+ if (unusable.length > 0) {
558
+ for (const { path, kind } of unusable) {
559
+ report('problem', path, `${kind} — this tool reads and writes it as a file. Move or remove it, then re-run.`);
560
+ }
561
+ return { actions, problems: unusable.length };
562
+ }
563
+
468
564
  if (mode === 'check') {
469
565
  check({ target, toolkit, report });
470
566
  } else if (mode === 'update') {
471
567
  vendor({ target, toolkit, apply, report });
472
- vendorCommands({ target, toolkit, apply, update: true, force, report });
568
+ vendorAdaptable({ target, toolkit, apply, update: true, force, report });
473
569
  if (apply) lintAfterUpdate({ target, report });
474
570
  } else {
475
571
  scaffold({ target, toolkit, apply, report });
476
572
  vendor({ target, toolkit, apply, report });
477
- vendorCommands({ target, toolkit, apply, update: false, force: false, report });
573
+ vendorAdaptable({ target, toolkit, apply, update: false, force: false, report });
478
574
  buildIndex({ target, apply, report });
479
575
  installHook({ target, apply, report });
480
576
  }
@@ -4,10 +4,14 @@
4
4
 
5
5
  Stack: {{STACK}}
6
6
 
7
- General working practices — quality bar, commit hygiene, delegation, correction, language
8
- — live in `~/.claude/CLAUDE.md` and apply here without being restated. This file carries
9
- only what is specific to **this** repo. Restating a global rule here would create a second
10
- copy with no sync path, which is the failure stele:ADR-0005 exists to prevent.
7
+ General working practices — quality bar, testing standard, commit hygiene, delegation,
8
+ correction, language — live in [`docs/quality-bar.md`](docs/quality-bar.md) and apply here
9
+ without being restated. It ships with the method and is vendored into this repo, so it is
10
+ yours to adapt; an update keeps what you changed (stele:ADR-0024).
11
+
12
+ This file carries only what is specific to **this** repo. Restating a rule from the bar here
13
+ would create a second copy with no sync path, which is the failure stele:ADR-0005 exists to
14
+ prevent.
11
15
 
12
16
  ## 1. Document taxonomy — four kinds
13
17