simt-dev 0.6.3 → 0.7.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.
Files changed (187) hide show
  1. package/CHANGELOG.md +162 -841
  2. package/README.md +21 -135
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -55
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  7. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  8. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  9. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  10. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  11. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  12. 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
  13. 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
  14. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  15. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  16. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  17. 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
  18. 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
  19. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  20. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  21. package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
  22. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  23. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  24. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  25. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  26. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  27. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  28. package/package.json +13 -3
  29. package/rules/core.md +9 -2
  30. package/rules/mdc/slice-commit.mdc +57 -4
  31. package/rules/mdc/test-gate.mdc +2 -0
  32. package/rules/stacks/android.md +3 -3
  33. package/rules/stacks/connectiq.md +2 -2
  34. package/rules/stacks/dart.md +1 -1
  35. package/rules/stacks/deno.md +1 -1
  36. package/rules/stacks/dotnet.md +1 -1
  37. package/rules/stacks/elixir.md +1 -1
  38. package/rules/stacks/go.md +1 -1
  39. package/rules/stacks/jvm.md +3 -2
  40. package/rules/stacks/node.md +2 -2
  41. package/rules/stacks/python.md +7 -6
  42. package/rules/stacks/ruby.md +1 -1
  43. package/rules/stacks/rust.md +1 -1
  44. package/rules/stacks/swift.md +1 -1
  45. package/rules/stacks/typescript.md +2 -2
  46. package/rules/stacks/uv.md +3 -2
  47. package/skills/simt-commands/SKILL.md +70 -0
  48. package/src/advice/advice.mjs +7 -3
  49. package/src/agenthooks/decide.mjs +141 -9
  50. package/src/agenthooks/opencode.mjs +72 -0
  51. package/src/agenthooks/prompt.mjs +10 -3
  52. package/src/agenthooks/render.mjs +186 -19
  53. package/src/agenthooks/shim.mjs +55 -0
  54. package/src/agents/render.mjs +277 -0
  55. package/src/api.mjs +29 -0
  56. package/src/argv.mjs +37 -3
  57. package/src/bench/ablate.mjs +31 -14
  58. package/src/bench/adapters.json +16 -0
  59. package/src/bench/corpus.mjs +150 -0
  60. package/src/bench/grade.mjs +23 -49
  61. package/src/bench/mine.mjs +247 -36
  62. package/src/bench/probes.mjs +104 -68
  63. package/src/bench/process.mjs +128 -0
  64. package/src/bench/report.mjs +260 -84
  65. package/src/bench/run.mjs +251 -150
  66. package/src/bench/variants.mjs +112 -40
  67. package/src/brief/assemble.mjs +44 -3
  68. package/src/ci/workflow.mjs +46 -14
  69. package/src/cli.mjs +45 -0
  70. package/src/commands/agent.mjs +205 -0
  71. package/src/commands/agenthook.mjs +113 -20
  72. package/src/commands/bench.mjs +192 -18
  73. package/src/commands/brief.mjs +5 -4
  74. package/src/commands/ci.mjs +2 -1
  75. package/src/commands/compare.mjs +50 -49
  76. package/src/commands/completions.mjs +2 -6
  77. package/src/commands/decide.mjs +10 -9
  78. package/src/commands/distill.mjs +4 -3
  79. package/src/commands/doctor.mjs +11 -7
  80. package/src/commands/evidence.mjs +19 -12
  81. package/src/commands/explain.mjs +0 -1
  82. package/src/commands/gate.mjs +98 -28
  83. package/src/commands/graph.mjs +20 -8
  84. package/src/commands/harness.mjs +3 -2
  85. package/src/commands/help.mjs +160 -31
  86. package/src/commands/hooks.mjs +163 -50
  87. package/src/commands/ignore.mjs +2 -1
  88. package/src/commands/impact.mjs +8 -9
  89. package/src/commands/index.mjs +278 -180
  90. package/src/commands/init.mjs +171 -38
  91. package/src/commands/lease.mjs +206 -39
  92. package/src/commands/lessons.mjs +72 -15
  93. package/src/commands/local.mjs +3 -3
  94. package/src/commands/map.mjs +47 -27
  95. package/src/commands/mcp.mjs +0 -1
  96. package/src/commands/pack.mjs +9 -6
  97. package/src/commands/plan.mjs +6 -5
  98. package/src/commands/plugin.mjs +3 -3
  99. package/src/commands/prbody.mjs +3 -3
  100. package/src/commands/repoindex.mjs +5 -3
  101. package/src/commands/review.mjs +2 -2
  102. package/src/commands/rules.mjs +124 -33
  103. package/src/commands/shared.mjs +2 -3
  104. package/src/commands/skills.mjs +82 -45
  105. package/src/commands/skillscompile.mjs +12 -12
  106. package/src/commands/skillsinstall.mjs +14 -2
  107. package/src/commands/slice.mjs +396 -26
  108. package/src/commands/spec.mjs +151 -0
  109. package/src/commands/status.mjs +4 -7
  110. package/src/commands/sync.mjs +50 -33
  111. package/src/commands/tests.mjs +11 -11
  112. package/src/commands/tokens.mjs +2 -2
  113. package/src/commands/undo.mjs +29 -31
  114. package/src/commands/upgrade.mjs +40 -4
  115. package/src/commands/why.mjs +19 -12
  116. package/src/compare/hooksstate.mjs +23 -1
  117. package/src/compare/learn.mjs +82 -57
  118. package/src/compare/parallel.mjs +50 -0
  119. package/src/compare/row.mjs +26 -3
  120. package/src/completions.mjs +5 -0
  121. package/src/context.mjs +64 -0
  122. package/src/decisions/dir.mjs +84 -0
  123. package/src/decisions/index-doc.mjs +46 -0
  124. package/src/decisions/index.mjs +109 -11
  125. package/src/diff.mjs +1 -1
  126. package/src/distill/distill.mjs +17 -2
  127. package/src/distill/engine.mjs +5 -1
  128. package/src/ecosystem/node-runner.mjs +144 -0
  129. package/src/explain.mjs +18 -6
  130. package/src/graph/load.mjs +42 -20
  131. package/src/hooks/capability.mjs +17 -4
  132. package/src/hooks/dispatcher.mjs +513 -36
  133. package/src/hooks/gates.mjs +0 -43
  134. package/src/hooks/resolve.mjs +11 -10
  135. package/src/hooks/shim.mjs +17 -9
  136. package/src/identity.mjs +96 -15
  137. package/src/index/extract.mjs +192 -11
  138. package/src/index/persist.mjs +307 -47
  139. package/src/index/store.mjs +263 -60
  140. package/src/index/tests.mjs +6 -35
  141. package/src/journal.mjs +372 -84
  142. package/src/kernel/card.mjs +37 -12
  143. package/src/kernel/checks.mjs +44 -9
  144. package/src/kernel/variants.mjs +27 -16
  145. package/src/lease.mjs +97 -15
  146. package/src/ledger/index.mjs +120 -25
  147. package/src/local/client.mjs +4 -6
  148. package/src/locate.mjs +33 -9
  149. package/src/manifest/schema.mjs +213 -0
  150. package/src/mcp/resources.mjs +12 -13
  151. package/src/mcp/server.mjs +151 -38
  152. package/src/mcp/tools.mjs +90 -16
  153. package/src/measure/session.mjs +3 -2
  154. package/src/memory/decide.mjs +98 -43
  155. package/src/memory/lessons.mjs +168 -33
  156. package/src/name.mjs +9 -0
  157. package/src/node-version.mjs +37 -0
  158. package/src/output/index.mjs +111 -3
  159. package/src/packs/manage.mjs +17 -3
  160. package/src/packs.mjs +20 -2
  161. package/src/plan/index.mjs +31 -8
  162. package/src/plan/template.mjs +27 -5
  163. package/src/plugins.mjs +6 -2
  164. package/src/prbody.mjs +7 -5
  165. package/src/projection/baselines.mjs +1 -1
  166. package/src/projection/disclosure.mjs +2 -2
  167. package/src/projection/loaders.mjs +41 -1
  168. package/src/projection/lock.mjs +31 -14
  169. package/src/projection/manifest.mjs +89 -53
  170. package/src/projection/markers.mjs +43 -3
  171. package/src/projection/mdc.mjs +34 -7
  172. package/src/projection/project.mjs +162 -18
  173. package/src/projection/rules.mjs +58 -6
  174. package/src/projection/surfaces.mjs +9 -1
  175. package/src/repo-root.mjs +88 -0
  176. package/src/resolve/body.mjs +4 -0
  177. package/src/resolve/fingerprint.mjs +24 -15
  178. package/src/resolve/local.mjs +58 -0
  179. package/src/review/critic.mjs +6 -3
  180. package/src/secrets.mjs +165 -22
  181. package/src/state.mjs +76 -0
  182. package/src/status.mjs +9 -4
  183. package/src/toolwords.mjs +8 -0
  184. package/src/tsconfig.mjs +39 -99
  185. package/src/workspace.mjs +55 -10
  186. package/templates/plan/tracker.json +1 -1
  187. package/docs/decisions/README.md +0 -591
package/src/journal.mjs CHANGED
@@ -1,20 +1,29 @@
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
  //
11
16
  // - The plan row says `.simt-dev/journal/`, which predates the STATE_DIR
12
17
  // rename (the same stale spelling survives in bench/probes.mjs comments).
13
18
  // The journal lives under STATE_DIR like every other per-repo state.
14
- // - Nothing under `.simt/` is journaled. The journal recording itself is
15
- // recursion; the ledger, index and candidates are evidence and caches, and
16
- // restoring those to an earlier byte-shape would corrupt R17 rather than
17
- // repair anything. `journaledWriteFile` performs those writes unrecorded.
19
+ // - Nothing under `.simt/` is journaled, one named exception: the shim
20
+ // directory. The journal recording itself is recursion; the ledger, index
21
+ // and candidates are evidence and caches, and restoring those to an earlier
22
+ // byte-shape would corrupt R17 rather than repair anything. The shims are
23
+ // the opposite of that: git's routing points at them, and the undo that
24
+ // restores the routing must restore the shims with it (review 0003 Q22;
25
+ // ADR-122 moved them under STATE_DIR). `journaledWriteFile` performs the
26
+ // other writes unrecorded.
18
27
  // - Only regular files. Symlinks carry no bytes for beforeBytes to hold, and
19
28
  // the record shape is file bytes — pointer changes are reported by the
20
29
  // command as before and are not undone. Removals are journaled as entries
@@ -32,15 +41,30 @@
32
41
  // a second `undo` naturally targets the set before it, and the untouched-check
33
42
  // refuses when the two sets overlap on a file the first undo already restored.
34
43
 
44
+ import { execFile } from 'node:child_process';
35
45
  import { createHash } from 'node:crypto';
36
- import { cp, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
46
+ import { chmod, cp, mkdir, readFile, readdir, rename, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
37
47
  import { dirname, join, relative, sep } from 'node:path';
48
+ import { promisify } from 'node:util';
38
49
 
39
50
  import { STATE_DIR } from './name.mjs';
51
+ import { LOCAL_DIR } from './state.mjs';
52
+ import { HOOKS_DIR } from './hooks/shim.mjs';
40
53
 
41
- const JOURNAL_DIR = join(STATE_DIR, 'journal');
54
+ const exec = promisify(execFile);
55
+
56
+ const JOURNAL_DIR = join(LOCAL_DIR, 'journal');
57
+ const JOURNAL_DIR_LEGACY = '.simt/journal';
42
58
  const SET_SUFFIX = '.json';
43
59
  const UNDONE_SUFFIX = '.undone.json';
60
+ /** A crashed `undo` leaves its intent behind under this suffix; the next undo
61
+ * of the same set resumes it instead of starting over (review 0003 Q22). A
62
+ * marker is history, not a set, so the set listing excludes it. */
63
+ const UNDOING_SUFFIX = '.undoing.json';
64
+ /** Sets that predate entry kinds store `beforeBytes` as utf8 text and carry no
65
+ * `kind`; they are still restorable, through the legacy branch of the restore
66
+ * below. `commitJournal` only writes version 2. */
67
+ const SET_VERSION = 2;
44
68
 
45
69
  function digest(bytes) {
46
70
  return `sha256-${createHash('sha256').update(bytes).digest('hex')}`;
@@ -51,18 +75,49 @@ function rel(root, path) {
51
75
  }
52
76
 
53
77
  /**
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.
78
+ * Read the current bytes and permission bits of a path, or null when it does
79
+ * not exist. Any other failure propagates: an unreadable file is fail-closed,
80
+ * not treated as absent.
56
81
  */
57
82
  async function readOrNull(path) {
58
83
  try {
59
- return await readFile(path);
84
+ const [bytes, st] = await Promise.all([readFile(path), stat(path)]);
85
+ return { bytes, mode: st.mode & 0o777 };
60
86
  } catch (error) {
61
87
  if (error.code === 'ENOENT') return null;
62
88
  throw error;
63
89
  }
64
90
  }
65
91
 
92
+ /**
93
+ * Record a file the run created with an exclusive create (`O_EXCL`).
94
+ *
95
+ * The create itself is the proof nothing was there before, so `before` is
96
+ * null by construction rather than by re-reading: a check-then-record would
97
+ * reopen the race the exclusive flag just closed. For ordinary overwrites use
98
+ * `journaledWriteFile`, which snapshots `before` itself.
99
+ */
100
+ export function recordCreatedFile(root, acc, path, text) {
101
+ if (relative(root, path).startsWith('..')) {
102
+ throw new Error(`journal refuses a write outside the repository: ${path}`);
103
+ }
104
+ acc.push(fileEntry(rel(root, path), null, Buffer.from(text, 'utf8')));
105
+ }
106
+
107
+ /** A `file` entry: the pre-run bytes as base64 (binary-safe, unlike the
108
+ * version-1 utf8 spelling) with the pre-run permission bits, so `undo`
109
+ * restores both. Removals carry `afterDigest: null` and are re-created. */
110
+ function fileEntry(path, before, after) {
111
+ return {
112
+ kind: 'file',
113
+ path,
114
+ beforeDigest: before === null ? null : digest(before.bytes),
115
+ afterDigest: after === null ? null : digest(after),
116
+ beforeBytes: before === null ? null : before.bytes.toString('base64'),
117
+ modeBefore: before === null ? null : before.mode,
118
+ };
119
+ }
120
+
66
121
  /**
67
122
  * Write a repo file and record the write in the run's set.
68
123
  *
@@ -70,23 +125,30 @@ async function readOrNull(path) {
70
125
  * @param {Array} acc the run's set, created by the caller and committed once
71
126
  * @param {string} path absolute path to write
72
127
  * @param {string} text the bytes to write
128
+ * @param {{mode?: number}} [opts] permission bits to set after the write, recorded
129
+ * in the entry — a shim written then chmodded is one change, not two, and
130
+ * `undo` must restore the mode the run found, not just the bytes
73
131
  */
74
- export async function journaledWriteFile(root, acc, path, text) {
132
+ export async function journaledWriteFile(root, acc, path, text, { mode } = {}) {
75
133
  if (relative(root, path).startsWith('..')) {
76
134
  throw new Error(`journal refuses a write outside the repository: ${path}`);
77
135
  }
78
136
  const before = await readOrNull(path);
79
- // Internal state is written, not recorded — see the module header.
80
- const internal = rel(root, path) === STATE_DIR || rel(root, path).startsWith(`${STATE_DIR}/`);
137
+ // Internal state is written, not recorded — see the module header, which
138
+ // names the shim directory as the one exception under STATE_DIR.
139
+ const r = rel(root, path);
140
+ const internal =
141
+ (r === STATE_DIR || r.startsWith(`${STATE_DIR}/`)) && r !== HOOKS_DIR && !r.startsWith(`${HOOKS_DIR}/`);
81
142
  await mkdir(dirname(path), { recursive: true });
82
- await writeFile(path, text, 'utf8');
143
+ // tmp + rename, the way this module writes its own set files: a crash
144
+ // mid-write leaves the old bytes in place, never a half-written file
145
+ // (review 0003 Q25). The mode lands on the tmp, so the rename carries it.
146
+ const tmp = `${path}.tmp-${process.pid}`;
147
+ await writeFile(tmp, text, 'utf8');
148
+ if (mode !== undefined) await chmod(tmp, mode);
149
+ await rename(tmp, path);
83
150
  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
- });
151
+ acc.push(fileEntry(rel(root, path), before, Buffer.from(text, 'utf8')));
90
152
  }
91
153
  }
92
154
 
@@ -128,22 +190,12 @@ export async function journaledCopyDir(root, acc, src, dest) {
128
190
  const prev = before.get(path) ?? null;
129
191
  before.delete(path);
130
192
  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
- });
193
+ if (prev !== null && after !== null && prev.bytes.equals(after.bytes)) continue;
194
+ acc.push(fileEntry(path, prev, after === null ? null : after.bytes));
138
195
  }
139
196
  // Files the copy removed (an overwrite that drops a name) are removals.
140
197
  for (const [path, prev] of before) {
141
- acc.push({
142
- path,
143
- beforeDigest: digest(prev),
144
- afterDigest: null,
145
- beforeBytes: prev.toString('utf8'),
146
- });
198
+ acc.push(fileEntry(path, prev, null));
147
199
  }
148
200
  }
149
201
 
@@ -158,16 +210,49 @@ export async function journaledRemoveFile(root, acc, path) {
158
210
  }
159
211
  const before = await readOrNull(path);
160
212
  if (before === null) return;
161
- const internal = rel(root, path) === STATE_DIR || rel(root, path).startsWith(`${STATE_DIR}/`);
213
+ const r = rel(root, path);
214
+ const internal =
215
+ (r === STATE_DIR || r.startsWith(`${STATE_DIR}/`)) && r !== HOOKS_DIR && !r.startsWith(`${HOOKS_DIR}/`);
162
216
  await unlink(path);
163
217
  if (!internal) {
164
- acc.push({
165
- path: rel(root, path),
166
- beforeDigest: digest(before),
167
- afterDigest: null,
168
- beforeBytes: before.toString('utf8'),
169
- });
218
+ acc.push(fileEntry(rel(root, path), before, null));
219
+ }
220
+ }
221
+
222
+ /** Read one git config value in a repo, or null when unset. */
223
+ export async function readGitValue(root, key) {
224
+ try {
225
+ const { stdout } = await exec('git', ['config', '--get', key], { cwd: root });
226
+ return stdout.replace(/\n$/, '');
227
+ } catch (error) {
228
+ if (error.code === 1) return null;
229
+ throw error;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Set (or, with `value: null`, unset) one git config key and record it as a
235
+ * `git-config` entry, so `undo` restores the routing a run changed — a shim
236
+ * without its `core.hooksPath` is a hook that no longer runs (review 0003
237
+ * Q22). A set to the value already there records nothing: there is no state
238
+ * to restore.
239
+ *
240
+ * @returns {Promise<object|null>} the entry, or null when nothing changed
241
+ */
242
+ export async function journaledGitConfig(root, acc, key, value) {
243
+ if (typeof key !== 'string' || key === '' || /[\s]/.test(key)) {
244
+ throw new Error(`journal refuses an unparsable git config key: ${key}`);
245
+ }
246
+ const before = await readGitValue(root, key);
247
+ if (before === value) return null;
248
+ if (value === null) {
249
+ await exec('git', ['config', '--unset', key], { cwd: root });
250
+ } else {
251
+ await exec('git', ['config', key, value], { cwd: root });
170
252
  }
253
+ const entry = { kind: 'git-config', key, before, after: value };
254
+ acc.push(entry);
255
+ return entry;
171
256
  }
172
257
 
173
258
  /**
@@ -179,14 +264,19 @@ export async function journaledRemoveFile(root, acc, path) {
179
264
  */
180
265
  /**
181
266
  * The .gitignore bytes `ignoreJournal` would write for `text`, or null when
182
- * `text` already ignores the journal directory. One owner for the line
267
+ * `text` already ignores machine-local state. One owner for the line
183
268
  * spelling: `ignoreJournal` writes it, `journalHygiene` reads whether it is
184
269
  * there, and `doctor --fix --diff` previews it — all through this function so
185
270
  * the three cannot disagree about what counts as ignored (plan 0003 R50).
271
+ *
272
+ * The ignored line is the whole local area (review 0003 Q19), not just the
273
+ * journal: every machine-local write lives under it, and one line covers them.
186
274
  */
187
275
  export function journalIgnoreNext(text) {
188
- const line = `${JOURNAL_DIR}/`;
189
- if (text.split('\n').map((l) => l.trim()).includes(line)) return null;
276
+ const line = `${LOCAL_DIR}/`;
277
+ const legacy = `${JOURNAL_DIR_LEGACY}/`;
278
+ const lines = text.split('\n').map((l) => l.trim());
279
+ if (lines.includes(line) || lines.includes(legacy)) return null;
190
280
  const prefix = text === '' || text.endsWith('\n') ? '' : '\n';
191
281
  return `${text}${prefix}\n# Write journal for \`simt undo\`. Local state, never shared.\n${line}\n`;
192
282
  }
@@ -212,6 +302,93 @@ function setName(command, at) {
212
302
  return `${stamp}-${command}-${process.pid}.json`;
213
303
  }
214
304
 
305
+ /**
306
+ * Fold one run's entries so each path (each config key) appears once: the
307
+ * first before, the last after. Two writes to one path used to refuse `undo`
308
+ * outright, although the run's net effect is a single restore. A create
309
+ * followed by a removal in the same run is no change at all and folds away;
310
+ * unequal ghosts — a removal folded with a write — fold to the write's after
311
+ * with the removal's before.
312
+ */
313
+ function coalesceEntries(acc) {
314
+ const order = [];
315
+ const folded = new Map();
316
+ for (const entry of acc) {
317
+ const slot = entry.kind === 'git-config' ? `\0${entry.key}` : entry.path;
318
+ const prev = folded.get(slot);
319
+ if (prev === undefined) {
320
+ order.push(slot);
321
+ folded.set(slot, entry);
322
+ continue;
323
+ }
324
+ if (entry.kind === 'git-config') {
325
+ folded.set(slot, { ...entry, before: prev.before });
326
+ } else {
327
+ folded.set(slot, { ...entry, beforeDigest: prev.beforeDigest, beforeBytes: prev.beforeBytes, modeBefore: prev.modeBefore });
328
+ }
329
+ }
330
+ return order.map((slot) => folded.get(slot)).filter((entry) => {
331
+ if (entry.kind === 'git-config') return entry.before !== entry.after;
332
+ // Created then removed (or set back byte-for-byte, mode aside): nothing to restore.
333
+ return entry.beforeDigest !== null || entry.afterDigest !== null;
334
+ });
335
+ }
336
+
337
+ /**
338
+ * Move the sets that predate the move into `.simt/local/journal` (review 0003
339
+ * Q19's fallback directory) into the current directory, and take the legacy
340
+ * directory down when the move empties it. While the current directory is
341
+ * absent, `listJournalSets` falls back to the legacy one; once it exists, the
342
+ * two are disjoint and the legacy sets are invisible to `undo` — a repo that
343
+ * accumulated history under the old path loses it on its first write after the
344
+ * move (plan 0005 H3). `commitJournal` is the only writer, so the migration
345
+ * rides on it and heals on the next journal activity. Every legacy set
346
+ * predates every current set — the current directory is created by this
347
+ * writer — so the move cannot disturb recency order. A name present in both
348
+ * keeps both copies when the bytes differ: a set file is history, and a
349
+ * collision is not a reason to drop one of the two records. A crash mid-move
350
+ * splits the sets between the two directories; the move is idempotent, so the
351
+ * next call finishes it.
352
+ */
353
+ async function migrateLegacyJournal(root) {
354
+ const legacy = join(root, JOURNAL_DIR_LEGACY);
355
+ let names;
356
+ try {
357
+ names = await readdir(legacy);
358
+ } catch (error) {
359
+ if (error.code !== 'ENOENT') throw error;
360
+ return;
361
+ }
362
+ const dir = join(root, JOURNAL_DIR);
363
+ await mkdir(dir, { recursive: true });
364
+ for (const name of names) {
365
+ // Sets, `.undone.json` history and `.undoing.json` markers all end in
366
+ // `.json`; a stray file of any other kind keeps the directory in place.
367
+ if (!name.endsWith(SET_SUFFIX)) continue;
368
+ const from = join(legacy, name);
369
+ const to = join(dir, name);
370
+ try {
371
+ await stat(to);
372
+ } catch (error) {
373
+ if (error.code !== 'ENOENT') throw error;
374
+ await rename(from, to);
375
+ continue;
376
+ }
377
+ // The name is taken: equal bytes are a duplicate of the same set (drop the
378
+ // legacy copy); differing bytes are a collision (keep both — the current
379
+ // copy shadows the legacy one in the listing).
380
+ const [a, b] = await Promise.all([readFile(from), readFile(to)]);
381
+ if (a.equals(b)) await unlink(from);
382
+ }
383
+ // `rmdir` fails when the directory is not empty, which is exactly the
384
+ // safety: a stray file the move does not take keeps the directory.
385
+ try {
386
+ await rmdir(legacy);
387
+ } catch (error) {
388
+ if (error.code !== 'ENOTEMPTY' && error.code !== 'EEXIST') throw error;
389
+ }
390
+ }
391
+
215
392
  /**
216
393
  * Persist a run's set. Writes nothing when the run changed nothing, so the
217
394
  * last set on disk is always the last run that wrote.
@@ -219,12 +396,14 @@ function setName(command, at) {
219
396
  * @returns {Promise<string|null>} the set id (file name), or null
220
397
  */
221
398
  export async function commitJournal(root, command, acc, { at = Date.now() } = {}) {
222
- if (acc.length === 0) return null;
399
+ await migrateLegacyJournal(root);
400
+ const entries = coalesceEntries(acc);
401
+ if (entries.length === 0) return null;
223
402
  await ignoreJournal(root);
224
403
  const dir = join(root, JOURNAL_DIR);
225
404
  await mkdir(dir, { recursive: true });
226
405
  const id = setName(command, at);
227
- const body = `${JSON.stringify({ version: 1, command, at, entries: acc }, null, 2)}\n`;
406
+ const body = `${JSON.stringify({ version: SET_VERSION, command, at, entries }, null, 2)}\n`;
228
407
  const tmp = join(dir, `${id}.tmp-${process.pid}`);
229
408
  await writeFile(tmp, body, 'utf8');
230
409
  await rename(tmp, join(dir, id));
@@ -246,71 +425,180 @@ export async function journalHygiene(root) {
246
425
  } catch {
247
426
  text = '';
248
427
  }
249
- const line = `${JOURNAL_DIR}/`;
250
- return { sets, ignored: text.split('\n').map((l) => l.trim()).includes(line) };
428
+ const lines = text.split('\n').map((l) => l.trim());
429
+ return { sets, ignored: lines.includes(`${LOCAL_DIR}/`) || lines.includes(`${JOURNAL_DIR_LEGACY}/`) };
251
430
  }
252
431
 
253
432
  /** Set ids newest-first, undone sets excluded. */
254
433
  export async function listJournalSets(root) {
434
+ // The pre-move directory is a fallback for one release (review 0003 Q19);
435
+ // the sets in it move into the current directory on the next write
436
+ // (`migrateLegacyJournal`, plan 0005 H3), so the fallback only serves a
437
+ // repo that has not written since the move.
255
438
  let names;
256
439
  try {
257
440
  names = await readdir(join(root, JOURNAL_DIR));
258
441
  } catch (error) {
259
- if (error.code === 'ENOENT') return [];
260
- throw error;
442
+ if (error.code !== 'ENOENT') throw error;
443
+ try {
444
+ names = await readdir(join(root, JOURNAL_DIR_LEGACY));
445
+ } catch (legacyError) {
446
+ if (legacyError.code === 'ENOENT') return [];
447
+ throw legacyError;
448
+ }
261
449
  }
262
450
  // An undone set ends in `.undone.json`, which itself ends in `.json` — the
263
451
  // 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();
452
+ // the same set twice. Exclude the undone (and the crash markers, which are
453
+ // intent, not sets) first.
454
+ return names
455
+ .filter((n) => n.endsWith(SET_SUFFIX) && !n.endsWith(UNDONE_SUFFIX) && !n.endsWith(UNDOING_SUFFIX))
456
+ .sort()
457
+ .reverse();
458
+ }
459
+
460
+ /** The directory holding set `id`, preferring the post-move one. */
461
+ async function journalDirFor(root, id) {
462
+ try {
463
+ await stat(join(root, JOURNAL_DIR, id));
464
+ return JOURNAL_DIR;
465
+ } catch {
466
+ return JOURNAL_DIR_LEGACY;
467
+ }
266
468
  }
267
469
 
268
470
  export async function readJournalSet(root, id) {
269
- const raw = await readFile(join(root, JOURNAL_DIR, id), 'utf8');
270
- return JSON.parse(raw);
471
+ const dir = await journalDirFor(root, id);
472
+ const raw = await readFile(join(root, dir, id), 'utf8');
473
+ const set = JSON.parse(raw);
474
+ if (set.version !== SET_VERSION) {
475
+ // Version 1 stored `beforeBytes` as utf8 text and knew no kinds: read it
476
+ // through the legacy branch of the restore rather than refusing history.
477
+ return { ...set, entries: set.entries.map((e) => ({ ...e, kind: e.kind ?? 'file-legacy' })) };
478
+ }
479
+ return set;
480
+ }
481
+
482
+ /** The pre-run bytes of an entry as a string, for previews and diffs. */
483
+ export function beforeText(entry) {
484
+ if (entry.kind === 'git-config' || entry.beforeBytes === null || entry.beforeBytes === undefined) return entry.before ?? null;
485
+ if (entry.kind === 'file-legacy') return entry.beforeBytes;
486
+ return Buffer.from(entry.beforeBytes, 'base64').toString('utf8');
487
+ }
488
+
489
+ /** The display name of an entry: a path for files, the key for config. */
490
+ export function entryLabel(entry) {
491
+ return entry.kind === 'git-config' ? `git config ${entry.key}` : entry.path;
492
+ }
493
+
494
+ /** The digest of a file's current bytes, or null when absent. */
495
+ function currentDigest(state) {
496
+ return state === null ? null : digest(state.bytes);
271
497
  }
272
498
 
273
499
  /**
274
- * Restore one set: verify every file is untouched since, then restore all.
275
- * Verification precedes any restore, so a refusal changes nothing.
500
+ * Whether an entry is already at its pre-run state — restored by an earlier
501
+ * attempt, or hand-set back to exactly what the run found. Restoring it again
502
+ * would be a no-op write, so a resumed undo skips it instead of refusing it.
503
+ */
504
+ async function alreadyRestored(root, entry) {
505
+ if (entry.kind === 'git-config') {
506
+ return (await readGitValue(root, entry.key)) === entry.before;
507
+ }
508
+ const state = await readOrNull(join(root, entry.path));
509
+ return currentDigest(state) === entry.beforeDigest;
510
+ }
511
+
512
+ /** Whether an entry is still at the state the run left: the precondition for
513
+ * restoring it. Anything else is somebody's edit, and the undo is refused. */
514
+ async function untouchedSince(root, entry) {
515
+ if (entry.kind === 'git-config') {
516
+ return (await readGitValue(root, entry.key)) === entry.after;
517
+ }
518
+ const state = await readOrNull(join(root, entry.path));
519
+ return currentDigest(state) === entry.afterDigest;
520
+ }
521
+
522
+ function entryBytes(entry) {
523
+ if (entry.beforeBytes === null) return null;
524
+ if (entry.kind === 'file-legacy') return Buffer.from(entry.beforeBytes, 'utf8');
525
+ return Buffer.from(entry.beforeBytes, 'base64');
526
+ }
527
+
528
+ /** Restore one entry: bytes and permission bits for files, the value for config. */
529
+ async function restoreEntry(root, entry) {
530
+ if (entry.kind === 'git-config') {
531
+ await exec('git', entry.before === null ? ['config', '--unset', entry.key] : ['config', entry.key, entry.before], {
532
+ cwd: root,
533
+ });
534
+ return;
535
+ }
536
+ const abs = join(root, entry.path);
537
+ if (entry.afterDigest === null) {
538
+ // The run removed this path: re-create it with its pre-run bytes.
539
+ await mkdir(dirname(abs), { recursive: true });
540
+ await writeFile(abs, entryBytes(entry));
541
+ } else if (entry.beforeDigest === null) {
542
+ // The run created this path: remove it.
543
+ await unlink(abs);
544
+ } else {
545
+ await writeFile(abs, entryBytes(entry));
546
+ }
547
+ if (entry.modeBefore !== null && entry.modeBefore !== undefined) {
548
+ await chmod(abs, entry.modeBefore);
549
+ }
550
+ }
551
+
552
+ /**
553
+ * Restore one set: verify every entry is untouched since, then restore all in
554
+ * reverse — later writes in the run unwind first, so overlapping restores land
555
+ * in pre-run order.
556
+ *
557
+ * The intent lands on disk before the first restore (the write-ahead marker),
558
+ * so a crash mid-undo is resumable: entries already at their pre-run state
559
+ * are skipped, the rest are verified against the run's after-state and
560
+ * restored, and anything at neither refuses the whole undo. Verification
561
+ * still precedes any restore when there is no marker, so a refusal on a fresh
562
+ * undo changes nothing.
276
563
  *
277
- * @returns {Promise<{restored: string[]}|{refused: {path: string, detail: string}}>}
564
+ * @returns {Promise<{restored: string[], resumed: boolean}|{refused: {path: string, detail: string}}>}
278
565
  */
279
566
  async function undoJournalSet(root, id) {
280
567
  const set = await readJournalSet(root, id);
568
+ const dir = await journalDirFor(root, id);
569
+ const marker = join(root, dir, `${id}${UNDOING_SUFFIX}`);
570
+ const resumed = (await readOrNull(marker)) !== null;
281
571
  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) {
572
+ if (await alreadyRestored(root, entry)) continue;
573
+ if (!(await untouchedSince(root, entry))) {
574
+ const label = entryLabel(entry);
286
575
  return {
287
576
  refused: {
288
- path: entry.path,
577
+ path: label,
289
578
  detail:
290
- bytes === null
291
- ? 'the file was deleted after the journaled write'
292
- : 'the file changed after the journaled write',
579
+ entry.kind === 'git-config'
580
+ ? `git config ${entry.key} changed after the journaled write`
581
+ : (await readOrNull(join(root, entry.path))) === null
582
+ ? 'the file was deleted after the journaled write'
583
+ : 'the file changed after the journaled write',
293
584
  },
294
585
  };
295
586
  }
296
587
  }
588
+ // The marker is the intent, written before anything is restored: a crash
589
+ // from here on resumes instead of refusing the files already done.
590
+ const tmp = `${marker}.tmp-${process.pid}`;
591
+ await writeFile(tmp, `${JSON.stringify({ version: 1, set: id, at: Date.now() }, null, 2)}\n`, 'utf8');
592
+ await rename(tmp, marker);
297
593
  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);
594
+ for (const entry of [...set.entries].reverse()) {
595
+ if (await alreadyRestored(root, entry)) continue;
596
+ await restoreEntry(root, entry);
597
+ restored.push(entryLabel(entry));
311
598
  }
312
- await rename(join(root, JOURNAL_DIR, id), join(root, JOURNAL_DIR, id.replace(/\.json$/, UNDONE_SUFFIX)));
313
- return { restored };
599
+ await unlink(marker);
600
+ await rename(join(root, dir, id), join(root, dir, id.replace(/\.json$/, UNDONE_SUFFIX)));
601
+ return { restored, resumed };
314
602
  }
315
603
 
316
604
  /**