simt-dev 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +2 -2
  3. package/agents/simt-agent.md +84 -0
  4. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  5. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  6. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  7. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  8. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  9. package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
  10. package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
  11. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  12. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  13. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  14. package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
  15. package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
  16. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  17. package/docs/decisions/README.md +9 -0
  18. package/package.json +4 -2
  19. package/rules/core.md +2 -2
  20. package/rules/mdc/test-gate.mdc +2 -0
  21. package/rules/stacks/android.md +3 -3
  22. package/rules/stacks/connectiq.md +2 -2
  23. package/rules/stacks/dart.md +1 -1
  24. package/rules/stacks/deno.md +1 -1
  25. package/rules/stacks/dotnet.md +1 -1
  26. package/rules/stacks/elixir.md +1 -1
  27. package/rules/stacks/go.md +1 -1
  28. package/rules/stacks/jvm.md +3 -2
  29. package/rules/stacks/node.md +2 -2
  30. package/rules/stacks/python.md +7 -6
  31. package/rules/stacks/ruby.md +1 -1
  32. package/rules/stacks/rust.md +1 -1
  33. package/rules/stacks/swift.md +1 -1
  34. package/rules/stacks/typescript.md +2 -2
  35. package/rules/stacks/uv.md +3 -2
  36. package/skills/simt-commands/SKILL.md +70 -0
  37. package/src/advice/advice.mjs +7 -3
  38. package/src/agenthooks/decide.mjs +141 -9
  39. package/src/agenthooks/opencode.mjs +72 -0
  40. package/src/agenthooks/prompt.mjs +6 -2
  41. package/src/agenthooks/render.mjs +186 -19
  42. package/src/agenthooks/shim.mjs +55 -0
  43. package/src/agents/render.mjs +277 -0
  44. package/src/argv.mjs +8 -0
  45. package/src/bench/ablate.mjs +31 -14
  46. package/src/bench/mine.mjs +48 -1
  47. package/src/bench/probes.mjs +5 -6
  48. package/src/bench/report.mjs +243 -80
  49. package/src/bench/run.mjs +12 -11
  50. package/src/bench/variants.mjs +112 -40
  51. package/src/brief/assemble.mjs +6 -0
  52. package/src/ci/workflow.mjs +16 -7
  53. package/src/commands/agent.mjs +206 -0
  54. package/src/commands/agenthook.mjs +109 -15
  55. package/src/commands/bench.mjs +2 -1
  56. package/src/commands/compare.mjs +8 -7
  57. package/src/commands/decide.mjs +6 -5
  58. package/src/commands/evidence.mjs +13 -3
  59. package/src/commands/gate.mjs +85 -19
  60. package/src/commands/graph.mjs +18 -5
  61. package/src/commands/help.mjs +35 -14
  62. package/src/commands/hooks.mjs +68 -25
  63. package/src/commands/impact.mjs +4 -2
  64. package/src/commands/index.mjs +17 -4
  65. package/src/commands/init.mjs +169 -37
  66. package/src/commands/lease.mjs +203 -35
  67. package/src/commands/lessons.mjs +65 -9
  68. package/src/commands/map.mjs +33 -18
  69. package/src/commands/plugin.mjs +1 -1
  70. package/src/commands/repoindex.mjs +1 -0
  71. package/src/commands/rules.mjs +92 -18
  72. package/src/commands/skills.mjs +60 -25
  73. package/src/commands/skillscompile.mjs +8 -7
  74. package/src/commands/skillsinstall.mjs +12 -0
  75. package/src/commands/slice.mjs +377 -18
  76. package/src/commands/sync.mjs +37 -22
  77. package/src/commands/tests.mjs +5 -2
  78. package/src/commands/undo.mjs +24 -16
  79. package/src/commands/upgrade.mjs +33 -1
  80. package/src/commands/why.mjs +10 -4
  81. package/src/compare/hooksstate.mjs +23 -1
  82. package/src/compare/learn.mjs +82 -57
  83. package/src/compare/parallel.mjs +50 -0
  84. package/src/compare/row.mjs +26 -1
  85. package/src/completions.mjs +5 -0
  86. package/src/decisions/dir.mjs +84 -0
  87. package/src/decisions/index.mjs +109 -11
  88. package/src/distill/distill.mjs +3 -1
  89. package/src/ecosystem/node-runner.mjs +144 -0
  90. package/src/explain.mjs +18 -6
  91. package/src/graph/load.mjs +38 -19
  92. package/src/hooks/capability.mjs +14 -1
  93. package/src/hooks/dispatcher.mjs +335 -13
  94. package/src/hooks/resolve.mjs +9 -3
  95. package/src/hooks/shim.mjs +12 -7
  96. package/src/identity.mjs +67 -14
  97. package/src/index/extract.mjs +192 -11
  98. package/src/index/persist.mjs +307 -47
  99. package/src/index/store.mjs +263 -60
  100. package/src/index/tests.mjs +6 -35
  101. package/src/journal.mjs +296 -77
  102. package/src/kernel/card.mjs +37 -12
  103. package/src/kernel/checks.mjs +44 -9
  104. package/src/kernel/variants.mjs +27 -16
  105. package/src/lease.mjs +97 -15
  106. package/src/ledger/index.mjs +91 -25
  107. package/src/mcp/resources.mjs +5 -3
  108. package/src/mcp/server.mjs +81 -22
  109. package/src/mcp/tools.mjs +45 -9
  110. package/src/memory/decide.mjs +98 -43
  111. package/src/memory/lessons.mjs +168 -33
  112. package/src/name.mjs +9 -0
  113. package/src/plan/index.mjs +31 -8
  114. package/src/plan/template.mjs +27 -5
  115. package/src/plugins.mjs +6 -2
  116. package/src/prbody.mjs +7 -5
  117. package/src/projection/baselines.mjs +1 -1
  118. package/src/projection/disclosure.mjs +2 -2
  119. package/src/projection/loaders.mjs +41 -1
  120. package/src/projection/lock.mjs +21 -14
  121. package/src/projection/manifest.mjs +69 -19
  122. package/src/projection/markers.mjs +43 -3
  123. package/src/projection/mdc.mjs +34 -7
  124. package/src/projection/project.mjs +162 -18
  125. package/src/projection/rules.mjs +54 -3
  126. package/src/projection/surfaces.mjs +9 -1
  127. package/src/repo-root.mjs +69 -0
  128. package/src/resolve/body.mjs +4 -0
  129. package/src/resolve/fingerprint.mjs +5 -0
  130. package/src/resolve/local.mjs +58 -0
  131. package/src/review/critic.mjs +4 -2
  132. package/src/secrets.mjs +165 -22
  133. package/src/state.mjs +76 -0
  134. package/src/status.mjs +9 -4
  135. package/src/toolwords.mjs +5 -0
  136. package/src/tsconfig.mjs +39 -99
  137. package/templates/plan/tracker.json +1 -1
package/src/journal.mjs CHANGED
@@ -1,10 +1,15 @@
1
- // The write journal and `undo` (plan 0003 R49).
1
+ // The write journal and `undo` (plan 0003 R49, entry kinds review 0003 Q22).
2
2
  //
3
- // Every repo-file write a command performs records {path, beforeDigest,
4
- // afterDigest, beforeBytes}, grouped per command run as one set in
5
- // `.simt/journal/`. `undo` restores the last set, but only when every file in
6
- // it is untouched since — a file whose current bytes no longer match its
7
- // afterDigest refuses the whole undo, naming the file, and nothing is restored.
3
+ // Every write a command performs is recorded as one entry in the run's set:
4
+ // `file` entries hold the pre-run bytes as base64 with the pre-run permission
5
+ // bits (binary-safe, unlike the version-1 utf8 spelling), and `git-config`
6
+ // entries hold one config key's before and after values, so `undo` restores
7
+ // the routing a run changed as well as the bytes. `commitJournal` folds each
8
+ // path and each key to a single entry — first before, last after — and `undo`
9
+ // restores entries in reverse, skipping the ones already at their pre-run
10
+ // state so a crashed undo resumes instead of refusing its own progress. The
11
+ // write-ahead marker records that intent; verification still precedes any
12
+ // restore on a fresh undo, so a refusal changes nothing.
8
13
  //
9
14
  // Three deliberate boundaries, each a failure this module refuses to be:
10
15
  //
@@ -32,15 +37,29 @@
32
37
  // a second `undo` naturally targets the set before it, and the untouched-check
33
38
  // refuses when the two sets overlap on a file the first undo already restored.
34
39
 
40
+ import { execFile } from 'node:child_process';
35
41
  import { createHash } from 'node:crypto';
36
- import { cp, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
42
+ import { chmod, cp, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
37
43
  import { dirname, join, relative, sep } from 'node:path';
44
+ import { promisify } from 'node:util';
38
45
 
39
46
  import { STATE_DIR } from './name.mjs';
47
+ import { LOCAL_DIR } from './state.mjs';
40
48
 
41
- const JOURNAL_DIR = join(STATE_DIR, 'journal');
49
+ const exec = promisify(execFile);
50
+
51
+ const JOURNAL_DIR = join(LOCAL_DIR, 'journal');
52
+ const JOURNAL_DIR_LEGACY = '.simt/journal';
42
53
  const SET_SUFFIX = '.json';
43
54
  const UNDONE_SUFFIX = '.undone.json';
55
+ /** A crashed `undo` leaves its intent behind under this suffix; the next undo
56
+ * of the same set resumes it instead of starting over (review 0003 Q22). A
57
+ * marker is history, not a set, so the set listing excludes it. */
58
+ const UNDOING_SUFFIX = '.undoing.json';
59
+ /** Sets that predate entry kinds store `beforeBytes` as utf8 text and carry no
60
+ * `kind`; they are still restorable, through the legacy branch of the restore
61
+ * below. `commitJournal` only writes version 2. */
62
+ const SET_VERSION = 2;
44
63
 
45
64
  function digest(bytes) {
46
65
  return `sha256-${createHash('sha256').update(bytes).digest('hex')}`;
@@ -51,18 +70,49 @@ function rel(root, path) {
51
70
  }
52
71
 
53
72
  /**
54
- * Read the current bytes of a path, or null when it does not exist. Any other
55
- * failure propagates: an unreadable file is fail-closed, not treated as absent.
73
+ * Read the current bytes and permission bits of a path, or null when it does
74
+ * not exist. Any other failure propagates: an unreadable file is fail-closed,
75
+ * not treated as absent.
56
76
  */
57
77
  async function readOrNull(path) {
58
78
  try {
59
- return await readFile(path);
79
+ const [bytes, st] = await Promise.all([readFile(path), stat(path)]);
80
+ return { bytes, mode: st.mode & 0o777 };
60
81
  } catch (error) {
61
82
  if (error.code === 'ENOENT') return null;
62
83
  throw error;
63
84
  }
64
85
  }
65
86
 
87
+ /**
88
+ * Record a file the run created with an exclusive create (`O_EXCL`).
89
+ *
90
+ * The create itself is the proof nothing was there before, so `before` is
91
+ * null by construction rather than by re-reading: a check-then-record would
92
+ * reopen the race the exclusive flag just closed. For ordinary overwrites use
93
+ * `journaledWriteFile`, which snapshots `before` itself.
94
+ */
95
+ export function recordCreatedFile(root, acc, path, text) {
96
+ if (relative(root, path).startsWith('..')) {
97
+ throw new Error(`journal refuses a write outside the repository: ${path}`);
98
+ }
99
+ acc.push(fileEntry(rel(root, path), null, Buffer.from(text, 'utf8')));
100
+ }
101
+
102
+ /** A `file` entry: the pre-run bytes as base64 (binary-safe, unlike the
103
+ * version-1 utf8 spelling) with the pre-run permission bits, so `undo`
104
+ * restores both. Removals carry `afterDigest: null` and are re-created. */
105
+ function fileEntry(path, before, after) {
106
+ return {
107
+ kind: 'file',
108
+ path,
109
+ beforeDigest: before === null ? null : digest(before.bytes),
110
+ afterDigest: after === null ? null : digest(after),
111
+ beforeBytes: before === null ? null : before.bytes.toString('base64'),
112
+ modeBefore: before === null ? null : before.mode,
113
+ };
114
+ }
115
+
66
116
  /**
67
117
  * Write a repo file and record the write in the run's set.
68
118
  *
@@ -70,8 +120,11 @@ async function readOrNull(path) {
70
120
  * @param {Array} acc the run's set, created by the caller and committed once
71
121
  * @param {string} path absolute path to write
72
122
  * @param {string} text the bytes to write
123
+ * @param {{mode?: number}} [opts] permission bits to set after the write, recorded
124
+ * in the entry — a shim written then chmodded is one change, not two, and
125
+ * `undo` must restore the mode the run found, not just the bytes
73
126
  */
74
- export async function journaledWriteFile(root, acc, path, text) {
127
+ export async function journaledWriteFile(root, acc, path, text, { mode } = {}) {
75
128
  if (relative(root, path).startsWith('..')) {
76
129
  throw new Error(`journal refuses a write outside the repository: ${path}`);
77
130
  }
@@ -79,14 +132,15 @@ export async function journaledWriteFile(root, acc, path, text) {
79
132
  // Internal state is written, not recorded — see the module header.
80
133
  const internal = rel(root, path) === STATE_DIR || rel(root, path).startsWith(`${STATE_DIR}/`);
81
134
  await mkdir(dirname(path), { recursive: true });
82
- await writeFile(path, text, 'utf8');
135
+ // tmp + rename, the way this module writes its own set files: a crash
136
+ // mid-write leaves the old bytes in place, never a half-written file
137
+ // (review 0003 Q25). The mode lands on the tmp, so the rename carries it.
138
+ const tmp = `${path}.tmp-${process.pid}`;
139
+ await writeFile(tmp, text, 'utf8');
140
+ if (mode !== undefined) await chmod(tmp, mode);
141
+ await rename(tmp, path);
83
142
  if (!internal) {
84
- acc.push({
85
- path: rel(root, path),
86
- beforeDigest: before === null ? null : digest(before),
87
- afterDigest: digest(Buffer.from(text, 'utf8')),
88
- beforeBytes: before === null ? null : before.toString('utf8'),
89
- });
143
+ acc.push(fileEntry(rel(root, path), before, Buffer.from(text, 'utf8')));
90
144
  }
91
145
  }
92
146
 
@@ -128,22 +182,12 @@ export async function journaledCopyDir(root, acc, src, dest) {
128
182
  const prev = before.get(path) ?? null;
129
183
  before.delete(path);
130
184
  const after = await readOrNull(abs);
131
- if (prev !== null && after !== null && prev.equals(after)) continue;
132
- acc.push({
133
- path,
134
- beforeDigest: prev === null ? null : digest(prev),
135
- afterDigest: digest(after),
136
- beforeBytes: prev === null ? null : prev.toString('utf8'),
137
- });
185
+ if (prev !== null && after !== null && prev.bytes.equals(after.bytes)) continue;
186
+ acc.push(fileEntry(path, prev, after === null ? null : after.bytes));
138
187
  }
139
188
  // Files the copy removed (an overwrite that drops a name) are removals.
140
189
  for (const [path, prev] of before) {
141
- acc.push({
142
- path,
143
- beforeDigest: digest(prev),
144
- afterDigest: null,
145
- beforeBytes: prev.toString('utf8'),
146
- });
190
+ acc.push(fileEntry(path, prev, null));
147
191
  }
148
192
  }
149
193
 
@@ -161,13 +205,44 @@ export async function journaledRemoveFile(root, acc, path) {
161
205
  const internal = rel(root, path) === STATE_DIR || rel(root, path).startsWith(`${STATE_DIR}/`);
162
206
  await unlink(path);
163
207
  if (!internal) {
164
- acc.push({
165
- path: rel(root, path),
166
- beforeDigest: digest(before),
167
- afterDigest: null,
168
- beforeBytes: before.toString('utf8'),
169
- });
208
+ acc.push(fileEntry(rel(root, path), before, null));
209
+ }
210
+ }
211
+
212
+ /** Read one git config value in a repo, or null when unset. */
213
+ export async function readGitValue(root, key) {
214
+ try {
215
+ const { stdout } = await exec('git', ['config', '--get', key], { cwd: root });
216
+ return stdout.replace(/\n$/, '');
217
+ } catch (error) {
218
+ if (error.code === 1) return null;
219
+ throw error;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Set (or, with `value: null`, unset) one git config key and record it as a
225
+ * `git-config` entry, so `undo` restores the routing a run changed — a shim
226
+ * without its `core.hooksPath` is a hook that no longer runs (review 0003
227
+ * Q22). A set to the value already there records nothing: there is no state
228
+ * to restore.
229
+ *
230
+ * @returns {Promise<object|null>} the entry, or null when nothing changed
231
+ */
232
+ export async function journaledGitConfig(root, acc, key, value) {
233
+ if (typeof key !== 'string' || key === '' || /[\s]/.test(key)) {
234
+ throw new Error(`journal refuses an unparsable git config key: ${key}`);
170
235
  }
236
+ const before = await readGitValue(root, key);
237
+ if (before === value) return null;
238
+ if (value === null) {
239
+ await exec('git', ['config', '--unset', key], { cwd: root });
240
+ } else {
241
+ await exec('git', ['config', key, value], { cwd: root });
242
+ }
243
+ const entry = { kind: 'git-config', key, before, after: value };
244
+ acc.push(entry);
245
+ return entry;
171
246
  }
172
247
 
173
248
  /**
@@ -179,14 +254,19 @@ export async function journaledRemoveFile(root, acc, path) {
179
254
  */
180
255
  /**
181
256
  * The .gitignore bytes `ignoreJournal` would write for `text`, or null when
182
- * `text` already ignores the journal directory. One owner for the line
257
+ * `text` already ignores machine-local state. One owner for the line
183
258
  * spelling: `ignoreJournal` writes it, `journalHygiene` reads whether it is
184
259
  * there, and `doctor --fix --diff` previews it — all through this function so
185
260
  * the three cannot disagree about what counts as ignored (plan 0003 R50).
261
+ *
262
+ * The ignored line is the whole local area (review 0003 Q19), not just the
263
+ * journal: every machine-local write lives under it, and one line covers them.
186
264
  */
187
265
  export function journalIgnoreNext(text) {
188
- const line = `${JOURNAL_DIR}/`;
189
- if (text.split('\n').map((l) => l.trim()).includes(line)) return null;
266
+ const line = `${LOCAL_DIR}/`;
267
+ const legacy = `${JOURNAL_DIR_LEGACY}/`;
268
+ const lines = text.split('\n').map((l) => l.trim());
269
+ if (lines.includes(line) || lines.includes(legacy)) return null;
190
270
  const prefix = text === '' || text.endsWith('\n') ? '' : '\n';
191
271
  return `${text}${prefix}\n# Write journal for \`simt undo\`. Local state, never shared.\n${line}\n`;
192
272
  }
@@ -212,6 +292,38 @@ function setName(command, at) {
212
292
  return `${stamp}-${command}-${process.pid}.json`;
213
293
  }
214
294
 
295
+ /**
296
+ * Fold one run's entries so each path (each config key) appears once: the
297
+ * first before, the last after. Two writes to one path used to refuse `undo`
298
+ * outright, although the run's net effect is a single restore. A create
299
+ * followed by a removal in the same run is no change at all and folds away;
300
+ * unequal ghosts — a removal folded with a write — fold to the write's after
301
+ * with the removal's before.
302
+ */
303
+ function coalesceEntries(acc) {
304
+ const order = [];
305
+ const folded = new Map();
306
+ for (const entry of acc) {
307
+ const slot = entry.kind === 'git-config' ? `\0${entry.key}` : entry.path;
308
+ const prev = folded.get(slot);
309
+ if (prev === undefined) {
310
+ order.push(slot);
311
+ folded.set(slot, entry);
312
+ continue;
313
+ }
314
+ if (entry.kind === 'git-config') {
315
+ folded.set(slot, { ...entry, before: prev.before });
316
+ } else {
317
+ folded.set(slot, { ...entry, beforeDigest: prev.beforeDigest, beforeBytes: prev.beforeBytes, modeBefore: prev.modeBefore });
318
+ }
319
+ }
320
+ return order.map((slot) => folded.get(slot)).filter((entry) => {
321
+ if (entry.kind === 'git-config') return entry.before !== entry.after;
322
+ // Created then removed (or set back byte-for-byte, mode aside): nothing to restore.
323
+ return entry.beforeDigest !== null || entry.afterDigest !== null;
324
+ });
325
+ }
326
+
215
327
  /**
216
328
  * Persist a run's set. Writes nothing when the run changed nothing, so the
217
329
  * last set on disk is always the last run that wrote.
@@ -219,12 +331,13 @@ function setName(command, at) {
219
331
  * @returns {Promise<string|null>} the set id (file name), or null
220
332
  */
221
333
  export async function commitJournal(root, command, acc, { at = Date.now() } = {}) {
222
- if (acc.length === 0) return null;
334
+ const entries = coalesceEntries(acc);
335
+ if (entries.length === 0) return null;
223
336
  await ignoreJournal(root);
224
337
  const dir = join(root, JOURNAL_DIR);
225
338
  await mkdir(dir, { recursive: true });
226
339
  const id = setName(command, at);
227
- const body = `${JSON.stringify({ version: 1, command, at, entries: acc }, null, 2)}\n`;
340
+ const body = `${JSON.stringify({ version: SET_VERSION, command, at, entries }, null, 2)}\n`;
228
341
  const tmp = join(dir, `${id}.tmp-${process.pid}`);
229
342
  await writeFile(tmp, body, 'utf8');
230
343
  await rename(tmp, join(dir, id));
@@ -246,71 +359,177 @@ export async function journalHygiene(root) {
246
359
  } catch {
247
360
  text = '';
248
361
  }
249
- const line = `${JOURNAL_DIR}/`;
250
- return { sets, ignored: text.split('\n').map((l) => l.trim()).includes(line) };
362
+ const lines = text.split('\n').map((l) => l.trim());
363
+ return { sets, ignored: lines.includes(`${LOCAL_DIR}/`) || lines.includes(`${JOURNAL_DIR_LEGACY}/`) };
251
364
  }
252
365
 
253
366
  /** Set ids newest-first, undone sets excluded. */
254
367
  export async function listJournalSets(root) {
368
+ // The pre-move directory is a fallback for one release (review 0003 Q19).
255
369
  let names;
256
370
  try {
257
371
  names = await readdir(join(root, JOURNAL_DIR));
258
372
  } catch (error) {
259
- if (error.code === 'ENOENT') return [];
260
- throw error;
373
+ if (error.code !== 'ENOENT') throw error;
374
+ try {
375
+ names = await readdir(join(root, JOURNAL_DIR_LEGACY));
376
+ } catch (legacyError) {
377
+ if (legacyError.code === 'ENOENT') return [];
378
+ throw legacyError;
379
+ }
261
380
  }
262
381
  // An undone set ends in `.undone.json`, which itself ends in `.json` — the
263
382
  // suffix test alone would list history as current and `undo` would restore
264
- // the same set twice. Exclude the undone first.
265
- return names.filter((n) => n.endsWith(SET_SUFFIX) && !n.endsWith(UNDONE_SUFFIX)).sort().reverse();
383
+ // the same set twice. Exclude the undone (and the crash markers, which are
384
+ // intent, not sets) first.
385
+ return names
386
+ .filter((n) => n.endsWith(SET_SUFFIX) && !n.endsWith(UNDONE_SUFFIX) && !n.endsWith(UNDOING_SUFFIX))
387
+ .sort()
388
+ .reverse();
389
+ }
390
+
391
+ /** The directory holding set `id`, preferring the post-move one. */
392
+ async function journalDirFor(root, id) {
393
+ try {
394
+ await stat(join(root, JOURNAL_DIR, id));
395
+ return JOURNAL_DIR;
396
+ } catch {
397
+ return JOURNAL_DIR_LEGACY;
398
+ }
266
399
  }
267
400
 
268
401
  export async function readJournalSet(root, id) {
269
- const raw = await readFile(join(root, JOURNAL_DIR, id), 'utf8');
270
- return JSON.parse(raw);
402
+ const dir = await journalDirFor(root, id);
403
+ const raw = await readFile(join(root, dir, id), 'utf8');
404
+ const set = JSON.parse(raw);
405
+ if (set.version !== SET_VERSION) {
406
+ // Version 1 stored `beforeBytes` as utf8 text and knew no kinds: read it
407
+ // through the legacy branch of the restore rather than refusing history.
408
+ return { ...set, entries: set.entries.map((e) => ({ ...e, kind: e.kind ?? 'file-legacy' })) };
409
+ }
410
+ return set;
411
+ }
412
+
413
+ /** The pre-run bytes of an entry as a string, for previews and diffs. */
414
+ export function beforeText(entry) {
415
+ if (entry.kind === 'git-config' || entry.beforeBytes === null || entry.beforeBytes === undefined) return entry.before ?? null;
416
+ if (entry.kind === 'file-legacy') return entry.beforeBytes;
417
+ return Buffer.from(entry.beforeBytes, 'base64').toString('utf8');
418
+ }
419
+
420
+ /** The display name of an entry: a path for files, the key for config. */
421
+ export function entryLabel(entry) {
422
+ return entry.kind === 'git-config' ? `git config ${entry.key}` : entry.path;
423
+ }
424
+
425
+ /** The digest of a file's current bytes, or null when absent. */
426
+ function currentDigest(state) {
427
+ return state === null ? null : digest(state.bytes);
428
+ }
429
+
430
+ /**
431
+ * Whether an entry is already at its pre-run state — restored by an earlier
432
+ * attempt, or hand-set back to exactly what the run found. Restoring it again
433
+ * would be a no-op write, so a resumed undo skips it instead of refusing it.
434
+ */
435
+ async function alreadyRestored(root, entry) {
436
+ if (entry.kind === 'git-config') {
437
+ return (await readGitValue(root, entry.key)) === entry.before;
438
+ }
439
+ const state = await readOrNull(join(root, entry.path));
440
+ return currentDigest(state) === entry.beforeDigest;
441
+ }
442
+
443
+ /** Whether an entry is still at the state the run left: the precondition for
444
+ * restoring it. Anything else is somebody's edit, and the undo is refused. */
445
+ async function untouchedSince(root, entry) {
446
+ if (entry.kind === 'git-config') {
447
+ return (await readGitValue(root, entry.key)) === entry.after;
448
+ }
449
+ const state = await readOrNull(join(root, entry.path));
450
+ return currentDigest(state) === entry.afterDigest;
451
+ }
452
+
453
+ function entryBytes(entry) {
454
+ if (entry.beforeBytes === null) return null;
455
+ if (entry.kind === 'file-legacy') return Buffer.from(entry.beforeBytes, 'utf8');
456
+ return Buffer.from(entry.beforeBytes, 'base64');
457
+ }
458
+
459
+ /** Restore one entry: bytes and permission bits for files, the value for config. */
460
+ async function restoreEntry(root, entry) {
461
+ if (entry.kind === 'git-config') {
462
+ await exec('git', entry.before === null ? ['config', '--unset', entry.key] : ['config', entry.key, entry.before], {
463
+ cwd: root,
464
+ });
465
+ return;
466
+ }
467
+ const abs = join(root, entry.path);
468
+ if (entry.afterDigest === null) {
469
+ // The run removed this path: re-create it with its pre-run bytes.
470
+ await mkdir(dirname(abs), { recursive: true });
471
+ await writeFile(abs, entryBytes(entry));
472
+ } else if (entry.beforeDigest === null) {
473
+ // The run created this path: remove it.
474
+ await unlink(abs);
475
+ } else {
476
+ await writeFile(abs, entryBytes(entry));
477
+ }
478
+ if (entry.modeBefore !== null && entry.modeBefore !== undefined) {
479
+ await chmod(abs, entry.modeBefore);
480
+ }
271
481
  }
272
482
 
273
483
  /**
274
- * Restore one set: verify every file is untouched since, then restore all.
275
- * Verification precedes any restore, so a refusal changes nothing.
484
+ * Restore one set: verify every entry is untouched since, then restore all in
485
+ * reverse — later writes in the run unwind first, so overlapping restores land
486
+ * in pre-run order.
276
487
  *
277
- * @returns {Promise<{restored: string[]}|{refused: {path: string, detail: string}}>}
488
+ * The intent lands on disk before the first restore (the write-ahead marker),
489
+ * so a crash mid-undo is resumable: entries already at their pre-run state
490
+ * are skipped, the rest are verified against the run's after-state and
491
+ * restored, and anything at neither refuses the whole undo. Verification
492
+ * still precedes any restore when there is no marker, so a refusal on a fresh
493
+ * undo changes nothing.
494
+ *
495
+ * @returns {Promise<{restored: string[], resumed: boolean}|{refused: {path: string, detail: string}}>}
278
496
  */
279
497
  async function undoJournalSet(root, id) {
280
498
  const set = await readJournalSet(root, id);
499
+ const dir = await journalDirFor(root, id);
500
+ const marker = join(root, dir, `${id}${UNDOING_SUFFIX}`);
501
+ const resumed = (await readOrNull(marker)) !== null;
281
502
  for (const entry of set.entries) {
282
- const abs = join(root, entry.path);
283
- const bytes = await readOrNull(abs);
284
- const now = bytes === null ? null : digest(bytes);
285
- if (now !== entry.afterDigest) {
503
+ if (await alreadyRestored(root, entry)) continue;
504
+ if (!(await untouchedSince(root, entry))) {
505
+ const label = entryLabel(entry);
286
506
  return {
287
507
  refused: {
288
- path: entry.path,
508
+ path: label,
289
509
  detail:
290
- bytes === null
291
- ? 'the file was deleted after the journaled write'
292
- : 'the file changed after the journaled write',
510
+ entry.kind === 'git-config'
511
+ ? `git config ${entry.key} changed after the journaled write`
512
+ : (await readOrNull(join(root, entry.path))) === null
513
+ ? 'the file was deleted after the journaled write'
514
+ : 'the file changed after the journaled write',
293
515
  },
294
516
  };
295
517
  }
296
518
  }
519
+ // The marker is the intent, written before anything is restored: a crash
520
+ // from here on resumes instead of refusing the files already done.
521
+ const tmp = `${marker}.tmp-${process.pid}`;
522
+ await writeFile(tmp, `${JSON.stringify({ version: 1, set: id, at: Date.now() }, null, 2)}\n`, 'utf8');
523
+ await rename(tmp, marker);
297
524
  const restored = [];
298
- for (const entry of set.entries) {
299
- const abs = join(root, entry.path);
300
- if (entry.afterDigest === null) {
301
- // The run removed this path: re-create it with its pre-run bytes.
302
- await mkdir(dirname(abs), { recursive: true });
303
- await writeFile(abs, entry.beforeBytes, 'utf8');
304
- } else if (entry.beforeBytes === null) {
305
- // The run created this path: remove it.
306
- await unlink(abs);
307
- } else {
308
- await writeFile(abs, entry.beforeBytes, 'utf8');
309
- }
310
- restored.push(entry.path);
525
+ for (const entry of [...set.entries].reverse()) {
526
+ if (await alreadyRestored(root, entry)) continue;
527
+ await restoreEntry(root, entry);
528
+ restored.push(entryLabel(entry));
311
529
  }
312
- await rename(join(root, JOURNAL_DIR, id), join(root, JOURNAL_DIR, id.replace(/\.json$/, UNDONE_SUFFIX)));
313
- return { restored };
530
+ await unlink(marker);
531
+ await rename(join(root, dir, id), join(root, dir, id.replace(/\.json$/, UNDONE_SUFFIX)));
532
+ return { restored, resumed };
314
533
  }
315
534
 
316
535
  /**
@@ -23,6 +23,7 @@ import { readFile } from 'node:fs/promises';
23
23
  import { join } from 'node:path';
24
24
 
25
25
  import { ECOSYSTEMS } from '../projection/manifest.mjs';
26
+ import { resolveNodeRunner, runScript } from '../ecosystem/node-runner.mjs';
26
27
 
27
28
  /**
28
29
  * The card's ceiling in bytes (plan 0003 R10). Over it, the least valuable lines are
@@ -93,10 +94,12 @@ const FOCUSED = [
93
94
  // The repo's own runner decides the shape. A repository whose test script is vitest
94
95
  // or jest takes a file argument through the script; one with no such script gets the
95
96
  // built-in runner, which is the only answer that needs nothing installed to already
96
- // be true. `npm test -- <file>` is the ADR-010 shape (never `npx`).
97
- template: (s, f, scripts) => {
97
+ // be true. `<manager> test -- <file>` is the ADR-010 shape (never `npx`).
98
+ template: (s, f, scripts, runner) => {
98
99
  const test = scripts?.test ?? '';
99
- return /vitest|jest/.test(test) ? 'npm test -- {file}' : 'node --test {file}';
100
+ if (!/vitest|jest/.test(test)) return 'node --test {file}';
101
+ const run = runner?.run ?? 'npm';
102
+ return `${run} test -- {file}`;
100
103
  },
101
104
  },
102
105
  ];
@@ -123,15 +126,32 @@ function text(value) {
123
126
  return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
124
127
  }
125
128
 
126
- /** The repository's own `package.json` scripts, or an empty map. */
129
+ /** The repository's own `package.json` scripts, plus the manager that runs them. */
127
130
  async function readScripts(root) {
128
131
  try {
129
132
  const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
130
- return pkg && typeof pkg.scripts === 'object' && pkg.scripts ? pkg.scripts : {};
133
+ const scripts = pkg && typeof pkg.scripts === 'object' && pkg.scripts ? pkg.scripts : {};
134
+ // Q31: the runner is committed evidence (lockfile, then `packageManager`),
135
+ // never an assumption of npm — the card, the gates and CI read one answer.
136
+ const lockfiles = [];
137
+ for (const file of ['pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb', 'package-lock.json']) {
138
+ try {
139
+ await readFile(join(root, file), 'utf8');
140
+ lockfiles.push(file);
141
+ } catch {
142
+ // Absent is the normal case; only present files name the runner.
143
+ }
144
+ }
145
+ const runner = resolveNodeRunner({
146
+ facts: [],
147
+ lockfiles,
148
+ packageManager: typeof pkg.packageManager === 'string' ? pkg.packageManager : null,
149
+ });
150
+ return { scripts, runner };
131
151
  } catch {
132
152
  // No package.json is a normal shape (a Python or Go repository), and an unreadable one
133
153
  // is reported by the manifest path. Either way the card has no scripts to draw on.
134
- return {};
154
+ return { scripts: {}, runner: null };
135
155
  }
136
156
  }
137
157
 
@@ -145,7 +165,7 @@ async function readScripts(root) {
145
165
  * repository has declared nothing. The last is the same table the tool seeds gates from,
146
166
  * so the card and the gate list cannot name two different commands for one repository.
147
167
  */
148
- function resolveVerb(verb, { manifest, facts, stack, scripts }) {
168
+ function resolveVerb(verb, { manifest, facts, stack, scripts, runner }) {
149
169
  const declared = text(manifest?.gates?.commands?.[verb]);
150
170
  if (declared) return declared;
151
171
 
@@ -154,11 +174,11 @@ function resolveVerb(verb, { manifest, facts, stack, scripts }) {
154
174
  // is the one a hook and CI run, which is what the card is reporting.
155
175
  const scriptNames = verb === 'format' ? ['format:check', 'format'] : [verb];
156
176
  for (const name of scriptNames) {
157
- if (text(scripts?.[name])) return `npm run ${name}`;
177
+ if (text(scripts?.[name])) return runScript(runner?.id ?? 'npm', name);
158
178
  }
159
179
 
160
180
  if (verb === 'build') {
161
- if (text(scripts?.build)) return 'npm run build';
181
+ if (text(scripts?.build)) return runScript(runner?.id ?? 'npm', 'build');
162
182
  for (const row of BUILDS) {
163
183
  if (row.fact && facts.has(row.fact)) return row.command;
164
184
  if (row.stack && stack.has(row.stack)) return row.command;
@@ -217,11 +237,16 @@ function pickDroppable(lines) {
217
237
  export async function commandCard({ root, manifest = null, stack = [], facts = [] }) {
218
238
  const factSet = new Set(facts);
219
239
  const stackSet = new Set(stack);
220
- const scripts = await readScripts(root);
240
+ const { scripts, runner } = await readScripts(root);
241
+ // Q31: the lockfile facts name the runner even when `readScripts` saw no
242
+ // lockfile (a fixture with a bare package.json keeps npm; a pnpm checkout
243
+ // resolves pnpm from its fact). Facts win inside the resolver already.
244
+ const { resolveNodeRunner: resolveRunner } = await import('../ecosystem/node-runner.mjs');
245
+ const effective = runner ?? resolveRunner({ facts: factSet });
221
246
 
222
247
  const lines = [];
223
248
  for (const verb of VERBS) {
224
- const command = resolveVerb(verb, { manifest, facts: factSet, stack: stackSet, scripts });
249
+ const command = resolveVerb(verb, { manifest, facts: factSet, stack: stackSet, scripts, runner: effective });
225
250
  if (command) lines.push({ label: verb, command, verb });
226
251
  }
227
252
  for (const entry of FOCUSED) {
@@ -231,7 +256,7 @@ export async function commandCard({ root, manifest = null, stack = [], facts = [
231
256
  if (lines.some((l) => l.kind === entry.kind)) continue;
232
257
  lines.push({
233
258
  label: `focused test (${entry.kind})`,
234
- command: entry.template(stackSet, factSet, scripts),
259
+ command: entry.template(stackSet, factSet, scripts, effective),
235
260
  kind: entry.kind,
236
261
  verb: 'focused',
237
262
  });