simt-dev 0.7.0 → 0.8.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 (235) hide show
  1. package/CHANGELOG.md +191 -870
  2. package/README.md +188 -213
  3. package/bin/simt.mjs +19 -55
  4. package/docs/decisions/ADR-014-workspace-is-configuration.md +2 -2
  5. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  7. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  8. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  9. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  10. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  11. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  12. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  13. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  14. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  15. 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
  16. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  17. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  18. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  19. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  20. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  21. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  22. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  23. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  24. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  25. package/ecosystems/bun.json +6 -0
  26. package/ecosystems/cargo.json +25 -0
  27. package/ecosystems/composer.json +6 -0
  28. package/ecosystems/dart.json +24 -0
  29. package/ecosystems/deno.json +21 -0
  30. package/ecosystems/devcontainers.json +6 -0
  31. package/ecosystems/docker-compose.json +6 -0
  32. package/ecosystems/docker.json +6 -0
  33. package/ecosystems/dotnet.json +25 -0
  34. package/ecosystems/elixir.json +23 -0
  35. package/ecosystems/gitsubmodule.json +6 -0
  36. package/ecosystems/go.json +25 -0
  37. package/ecosystems/gradle.json +13 -0
  38. package/ecosystems/maven.json +23 -0
  39. package/ecosystems/node.json +25 -0
  40. package/ecosystems/python.json +20 -0
  41. package/ecosystems/ruby.json +23 -0
  42. package/ecosystems/swift.json +25 -0
  43. package/ecosystems/terraform.json +6 -0
  44. package/ecosystems/uv.json +6 -0
  45. package/harnesses/antigravity.json +162 -0
  46. package/harnesses/claude.json +206 -0
  47. package/harnesses/codex.json +136 -0
  48. package/harnesses/copilot.json +88 -0
  49. package/harnesses/cursor.json +140 -0
  50. package/harnesses/deepseek.json +99 -0
  51. package/harnesses/gemini.json +122 -0
  52. package/harnesses/opencode.json +173 -0
  53. package/package.json +12 -2
  54. package/rules/core.md +7 -0
  55. package/rules/mdc/intake-questions.mdc +8 -0
  56. package/rules/mdc/slice-commit.mdc +57 -4
  57. package/src/advice/advice.mjs +3 -2
  58. package/src/agenthooks/prompt.mjs +5 -2
  59. package/src/agenthooks/render.mjs +86 -180
  60. package/src/agenthooks/shim.mjs +1 -1
  61. package/src/agents/render.mjs +15 -18
  62. package/src/api.mjs +31 -0
  63. package/src/argv.mjs +48 -3
  64. package/src/bench/ablate.mjs +3 -4
  65. package/src/bench/adapters.json +163 -105
  66. package/src/bench/adapters.mjs +466 -0
  67. package/src/bench/arms.mjs +333 -0
  68. package/src/bench/grade.mjs +33 -73
  69. package/src/bench/graders.mjs +42 -0
  70. package/src/bench/mine.mjs +121 -475
  71. package/src/bench/probe-ledger.mjs +149 -0
  72. package/src/bench/probes.catalog.mjs +918 -0
  73. package/src/bench/probes.mjs +79 -1064
  74. package/src/bench/render.mjs +111 -0
  75. package/src/bench/report.mjs +120 -962
  76. package/src/bench/run.mjs +185 -802
  77. package/src/bench/variants.mjs +4 -5
  78. package/src/bench/workspace.mjs +160 -0
  79. package/src/brief/assemble.mjs +48 -15
  80. package/src/ci/dependabot.mjs +25 -55
  81. package/src/ci/workflow.mjs +30 -7
  82. package/src/cli.mjs +45 -0
  83. package/src/commands/agent.mjs +4 -13
  84. package/src/commands/agenthook.mjs +70 -40
  85. package/src/commands/bench.mjs +191 -18
  86. package/src/commands/brief.mjs +5 -4
  87. package/src/commands/ci.mjs +13 -14
  88. package/src/commands/compare.mjs +42 -42
  89. package/src/commands/completions.mjs +2 -6
  90. package/src/commands/decide.mjs +4 -4
  91. package/src/commands/distill.mjs +4 -3
  92. package/src/commands/docs.mjs +88 -0
  93. package/src/commands/doctor.mjs +11 -7
  94. package/src/commands/evidence.mjs +6 -9
  95. package/src/commands/explain.mjs +0 -1
  96. package/src/commands/gate.mjs +26 -25
  97. package/src/commands/graph.mjs +2 -3
  98. package/src/commands/harness.mjs +6 -5
  99. package/src/commands/help.mjs +168 -19
  100. package/src/commands/hooks.mjs +119 -49
  101. package/src/commands/ignore.mjs +13 -8
  102. package/src/commands/impact.mjs +8 -15
  103. package/src/commands/index.mjs +318 -193
  104. package/src/commands/init-scan.mjs +90 -0
  105. package/src/commands/init.mjs +124 -105
  106. package/src/commands/lease.mjs +73 -25
  107. package/src/commands/lessons.mjs +10 -9
  108. package/src/commands/local.mjs +3 -3
  109. package/src/commands/map.mjs +24 -14
  110. package/src/commands/mcp.mjs +2 -18
  111. package/src/commands/pack.mjs +9 -6
  112. package/src/commands/plan.mjs +6 -5
  113. package/src/commands/plugin.mjs +2 -2
  114. package/src/commands/prbody.mjs +3 -3
  115. package/src/commands/repoindex.mjs +12 -11
  116. package/src/commands/review.mjs +2 -2
  117. package/src/commands/rules.mjs +37 -16
  118. package/src/commands/shared.mjs +14 -19
  119. package/src/commands/skills.mjs +33 -32
  120. package/src/commands/skillscompile.mjs +7 -8
  121. package/src/commands/skillsinstall.mjs +7 -6
  122. package/src/commands/slice.mjs +220 -74
  123. package/src/commands/spec.mjs +167 -0
  124. package/src/commands/status.mjs +27 -18
  125. package/src/commands/sync.mjs +27 -25
  126. package/src/commands/tests.mjs +18 -21
  127. package/src/commands/tokens.mjs +21 -26
  128. package/src/commands/undo.mjs +5 -15
  129. package/src/commands/upgrade.mjs +15 -7
  130. package/src/commands/why.mjs +15 -21
  131. package/src/compare/hooksstate.mjs +2 -6
  132. package/src/compare/learn.mjs +3 -1
  133. package/src/compare/row.mjs +0 -2
  134. package/src/context.mjs +64 -0
  135. package/src/decisions/index-doc.mjs +46 -0
  136. package/src/decisions/index.mjs +3 -2
  137. package/src/diff.mjs +1 -1
  138. package/src/distill/distill.mjs +14 -1
  139. package/src/distill/engine.mjs +5 -1
  140. package/src/docs/commands.mjs +65 -0
  141. package/src/ecosystem/records.mjs +219 -0
  142. package/src/explain.mjs +31 -0
  143. package/src/gates/index.mjs +385 -0
  144. package/src/graph/compile.mjs +8 -4
  145. package/src/graph/load.mjs +6 -2
  146. package/src/graph/spec.mjs +10 -8
  147. package/src/harness/records.mjs +143 -0
  148. package/src/hooks/capability.mjs +7 -5
  149. package/src/hooks/dispatcher.mjs +209 -60
  150. package/src/hooks/gates.mjs +10 -43
  151. package/src/hooks/resolve.mjs +4 -14
  152. package/src/hooks/shim.mjs +5 -2
  153. package/src/hooks/where.mjs +2 -7
  154. package/src/identity.mjs +31 -12
  155. package/src/index/coverage.mjs +58 -0
  156. package/src/index/extract.mjs +6 -16
  157. package/src/index/gate.mjs +1 -1
  158. package/src/index/govern.mjs +26 -9
  159. package/src/index/impact.mjs +4 -8
  160. package/src/index/map.mjs +14 -15
  161. package/src/index/open.mjs +173 -0
  162. package/src/index/persist.mjs +224 -57
  163. package/src/index/store.mjs +82 -39
  164. package/src/index/tests.mjs +2 -6
  165. package/src/journal.mjs +94 -20
  166. package/src/kernel/card.mjs +37 -63
  167. package/src/kernel/checks.mjs +4 -10
  168. package/src/lease.mjs +102 -7
  169. package/src/ledger/index.mjs +150 -14
  170. package/src/local/client.mjs +4 -6
  171. package/src/locate.mjs +33 -9
  172. package/src/manifest/schema.mjs +213 -0
  173. package/src/mcp/paginate.mjs +33 -0
  174. package/src/mcp/resources.mjs +7 -10
  175. package/src/mcp/schemas.mjs +512 -0
  176. package/src/mcp/server.mjs +119 -25
  177. package/src/mcp/tools-facts.mjs +326 -0
  178. package/src/mcp/tools-read.mjs +221 -0
  179. package/src/mcp/tools.mjs +4 -967
  180. package/src/measure/corpus.mjs +150 -0
  181. package/src/measure/metrics.mjs +236 -0
  182. package/src/measure/records.mjs +211 -0
  183. package/src/measure/session.mjs +3 -2
  184. package/src/measure/stats.mjs +538 -0
  185. package/src/measure/task-schema.mjs +125 -0
  186. package/src/measure/tasks.mjs +69 -0
  187. package/src/memory/decide.mjs +6 -15
  188. package/src/memory/lesson-text.mjs +61 -0
  189. package/src/memory/lessons.mjs +29 -82
  190. package/src/name.mjs +7 -0
  191. package/src/node-version.mjs +37 -0
  192. package/src/output/index.mjs +209 -10
  193. package/src/packs/manage.mjs +32 -12
  194. package/src/packs.mjs +20 -2
  195. package/src/plan/index.mjs +1 -1
  196. package/src/plugins.mjs +22 -16
  197. package/src/prbody.mjs +7 -11
  198. package/src/projection/loaders.mjs +7 -692
  199. package/src/projection/lock.mjs +11 -1
  200. package/src/projection/manifest.mjs +29 -468
  201. package/src/projection/mdc.mjs +7 -8
  202. package/src/projection/project.mjs +126 -221
  203. package/src/projection/region.mjs +561 -0
  204. package/src/projection/rules.mjs +20 -550
  205. package/src/projection/surfaces.mjs +43 -210
  206. package/src/projection/targets.mjs +5 -5
  207. package/src/repo-root.mjs +31 -0
  208. package/src/resolve/eval.mjs +2 -1
  209. package/src/resolve/fingerprint.mjs +19 -22
  210. package/src/resolve/resolve.mjs +1 -41
  211. package/src/review/critic.mjs +10 -13
  212. package/src/run.mjs +98 -0
  213. package/src/secrets.mjs +14 -22
  214. package/src/state/events.mjs +129 -0
  215. package/src/status-snapshot.mjs +233 -0
  216. package/src/status.mjs +2 -6
  217. package/src/sys/git.mjs +82 -0
  218. package/src/sys/proc.mjs +151 -0
  219. package/src/testing/parsers.mjs +265 -0
  220. package/src/testing/patches.mjs +23 -0
  221. package/src/testing/paths.mjs +49 -0
  222. package/src/testing/runners.mjs +186 -0
  223. package/src/testing/vault.mjs +68 -0
  224. package/src/text/frontmatter.mjs +49 -0
  225. package/src/text/tokens.mjs +52 -0
  226. package/src/toolwords.mjs +3 -0
  227. package/src/util/fs.mjs +20 -0
  228. package/src/util/sort.mjs +12 -0
  229. package/src/workspace.mjs +58 -11
  230. package/src/write/plan.mjs +82 -0
  231. package/templates/plan/tracker.json +1 -0
  232. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  233. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  234. package/docs/decisions/README.md +0 -600
  235. package/src/bench/tasks.mjs +0 -36
package/src/journal.mjs CHANGED
@@ -16,10 +16,14 @@
16
16
  // - The plan row says `.simt-dev/journal/`, which predates the STATE_DIR
17
17
  // rename (the same stale spelling survives in bench/probes.mjs comments).
18
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.
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.
23
27
  // - Only regular files. Symlinks carry no bytes for beforeBytes to hold, and
24
28
  // the record shape is file bytes — pointer changes are reported by the
25
29
  // command as before and are not undone. Removals are journaled as entries
@@ -37,16 +41,15 @@
37
41
  // a second `undo` naturally targets the set before it, and the untouched-check
38
42
  // refuses when the two sets overlap on a file the first undo already restored.
39
43
 
40
- import { execFile } from 'node:child_process';
41
44
  import { createHash } from 'node:crypto';
42
- import { chmod, cp, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
45
+ import { chmod, cp, mkdir, readFile, readdir, rename, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
43
46
  import { dirname, join, relative, sep } from 'node:path';
44
- import { promisify } from 'node:util';
45
47
 
48
+ import { noteEvent } from './state/events.mjs';
46
49
  import { STATE_DIR } from './name.mjs';
47
50
  import { LOCAL_DIR } from './state.mjs';
48
-
49
- const exec = promisify(execFile);
51
+ import { HOOKS_DIR } from './hooks/shim.mjs';
52
+ import { git, gitText } from './sys/git.mjs';
50
53
 
51
54
  const JOURNAL_DIR = join(LOCAL_DIR, 'journal');
52
55
  const JOURNAL_DIR_LEGACY = '.simt/journal';
@@ -129,8 +132,11 @@ export async function journaledWriteFile(root, acc, path, text, { mode } = {}) {
129
132
  throw new Error(`journal refuses a write outside the repository: ${path}`);
130
133
  }
131
134
  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}/`);
135
+ // Internal state is written, not recorded — see the module header, which
136
+ // names the shim directory as the one exception under STATE_DIR.
137
+ const r = rel(root, path);
138
+ const internal =
139
+ (r === STATE_DIR || r.startsWith(`${STATE_DIR}/`)) && r !== HOOKS_DIR && !r.startsWith(`${HOOKS_DIR}/`);
134
140
  await mkdir(dirname(path), { recursive: true });
135
141
  // tmp + rename, the way this module writes its own set files: a crash
136
142
  // mid-write leaves the old bytes in place, never a half-written file
@@ -202,7 +208,9 @@ export async function journaledRemoveFile(root, acc, path) {
202
208
  }
203
209
  const before = await readOrNull(path);
204
210
  if (before === null) return;
205
- const internal = rel(root, path) === STATE_DIR || rel(root, path).startsWith(`${STATE_DIR}/`);
211
+ const r = rel(root, path);
212
+ const internal =
213
+ (r === STATE_DIR || r.startsWith(`${STATE_DIR}/`)) && r !== HOOKS_DIR && !r.startsWith(`${HOOKS_DIR}/`);
206
214
  await unlink(path);
207
215
  if (!internal) {
208
216
  acc.push(fileEntry(rel(root, path), before, null));
@@ -212,8 +220,7 @@ export async function journaledRemoveFile(root, acc, path) {
212
220
  /** Read one git config value in a repo, or null when unset. */
213
221
  export async function readGitValue(root, key) {
214
222
  try {
215
- const { stdout } = await exec('git', ['config', '--get', key], { cwd: root });
216
- return stdout.replace(/\n$/, '');
223
+ return await gitText(root, ['config', '--get', key]);
217
224
  } catch (error) {
218
225
  if (error.code === 1) return null;
219
226
  throw error;
@@ -236,9 +243,9 @@ export async function journaledGitConfig(root, acc, key, value) {
236
243
  const before = await readGitValue(root, key);
237
244
  if (before === value) return null;
238
245
  if (value === null) {
239
- await exec('git', ['config', '--unset', key], { cwd: root });
246
+ await git(root, ['config', '--unset', key]);
240
247
  } else {
241
- await exec('git', ['config', key, value], { cwd: root });
248
+ await git(root, ['config', key, value]);
242
249
  }
243
250
  const entry = { kind: 'git-config', key, before, after: value };
244
251
  acc.push(entry);
@@ -324,6 +331,61 @@ function coalesceEntries(acc) {
324
331
  });
325
332
  }
326
333
 
334
+ /**
335
+ * Move the sets that predate the move into `.simt/local/journal` (review 0003
336
+ * Q19's fallback directory) into the current directory, and take the legacy
337
+ * directory down when the move empties it. While the current directory is
338
+ * absent, `listJournalSets` falls back to the legacy one; once it exists, the
339
+ * two are disjoint and the legacy sets are invisible to `undo` — a repo that
340
+ * accumulated history under the old path loses it on its first write after the
341
+ * move (plan 0005 H3). `commitJournal` is the only writer, so the migration
342
+ * rides on it and heals on the next journal activity. Every legacy set
343
+ * predates every current set — the current directory is created by this
344
+ * writer — so the move cannot disturb recency order. A name present in both
345
+ * keeps both copies when the bytes differ: a set file is history, and a
346
+ * collision is not a reason to drop one of the two records. A crash mid-move
347
+ * splits the sets between the two directories; the move is idempotent, so the
348
+ * next call finishes it.
349
+ */
350
+ async function migrateLegacyJournal(root) {
351
+ const legacy = join(root, JOURNAL_DIR_LEGACY);
352
+ let names;
353
+ try {
354
+ names = await readdir(legacy);
355
+ } catch (error) {
356
+ if (error.code !== 'ENOENT') throw error;
357
+ return;
358
+ }
359
+ const dir = join(root, JOURNAL_DIR);
360
+ await mkdir(dir, { recursive: true });
361
+ for (const name of names) {
362
+ // Sets, `.undone.json` history and `.undoing.json` markers all end in
363
+ // `.json`; a stray file of any other kind keeps the directory in place.
364
+ if (!name.endsWith(SET_SUFFIX)) continue;
365
+ const from = join(legacy, name);
366
+ const to = join(dir, name);
367
+ try {
368
+ await stat(to);
369
+ } catch (error) {
370
+ if (error.code !== 'ENOENT') throw error;
371
+ await rename(from, to);
372
+ continue;
373
+ }
374
+ // The name is taken: equal bytes are a duplicate of the same set (drop the
375
+ // legacy copy); differing bytes are a collision (keep both — the current
376
+ // copy shadows the legacy one in the listing).
377
+ const [a, b] = await Promise.all([readFile(from), readFile(to)]);
378
+ if (a.equals(b)) await unlink(from);
379
+ }
380
+ // `rmdir` fails when the directory is not empty, which is exactly the
381
+ // safety: a stray file the move does not take keeps the directory.
382
+ try {
383
+ await rmdir(legacy);
384
+ } catch (error) {
385
+ if (error.code !== 'ENOTEMPTY' && error.code !== 'EEXIST') throw error;
386
+ }
387
+ }
388
+
327
389
  /**
328
390
  * Persist a run's set. Writes nothing when the run changed nothing, so the
329
391
  * last set on disk is always the last run that wrote.
@@ -331,6 +393,7 @@ function coalesceEntries(acc) {
331
393
  * @returns {Promise<string|null>} the set id (file name), or null
332
394
  */
333
395
  export async function commitJournal(root, command, acc, { at = Date.now() } = {}) {
396
+ await migrateLegacyJournal(root);
334
397
  const entries = coalesceEntries(acc);
335
398
  if (entries.length === 0) return null;
336
399
  await ignoreJournal(root);
@@ -341,6 +404,14 @@ export async function commitJournal(root, command, acc, { at = Date.now() } = {}
341
404
  const tmp = join(dir, `${id}.tmp-${process.pid}`);
342
405
  await writeFile(tmp, body, 'utf8');
343
406
  await rename(tmp, join(dir, id));
407
+ // The log is the record; the journal set is what `undo` restores from. Written after the
408
+ // set, so the event describes what landed rather than what was intended (Q73).
409
+ await noteEvent(root, {
410
+ kind: 'write.committed',
411
+ subject: command,
412
+ tree: null,
413
+ data: { entries: entries.length, paths: entries.map((entry) => entry.path) },
414
+ });
344
415
  return id;
345
416
  }
346
417
 
@@ -365,7 +436,10 @@ export async function journalHygiene(root) {
365
436
 
366
437
  /** Set ids newest-first, undone sets excluded. */
367
438
  export async function listJournalSets(root) {
368
- // The pre-move directory is a fallback for one release (review 0003 Q19).
439
+ // The pre-move directory is a fallback for one release (review 0003 Q19);
440
+ // the sets in it move into the current directory on the next write
441
+ // (`migrateLegacyJournal`, plan 0005 H3), so the fallback only serves a
442
+ // repo that has not written since the move.
369
443
  let names;
370
444
  try {
371
445
  names = await readdir(join(root, JOURNAL_DIR));
@@ -459,9 +533,7 @@ function entryBytes(entry) {
459
533
  /** Restore one entry: bytes and permission bits for files, the value for config. */
460
534
  async function restoreEntry(root, entry) {
461
535
  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
- });
536
+ await git(root, entry.before === null ? ['config', '--unset', entry.key] : ['config', entry.key, entry.before]);
465
537
  return;
466
538
  }
467
539
  const abs = join(root, entry.path);
@@ -541,5 +613,7 @@ export async function undoLastJournalSet(root) {
541
613
  const [id] = await listJournalSets(root);
542
614
  if (!id) return { empty: true };
543
615
  const result = await undoJournalSet(root, id);
616
+ // The set is gone from disk, so the log is now the only record it existed (Q73).
617
+ await noteEvent(root, { kind: 'write.undone', subject: id, data: { restored: result?.restored?.length ?? 0 } });
544
618
  return { set: id, ...result };
545
619
  }
@@ -22,7 +22,7 @@
22
22
  import { readFile } from 'node:fs/promises';
23
23
  import { join } from 'node:path';
24
24
 
25
- import { ECOSYSTEMS } from '../projection/manifest.mjs';
25
+ import { cardBuilds, cardFocused, cardFallbacks, ecosystemGateRows, evalTemplate, matches } from '../ecosystem/records.mjs';
26
26
  import { resolveNodeRunner, runScript } from '../ecosystem/node-runner.mjs';
27
27
 
28
28
  /**
@@ -53,59 +53,35 @@ const VERBS = CARD_VERBS;
53
53
  const DROP_ORDER = ['format', 'typecheck', 'lint', 'build'];
54
54
 
55
55
  /**
56
- * Build commands for ecosystems whose build entrypoint is not a package script. Keyed by
57
- * a marker fact or stack tag, whichever the ecosystem is identified by. `npm` is handled
58
- * before this table, because a Node repository states its own build script and stating it
59
- * again here would be a second answer to the same question.
56
+ * Build commands for ecosystems whose build entrypoint is not a package script. The rows are
57
+ * the records' `card.build` facet, in first-match-wins order; `npm` is handled before this
58
+ * table, because a Node repository states its own build script and stating it again here
59
+ * would be a second answer to the same question.
60
60
  */
61
- const BUILDS = [
62
- { fact: 'has-cargo-toml', command: 'cargo build' },
63
- { fact: 'has-go-mod', command: 'go build ./...' },
64
- { fact: 'has-nuget', command: 'dotnet build' },
65
- { fact: 'has-swift-package', command: 'swift build' },
66
- { fact: 'has-mix', command: 'mix compile' },
67
- { stack: 'gradle', command: './gradlew build' },
68
- ];
61
+ function buildRows(root) {
62
+ return cardBuilds(root);
63
+ }
69
64
 
70
65
  /**
71
- * One focused-test template per path kind. Entries are ordered, and the FIRST match wins,
72
- * so a repository that carries more than one marker gets the more specific template:
73
- * `has-uv-lock` before `has-pyproject`, `has-android-manifest` before the plain `gradle`
74
- * tag, `has-deno-config` before the Node fallback. A duplicate template for the same path
75
- * kind would be two answers, and the second is the wrong one.
66
+ * One focused-test template per path kind. The rows are the records' `card.focused` facet,
67
+ * in first-match-wins order: a repository that carries more than one marker gets the more
68
+ * specific template — `has-uv-lock` before `has-pyproject`, `has-android-manifest` before
69
+ * the plain `gradle` tag, `has-deno-config` before the Node fallback. A duplicate template
70
+ * for the same path kind would be two answers, and the second is the wrong one.
71
+ *
72
+ * The Node row is the one whose shape depends on the repo's own runner: a repository whose
73
+ * test script is vitest or jest takes a file argument through the script; one with no such
74
+ * script gets the built-in runner, which is the only answer that needs nothing installed to
75
+ * already be true. `<manager> test -- <file>` is the ADR-010 shape (never `npx`) — that
76
+ * choice is the template's `dynamic` form in the record, resolved by `evalTemplate`.
76
77
  */
77
- const FOCUSED = [
78
- { kind: 'Android', when: (s, f) => f.has('has-android-manifest'), template: () => './gradlew :{module}:testDebugUnitTest --tests {class}' },
79
- { kind: 'JVM', when: (s) => s.has('gradle'), template: () => './gradlew test --tests {class}' },
80
- { kind: 'Maven', when: (s, f) => f.has('has-maven-pom'), template: () => './mvnw test -Dtest={class}' },
81
- { kind: 'Go package', when: (s, f) => f.has('has-go-mod'), template: () => 'go test ./{pkg} -run {Test}' },
82
- { kind: 'Rust', when: (s, f) => f.has('has-cargo-toml'), template: () => 'cargo test {name}' },
83
- { kind: 'Python', when: (s, f) => f.has('has-uv-lock'), template: () => 'uv run -m pytest {file}::{test}' },
84
- { kind: 'Python', when: (s, f) => f.has('has-pyproject') || f.has('has-requirements'), template: () => 'pytest {file}::{test}' },
85
- { kind: '.NET', when: (s, f) => f.has('has-nuget'), template: () => 'dotnet test --filter {test}' },
86
- { kind: 'Swift', when: (s, f) => f.has('has-swift-package'), template: () => 'swift test --filter {test}' },
87
- { kind: 'Deno', when: (s, f) => f.has('has-deno-config'), template: () => 'deno test {file}' },
88
- { kind: 'Ruby', when: (s, f) => f.has('has-gemfile'), template: () => 'bundle exec rspec {file}' },
89
- { kind: 'Dart', when: (s, f) => f.has('has-pubspec'), template: () => 'dart test {file}' },
90
- { kind: 'Elixir', when: (s, f) => f.has('has-mix'), template: () => 'mix test {file}' },
91
- {
92
- kind: 'Node',
93
- when: (s) => s.has('node'),
94
- // The repo's own runner decides the shape. A repository whose test script is vitest
95
- // or jest takes a file argument through the script; one with no such script gets the
96
- // built-in runner, which is the only answer that needs nothing installed to already
97
- // be true. `<manager> test -- <file>` is the ADR-010 shape (never `npx`).
98
- template: (s, f, scripts, runner) => {
99
- const test = scripts?.test ?? '';
100
- if (!/vitest|jest/.test(test)) return 'node --test {file}';
101
- const run = runner?.run ?? 'npm';
102
- return `${run} test -- {file}`;
103
- },
104
- },
105
- ];
78
+ function focusedRows(root) {
79
+ return cardFocused(root);
80
+ }
106
81
 
107
82
  /**
108
- * Verbs for ecosystems the gate table does not seed, used by the card alone.
83
+ * Verbs for ecosystems the gate table does not seed, used by the card alone. The rows are
84
+ * the records' `card.fallback` facet.
109
85
  *
110
86
  * Python is the case, and it is not an oversight that the tool seeds no Python gate: the
111
87
  * runner genuinely varies (uv, poetry, tox, nox, a project wrapper), so a seeded gate would
@@ -116,10 +92,9 @@ const FOCUSED = [
116
92
  * test. Without a lockfile the card names the plain runner, which is what the python
117
93
  * profile assumes when it says "no `pytest.skip`".
118
94
  */
119
- const CARD_FALLBACKS = [
120
- { when: (s, f) => f.has('has-uv-lock'), commands: { test: 'uv run -m pytest' } },
121
- { when: (s, f) => f.has('has-pyproject') || f.has('has-requirements'), commands: { test: 'pytest' } },
122
- ];
95
+ function fallbackRows(root) {
96
+ return cardFallbacks(root);
97
+ }
123
98
 
124
99
  /** A non-empty string, or null. Guards JSON that is present but not a command. */
125
100
  function text(value) {
@@ -165,7 +140,7 @@ async function readScripts(root) {
165
140
  * repository has declared nothing. The last is the same table the tool seeds gates from,
166
141
  * so the card and the gate list cannot name two different commands for one repository.
167
142
  */
168
- function resolveVerb(verb, { manifest, facts, stack, scripts, runner }) {
143
+ function resolveVerb(verb, { manifest, facts, stack, scripts, runner, root }) {
169
144
  const declared = text(manifest?.gates?.commands?.[verb]);
170
145
  if (declared) return declared;
171
146
 
@@ -179,20 +154,19 @@ function resolveVerb(verb, { manifest, facts, stack, scripts, runner }) {
179
154
 
180
155
  if (verb === 'build') {
181
156
  if (text(scripts?.build)) return runScript(runner?.id ?? 'npm', 'build');
182
- for (const row of BUILDS) {
183
- if (row.fact && facts.has(row.fact)) return row.command;
184
- if (row.stack && stack.has(row.stack)) return row.command;
157
+ for (const row of buildRows(root)) {
158
+ if (matches(row.condition, { facts, stack })) return row.command;
185
159
  }
186
160
  return null;
187
161
  }
188
162
 
189
- for (const eco of ECOSYSTEMS) {
163
+ for (const eco of ecosystemGateRows(root)) {
190
164
  if (!facts.has(eco.fact)) continue;
191
165
  const command = text(eco.commands?.[verb]);
192
166
  if (command) return command;
193
167
  }
194
- for (const row of CARD_FALLBACKS) {
195
- if (!row.when(stack, facts, scripts)) continue;
168
+ for (const row of fallbackRows(root)) {
169
+ if (!matches(row.condition, { facts, stack })) continue;
196
170
  const command = text(row.commands?.[verb]);
197
171
  if (command) return command;
198
172
  }
@@ -246,17 +220,17 @@ export async function commandCard({ root, manifest = null, stack = [], facts = [
246
220
 
247
221
  const lines = [];
248
222
  for (const verb of VERBS) {
249
- const command = resolveVerb(verb, { manifest, facts: factSet, stack: stackSet, scripts, runner: effective });
223
+ const command = resolveVerb(verb, { manifest, facts: factSet, stack: stackSet, scripts, runner: effective, root });
250
224
  if (command) lines.push({ label: verb, command, verb });
251
225
  }
252
- for (const entry of FOCUSED) {
253
- if (!entry.when(stackSet, factSet, scripts)) continue;
226
+ for (const entry of focusedRows(root)) {
227
+ if (!matches(entry.condition, { facts: factSet, stack: stackSet })) continue;
254
228
  // One template per path kind: `has-uv-lock` and `has-pyproject` both match a uv repo,
255
229
  // and two Python lines would be two answers with the second one wrong.
256
230
  if (lines.some((l) => l.kind === entry.kind)) continue;
257
231
  lines.push({
258
232
  label: `focused test (${entry.kind})`,
259
- command: entry.template(stackSet, factSet, scripts, effective),
233
+ command: evalTemplate(entry.template, { scripts, runner: effective }),
260
234
  kind: entry.kind,
261
235
  verb: 'focused',
262
236
  });
@@ -9,14 +9,11 @@
9
9
  // index has the edges (R21). A direction still has to be named before the prose
10
10
  // can leave, or the line would say Enforced and nothing would be enforcing it.
11
11
 
12
- import { execFile } from 'node:child_process';
13
12
  import { readFile, stat } from 'node:fs/promises';
14
13
  import { relative, resolve, sep } from 'node:path';
15
- import { promisify } from 'node:util';
16
14
 
17
15
  import { CLI } from '../name.mjs';
18
-
19
- const exec = promisify(execFile);
16
+ import { git, gitPaths } from '../sys/git.mjs';
20
17
 
21
18
  export const CHECK_MARKER = 'simt-check';
22
19
 
@@ -360,8 +357,7 @@ function exempt(rel, prefixes) {
360
357
 
361
358
  async function tracked(root) {
362
359
  try {
363
- const { stdout } = await exec('git', ['ls-files', '-z'], { cwd: root });
364
- return stdout.split('\0').filter((p) => p !== '');
360
+ return await gitPaths(root, ['ls-files']);
365
361
  } catch {
366
362
  return null;
367
363
  }
@@ -370,8 +366,7 @@ async function tracked(root) {
370
366
  async function workDiff(root) {
371
367
  let text = '';
372
368
  try {
373
- const { stdout } = await exec('git', ['diff', '--no-ext-diff', '--unified=0', 'HEAD'], { cwd: root, maxBuffer: 8 * 1024 * 1024 });
374
- text = stdout;
369
+ text = await git(root, ['diff', '--no-ext-diff', '--unified=0', 'HEAD'], { maxBuffer: 8 * 1024 * 1024 });
375
370
  } catch (error) {
376
371
  if (error.code === 'ENOENT') return null;
377
372
  text = typeof error.stdout === 'string' ? error.stdout : '';
@@ -379,8 +374,7 @@ async function workDiff(root) {
379
374
  const files = parseDiff(text);
380
375
  let untracked = [];
381
376
  try {
382
- const { stdout } = await exec('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: root });
383
- untracked = stdout.split('\0').filter((p) => p !== '');
377
+ untracked = await gitPaths(root, ['ls-files', '--others', '--exclude-standard']);
384
378
  } catch {
385
379
  return { files };
386
380
  }
package/src/lease.mjs CHANGED
@@ -25,7 +25,7 @@ export const LEASE_FILE = localRel('leases.json');
25
25
  const LEASE_FILE_LEGACY = '.simt/leases.json';
26
26
 
27
27
  /** The schema the file is written with; a reader that does not know it refuses. */
28
- const LEASE_SCHEMA = 1;
28
+ const LEASE_SCHEMA = 2;
29
29
 
30
30
  /**
31
31
  * How long a claim lives when the caller does not say.
@@ -35,6 +35,89 @@ const LEASE_SCHEMA = 1;
35
35
  */
36
36
  export const DEFAULT_TTL_SECONDS = 3600;
37
37
 
38
+ /**
39
+ * A v1 record in the v2 shape.
40
+ *
41
+ * v1 stored `{path, session, harness, expiresAt}`; v2 stores a resource, a holder, a
42
+ * fencing token and an acquisition time (Q74). The reader accepts both because a claim
43
+ * file written by the previous release is still a claim the hook must honour, and
44
+ * refusing it would silently un-lease every held path on upgrade.
45
+ */
46
+ export function toV2(entry) {
47
+ if (entry !== null && typeof entry === 'object' && entry.resource !== undefined && entry.holder !== undefined) {
48
+ return entry;
49
+ }
50
+ // `...entry` keeps the fields a caller put on its own claim - a slice claim carries
51
+ // `kind: 'slice'` and the slice id - which the schema does not name but must not drop.
52
+ return {
53
+ ...entry,
54
+ resource: { kind: 'path', id: entry.path },
55
+ holder: { session: entry.session, harness: entry.harness ?? null },
56
+ fence: Number.isInteger(entry.fence) ? entry.fence : 1,
57
+ acquiredAt: typeof entry.acquiredAt === 'number' ? entry.acquiredAt : null,
58
+ expiresAt: entry.expiresAt,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * The v2 record flattened into the fields every reader of this tool already uses.
64
+ *
65
+ * Keeping `path`, `session`, `harness` and `expiresAt` on the in-memory entry is not
66
+ * backwards compatibility theatre: the hook, the command and four call sites compare
67
+ * those fields, and rewriting all of them to reach through `resource.id` would be a
68
+ * second change riding on a schema change.
69
+ */
70
+ export function flattenLease(entry) {
71
+ const v2 = toV2(entry);
72
+ return {
73
+ ...v2,
74
+ path: v2.resource.id,
75
+ session: v2.holder.session,
76
+ harness: v2.holder.harness ?? null,
77
+ expiresAt: v2.expiresAt,
78
+ };
79
+ }
80
+
81
+ /** A record that is worth honouring, or null. A bad expiry would hold a path forever. */
82
+ function normaliseLease(entry) {
83
+ if (entry === null || typeof entry !== 'object') return null;
84
+ const v2 = entry.resource !== undefined ? entry : toV2(entry);
85
+ const id = v2?.resource?.id;
86
+ const session = v2?.holder?.session;
87
+ if (typeof id !== 'string' || typeof session !== 'string' || !Number.isFinite(v2.expiresAt)) return null;
88
+ if (v2.resource.kind !== 'path' && v2.resource.kind !== 'dir') return null;
89
+ return flattenLease(v2);
90
+ }
91
+
92
+ /**
93
+ * Whether a claim covers a repository-relative path.
94
+ *
95
+ * A `dir` claim covers everything under it, which is what makes "this session owns
96
+ * `src/index/`" one claim instead of forty (Q74). The prefix test includes the separator
97
+ * so `src/index` does not cover `src/indexer`.
98
+ */
99
+ export function leaseCovers(lease, rel) {
100
+ const resource = toV2(lease).resource;
101
+ if (resource.kind === 'dir') return rel === resource.id || rel.startsWith(`${resource.id}/`);
102
+ return rel === resource.id;
103
+ }
104
+
105
+ /**
106
+ * The store the lease commands read and write through.
107
+ *
108
+ * A file today; a remote store and a server clock later (Q74). Everything above this line
109
+ * is a function of the records, not of the file, so the second implementation changes
110
+ * where `read` and `write` point and nothing else.
111
+ */
112
+ export function fileLeaseStore(root) {
113
+ return {
114
+ root,
115
+ read: () => readLeases(root),
116
+ write: (leases) => writeLeases(root, leases),
117
+ withLock: (fn) => withLeaseLock(root, fn),
118
+ };
119
+ }
120
+
38
121
  /**
39
122
  * The one spelling a path has for lease matching: repository-relative, symlinks
40
123
  * resolved, case-folded when the repository's git compares case-insensitively, or
@@ -105,10 +188,7 @@ export async function readLeases(root) {
105
188
  }
106
189
  // A malformed entry is dropped rather than trusted: an expiry that is not a number
107
190
  // would otherwise live forever, which is the opposite of what a TTL promises.
108
- const leases = parsed.leases.filter(
109
- (l) => l !== null && typeof l === 'object' && typeof l.path === 'string' &&
110
- typeof l.session === 'string' && Number.isFinite(l.expiresAt),
111
- );
191
+ const leases = parsed.leases.map(normaliseLease).filter((l) => l !== null);
112
192
  return { path, leases, error: null };
113
193
  } catch (error) {
114
194
  return { path, leases: [], error: `${LEASE_FILE} is not valid JSON (${error.message})` };
@@ -136,8 +216,23 @@ export function activeLeases({ leases } = { leases: [] }, now = Date.now()) {
136
216
  */
137
217
  export async function writeLeases(root, leases) {
138
218
  const path = join(root, LEASE_FILE);
139
- const sorted = [...leases].sort((a, b) => a.path.localeCompare(b.path));
140
- const body = `${JSON.stringify({ schemaVersion: LEASE_SCHEMA, leases: sorted }, null, 2)}\n`;
219
+ // v2 on disk: a resource, a holder and a fence, sorted by resource id so two writes of
220
+ // the same claims produce the same bytes (Q74).
221
+ const records = leases
222
+ .map(toV2)
223
+ .map((l) => ({
224
+ // Additive, not a rename: the pre-edit hook, the slice claim and the tests read
225
+ // `path` and `session` straight off the file, and a v2 writer that stopped writing
226
+ // them would break every one of those readers at once.
227
+ ...l,
228
+ resource: l.resource,
229
+ holder: { session: l.holder.session, harness: l.holder.harness ?? null },
230
+ fence: l.fence,
231
+ acquiredAt: l.acquiredAt,
232
+ expiresAt: l.expiresAt,
233
+ }))
234
+ .sort((a, b) => a.resource.id.localeCompare(b.resource.id));
235
+ const body = `${JSON.stringify({ schemaVersion: LEASE_SCHEMA, leases: records }, null, 2)}\n`;
141
236
  await mkdir(dirname(path), { recursive: true });
142
237
  // Atomic against a concurrent writer and a concurrent reader: a half-written
143
238
  // claim file fails closed for every edit (review 0003 Q20).