simt-dev 0.7.1 → 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 (186) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/README.md +169 -80
  3. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  4. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  5. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  7. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  8. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  9. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  10. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  11. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  12. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  13. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  14. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  15. package/ecosystems/bun.json +6 -0
  16. package/ecosystems/cargo.json +25 -0
  17. package/ecosystems/composer.json +6 -0
  18. package/ecosystems/dart.json +24 -0
  19. package/ecosystems/deno.json +21 -0
  20. package/ecosystems/devcontainers.json +6 -0
  21. package/ecosystems/docker-compose.json +6 -0
  22. package/ecosystems/docker.json +6 -0
  23. package/ecosystems/dotnet.json +25 -0
  24. package/ecosystems/elixir.json +23 -0
  25. package/ecosystems/gitsubmodule.json +6 -0
  26. package/ecosystems/go.json +25 -0
  27. package/ecosystems/gradle.json +13 -0
  28. package/ecosystems/maven.json +23 -0
  29. package/ecosystems/node.json +25 -0
  30. package/ecosystems/python.json +20 -0
  31. package/ecosystems/ruby.json +23 -0
  32. package/ecosystems/swift.json +25 -0
  33. package/ecosystems/terraform.json +6 -0
  34. package/ecosystems/uv.json +6 -0
  35. package/harnesses/antigravity.json +162 -0
  36. package/harnesses/claude.json +206 -0
  37. package/harnesses/codex.json +136 -0
  38. package/harnesses/copilot.json +88 -0
  39. package/harnesses/cursor.json +140 -0
  40. package/harnesses/deepseek.json +99 -0
  41. package/harnesses/gemini.json +122 -0
  42. package/harnesses/opencode.json +173 -0
  43. package/package.json +3 -1
  44. package/rules/mdc/intake-questions.mdc +8 -0
  45. package/src/advice/advice.mjs +3 -2
  46. package/src/agenthooks/render.mjs +86 -180
  47. package/src/agents/render.mjs +15 -18
  48. package/src/api.mjs +7 -5
  49. package/src/argv.mjs +19 -0
  50. package/src/bench/ablate.mjs +3 -4
  51. package/src/bench/adapters.json +155 -113
  52. package/src/bench/adapters.mjs +466 -0
  53. package/src/bench/arms.mjs +333 -0
  54. package/src/bench/grade.mjs +12 -26
  55. package/src/bench/graders.mjs +42 -0
  56. package/src/bench/mine.mjs +17 -535
  57. package/src/bench/probe-ledger.mjs +149 -0
  58. package/src/bench/probes.catalog.mjs +918 -0
  59. package/src/bench/probes.mjs +15 -1037
  60. package/src/bench/render.mjs +111 -0
  61. package/src/bench/report.mjs +116 -971
  62. package/src/bench/run.mjs +164 -881
  63. package/src/bench/variants.mjs +4 -5
  64. package/src/bench/workspace.mjs +160 -0
  65. package/src/brief/assemble.mjs +11 -13
  66. package/src/ci/dependabot.mjs +25 -55
  67. package/src/commands/agent.mjs +1 -9
  68. package/src/commands/agenthook.mjs +66 -35
  69. package/src/commands/bench.mjs +2 -2
  70. package/src/commands/ci.mjs +11 -13
  71. package/src/commands/docs.mjs +88 -0
  72. package/src/commands/gate.mjs +10 -13
  73. package/src/commands/harness.mjs +3 -3
  74. package/src/commands/help.mjs +44 -3
  75. package/src/commands/hooks.mjs +20 -20
  76. package/src/commands/ignore.mjs +11 -7
  77. package/src/commands/impact.mjs +4 -8
  78. package/src/commands/index.mjs +49 -9
  79. package/src/commands/init-scan.mjs +90 -0
  80. package/src/commands/init.mjs +122 -104
  81. package/src/commands/lease.mjs +67 -18
  82. package/src/commands/map.mjs +10 -5
  83. package/src/commands/mcp.mjs +2 -17
  84. package/src/commands/repoindex.mjs +8 -8
  85. package/src/commands/rules.mjs +8 -4
  86. package/src/commands/shared.mjs +12 -16
  87. package/src/commands/skills.mjs +5 -6
  88. package/src/commands/skillscompile.mjs +3 -3
  89. package/src/commands/skillsinstall.mjs +5 -4
  90. package/src/commands/slice.mjs +199 -64
  91. package/src/commands/spec.mjs +78 -62
  92. package/src/commands/status.mjs +24 -12
  93. package/src/commands/sync.mjs +14 -14
  94. package/src/commands/tests.mjs +12 -12
  95. package/src/commands/tokens.mjs +19 -24
  96. package/src/commands/upgrade.mjs +8 -4
  97. package/src/commands/why.mjs +6 -13
  98. package/src/compare/hooksstate.mjs +2 -6
  99. package/src/compare/learn.mjs +3 -1
  100. package/src/decisions/index.mjs +3 -2
  101. package/src/docs/commands.mjs +65 -0
  102. package/src/ecosystem/records.mjs +219 -0
  103. package/src/explain.mjs +31 -0
  104. package/src/gates/index.mjs +385 -0
  105. package/src/graph/compile.mjs +8 -4
  106. package/src/graph/load.mjs +2 -1
  107. package/src/graph/spec.mjs +10 -8
  108. package/src/harness/records.mjs +143 -0
  109. package/src/hooks/capability.mjs +5 -3
  110. package/src/hooks/dispatcher.mjs +26 -32
  111. package/src/hooks/gates.mjs +12 -2
  112. package/src/hooks/resolve.mjs +2 -7
  113. package/src/hooks/where.mjs +2 -7
  114. package/src/identity.mjs +2 -11
  115. package/src/index/coverage.mjs +58 -0
  116. package/src/index/extract.mjs +6 -16
  117. package/src/index/gate.mjs +1 -1
  118. package/src/index/govern.mjs +26 -9
  119. package/src/index/impact.mjs +4 -8
  120. package/src/index/map.mjs +14 -15
  121. package/src/index/open.mjs +173 -0
  122. package/src/index/persist.mjs +224 -57
  123. package/src/index/store.mjs +82 -39
  124. package/src/index/tests.mjs +2 -6
  125. package/src/journal.mjs +16 -11
  126. package/src/kernel/card.mjs +37 -63
  127. package/src/kernel/checks.mjs +4 -10
  128. package/src/lease.mjs +102 -7
  129. package/src/ledger/index.mjs +124 -17
  130. package/src/mcp/paginate.mjs +33 -0
  131. package/src/mcp/schemas.mjs +512 -0
  132. package/src/mcp/server.mjs +49 -9
  133. package/src/mcp/tools-facts.mjs +326 -0
  134. package/src/mcp/tools-read.mjs +221 -0
  135. package/src/mcp/tools.mjs +4 -1005
  136. package/src/{bench → measure}/corpus.mjs +1 -1
  137. package/src/measure/metrics.mjs +236 -0
  138. package/src/measure/records.mjs +211 -0
  139. package/src/measure/stats.mjs +538 -0
  140. package/src/measure/task-schema.mjs +125 -0
  141. package/src/measure/tasks.mjs +69 -0
  142. package/src/memory/decide.mjs +6 -15
  143. package/src/memory/lesson-text.mjs +61 -0
  144. package/src/memory/lessons.mjs +29 -82
  145. package/src/name.mjs +7 -0
  146. package/src/output/index.mjs +122 -31
  147. package/src/packs/manage.mjs +15 -9
  148. package/src/plan/index.mjs +1 -1
  149. package/src/plugins.mjs +22 -16
  150. package/src/prbody.mjs +7 -11
  151. package/src/projection/loaders.mjs +7 -692
  152. package/src/projection/manifest.mjs +10 -435
  153. package/src/projection/mdc.mjs +7 -8
  154. package/src/projection/project.mjs +126 -221
  155. package/src/projection/region.mjs +561 -0
  156. package/src/projection/rules.mjs +16 -547
  157. package/src/projection/surfaces.mjs +43 -210
  158. package/src/projection/targets.mjs +5 -5
  159. package/src/repo-root.mjs +13 -1
  160. package/src/resolve/eval.mjs +2 -1
  161. package/src/resolve/fingerprint.mjs +0 -7
  162. package/src/resolve/resolve.mjs +1 -41
  163. package/src/review/critic.mjs +8 -12
  164. package/src/run.mjs +98 -0
  165. package/src/secrets.mjs +14 -22
  166. package/src/state/events.mjs +129 -0
  167. package/src/status-snapshot.mjs +233 -0
  168. package/src/status.mjs +2 -6
  169. package/src/sys/git.mjs +82 -0
  170. package/src/{bench/process.mjs → sys/proc.mjs} +28 -5
  171. package/src/testing/parsers.mjs +265 -0
  172. package/src/testing/patches.mjs +23 -0
  173. package/src/testing/paths.mjs +49 -0
  174. package/src/testing/runners.mjs +186 -0
  175. package/src/testing/vault.mjs +68 -0
  176. package/src/text/frontmatter.mjs +49 -0
  177. package/src/text/tokens.mjs +52 -0
  178. package/src/toolwords.mjs +1 -1
  179. package/src/util/fs.mjs +20 -0
  180. package/src/util/sort.mjs +12 -0
  181. package/src/workspace.mjs +3 -1
  182. package/src/write/plan.mjs +82 -0
  183. package/templates/plan/tracker.json +1 -0
  184. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  185. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  186. package/src/bench/tasks.mjs +0 -36
@@ -8,10 +8,16 @@ import { execFile, spawn } from 'node:child_process';
8
8
  import { stat } from 'node:fs/promises';
9
9
  import { dirname, join } from 'node:path';
10
10
 
11
+ import { git as gitRaw } from '../sys/git.mjs';
12
+
11
13
  import { readManifest } from '../projection/manifest.mjs';
12
14
  import { readLocalConfig } from '../local/config.mjs';
13
15
  import { localEmbed } from '../local/client.mjs';
14
16
  import { extract, resolveImports } from './extract.mjs';
17
+ // Q70: the extension-to-language map is the records' `languages[]` (ADR-013). `canonical`
18
+ // names the extensions that RESOLVE to a language (JavaScript claims `.ts` for extraction
19
+ // but not for resolution), so a record's extraction and resolution cannot desynchronize.
20
+ import { languageResolver } from '../ecosystem/records.mjs';
15
21
  import {
16
22
  FTS_SCHEMA,
17
23
  FTS_TRI_SCHEMA,
@@ -34,24 +40,32 @@ export { JSONL_FILE, SQLITE_FILE, decodeVectorBlob, encodeVectorBlob };
34
40
 
35
41
  const MARKERS = ['package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'pom.xml'];
36
42
 
37
- const LANG = {
38
- js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
39
- ts: 'typescript', tsx: 'typescript', kt: 'kotlin', kts: 'kotlin', java: 'java',
40
- py: 'python', go: 'go', rs: 'rust', swift: 'swift', dart: 'dart', rb: 'ruby', cs: 'csharp',
41
- };
43
+ // A tracked blob larger than this is recorded with its size and counted in
44
+ // `skipped`, not streamed and tokenized: a 65 MiB file is indexed as a row, and
45
+ // tokenizing it would buy nothing (Q76). The cap sits under that file on purpose
46
+ // so the 65 MiB test exercises the skip, not the tokenize path.
47
+ const BLOB_CAP = 16 * 1024 * 1024;
48
+
49
+ const LANG = languageResolver();
42
50
 
43
51
  /** `sqlite` when this process can open a database and create an FTS5 table. */
52
+ // Probed once per process: the check builds both FTS schemas in a memory
53
+ // database, and every read path used to pay for that (Q76).
54
+ let backendProbe;
44
55
  async function probeBackend() {
45
- try {
46
- const { DatabaseSync } = await import('node:sqlite');
47
- const db = new DatabaseSync(':memory:');
48
- db.exec(FTS_SCHEMA);
49
- db.exec(FTS_TRI_SCHEMA);
50
- db.close();
51
- return 'sqlite';
52
- } catch {
53
- return 'jsonl';
54
- }
56
+ backendProbe ??= (async () => {
57
+ try {
58
+ const { DatabaseSync } = await import('node:sqlite');
59
+ const db = new DatabaseSync(':memory:');
60
+ db.exec(FTS_SCHEMA);
61
+ db.exec(FTS_TRI_SCHEMA);
62
+ db.close();
63
+ return 'sqlite';
64
+ } catch {
65
+ return 'jsonl';
66
+ }
67
+ })();
68
+ return backendProbe;
55
69
  }
56
70
 
57
71
  export function ftsKindOf(filePath, lang = '') {
@@ -224,7 +238,10 @@ export async function reindex(root, opts = {}) {
224
238
  changed.push(entry);
225
239
  }
226
240
  const sizes = await blobSizes(root, changed.map((entry) => entry.blob));
227
- const texts = await blobTexts(root, changed.map((entry) => entry.blob));
241
+ // Over-cap blobs are recorded with their size and counted in `skipped`, not
242
+ // streamed and tokenized: their row is written, their text is never read.
243
+ const textBlobs = changed.map((entry) => entry.blob).filter((blob) => (sizes.get(blob) ?? 0) <= BLOB_CAP);
244
+ const texts = await blobTexts(root, textBlobs);
228
245
  const adapter = await resolveAdapter(root, opts);
229
246
  const paths = new Set(listed.map((entry) => entry.path));
230
247
  const next = [];
@@ -383,7 +400,14 @@ function withReadError(rows, error) {
383
400
  export async function readFiles(root, opts = {}) {
384
401
  const backend = opts.backend ?? await probeBackend();
385
402
  if (backend === 'sqlite') {
386
- const { rows, error } = await readSqliteRows(root, 'select path, blob, lang, bytes, pkg from files order by path');
403
+ // The v5 layout keys files as `sources` rows; the git blob sha is `revision`
404
+ // and the package directory rides in `props`, which the v4 `files` table
405
+ // named `pkg`.
406
+ const { rows, error } = await readSqliteRows(
407
+ root,
408
+ `select s.path as path, s.revision as blob, s.lang as lang, s.bytes as bytes,
409
+ json_extract(s.props, '$.pkg') as pkg from sources s where s.kind = 'blob' order by s.path`,
410
+ );
387
411
  return withReadError(rows, error);
388
412
  }
389
413
  const { rows, error } = await readJsonl(root, 'files');
@@ -394,7 +418,14 @@ export async function readFiles(root, opts = {}) {
394
418
  export async function readSymbols(root, opts = {}) {
395
419
  const backend = opts.backend ?? await probeBackend();
396
420
  if (backend === 'sqlite') {
397
- const { rows, error } = await readSqliteRows(root, 'select path, name, kind, line, sig, exported from symbols order by path, line, name, kind');
421
+ const { rows, error } = await readSqliteRows(
422
+ root,
423
+ `select s.path as path, n.name as name, json_extract(n.props, '$.kind') as kind,
424
+ n.line as line, json_extract(n.props, '$.sig') as sig,
425
+ json_extract(n.props, '$.exported') as exported
426
+ from nodes n join sources s on s.id = n.source_id and s.kind = 'blob'
427
+ where n.kind = 'symbol' order by s.path, n.line, n.name, n.kind`,
428
+ );
398
429
  return withReadError(rows, error);
399
430
  }
400
431
  const { rows, error } = await readJsonl(root, 'symbols');
@@ -405,7 +436,14 @@ export async function readSymbols(root, opts = {}) {
405
436
  export async function readEdges(root, opts = {}) {
406
437
  const backend = opts.backend ?? await probeBackend();
407
438
  if (backend === 'sqlite') {
408
- const { rows, error } = await readSqliteRows(root, 'select src, dst, kind, weight from edges order by src, dst, kind');
439
+ // Paths come back from the node key (`file:<path>`) and the stable `dst_key`:
440
+ // node ids are regenerated when a file changes, so a path is the only key
441
+ // that survives a reindex of one endpoint.
442
+ const { rows, error } = await readSqliteRows(
443
+ root,
444
+ `select substr(ns.key, 6) as src, e.dst_key as dst, e.kind as kind, e.weight as weight
445
+ from edges e join nodes ns on ns.id = e.src_id order by src, dst, kind`,
446
+ );
409
447
  return withReadError(rows, error);
410
448
  }
411
449
  const { rows, error } = await readJsonl(root, 'edges');
@@ -416,7 +454,11 @@ export async function readEdges(root, opts = {}) {
416
454
  export async function readFts(root, opts = {}) {
417
455
  const backend = opts.backend ?? await probeBackend();
418
456
  if (backend === 'sqlite') {
419
- const { rows, error } = await readSqliteRows(root, 'select kind, ref, body from fts order by ref, kind, rowid');
457
+ const { rows, error } = await readSqliteRows(
458
+ root,
459
+ `select f.kind as kind, substr(n.key, 6) as ref, f.body as body
460
+ from fts f join nodes n on n.id = f.node_id order by ref, kind, f.rowid`,
461
+ );
420
462
  return withReadError(rows, error);
421
463
  }
422
464
  const { rows, error } = await readJsonl(root, 'fts');
@@ -427,7 +469,12 @@ export async function readFts(root, opts = {}) {
427
469
  export async function readEmbeddings(root, opts = {}) {
428
470
  const backend = opts.backend ?? await probeBackend();
429
471
  if (backend === 'sqlite') {
430
- const { rows: raw, error } = await readSqliteRows(root, 'select path, blob, model, dims, vec from embeddings order by path');
472
+ const { rows: raw, error } = await readSqliteRows(
473
+ root,
474
+ `select substr(n.key, 6) as path, s.revision as blob, v.model as model, v.dims as dims, v.vec as vec
475
+ from vectors v join nodes n on n.id = v.node_id
476
+ join sources s on s.id = n.source_id and s.kind = 'blob' order by path`,
477
+ );
431
478
  const rows = raw.map((row) => {
432
479
  const vec = decodeVectorBlob(row.vec);
433
480
  return {
@@ -500,11 +547,15 @@ export async function searchFtsBm25(root, task, opts = {}) {
500
547
  async function ftsBranch(root, table, terms) {
501
548
  if (terms.length === 0) return { rows: [], error: null };
502
549
  const matchExpr = terms.map((t) => `"${t}"`).join(' OR ');
503
- const { rows, error } = await readSqliteRows(
504
- root,
505
- `select kind, ref as path, -bm25(${table}) as score from ${table} where ${table} match ? order by score desc, path asc limit ?`,
506
- [matchExpr, FTS_LIMIT],
507
- );
550
+ // `fts` is the v5 node-keyed table: the path comes back through the node key.
551
+ // `fts_tri` keeps its v4 (kind, ref, body) shape for the CJK trigram path (Q43),
552
+ // so it is queried the old way.
553
+ const sql = table === 'fts'
554
+ ? `select f.kind as kind, substr(n.key, 6) as path, -bm25(f) as score
555
+ from fts f join nodes n on n.id = f.node_id
556
+ where f match ? order by score desc, path asc limit ?`
557
+ : `select kind, ref as path, -bm25(${table}) as score from ${table} where ${table} match ? order by score desc, path asc limit ?`;
558
+ const { rows, error } = await readSqliteRows(root, sql, [matchExpr, FTS_LIMIT]);
508
559
  return {
509
560
  rows: rows.map((row) => ({ kind: row.kind, path: row.path, score: Number(row.score) || 0 })),
510
561
  error,
@@ -590,20 +641,10 @@ export async function packageOf(root, filePath, cache) {
590
641
  }
591
642
  }
592
643
 
593
- function git(root, args, input) {
594
- return new Promise((resolve, reject) => {
595
- const child = execFile('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
596
- if (error) reject(Object.assign(error, { stdout, stderr }));
597
- else resolve(stdout);
598
- });
599
- if (input !== undefined) {
600
- child.stdin.end(input);
601
- }
602
- });
603
- }
644
+
604
645
 
605
646
  async function lsFiles(root) {
606
- const stdout = await git(root, ['ls-files', '-s', '-z']);
647
+ const stdout = await gitRaw(root, ['ls-files', '-s', '-z']);
607
648
  const rows = [];
608
649
  for (const rec of stdout.split('\0')) {
609
650
  if (rec === '') continue;
@@ -738,7 +779,9 @@ async function blobSizes(root, blobs) {
738
779
  const unique = [...new Set(blobs)];
739
780
  const sizes = new Map();
740
781
  if (unique.length === 0) return sizes;
741
- const stdout = await git(root, ['cat-file', '--batch-check=%(objectname) %(objectsize)'], `${unique.join('\n')}\n`);
782
+ const stdout = await gitRaw(root, ['cat-file', '--batch-check=%(objectname) %(objectsize)'], {
783
+ input: `${unique.join('\n')}\n`,
784
+ });
742
785
  for (const line of stdout.split('\n')) {
743
786
  if (line === '') continue;
744
787
  const sp = line.indexOf(' ');
@@ -6,8 +6,9 @@
6
6
  // the tree is not a guess. Co-change is a different kind: files that land in
7
7
  // the same commit are not, by that fact, a test.
8
8
 
9
- import { isTestPath } from '../bench/mine.mjs';
9
+ import { isTestPath } from '../testing/paths.mjs';
10
10
  import { extract, resolveImports } from './extract.mjs';
11
+ import { cmp } from '../util/sort.mjs';
11
12
 
12
13
  export { scriptSpecifiers } from './extract.mjs';
13
14
 
@@ -251,11 +252,6 @@ function byEdge(a, b) {
251
252
  return cmp(a.kind, b.kind) || cmp(a.src, b.src) || cmp(a.dst, b.dst);
252
253
  }
253
254
 
254
- function cmp(a, b) {
255
- if (a < b) return -1;
256
- if (a > b) return 1;
257
- return 0;
258
- }
259
255
 
260
256
  /** Imports in a file, resolved against the tracked tree. One path now: `extract`
261
257
  * reads scripts with `scriptSpecifiers`, so gate, impact and the index agree on
package/src/journal.mjs CHANGED
@@ -41,17 +41,15 @@
41
41
  // a second `undo` naturally targets the set before it, and the untouched-check
42
42
  // refuses when the two sets overlap on a file the first undo already restored.
43
43
 
44
- import { execFile } from 'node:child_process';
45
44
  import { createHash } from 'node:crypto';
46
45
  import { chmod, cp, mkdir, readFile, readdir, rename, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
47
46
  import { dirname, join, relative, sep } from 'node:path';
48
- import { promisify } from 'node:util';
49
47
 
48
+ import { noteEvent } from './state/events.mjs';
50
49
  import { STATE_DIR } from './name.mjs';
51
50
  import { LOCAL_DIR } from './state.mjs';
52
51
  import { HOOKS_DIR } from './hooks/shim.mjs';
53
-
54
- const exec = promisify(execFile);
52
+ import { git, gitText } from './sys/git.mjs';
55
53
 
56
54
  const JOURNAL_DIR = join(LOCAL_DIR, 'journal');
57
55
  const JOURNAL_DIR_LEGACY = '.simt/journal';
@@ -222,8 +220,7 @@ export async function journaledRemoveFile(root, acc, path) {
222
220
  /** Read one git config value in a repo, or null when unset. */
223
221
  export async function readGitValue(root, key) {
224
222
  try {
225
- const { stdout } = await exec('git', ['config', '--get', key], { cwd: root });
226
- return stdout.replace(/\n$/, '');
223
+ return await gitText(root, ['config', '--get', key]);
227
224
  } catch (error) {
228
225
  if (error.code === 1) return null;
229
226
  throw error;
@@ -246,9 +243,9 @@ export async function journaledGitConfig(root, acc, key, value) {
246
243
  const before = await readGitValue(root, key);
247
244
  if (before === value) return null;
248
245
  if (value === null) {
249
- await exec('git', ['config', '--unset', key], { cwd: root });
246
+ await git(root, ['config', '--unset', key]);
250
247
  } else {
251
- await exec('git', ['config', key, value], { cwd: root });
248
+ await git(root, ['config', key, value]);
252
249
  }
253
250
  const entry = { kind: 'git-config', key, before, after: value };
254
251
  acc.push(entry);
@@ -407,6 +404,14 @@ export async function commitJournal(root, command, acc, { at = Date.now() } = {}
407
404
  const tmp = join(dir, `${id}.tmp-${process.pid}`);
408
405
  await writeFile(tmp, body, 'utf8');
409
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
+ });
410
415
  return id;
411
416
  }
412
417
 
@@ -528,9 +533,7 @@ function entryBytes(entry) {
528
533
  /** Restore one entry: bytes and permission bits for files, the value for config. */
529
534
  async function restoreEntry(root, entry) {
530
535
  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
- });
536
+ await git(root, entry.before === null ? ['config', '--unset', entry.key] : ['config', entry.key, entry.before]);
534
537
  return;
535
538
  }
536
539
  const abs = join(root, entry.path);
@@ -610,5 +613,7 @@ export async function undoLastJournalSet(root) {
610
613
  const [id] = await listJournalSets(root);
611
614
  if (!id) return { empty: true };
612
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 } });
613
618
  return { set: id, ...result };
614
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).