simt-dev 0.6.2 → 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 (170) hide show
  1. package/CHANGELOG.md +83 -1
  2. package/README.md +46 -17
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -6
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  7. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  8. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  9. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  10. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  11. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  12. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  13. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  14. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  15. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  16. 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
  17. 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
  18. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  19. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  20. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  21. 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
  22. 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
  23. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  24. package/docs/decisions/README.md +53 -0
  25. package/package.json +4 -2
  26. package/rules/core.md +2 -2
  27. package/rules/mdc/preview-postflight.mdc +5 -6
  28. package/rules/mdc/test-gate.mdc +2 -0
  29. package/rules/stacks/android.md +10 -11
  30. package/rules/stacks/cloudflare-workers.md +1 -1
  31. package/rules/stacks/connectiq.md +2 -2
  32. package/rules/stacks/container.md +2 -0
  33. package/rules/stacks/dart.md +1 -1
  34. package/rules/stacks/deno.md +1 -1
  35. package/rules/stacks/dotnet.md +1 -1
  36. package/rules/stacks/elixir.md +1 -1
  37. package/rules/stacks/go.md +1 -1
  38. package/rules/stacks/jvm.md +4 -3
  39. package/rules/stacks/node.md +2 -2
  40. package/rules/stacks/python.md +7 -6
  41. package/rules/stacks/react-native.md +3 -5
  42. package/rules/stacks/react-web.md +9 -9
  43. package/rules/stacks/ruby.md +1 -1
  44. package/rules/stacks/rust.md +1 -1
  45. package/rules/stacks/swift.md +1 -1
  46. package/rules/stacks/typescript.md +2 -2
  47. package/rules/stacks/uv.md +3 -2
  48. package/skills/simt-commands/SKILL.md +70 -0
  49. package/src/advice/advice.mjs +7 -3
  50. package/src/agenthooks/decide.mjs +238 -15
  51. package/src/agenthooks/opencode.mjs +72 -0
  52. package/src/agenthooks/policy.mjs +81 -5
  53. package/src/agenthooks/prompt.mjs +6 -2
  54. package/src/agenthooks/render.mjs +186 -19
  55. package/src/agenthooks/shell.mjs +523 -0
  56. package/src/agenthooks/shim.mjs +55 -0
  57. package/src/agents/render.mjs +277 -0
  58. package/src/argv.mjs +62 -9
  59. package/src/bench/ablate.mjs +31 -14
  60. package/src/bench/mine.mjs +48 -1
  61. package/src/bench/probes.mjs +5 -6
  62. package/src/bench/report.mjs +243 -80
  63. package/src/bench/run.mjs +12 -11
  64. package/src/bench/variants.mjs +112 -40
  65. package/src/brief/assemble.mjs +6 -0
  66. package/src/ci/workflow.mjs +16 -7
  67. package/src/commands/agent.mjs +206 -0
  68. package/src/commands/agenthook.mjs +133 -17
  69. package/src/commands/bench.mjs +2 -1
  70. package/src/commands/ci.mjs +28 -3
  71. package/src/commands/compare.mjs +8 -7
  72. package/src/commands/completions.mjs +27 -0
  73. package/src/commands/decide.mjs +20 -6
  74. package/src/commands/doctor.mjs +367 -0
  75. package/src/commands/evidence.mjs +13 -3
  76. package/src/commands/explain.mjs +52 -0
  77. package/src/commands/gate.mjs +85 -19
  78. package/src/commands/graph.mjs +18 -5
  79. package/src/commands/help.mjs +96 -26
  80. package/src/commands/hooks.mjs +258 -67
  81. package/src/commands/ignore.mjs +152 -0
  82. package/src/commands/impact.mjs +4 -2
  83. package/src/commands/index.mjs +69 -102
  84. package/src/commands/init.mjs +239 -37
  85. package/src/commands/lease.mjs +304 -0
  86. package/src/commands/lessons.mjs +77 -9
  87. package/src/commands/map.mjs +33 -18
  88. package/src/commands/mcp.mjs +2 -2
  89. package/src/commands/pack.mjs +9 -0
  90. package/src/commands/plan.mjs +13 -1
  91. package/src/commands/plugin.mjs +17 -2
  92. package/src/commands/prbody.mjs +45 -0
  93. package/src/commands/repoindex.mjs +4 -0
  94. package/src/commands/rules.mjs +219 -105
  95. package/src/commands/shared.mjs +36 -5
  96. package/src/commands/skills.mjs +329 -206
  97. package/src/commands/skillscompile.mjs +8 -7
  98. package/src/commands/skillsinstall.mjs +112 -30
  99. package/src/commands/slice.mjs +712 -0
  100. package/src/commands/status.mjs +62 -0
  101. package/src/commands/sync.mjs +146 -36
  102. package/src/commands/tests.mjs +5 -2
  103. package/src/commands/undo.mjs +114 -0
  104. package/src/commands/upgrade.mjs +103 -9
  105. package/src/commands/why.mjs +10 -4
  106. package/src/compare/hooksstate.mjs +23 -1
  107. package/src/compare/learn.mjs +82 -57
  108. package/src/compare/parallel.mjs +50 -0
  109. package/src/compare/row.mjs +26 -1
  110. package/src/completions.mjs +196 -0
  111. package/src/decisions/dir.mjs +84 -0
  112. package/src/decisions/index.mjs +109 -11
  113. package/src/diff.mjs +254 -0
  114. package/src/distill/distill.mjs +3 -1
  115. package/src/ecosystem/node-runner.mjs +144 -0
  116. package/src/explain.mjs +270 -0
  117. package/src/federation/config.mjs +44 -0
  118. package/src/graph/load.mjs +52 -24
  119. package/src/hooks/capability.mjs +14 -1
  120. package/src/hooks/dispatcher.mjs +441 -9
  121. package/src/hooks/gates.mjs +14 -3
  122. package/src/hooks/resolve.mjs +23 -3
  123. package/src/hooks/shim.mjs +12 -7
  124. package/src/identity.mjs +67 -14
  125. package/src/ignore.mjs +133 -0
  126. package/src/index/extract.mjs +192 -11
  127. package/src/index/persist.mjs +372 -55
  128. package/src/index/store.mjs +263 -60
  129. package/src/index/tests.mjs +6 -35
  130. package/src/journal.mjs +545 -0
  131. package/src/kernel/card.mjs +37 -12
  132. package/src/kernel/checks.mjs +44 -9
  133. package/src/kernel/variants.mjs +27 -16
  134. package/src/lease.mjs +195 -0
  135. package/src/ledger/index.mjs +91 -25
  136. package/src/mcp/resources.mjs +5 -3
  137. package/src/mcp/server.mjs +86 -13
  138. package/src/mcp/tools.mjs +340 -264
  139. package/src/memory/decide.mjs +98 -38
  140. package/src/memory/lessons.mjs +180 -33
  141. package/src/name.mjs +9 -0
  142. package/src/output/index.mjs +82 -12
  143. package/src/package-version.mjs +49 -1
  144. package/src/packs/manage.mjs +13 -4
  145. package/src/plan/index.mjs +37 -8
  146. package/src/plan/template.mjs +27 -5
  147. package/src/plugins.mjs +33 -8
  148. package/src/prbody.mjs +253 -0
  149. package/src/projection/baselines.mjs +1 -1
  150. package/src/projection/disclosure.mjs +2 -2
  151. package/src/projection/loaders.mjs +41 -1
  152. package/src/projection/lock.mjs +21 -14
  153. package/src/projection/manifest.mjs +82 -21
  154. package/src/projection/markers.mjs +43 -3
  155. package/src/projection/mdc.mjs +34 -7
  156. package/src/projection/project.mjs +162 -18
  157. package/src/projection/rules.mjs +54 -3
  158. package/src/projection/shared.mjs +4 -1
  159. package/src/projection/surfaces.mjs +9 -1
  160. package/src/repo-root.mjs +69 -0
  161. package/src/resolve/body.mjs +4 -0
  162. package/src/resolve/fingerprint.mjs +5 -0
  163. package/src/resolve/local.mjs +58 -0
  164. package/src/review/critic.mjs +4 -2
  165. package/src/secrets.mjs +374 -0
  166. package/src/state.mjs +76 -0
  167. package/src/status.mjs +157 -0
  168. package/src/toolwords.mjs +5 -0
  169. package/src/tsconfig.mjs +39 -99
  170. package/templates/plan/tracker.json +1 -1
@@ -0,0 +1,545 @@
1
+ // The write journal and `undo` (plan 0003 R49, entry kinds review 0003 Q22).
2
+ //
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.
13
+ //
14
+ // Three deliberate boundaries, each a failure this module refuses to be:
15
+ //
16
+ // - The plan row says `.simt-dev/journal/`, which predates the STATE_DIR
17
+ // rename (the same stale spelling survives in bench/probes.mjs comments).
18
+ // The journal lives under STATE_DIR like every other per-repo state.
19
+ // - Nothing under `.simt/` is journaled. The journal recording itself is
20
+ // recursion; the ledger, index and candidates are evidence and caches, and
21
+ // restoring those to an earlier byte-shape would corrupt R17 rather than
22
+ // repair anything. `journaledWriteFile` performs those writes unrecorded.
23
+ // - Only regular files. Symlinks carry no bytes for beforeBytes to hold, and
24
+ // the record shape is file bytes — pointer changes are reported by the
25
+ // command as before and are not undone. Removals are journaled as entries
26
+ // with afterDigest null (the run deleted the path; undo re-creates it).
27
+ // Writes outside the repository (`shared install` into $HOME, `plugin`
28
+ // into --out) are never journaled: undo must not reach past the repo root.
29
+ // Commands that span the boundary (`shared install` edits the repo manifest
30
+ // as part of a $HOME write) and multi-root runs (`bench`, `compare` write
31
+ // into several repos at once) are excluded as a unit — a set belongs to one
32
+ // repo, and restoring half a command would lie about the other half.
33
+ //
34
+ // A set file is written atomically (tmp + rename) and only when non-empty, so
35
+ // the last set on disk is always the last run that changed something. An undone
36
+ // set is renamed to `.undone.json` rather than deleted: the history survives,
37
+ // a second `undo` naturally targets the set before it, and the untouched-check
38
+ // refuses when the two sets overlap on a file the first undo already restored.
39
+
40
+ import { execFile } from 'node:child_process';
41
+ import { createHash } from 'node:crypto';
42
+ import { chmod, cp, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
43
+ import { dirname, join, relative, sep } from 'node:path';
44
+ import { promisify } from 'node:util';
45
+
46
+ import { STATE_DIR } from './name.mjs';
47
+ import { LOCAL_DIR } from './state.mjs';
48
+
49
+ const exec = promisify(execFile);
50
+
51
+ const JOURNAL_DIR = join(LOCAL_DIR, 'journal');
52
+ const JOURNAL_DIR_LEGACY = '.simt/journal';
53
+ const SET_SUFFIX = '.json';
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;
63
+
64
+ function digest(bytes) {
65
+ return `sha256-${createHash('sha256').update(bytes).digest('hex')}`;
66
+ }
67
+
68
+ function rel(root, path) {
69
+ return relative(root, path).split(sep).join('/');
70
+ }
71
+
72
+ /**
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.
76
+ */
77
+ async function readOrNull(path) {
78
+ try {
79
+ const [bytes, st] = await Promise.all([readFile(path), stat(path)]);
80
+ return { bytes, mode: st.mode & 0o777 };
81
+ } catch (error) {
82
+ if (error.code === 'ENOENT') return null;
83
+ throw error;
84
+ }
85
+ }
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
+
116
+ /**
117
+ * Write a repo file and record the write in the run's set.
118
+ *
119
+ * @param {string} root the repository root; paths outside it are refused
120
+ * @param {Array} acc the run's set, created by the caller and committed once
121
+ * @param {string} path absolute path to write
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
126
+ */
127
+ export async function journaledWriteFile(root, acc, path, text, { mode } = {}) {
128
+ if (relative(root, path).startsWith('..')) {
129
+ throw new Error(`journal refuses a write outside the repository: ${path}`);
130
+ }
131
+ const before = await readOrNull(path);
132
+ // Internal state is written, not recorded — see the module header.
133
+ const internal = rel(root, path) === STATE_DIR || rel(root, path).startsWith(`${STATE_DIR}/`);
134
+ await mkdir(dirname(path), { recursive: true });
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);
142
+ if (!internal) {
143
+ acc.push(fileEntry(rel(root, path), before, Buffer.from(text, 'utf8')));
144
+ }
145
+ }
146
+
147
+ /** List every regular file under a directory, absolute paths. Missing dir → []. */
148
+ async function treeFiles(dir) {
149
+ let st;
150
+ try {
151
+ st = await stat(dir);
152
+ } catch (error) {
153
+ if (error.code === 'ENOENT') return [];
154
+ throw error;
155
+ }
156
+ if (!st.isDirectory()) return [dir];
157
+ const out = [];
158
+ for (const name of await readdir(dir)) {
159
+ out.push(...(await treeFiles(join(dir, name))));
160
+ }
161
+ return out;
162
+ }
163
+
164
+ /**
165
+ * Copy a skill source tree into the repo and record every file landed, so
166
+ * `undo` removes what the copy created and restores what it overwrote. The
167
+ * before-snapshot is taken first: a `cp` that overwrites in place would
168
+ * otherwise destroy the bytes the entry needs.
169
+ */
170
+ export async function journaledCopyDir(root, acc, src, dest) {
171
+ if (relative(root, dest).startsWith('..')) {
172
+ throw new Error(`journal refuses a copy outside the repository: ${dest}`);
173
+ }
174
+ const before = new Map();
175
+ for (const abs of await treeFiles(dest)) {
176
+ before.set(rel(root, abs), await readOrNull(abs));
177
+ }
178
+ await mkdir(dirname(dest), { recursive: true });
179
+ await cp(src, dest, { recursive: true });
180
+ for (const abs of await treeFiles(dest)) {
181
+ const path = rel(root, abs);
182
+ const prev = before.get(path) ?? null;
183
+ before.delete(path);
184
+ const after = await readOrNull(abs);
185
+ if (prev !== null && after !== null && prev.bytes.equals(after.bytes)) continue;
186
+ acc.push(fileEntry(path, prev, after === null ? null : after.bytes));
187
+ }
188
+ // Files the copy removed (an overwrite that drops a name) are removals.
189
+ for (const [path, prev] of before) {
190
+ acc.push(fileEntry(path, prev, null));
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Remove a repo file and record the removal: undo re-creates the path with its
196
+ * pre-run bytes, provided nothing has appeared there since. Removing what is
197
+ * already absent records nothing — there is no state to restore.
198
+ */
199
+ export async function journaledRemoveFile(root, acc, path) {
200
+ if (relative(root, path).startsWith('..')) {
201
+ throw new Error(`journal refuses a removal outside the repository: ${path}`);
202
+ }
203
+ const before = await readOrNull(path);
204
+ if (before === null) return;
205
+ const internal = rel(root, path) === STATE_DIR || rel(root, path).startsWith(`${STATE_DIR}/`);
206
+ await unlink(path);
207
+ if (!internal) {
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}`);
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;
246
+ }
247
+
248
+ /**
249
+ * Add the journal directory to .gitignore, mirroring `ignoreIdentity`: match on
250
+ * the exact line, never append a duplicate. Called by commitJournal so every
251
+ * writer command is covered at once rather than each one remembering.
252
+ * Exported so `doctor --fix` repairs a missing entry with the same bytes a
253
+ * writer would have left (plan 0003 R47).
254
+ */
255
+ /**
256
+ * The .gitignore bytes `ignoreJournal` would write for `text`, or null when
257
+ * `text` already ignores machine-local state. One owner for the line
258
+ * spelling: `ignoreJournal` writes it, `journalHygiene` reads whether it is
259
+ * there, and `doctor --fix --diff` previews it — all through this function so
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.
264
+ */
265
+ export function journalIgnoreNext(text) {
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;
270
+ const prefix = text === '' || text.endsWith('\n') ? '' : '\n';
271
+ return `${text}${prefix}\n# Write journal for \`simt undo\`. Local state, never shared.\n${line}\n`;
272
+ }
273
+
274
+ export async function ignoreJournal(root, journal = null) {
275
+ const ignorePath = join(root, '.gitignore');
276
+ let text;
277
+ try {
278
+ text = await readFile(ignorePath, 'utf8');
279
+ } catch {
280
+ text = '';
281
+ }
282
+ const next = journalIgnoreNext(text);
283
+ if (next === null) return;
284
+ // A caller-supplied journal records the edit so `undo` restores it (R47);
285
+ // without one the write stays unrecorded, as `commitJournal` needs it.
286
+ if (journal) await journaledWriteFile(root, journal, ignorePath, next);
287
+ else await writeFile(ignorePath, next);
288
+ }
289
+
290
+ function setName(command, at) {
291
+ const stamp = new Date(at).toISOString().replace(/[:.]/g, '-');
292
+ return `${stamp}-${command}-${process.pid}.json`;
293
+ }
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
+
327
+ /**
328
+ * Persist a run's set. Writes nothing when the run changed nothing, so the
329
+ * last set on disk is always the last run that wrote.
330
+ *
331
+ * @returns {Promise<string|null>} the set id (file name), or null
332
+ */
333
+ export async function commitJournal(root, command, acc, { at = Date.now() } = {}) {
334
+ const entries = coalesceEntries(acc);
335
+ if (entries.length === 0) return null;
336
+ await ignoreJournal(root);
337
+ const dir = join(root, JOURNAL_DIR);
338
+ await mkdir(dir, { recursive: true });
339
+ const id = setName(command, at);
340
+ const body = `${JSON.stringify({ version: SET_VERSION, command, at, entries }, null, 2)}\n`;
341
+ const tmp = join(dir, `${id}.tmp-${process.pid}`);
342
+ await writeFile(tmp, body, 'utf8');
343
+ await rename(tmp, join(dir, id));
344
+ return id;
345
+ }
346
+
347
+ /**
348
+ * The `journal-unignored` finding's facts (plan 0003 R47): the sets on disk and
349
+ * whether .gitignore names the directory. One owner for the line spelling —
350
+ * `ignoreJournal` writes it, this reads it — so the two cannot disagree about
351
+ * what counts as ignored.
352
+ */
353
+ export async function journalHygiene(root) {
354
+ const sets = await listJournalSets(root);
355
+ if (sets.length === 0) return { sets, ignored: true };
356
+ let text;
357
+ try {
358
+ text = await readFile(join(root, '.gitignore'), 'utf8');
359
+ } catch {
360
+ text = '';
361
+ }
362
+ const lines = text.split('\n').map((l) => l.trim());
363
+ return { sets, ignored: lines.includes(`${LOCAL_DIR}/`) || lines.includes(`${JOURNAL_DIR_LEGACY}/`) };
364
+ }
365
+
366
+ /** Set ids newest-first, undone sets excluded. */
367
+ export async function listJournalSets(root) {
368
+ // The pre-move directory is a fallback for one release (review 0003 Q19).
369
+ let names;
370
+ try {
371
+ names = await readdir(join(root, JOURNAL_DIR));
372
+ } catch (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
+ }
380
+ }
381
+ // An undone set ends in `.undone.json`, which itself ends in `.json` — the
382
+ // suffix test alone would list history as current and `undo` would restore
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
+ }
399
+ }
400
+
401
+ export async function readJournalSet(root, id) {
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
+ }
481
+ }
482
+
483
+ /**
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.
487
+ *
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}}>}
496
+ */
497
+ async function undoJournalSet(root, id) {
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;
502
+ for (const entry of set.entries) {
503
+ if (await alreadyRestored(root, entry)) continue;
504
+ if (!(await untouchedSince(root, entry))) {
505
+ const label = entryLabel(entry);
506
+ return {
507
+ refused: {
508
+ path: label,
509
+ detail:
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',
515
+ },
516
+ };
517
+ }
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);
524
+ const restored = [];
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));
529
+ }
530
+ await unlink(marker);
531
+ await rename(join(root, dir, id), join(root, dir, id.replace(/\.json$/, UNDONE_SUFFIX)));
532
+ return { restored, resumed };
533
+ }
534
+
535
+ /**
536
+ * Restore the most recent set that has not been undone.
537
+ *
538
+ * @returns {Promise<{empty: true}|{set: string, restored: string[]}|{set: string, refused: {path: string, detail: string}}>}
539
+ */
540
+ export async function undoLastJournalSet(root) {
541
+ const [id] = await listJournalSets(root);
542
+ if (!id) return { empty: true };
543
+ const result = await undoJournalSet(root, id);
544
+ return { set: id, ...result };
545
+ }
@@ -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
  });