@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.
- package/.claude/commands/adr.md +43 -0
- package/.claude/commands/audit.md +25 -0
- package/.claude/commands/init-method.md +105 -0
- package/.claude/commands/remember.md +61 -0
- package/.claude/commands/slice.md +72 -0
- package/.claude/commands/wrap-up.md +30 -0
- package/.claude/hooks/pre-commit +29 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/package.json +48 -0
- package/scripts/build-index.mjs +129 -0
- package/scripts/init-method.mjs +359 -0
- package/scripts/lint-docs.mjs +463 -0
- package/scripts/migrate-adrs.mjs +218 -0
- package/scripts/scan-legacy.mjs +266 -0
- package/templates/CLAUDE.md +114 -0
- package/templates/LEDGER.md +24 -0
|
@@ -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.
|