@isonimus/stele 0.2.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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // Installs the method kit into a git repo (ADR-0006). Dry-run by default.
3
3
  //
4
- // node scripts/init-method.mjs <repo-root> [--apply] [--check] [--update]
4
+ // node scripts/init-method.mjs <repo-root> [--apply] [--check] [--update [--force]]
5
5
  //
6
6
  // The load-bearing rule lives in installHook(): the pre-commit hook is linked ONLY
7
7
  // against a corpus the linter calls clean. An unwired scripts/*-verify.mjs is an R11
@@ -12,7 +12,8 @@
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
+ import { createHash } from 'node:crypto';
16
17
  import { join, dirname, relative } from 'node:path';
17
18
  import { fileURLToPath } from 'node:url';
18
19
 
@@ -34,17 +35,49 @@ const VENDORED = [
34
35
 
35
36
  const COMMANDS_DIR = '.claude/commands';
36
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
+
37
44
  /**
38
- * The slash commands, vendored too (ADR-0007) — 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`.
39
47
  *
40
- * Read from disk rather than listed, so a new command reaches installed repos without
41
- * 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.
42
50
  */
43
- const commandFiles = (toolkit) =>
44
- readdirSync(join(toolkit, COMMANDS_DIR))
51
+ const adaptableFiles = (toolkit) => [
52
+ ...readdirSync(join(toolkit, COMMANDS_DIR))
45
53
  .filter((name) => name.endsWith('.md'))
46
54
  .sort()
47
- .map((name) => `${COMMANDS_DIR}/${name}`);
55
+ .map((name) => `${COMMANDS_DIR}/${name}`),
56
+ ...ADAPTABLE_DOCS,
57
+ ];
58
+
59
+ /**
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.
68
+ *
69
+ * Without it an update sees two states where three are needed — a stale copy of an older
70
+ * release and a deliberate local adaptation are the same observation, "differs from the
71
+ * toolkit", and the only safe reading of that is the destructive one. Committed, not
72
+ * ignored: a fresh clone missing it classifies every command as unreconciled.
73
+ */
74
+ const PROVENANCE = '.claude/.stele-vendored.json';
75
+
76
+ /** Bumped only when the record's shape changes; an unrecognised version is treated as no
77
+ * record at all, which keeps every command rather than overwriting it. */
78
+ const PROVENANCE_VERSION = 1;
79
+
80
+ const digest = (text) => createHash('sha256').update(text).digest('hex');
48
81
 
49
82
  /** Scaffolded once and never overwritten: target path ← template path. */
50
83
  const SCAFFOLD = [
@@ -109,6 +142,76 @@ function isSymlink(path) {
109
142
  }
110
143
  }
111
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
+
112
215
  /** The linter's error findings for a repo.
113
216
  * Always the repo ROOT: pointed at a subdirectory holding no documents the linter
114
217
  * reports "0 document(s) — ok", certifying an install that checks nothing (rule 10). */
@@ -133,38 +236,123 @@ function vendor({ target, toolkit, apply, report }) {
133
236
  }
134
237
  mkdirSync(dirname(to), { recursive: true });
135
238
  copyFileSync(from, to);
136
- report('wrote', to, `${verb}d from toolkit`);
239
+ report('wrote', to, `${verb === 'copy' ? 'copied' : 'updated'} from toolkit`);
137
240
  }
138
241
  }
139
242
 
140
243
  /**
141
- * Slash commands, which are prose and therefore adaptable (ADR-0007).
244
+ * The recorded digests, or `{}` when there is no usable record.
245
+ *
246
+ * A record we cannot read is reported and then treated as absent. That is the safe
247
+ * direction and not a silenced error: every command classifies as `unknown`, so nothing is
248
+ * overwritten and the operator sees why (ADR-0023).
249
+ */
250
+ export function readProvenance(target, report) {
251
+ const path = join(target, PROVENANCE);
252
+ if (!existsSync(path)) return {};
253
+
254
+ let parsed;
255
+ try {
256
+ parsed = JSON.parse(read(path));
257
+ } catch (error) {
258
+ report('problem', path, `unreadable (${error.message}) — treating every command as unreconciled, so none will be overwritten. Delete it to start a fresh record.`);
259
+ return {};
260
+ }
261
+
262
+ const commands = parsed?.commands;
263
+ if (parsed?.version !== PROVENANCE_VERSION || typeof commands !== 'object' || commands === null) {
264
+ report('problem', path, `unrecognised shape (expected version ${PROVENANCE_VERSION}) — treating every command as unreconciled, so none will be overwritten.`);
265
+ return {};
266
+ }
267
+ return commands;
268
+ }
269
+
270
+ function writeProvenance(target, commands) {
271
+ const path = join(target, PROVENANCE);
272
+ const ordered = Object.fromEntries(Object.entries(commands).sort(([a], [b]) => a.localeCompare(b)));
273
+ mkdirSync(dirname(path), { recursive: true });
274
+ writeFileSync(path, `${JSON.stringify({ version: PROVENANCE_VERSION, commands: ordered }, null, 2)}\n`);
275
+ }
276
+
277
+ /**
278
+ * What an installed command is, relative to the toolkit and to what we last handed over.
279
+ *
280
+ * `stale` and `adapted` are the two states the old boolean could not tell apart, and the
281
+ * whole of ADR-0023 is the ability to name them separately. `unknown` is a differing file
282
+ * with no record — an install predating the record — which is kept, because assuming
283
+ * permission to overwrite is exactly the incident.
284
+ *
285
+ * @returns {'absent'|'current'|'stale'|'adapted'|'unknown'}
286
+ */
287
+ export function classifyVendored({ targetText, toolkitText, recordedDigest }) {
288
+ if (targetText === null) return 'absent';
289
+ if (targetText === toolkitText) return 'current';
290
+ if (recordedDigest === undefined) return 'unknown';
291
+ return digest(targetText) === recordedDigest ? 'stale' : 'adapted';
292
+ }
293
+
294
+ const KEPT_REASON = {
295
+ stale: 'behind the toolkit but unmodified here — `--update` takes the new version',
296
+ adapted: 'adapted locally — kept; `--update --force` discards the adaptation',
297
+ unknown: 'differs from the toolkit and predates the vendoring record, so it cannot be told from a local adaptation — kept. Reconcile it once by hand, or `--update --force` to take the toolkit version',
298
+ };
299
+
300
+ /**
301
+ * Slash commands and the quality bar — prose, and therefore adaptable (ADR-0023, ADR-0024).
142
302
  *
143
303
  * Copy-if-absent, unlike vendor(): a repo that has tailored `/slice` to its own workflow
144
- * must not have that overwritten by an install. `--update` is the explicit way to take
145
- * the toolkit's version back.
304
+ * must not have that overwritten. `--update` additionally refreshes anything the repo has
305
+ * not touched, and only `--force` discards an adaptation (ADR-0023).
146
306
  */
147
- function vendorCommands({ target, toolkit, apply, force, report }) {
148
- for (const path of commandFiles(toolkit)) {
307
+ function vendorAdaptable({ target, toolkit, apply, update, force, report }) {
308
+ const recorded = readProvenance(target, report);
309
+ const learned = { ...recorded };
310
+ let changed = false;
311
+
312
+ for (const path of adaptableFiles(toolkit)) {
149
313
  const to = join(target, path);
150
- const from = join(toolkit, path);
151
- if (matches(to, from)) {
314
+ const toolkitText = read(join(toolkit, path));
315
+ const state = classifyVendored({
316
+ targetText: existsSync(to) ? read(to) : null,
317
+ toolkitText,
318
+ recordedDigest: recorded[path],
319
+ });
320
+
321
+ // An up-to-date command is how an install predating the record acquires one: its bytes
322
+ // ARE the toolkit's, so the digest is known without having written anything.
323
+ if (state === 'current') {
152
324
  report('ok', to, 'current');
325
+ if (recorded[path] !== digest(toolkitText)) {
326
+ learned[path] = digest(toolkitText);
327
+ changed = true;
328
+ }
153
329
  continue;
154
330
  }
155
- if (existsSync(to) && !force) {
156
- report('keep', to, 'differs from the toolkit left as it is; `--update` takes the toolkit version');
331
+
332
+ const takeover = state === 'absent' || (update && (state === 'stale' || force));
333
+ if (!takeover) {
334
+ report('keep', to, KEPT_REASON[state]);
157
335
  continue;
158
336
  }
159
- const verb = existsSync(to) ? 'overwrite' : 'copy';
337
+
338
+ const verb = state === 'absent' ? 'copy' : 'overwrite';
160
339
  if (!apply) {
161
340
  report('would', to, `${verb} from toolkit`);
162
341
  continue;
163
342
  }
164
343
  mkdirSync(dirname(to), { recursive: true });
165
- copyFileSync(from, to);
344
+ writeFileSync(to, toolkitText);
345
+ learned[path] = digest(toolkitText);
346
+ changed = true;
166
347
  report('wrote', to, `${verb === 'copy' ? 'copied' : 'overwritten'} from toolkit`);
167
348
  }
349
+
350
+ // Never on a dry run: the record describes what is on disk, and writing it while writing
351
+ // nothing else would claim we handed over files we did not.
352
+ if (apply && changed) {
353
+ writeProvenance(target, learned);
354
+ report('wrote', join(target, PROVENANCE), 'recorded what was vendored, so a later --update can tell a stale command from an adapted one');
355
+ }
168
356
  }
169
357
 
170
358
  function scaffold({ target, toolkit, apply, report }) {
@@ -302,12 +490,22 @@ function check({ target, toolkit, report }) {
302
490
  else report('ok', to, 'current');
303
491
  }
304
492
 
305
- // Commands are prose a repo may legitimately adapt, so their drift is informational
306
- // (ADR-0007) reported so it is visible, never counted against a clean check.
307
- for (const path of commandFiles(toolkit)) {
493
+ // An *adaptation* is prose a repo may legitimately own, so it stays informational. A
494
+ // command merely behind and unmodified is a repo missing a fix, which is a problem — the
495
+ // record is what lets --check tell those two apart at all (ADR-0023, superseding ADR-0007,
496
+ // under which no command difference could be counted and so a shipped defect in one was
497
+ // invisible in every installed repo).
498
+ const recorded = readProvenance(target, report);
499
+ for (const path of adaptableFiles(toolkit)) {
308
500
  const to = join(target, path);
309
- if (!existsSync(to)) report('missing', to, 'not installed — run /init-method --apply');
310
- else if (!matches(to, join(toolkit, path))) report('local', to, 'differs from the toolkit — kept; `--update` takes the toolkit version');
501
+ const state = classifyVendored({
502
+ targetText: existsSync(to) ? read(to) : null,
503
+ toolkitText: read(join(toolkit, path)),
504
+ recordedDigest: recorded[path],
505
+ });
506
+ if (state === 'absent') report('missing', to, 'not installed — run /init-method --apply');
507
+ else if (state === 'stale') report('problem', to, 'behind the toolkit and unmodified here — run /init-method --update');
508
+ else if (state !== 'current') report('local', to, KEPT_REASON[state]);
311
509
  else report('ok', to, 'current');
312
510
  }
313
511
 
@@ -340,9 +538,10 @@ function check({ target, toolkit, report }) {
340
538
  * @param {string} [options.toolkit] this kit's root (overridable for tests)
341
539
  * @param {'install'|'check'|'update'} [options.mode]
342
540
  * @param {boolean} [options.apply] false = dry run, the default
541
+ * @param {boolean} [options.force] update only: discard local command adaptations too
343
542
  * @returns {{actions: Array<{status: string, path: string, message: string}>, problems: number}}
344
543
  */
345
- export function initMethod({ target, toolkit = TOOLKIT, mode = 'install', apply = false }) {
544
+ export function initMethod({ target, toolkit = TOOLKIT, mode = 'install', apply = false, force = false }) {
346
545
  const actions = [];
347
546
  const report = (status, path, message) => actions.push({ status, path, message });
348
547
 
@@ -351,16 +550,27 @@ export function initMethod({ target, toolkit = TOOLKIT, mode = 'install', apply
351
550
  return { actions, problems: 1 };
352
551
  }
353
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
+
354
564
  if (mode === 'check') {
355
565
  check({ target, toolkit, report });
356
566
  } else if (mode === 'update') {
357
567
  vendor({ target, toolkit, apply, report });
358
- vendorCommands({ target, toolkit, apply, force: true, report });
568
+ vendorAdaptable({ target, toolkit, apply, update: true, force, report });
359
569
  if (apply) lintAfterUpdate({ target, report });
360
570
  } else {
361
571
  scaffold({ target, toolkit, apply, report });
362
572
  vendor({ target, toolkit, apply, report });
363
- vendorCommands({ target, toolkit, apply, force: false, report });
573
+ vendorAdaptable({ target, toolkit, apply, update: false, force: false, report });
364
574
  buildIndex({ target, apply, report });
365
575
  installHook({ target, apply, report });
366
576
  }
@@ -374,8 +584,14 @@ function main(argv) {
374
584
  const target = positional[0] ?? process.cwd();
375
585
  const mode = flags.has('--check') ? 'check' : flags.has('--update') ? 'update' : 'install';
376
586
  const apply = flags.has('--apply');
587
+ const force = flags.has('--force');
588
+
589
+ if (force && mode !== 'update') {
590
+ console.error('--force only means anything with --update: it discards local command adaptations.');
591
+ return 1;
592
+ }
377
593
 
378
- const { actions, problems } = initMethod({ target, mode, apply });
594
+ const { actions, problems } = initMethod({ target, mode, apply, force });
379
595
 
380
596
  console.log(`\n${target} — /init-method ${mode}${apply || mode === 'check' ? '' : ' (dry run)'}`);
381
597
  for (const a of actions) {
@@ -77,7 +77,13 @@ const isId = (v) => /^\d{1,4}$/.test(String(v).trim());
77
77
  function isCalendarDate(v) {
78
78
  const text = String(v).trim();
79
79
  if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) return false;
80
- return new Date(`${text}T00:00:00Z`).toISOString().startsWith(text);
80
+ // `2026-02-30` parses and normalises to March, so the round-trip catches it. `2026-00-01`
81
+ // does not parse at all, and `toISOString()` on an invalid Date throws — checked first,
82
+ // because a linter that dies with a stack trace on a malformed date reports nothing about
83
+ // the other documents and breaks `--update`'s report (ADR-0021).
84
+ const parsed = new Date(`${text}T00:00:00Z`);
85
+ if (Number.isNaN(parsed.getTime())) return false;
86
+ return parsed.toISOString().startsWith(text);
81
87
  }
82
88
 
83
89
  // Citations, bare or qualified (ADR-0009). A leading `<repo>:` says the decision lives in
@@ -86,6 +92,19 @@ function isCalendarDate(v) {
86
92
  // resolving locally as before.
87
93
  const CITATION = /(?:([A-Za-z][\w.-]*):)?ADR[-\s](\d{1,4})/g;
88
94
 
95
+ /**
96
+ * `text` with link destinations and URLs removed, so only prose is scanned for citations.
97
+ *
98
+ * A URL path can contain an `ADR-1234`-shaped run that cites nothing —
99
+ * `https://example.com/docs/ADR-9999`, or a ticket link. Rules 8 and 14 are error severity,
100
+ * so one coincidence blocks a correct commit, and the advice their message gives is
101
+ * unusable: the `<repo>:` qualifier cannot be written inside a URL. Link *text* is kept,
102
+ * because `[ADR-0020](adr/0020-….md)` is a citation and rule 15 checks the target
103
+ * separately.
104
+ */
105
+ const citableText = (text) =>
106
+ text.replace(/\]\([^)]*\)/g, ']()').replace(/\S*:\/\/\S*/g, '');
107
+
89
108
  /**
90
109
  * Ids cited in `text` that this repo is expected to own — cross-repo refs skipped.
91
110
  *
@@ -96,7 +115,7 @@ const CITATION = /(?:([A-Za-z][\w.-]*):)?ADR[-\s](\d{1,4})/g;
96
115
  * `stele:ADR-0005` verified in the one corpus that can verify it.
97
116
  */
98
117
  function* localCitations(text, selfRepo = null) {
99
- for (const [, repo, id] of text.matchAll(CITATION)) {
118
+ for (const [, repo, id] of citableText(text).matchAll(CITATION)) {
100
119
  if (repo === undefined || (selfRepo !== null && repo === selfRepo)) yield normId(id);
101
120
  }
102
121
  }
@@ -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