@isonimus/stele 0.1.2

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.
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env node
2
+ // Reads UNMIGRATED ADRs and recovers their status and supersession claims from prose.
3
+ //
4
+ // Two jobs, deliberately in one place:
5
+ // 1. It is the input to migrate-adrs.mjs — the facts that become frontmatter.
6
+ // 2. It is the known-answer test (ADR-0003): run against boxel it must independently
7
+ // rediscover exactly the four catalogued defects and nothing else. A scanner that
8
+ // finds three, or five, is wrong and must not be trusted to drive a migration.
9
+ //
10
+ // Three claim sources, each matched to a dialect's structure — see extractSupersession.
11
+ // Every widening of them cost a false positive somewhere, so each is deliberately narrow.
12
+ //
13
+ // node scripts/scan-legacy.mjs <repo-root> [--json] [--defects]
14
+
15
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+
18
+ const normId = (v) => String(v).trim().padStart(4, '0');
19
+
20
+ /** Legacy status vocabulary -> the ADR-0002 closed set. */
21
+ const STATUS_MAP = {
22
+ accepted: 'accepted',
23
+ implemented: 'accepted', // used exactly once (boxel 0129)
24
+ proposed: 'proposed',
25
+ superseded: 'superseded',
26
+ amended: 'amended',
27
+ };
28
+
29
+ /**
30
+ * Recovers the status value across all three dialects.
31
+ *
32
+ * Anchoring matters more than pattern breadth here. `0095-status-effects.md` has an H1
33
+ * reading "# ADR 0095 — Status effects: ...", so a first-hit grep for /status/i returns
34
+ * the title. Every pattern below is anchored to line-start structure the title cannot
35
+ * satisfy, which defeats that hazard structurally rather than by special-casing.
36
+ */
37
+ function extractStatus(lines) {
38
+ // Dialect A (68 files): "## Status" heading, value on the next non-empty line.
39
+ const heading = lines.findIndex((l) => /^#{2,3}\s+Status\s*$/i.test(l.trim()));
40
+ if (heading !== -1) {
41
+ for (let i = heading + 1; i < Math.min(heading + 5, lines.length); i++) {
42
+ if (lines[i].trim()) return { raw: lines[i], line: i + 1, dialect: 'heading' };
43
+ }
44
+ }
45
+
46
+ for (let i = 0; i < Math.min(12, lines.length); i++) {
47
+ const line = lines[i];
48
+ // Dialect C (8 files, gamatar): "- Status: superseded (2026-07-20) by [ADR-0008](...)"
49
+ const bullet = line.match(/^\s*[-*]\s*\*{0,2}Status\*{0,2}\s*:\s*(.+)$/i);
50
+ if (bullet) return { raw: bullet[1], line: i + 1, dialect: 'bullet' };
51
+
52
+ // Dialect B (62 files): bare "Status: ..." heading a multi-paragraph prose blob.
53
+ const inline = line.match(/^\*{0,2}Status\*{0,2}\s*:\s*(.+)$/i);
54
+ if (inline) return { raw: inline[1], line: i + 1, dialect: 'inline' };
55
+ }
56
+
57
+ return null;
58
+ }
59
+
60
+ /**
61
+ * Reduces a raw status blob to one vocabulary token.
62
+ *
63
+ * The inline dialect opens a blob that runs to the next `## ` heading, so the value must
64
+ * terminate at the first sentence/parenthetical boundary rather than at end-of-line.
65
+ */
66
+ function normaliseStatus(raw) {
67
+ const head = raw
68
+ .replace(/\*\*/g, '')
69
+ .split(/[.;(]/)[0]
70
+ .trim()
71
+ .toLowerCase();
72
+
73
+ for (const [legacy, mapped] of Object.entries(STATUS_MAP)) {
74
+ if (head.startsWith(legacy)) return { status: mapped, legacy: head };
75
+ }
76
+ return { status: null, legacy: head };
77
+ }
78
+
79
+ const idsIn = (s) => [...s.matchAll(/ADR[-\s]*(\d{2,4})\b/gi)].map((m) => normId(m[1]));
80
+
81
+ /** Bold spans, which may wrap across source lines. */
82
+ const BOLD_SPAN = /\*\*([^*]+?)\*\*/gs;
83
+
84
+ /** How far past a bold span to look for the target id, in characters. `**Superseded by
85
+ * ADR 0122**` carries it inside; a span ending at the colon may carry it just after. */
86
+ const LOOKAHEAD = 80;
87
+
88
+ /**
89
+ * Recovers supersession claims and flags claims that name no target.
90
+ *
91
+ * The discriminator is **bold**, not line position. Across all ten supersession-shaped
92
+ * lines in boxel this separates the four genuine claims from the six traps perfectly,
93
+ * and it is semantically honest: authors bolded exactly the ones they meant as status
94
+ * declarations. Line-start anchoring appeared to work only by accident — boxel 0112
95
+ * reads `**Placement\nsuperseded by ADR 0122**`, where the word reaches column zero
96
+ * purely because of where the paragraph happened to wrap. Rewrapping that file would
97
+ * have silently hidden a real defect.
98
+ */
99
+ function extractSupersession(text, lines, status, selfId) {
100
+ const supersedes = new Set();
101
+ const supersededBy = new Set();
102
+ const dangling = [];
103
+
104
+ const lineAt = (index) => text.slice(0, index).split('\n').length;
105
+
106
+ /** "superseded by X" is passive and inverts the direction of "supersedes X". */
107
+ const record = (word, targets) => {
108
+ const set = /^superseded$/i.test(word) ? supersededBy : supersedes;
109
+ for (const t of targets) set.add(t);
110
+ };
111
+
112
+ const targetsIn = (s) => [...new Set(idsIn(s))].filter((id) => id !== selfId);
113
+
114
+ // Source 1 — the status field, but ONLY in the bullet dialect:
115
+ // "- Status: superseded (2026-07-20) by [ADR-0008](...)"
116
+ //
117
+ // Restricting this to `bullet` is the whole point. There, status is a structured
118
+ // one-line field and its content is a declaration by position. In the heading and
119
+ // inline dialects the "status" is a multi-sentence paragraph — boxel 0051's runs
120
+ // "**Accepted** (2026-07-06). Design locked before any code. Supersedes the scattered
121
+ // IOUs in 0022, 0025, 0026, 0044." — where a supersession word is ordinary prose and
122
+ // carries no declarative weight. For those dialects, bold (Source 3) is the signal.
123
+ if (status?.dialect === 'bullet' && status.raw) {
124
+ const word = status.raw.match(/(supersed\w+)/i)?.[1];
125
+ if (word) {
126
+ const targets = targetsIn(status.raw);
127
+ if (targets.length) record(word, targets);
128
+ else dangling.push({ line: status.line, text: status.raw.slice(0, 90), word: word.toLowerCase() });
129
+ }
130
+ }
131
+
132
+ // Source 2 — a field-form bullet: "- Supersedes: [ADR-0005](...)".
133
+ //
134
+ // The colon must follow the word immediately. gamatar 0007 reads "- Supersedes no ADR:
135
+ // ADR-0001's procedural-parts decision is unchanged" — a bullet that declares it
136
+ // supersedes *nothing* while naming an ADR. Accepting a colon anywhere on the line
137
+ // would mark ADR-0001, a live architecture decision, as dead. gamatar 0006's
138
+ // "- Supersedes the parenthetical in [ADR-0002]" is the same trap without a colon:
139
+ // it retires a parenthetical inside that ADR, not the decision.
140
+ for (const line of lines) {
141
+ const m = line.match(/^\s*[-*]\s*\*{0,2}(Supersedes|Superseded by)\*{0,2}\s*:/i);
142
+ if (!m) continue;
143
+ const word = m[1].toLowerCase().startsWith('superseded') ? 'superseded' : 'supersedes';
144
+ const targets = targetsIn(line);
145
+ if (targets.length) record(word, targets);
146
+ }
147
+
148
+ // Source 3 — bold spans (boxel's dialect).
149
+ for (const m of text.matchAll(BOLD_SPAN)) {
150
+ const span = m[1].replace(/\s+/g, ' ');
151
+ const word = span.match(/(supersed\w+)/i)?.[1];
152
+ if (!word) continue;
153
+
154
+ const window = span + ' ' + text.slice(m.index + m[0].length, m.index + m[0].length + LOOKAHEAD);
155
+ const targets = [...new Set(idsIn(window))].filter((id) => id !== selfId);
156
+
157
+ if (targets.length === 0) {
158
+ dangling.push({ line: lineAt(m.index), text: span.slice(0, 90), word: word.toLowerCase() });
159
+ continue;
160
+ }
161
+ record(word, targets);
162
+ }
163
+
164
+ return { supersedes: [...supersedes], supersededBy: [...supersededBy], dangling };
165
+ }
166
+
167
+ export function scanLegacy(root) {
168
+ const dir = join(root, 'adr');
169
+ if (!existsSync(dir)) return [];
170
+
171
+ return readdirSync(dir)
172
+ .filter((f) => f.endsWith('.md') && f !== 'INDEX.md' && /^\d/.test(f))
173
+ .sort()
174
+ .map((file) => {
175
+ const text = readFileSync(join(dir, file), 'utf8');
176
+ const lines = text.split('\n');
177
+ const id = normId(file.match(/^(\d{1,4})/)[1]);
178
+
179
+ const found = extractStatus(lines);
180
+ const { status, legacy } = found
181
+ ? normaliseStatus(found.raw)
182
+ : { status: null, legacy: null };
183
+
184
+ const title = (lines.find((l) => l.startsWith('# ')) || `# ${file}`)
185
+ .replace(/^#\s*/, '')
186
+ .replace(/^ADR[-\s]*\d+\s*[—–:-]\s*/i, '')
187
+ .trim();
188
+
189
+ return {
190
+ id,
191
+ file,
192
+ title,
193
+ status,
194
+ legacyStatus: legacy,
195
+ dialect: found?.dialect ?? null,
196
+ statusLine: found?.line ?? null,
197
+ ...extractSupersession(text, lines, found, id),
198
+ };
199
+ });
200
+ }
201
+
202
+ /** The defect classes the linter's R4/R5 will enforce once frontmatter exists. */
203
+ export function findDefects(records) {
204
+ const byId = new Map(records.map((r) => [r.id, r]));
205
+ const defects = [];
206
+
207
+ for (const r of records) {
208
+ for (const target of r.supersededBy) {
209
+ const t = byId.get(target);
210
+ if (!t) {
211
+ defects.push({ kind: 'missing-target', id: r.id, target });
212
+ } else if (!t.supersedes.includes(r.id)) {
213
+ defects.push({ kind: 'one-way', id: r.id, target });
214
+ }
215
+ }
216
+ for (const target of r.supersedes) {
217
+ const t = byId.get(target);
218
+ if (t && !t.supersededBy.includes(r.id)) {
219
+ defects.push({ kind: 'one-way-reverse', id: r.id, target });
220
+ }
221
+ }
222
+ for (const d of r.dangling) {
223
+ defects.push({ kind: 'dangling', id: r.id, line: d.line, text: d.text });
224
+ }
225
+ }
226
+ return defects;
227
+ }
228
+
229
+ function main(argv) {
230
+ const root = argv.find((a) => !a.startsWith('--')) ?? process.cwd();
231
+ const records = scanLegacy(root);
232
+
233
+ if (argv.includes('--json')) {
234
+ console.log(JSON.stringify(records, null, 2));
235
+ return 0;
236
+ }
237
+
238
+ const defects = findDefects(records);
239
+
240
+ if (!argv.includes('--defects')) {
241
+ const dialects = {};
242
+ const statuses = {};
243
+ for (const r of records) {
244
+ dialects[r.dialect ?? 'NONE'] = (dialects[r.dialect ?? 'NONE'] ?? 0) + 1;
245
+ statuses[r.status ?? `UNMAPPED:${r.legacyStatus}`] =
246
+ (statuses[r.status ?? `UNMAPPED:${r.legacyStatus}`] ?? 0) + 1;
247
+ }
248
+ console.log(`${records.length} ADR(s) in ${root}`);
249
+ console.log('dialects:', dialects);
250
+ console.log('statuses:', statuses);
251
+ }
252
+
253
+ console.log(`\n${defects.length} defect(s):`);
254
+ for (const d of defects) {
255
+ if (d.kind === 'dangling') {
256
+ console.log(` ${d.kind.padEnd(16)} ADR ${d.id} line ${d.line}: "${d.text}"`);
257
+ } else {
258
+ console.log(` ${d.kind.padEnd(16)} ADR ${d.id} -> ${d.target}`);
259
+ }
260
+ }
261
+ return 0;
262
+ }
263
+
264
+ if (import.meta.url === `file://${process.argv[1]}`) {
265
+ process.exit(main(process.argv.slice(2)));
266
+ }
@@ -0,0 +1,114 @@
1
+ # {{PROJECT_NAME}} — Project Conventions
2
+
3
+ {{PROJECT_DESCRIPTION}}
4
+
5
+ Stack: {{STACK}}
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.
11
+
12
+ ## 1. Document taxonomy — four kinds
13
+
14
+ Every document is exactly one of four things.
15
+
16
+ | Kind | Files | Rule |
17
+ |---|---|---|
18
+ | **Immutable** | `adr/*.md`, `slices/*.md` | Written once. Body prose is never edited. Only status/supersession fields may change. |
19
+ | **Generated** | `adr/INDEX.md` | Built by script from frontmatter. Never hand-edited. |
20
+ | **Ledger** | `LEDGER.md` | Exactly one per repo. The only hand-maintained tracker. |
21
+ | **Live doc** | `README.md`, `docs/*.md` | Describes how something behaves *now*. Updated **in the same change** as the thing it describes, and cited from this file so it is never orphaned (stele:ADR-0010). |
22
+
23
+ - **ADR** — a decision later work must obey (a mechanism, data format, or boundary).
24
+ Asserts *"on date X we chose Y because Z"*: a historical claim, true forever.
25
+ - **Slice** — one feature work-unit. Written before implementation, **frozen at merge**
26
+ and rewritten to past tense: *"this is what shipped."* Freezing converts it from a
27
+ current-state claim (always going stale) into a historical one (never stale).
28
+ - **README.md** — live document; update it in the same change that alters any
29
+ user/dev-facing feature or API.
30
+
31
+ **Single writer, one direction.** An ADR records a deferral *once*, as a fact about that
32
+ decision. `LEDGER.md` cites the ADR. **Never reach back into an ADR to close a ledger
33
+ item.** Closing an item means deleting its line from the ledger.
34
+
35
+ Changing our minds means writing a **new** ADR that supersedes the old one, never editing
36
+ it. The superseding note must say *why the old reasoning was wrong* — that record is the
37
+ most valuable thing this workflow produces, and an in-place edit destroys it.
38
+
39
+ This is also how a justified rule-violation gets recorded. `~/.claude/CLAUDE.md` §2 says a
40
+ justified violation is written down as a decision rather than taken as a silent exception;
41
+ in this repo, that decision is a new or superseding ADR.
42
+
43
+ ## 2. Enforcement — invariants are executable
44
+
45
+ `node scripts/lint-docs.mjs` runs from a pre-commit hook and in CI.
46
+
47
+ A rule enforced by memory is a rule that holds until the first busy afternoon. If a
48
+ convention matters, it gets a rule; if it genuinely can't be checked, say so out loud
49
+ rather than writing it down and trusting it.
50
+
51
+ Run `/wrap-up` before finishing a task.
52
+
53
+ ## 3. Verification harness — measure, don't assume
54
+
55
+ Some changes cannot be asserted in a unit test: rendering, world generation, physics,
56
+ timing, anything whose correctness is "does it look and behave right at runtime". The
57
+ answer is **not** to skip verification and eyeball it once by hand.
58
+
59
+ **Any slice whose behaviour a unit test cannot assert ships a verification script.**
60
+
61
+ `scripts/<slice>-verify.mjs` — drives the real system headlessly, exercises the specific
62
+ behaviour the slice claims, and:
63
+
64
+ - **fails on any console error or page error** — this half is pass/fail and machine-checkable;
65
+ - **writes artifacts** (screenshots, dumps, measured numbers) for human review — this half
66
+ needs eyes, and that is fine, as long as the first half still runs unattended;
67
+ - **is named in the slice's `## Verification` section**, so the claim and its evidence are
68
+ linked;
69
+ - **is wired into `package.json`**, and its error-check half runs in CI.
70
+
71
+ That last point is the one that gets skipped, so it is **rule-checked** (R11): the linter
72
+ fails if any `scripts/*-verify.mjs` is absent from `package.json`. A verify script that is
73
+ not wired runs exactly once, on the day it was written, and is dead thereafter — it
74
+ documents that the slice worked once, which is not what a regression check is for. Probes
75
+ are exempt: a probe answers its question once and the number lands in an ADR.
76
+
77
+ Every slice carries two required sections, both rule-checked: `## Verification` names the
78
+ 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
80
+ `## Verification`; the linter checks the sections exist and that the Definition of Done
81
+ holds a full triad — it cannot check that a scenario is *right*, which is what `/wrap-up`
82
+ is for.
83
+
84
+ Probes are the same tool used before the fact: when a design question has a measurable
85
+ answer (how many caves per chunk, what the frame cost is), write `scripts/<topic>-probe.mjs`
86
+ and put the **measured numbers** in the ADR. Design decisions cite data, not estimates.
87
+
88
+ ## 4. Standing invariants
89
+
90
+ Repo-specific definition-of-done rules. A slice is not complete until it satisfies every
91
+ one that applies. Each cites the ADR that created it; exceptions are listed with the
92
+ reason, so nobody "fixes" a deliberate choice.
93
+
94
+ These live **here, in the repo** — not in assistant memory. A rule that governs the
95
+ codebase must be greppable, diffable, reviewable, and survive a change of machine
96
+ (stele:ADR-0005).
97
+
98
+ Each row declares how it is **enforced**, so an aspiration is never mistaken for a
99
+ guarantee:
100
+
101
+ - `verified_by: <script>` — a wired verify script or lint rule checks it;
102
+ - `pending (LEDGER)` — enforceable but not yet enforced; a ledger item carries the debt;
103
+ - `review-only` — enforceable only by human judgement, so `/wrap-up` is the enforcement.
104
+
105
+ The declaration is a convention checked at review, not by the linter — the linter does not
106
+ read this file (stele:ADR-0004). What it *does* enforce is that every `verified_by` script is
107
+ actually wired (R11), so a row cannot claim a check that runs nowhere.
108
+
109
+ | # | Invariant | Source | Enforced by |
110
+ |---|---|---|---|
111
+ | 1 | _(none yet — add as they are decided)_ | | |
112
+
113
+ When an ADR's consequences create a rule that all *future* work must follow, add the row
114
+ in the same commit as the ADR.
@@ -0,0 +1,24 @@
1
+ # Ledger
2
+
3
+ The single mutable file in this repo (stele:ADR-0001). Everything else is
4
+ immutable or generated. Open work, deferrals, and known defects all live here — there is
5
+ no second tracking file, because two files require manual sync and manual sync does not
6
+ happen.
7
+
8
+ **Closing an item means deleting its line here.** Do not annotate the source ADR; the
9
+ ADR's claim ("at the time of this decision we deferred X") stays true forever and needs
10
+ no update. Single writer, one direction.
11
+
12
+ Format: `- [type] description (ADR-NNNN)` — type is `bug` | `feature` | `deferred` |
13
+ `audit`. Cite the source ADR where one exists; rule 8 checks that the citation resolves.
14
+ A decision that lives in **another** repo is cited `<repo>:ADR-NNNN` — the linter cannot
15
+ open that corpus, so it skips qualified references (stele:ADR-0009).
16
+
17
+ ## Open
18
+
19
+ <!-- One line per open item. Delete the line to close it; the git log is the done-record. -->
20
+
21
+ ## Resolved
22
+
23
+ Entries move out of "Open" by deletion. Root-cause writeups worth keeping belong in the
24
+ ADR that fixed the problem, not here — this file is a worklist, not a changelog.