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
@@ -18,20 +18,19 @@ import { plan } from '../projection/project.mjs';
18
18
  import { isEligible, loadRules, readDeclared, select } from '../projection/rules.mjs';
19
19
  import { fingerprint } from '../resolve/fingerprint.mjs';
20
20
  import { CANARY_ALPHA, DEFAULT_CANARY_SIZE, DEL_R_TOLERANCE, shouldEscalateCanary } from './ablate.mjs';
21
+ import { evaluateRunRecord, loadPrices } from '../measure/metrics.mjs';
22
+ import { loadRunRecords } from '../measure/records.mjs';
21
23
  import {
22
24
  DEFAULT_BOOTSTRAP_SAMPLES,
23
25
  DEFAULT_BOOTSTRAP_SEED,
24
26
  collapseArmByTask,
25
27
  compareArms,
26
- evaluateRunRecord,
27
28
  holmAdjust,
28
- loadPrices,
29
- loadRunRecords,
30
- } from './report.mjs';
29
+ } from '../measure/stats.mjs';
31
30
 
32
31
  /**
33
32
  * Harnesses that grade, not answer: `gold` applies the oracle patch and `noop` does
34
- * nothing (`src/bench/adapters.json`, `kind: "builtin"`). Their runs measure the
33
+ * nothing (`src/bench/adapters.json`, `kind: "oracle"`). Their runs measure the
35
34
  * grader, not the rule text, so they must never close a variant experiment by
36
35
  * writing a lock entry (Q45). A test asserts this set matches the adapter table.
37
36
  */
@@ -0,0 +1,160 @@
1
+ // Isolated workspaces for a benchmarked run.
2
+ //
3
+ // One base tree is exported per task and copied per run (review 0003 Q78): every arm and
4
+ // repetition used to unpack the same `git archive` again, which made the cost of the
5
+ // harness include the cost of exporting the repository. The copy is a fresh directory
6
+ // with no `.git` of its own until the arm has materialised, so two runs of one task can
7
+ // never see each other's edits.
8
+ import { existsSync } from 'node:fs';
9
+ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+
13
+ import { STATE_DIR } from '../name.mjs';
14
+ import { LOCAL_DIR } from '../state.mjs';
15
+ import { sanitizedEnv, spawnBounded } from '../sys/proc.mjs';
16
+ import { materialiseArm } from './arms.mjs';
17
+
18
+ const DEFAULT_GIT_TIMEOUT_MS = 120_000;
19
+
20
+ /** Run one bounded child process with a sanitised environment. */
21
+ export function runCmd(cmd, args, { cwd, env, input = null, timeoutMs = DEFAULT_GIT_TIMEOUT_MS } = {}) {
22
+ return spawnBounded(cmd, args, { cwd, env: sanitizedEnv(env), input, timeoutMs });
23
+ }
24
+
25
+ /**
26
+ * Apply a bounded, order-preserving pool over `items`.
27
+ *
28
+ * @template T, R
29
+ * @param {T[]} items
30
+ * @param {number} concurrency
31
+ * @param {(item: T, index: number) => Promise<R>} fn
32
+ * @returns {Promise<R[]>} results in input order
33
+ */
34
+ export async function mapPool(items, concurrency, fn) {
35
+ const results = new Array(items.length);
36
+ let cursor = 0;
37
+ async function worker() {
38
+ while (true) {
39
+ const index = cursor;
40
+ cursor += 1;
41
+ if (index >= items.length) return;
42
+ results[index] = await fn(items[index], index);
43
+ }
44
+ }
45
+ const count = Math.max(1, Math.min(concurrency, items.length));
46
+ await Promise.all(Array.from({ length: count }, () => worker()));
47
+ return results;
48
+ }
49
+
50
+ /**
51
+ * Export one commit into a fresh directory: the tree every run of a task starts from.
52
+ *
53
+ * @param {string} root
54
+ * @param {string} baseSha
55
+ * @param {string} parentDir
56
+ * @returns {Promise<string>} the directory holding the unpacked tree
57
+ */
58
+ export async function prepareBaseTree(root, baseSha, parentDir) {
59
+ const baseDir = await mkdtemp(join(parentDir, 'base-'));
60
+ const archiveRes = await runCmd('git', ['archive', '--format=tar', baseSha], { cwd: root });
61
+ if (archiveRes.code !== 0) {
62
+ await rm(baseDir, { recursive: true, force: true }).catch(() => {});
63
+ throw new Error(`git archive failed for ${baseSha}: ${archiveRes.stderr.toString('utf8').trim()}`);
64
+ }
65
+ const tarRes = await runCmd('tar', ['-xf', '-', '-C', baseDir], {
66
+ cwd: baseDir,
67
+ input: archiveRes.stdout,
68
+ });
69
+ if (tarRes.code !== 0) {
70
+ await rm(baseDir, { recursive: true, force: true }).catch(() => {});
71
+ throw new Error(`tar unpack failed for ${baseSha}: ${tarRes.stderr.toString('utf8').trim()}`);
72
+ }
73
+ return baseDir;
74
+ }
75
+
76
+ /**
77
+ * Create the workspace one run is measured in: the base tree, the arm materialised over
78
+ * it, one commit, and no tool state.
79
+ *
80
+ * @param {string} root
81
+ * @param {string} baseSha
82
+ * @param {object} armSpec
83
+ * @param {object} [task]
84
+ * @param {{parentDir?: string, baseDir?: string}} [options]
85
+ * @returns {Promise<{slotDir: string, workDir: string, initialHead: string,
86
+ * env: Record<string, string>, promptPrefix: string}>}
87
+ */
88
+ export async function prepareIsolatedWorkspace(root, baseSha, armSpec, task = {}, { parentDir, baseDir } = {}) {
89
+ const slotDir = await mkdtemp(join(parentDir ?? tmpdir(), 'bench-run-'));
90
+ const workDir = join(slotDir, 'workspace');
91
+ await mkdir(workDir, { recursive: true });
92
+
93
+ if (baseDir) {
94
+ const copied = await runCmd('cp', ['-a', `${baseDir}/.`, workDir], { cwd: workDir });
95
+ if (copied.code !== 0) {
96
+ await rm(slotDir, { recursive: true, force: true }).catch(() => {});
97
+ throw new Error(`copying the base tree failed for ${baseSha}: ${copied.stderr.toString('utf8').trim()}`);
98
+ }
99
+ } else {
100
+ const archiveRes = await runCmd('git', ['archive', '--format=tar', baseSha], { cwd: root });
101
+ if (archiveRes.code !== 0) {
102
+ await rm(slotDir, { recursive: true, force: true }).catch(() => {});
103
+ throw new Error(`git archive failed for ${baseSha}: ${archiveRes.stderr.toString('utf8').trim()}`);
104
+ }
105
+ const tarRes = await runCmd('tar', ['-xf', '-', '-C', workDir], { cwd: workDir, input: archiveRes.stdout });
106
+ if (tarRes.code !== 0) {
107
+ await rm(slotDir, { recursive: true, force: true }).catch(() => {});
108
+ throw new Error(`tar unpack failed for ${baseSha}: ${tarRes.stderr.toString('utf8').trim()}`);
109
+ }
110
+ }
111
+
112
+ // Lessons and local profiles live outside the archived commit, so an adopted
113
+ // repository's memory reaches the arm without being written into its base tree.
114
+ const rootLessonsDir = join(root, 'docs', 'lessons');
115
+ const rootLessonFiles = (await readdir(rootLessonsDir).catch(() => [])).filter((f) => f.endsWith('.md'));
116
+ if (rootLessonFiles.length > 0) {
117
+ const destLessonsDir = join(workDir, 'docs', 'lessons');
118
+ await mkdir(destLessonsDir, { recursive: true });
119
+ for (const file of rootLessonFiles) {
120
+ const target = join(destLessonsDir, file);
121
+ if (!existsSync(target)) {
122
+ await writeFile(target, await readFile(join(rootLessonsDir, file), 'utf8'), 'utf8');
123
+ }
124
+ }
125
+ }
126
+
127
+ const rootProfilesDir = join(root, STATE_DIR, 'profiles');
128
+ const rootProfileFiles = (await readdir(rootProfilesDir).catch(() => [])).filter((f) => f.endsWith('.md'));
129
+ if (rootProfileFiles.length > 0) {
130
+ const destProfilesDir = join(workDir, STATE_DIR, 'profiles');
131
+ await mkdir(destProfilesDir, { recursive: true });
132
+ for (const file of rootProfileFiles) {
133
+ await writeFile(join(destProfilesDir, file), await readFile(join(rootProfilesDir, file), 'utf8'), 'utf8');
134
+ }
135
+ }
136
+
137
+ await runCmd('git', ['init', '-q'], { cwd: workDir });
138
+ await runCmd('git', ['config', 'user.name', 'bench'], { cwd: workDir });
139
+ await runCmd('git', ['config', 'user.email', 'bench@local'], { cwd: workDir });
140
+ await runCmd('git', ['config', 'commit.gpgsign', 'false'], { cwd: workDir });
141
+
142
+ // The repository exists before an arm materialises: the `brief` arm assembles its
143
+ // packet from git's answer, which a directory that is not yet a repository cannot give.
144
+ const { env, promptPrefix } = await materialiseArm(workDir, armSpec, task);
145
+ // Assembling a packet writes tool state. It belongs to no arm, and leaving it in the
146
+ // base commit would make the `brief` workspace differ by an index as well as by context.
147
+ await rm(join(workDir, LOCAL_DIR), { recursive: true, force: true }).catch(() => {});
148
+
149
+ await runCmd('git', ['add', '-A'], { cwd: workDir });
150
+ const commitRes = await runCmd('git', ['commit', '-qm', 'base', '--allow-empty', '--no-verify'], {
151
+ cwd: workDir,
152
+ });
153
+ if (commitRes.code !== 0) {
154
+ await rm(slotDir, { recursive: true, force: true }).catch(() => {});
155
+ throw new Error(`git commit failed in isolated workspace: ${commitRes.stderr.toString('utf8').trim()}`);
156
+ }
157
+
158
+ const headRes = await runCmd('git', ['rev-parse', 'HEAD'], { cwd: workDir });
159
+ return { slotDir, workDir, initialHead: headRes.stdout.toString('utf8').trim(), env, promptPrefix };
160
+ }
@@ -45,15 +45,17 @@ import { commandCard } from '../kernel/card.mjs';
45
45
  import { decisionHits, scopeHits, sourceRoots } from '../index/govern.mjs';
46
46
  import { embedTaskQuery, rankFiles, renderMap } from '../index/map.mjs';
47
47
  import { searchFtsBm25 } from '../index/store.mjs';
48
- import { testEdges, testsFor } from '../index/tests.mjs';
48
+ import { coverage } from '../index/coverage.mjs';
49
49
  import { loadRegistry } from '../graph/load.mjs';
50
50
  import { fingerprint } from '../resolve/fingerprint.mjs';
51
- import { closure, rankSkills, selectWithinBudget, tokenize } from '../resolve/resolve.mjs';
51
+ import { closure, rankSkills, selectWithinBudget } from '../resolve/resolve.mjs';
52
+ import { tokenize } from '../text/tokens.mjs';
52
53
  import { loadGraph } from '../commands/map.mjs';
53
54
  import { dirtyPaths, parsePathList } from '../commands/skills.mjs';
54
55
  import { decisionRecords, lessonScopes } from '../commands/why.mjs';
55
56
  import { readFederation } from '../federation/config.mjs';
56
57
  import { consult } from '../federation/query.mjs';
58
+ import { gitText } from '../sys/git.mjs';
57
59
 
58
60
  /**
59
61
  * How many ranked files the packet may consider as candidates for other sections.
@@ -93,17 +95,11 @@ const MAP_FACTOR = 8;
93
95
  * only the candidate list; the region parser decides.
94
96
  */
95
97
  async function generatedRegions(root) {
96
- const { execFile } = await import('node:child_process');
97
- const { promisify } = await import('node:util');
98
98
  const { readFile } = await import('node:fs/promises');
99
99
  let candidates;
100
100
  try {
101
- const { stdout } = await promisify(execFile)(
102
- 'git',
103
- ['grep', '-lF', '--', `${'simt'}:begin`],
104
- { cwd: root, maxBuffer: 8 << 20 },
105
- );
106
- candidates = stdout.split('\n').filter((line) => line.length > 0).sort();
101
+ const stdout = await gitText(root, ['grep', '-lF', '--', `${'simt'}:begin`], { maxBuffer: 8 << 20 });
102
+ candidates = (stdout ?? '').split('\n').filter((line) => line.length > 0).sort();
107
103
  } catch {
108
104
  // No match is exit 1, and a directory that is not a repository fails too. Both mean
109
105
  // the same thing to a reader: no generated region was found to warn about.
@@ -152,7 +148,7 @@ export async function assembleBrief({
152
148
  const dirty = (await dirtyPaths(root)).filter((path) => !path.startsWith(`${STATE_DIR}/`));
153
149
  const scope = named ? parsePathList(paths) : dirty;
154
150
 
155
- const graph = await loadGraph(root, { build });
151
+ const graph = await loadGraph(root, { build, task, embed: flags?.embed === true });
156
152
  if (graph.error) return { error: graph.error };
157
153
  if (graph.cold) return { cold: true };
158
154
  const graphStale = graph.stale === true;
@@ -251,13 +247,15 @@ export async function assembleBrief({
251
247
  }
252
248
 
253
249
  const likely = ranked.slice(0, LIKELY_FILES).map((row) => row.path);
254
- const known = testEdges({ files, imports: edges.filter((edge) => edge.kind === 'import') });
255
250
  const untested = [];
256
251
  packet.add('TESTS', `the index knows direct importers; \`${CLI} tests <path>\` is the authority for multi-line and dynamic imports`, {
257
252
  why: ['a cheap packet and an exact answer are different questions'],
258
253
  });
259
254
  for (const path of likely) {
260
- const found = testsFor(path, known);
255
+ // The packet's own edges, walked by the same function `tests` uses (Q77).
256
+ const found = coverage({ files, imports: edges.filter((edge) => edge.kind === 'import') }, [path], {
257
+ transitive: false,
258
+ });
261
259
  if (found.length === 0) untested.push(path);
262
260
  else packet.add('TESTS', `${path} -> ${found.join(' ')}`, { why: ['the test imports it, or sits beside it'] });
263
261
  }
@@ -40,61 +40,30 @@ import { join } from 'node:path';
40
40
  import { fingerprint } from '../resolve/fingerprint.mjs';
41
41
  import { isOwnRepo, WORKFLOWS_DIR } from './workflow.mjs';
42
42
  import { CLI } from '../name.mjs';
43
+ // Q70: the Dependabot entries are the records' `dependabot` facet, not a table in this
44
+ // file. Adding an ecosystem is a new file under `ecosystems/`, not a row here.
45
+ import { dependabotEntries } from '../ecosystem/records.mjs';
43
46
 
44
47
  /** The one file this module writes. */
45
48
  export const DEPENDABOT_FILE = '.github/dependabot.yml';
46
49
 
47
50
  /**
48
- * fact -> the `package-ecosystem` value Dependabot documents for it.
51
+ * fact/stack -> the `package-ecosystem` value Dependabot documents for it.
49
52
  *
50
- * The fact names are already in the fingerprint vocabulary and already carry
51
- * evidence, so this table adds no new observation vocabulary: it says which
52
- * Dependabot value watches which observable manifest. Facts absent from this
53
- * table are not ecosystems Dependabot updates, and adding one is a new ruling
54
- * about the estate rather than a code detail.
53
+ * Read from the ecosystem records (ADR-013: content is data, the engine is code), not
54
+ * written here. `dependabotEntries(root)` returns the rows in their declared order; `root`
55
+ * lets a repository's own `.simt/ecosystems/` supply an entry the shipped set does not have,
56
+ * so adding an ecosystem is a new file, not a row in a table the engine edits.
55
57
  *
56
- * Several facts map to one value on purpose. `has-pyproject` and
57
- * `has-requirements` are two ways of declaring python dependencies and
58
- * Dependabot reads both under `pip`; emitting two `pip` entries would be a
59
- * duplicate the config rejects or silently collapses, and the reader would have
60
- * no way to tell which one won.
61
- *
62
- * A `stack` tag is used where no fact exists and one is needed. npm is the
63
- * clearest case: `node` is a stack tag in `fingerprint.mjs` and `typescript` is
64
- * a dependency name, so a repo's package manager is either of those and adding
65
- * `has-node-manifest` would be a third name for one observation - the exact
66
- * duplication that table's own header rules out. `gradle` is the same shape:
67
- * the stack tag already names the build file. Every other entry below is a
68
- * real `has-*` fact.
69
- *
70
- * `has-build-system` is deliberately NOT mapped: Makefile, CMakeLists and
71
- * meson.build are build files, not dependency manifests, and Dependabot has no
72
- * ecosystem that reads them. Mapping it to `maven` would be a lie of exactly the
73
- * kind the detection-driven ruling exists to prevent.
58
+ * The fact names are already in the fingerprint vocabulary and already carry evidence, so
59
+ * the records add no new observation vocabulary: each entry says which Dependabot value
60
+ * watches which observable manifest. Several facts map to one value on purpose — `has-pyproject`
61
+ * and `has-requirements` are two ways of declaring python dependencies and Dependabot reads
62
+ * both under `pip`; emitting two `pip` entries would be a duplicate the config rejects. A
63
+ * `stack` tag stands in where no fact exists (`node`, `gradle`). `has-build-system` is
64
+ * deliberately NOT mapped: Makefile, CMakeLists and meson.build are build files, not
65
+ * dependency manifests, and Dependabot has no ecosystem that reads them.
74
66
  */
75
- const ECOSYSTEMS = [
76
- { stack: 'node', value: 'npm', manifest: 'package.json' },
77
- { stack: 'typescript', value: 'npm', manifest: 'package.json' },
78
- { fact: 'has-pyproject', value: 'pip', manifest: 'pyproject.toml' },
79
- { fact: 'has-requirements', value: 'pip', manifest: 'requirements.txt' },
80
- { fact: 'has-go-mod', value: 'gomod', manifest: 'go.mod' },
81
- { fact: 'has-cargo-toml', value: 'cargo', manifest: 'Cargo.toml' },
82
- { fact: 'has-gemfile', value: 'bundler', manifest: 'Gemfile' },
83
- { fact: 'has-composer', value: 'composer', manifest: 'composer.json' },
84
- { fact: 'has-maven-pom', value: 'maven', manifest: 'pom.xml' },
85
- { fact: 'has-dockerfile', value: 'docker', manifest: 'Dockerfile' },
86
- { fact: 'has-compose', value: 'docker-compose', manifest: 'compose file' },
87
- { fact: 'has-terraform', value: 'terraform', manifest: '*.tf' },
88
- { stack: 'gradle', value: 'gradle', manifest: 'build.gradle' },
89
- { fact: 'has-submodules', value: 'gitsubmodule', manifest: '.gitmodules' },
90
- { fact: 'has-nuget', value: 'nuget', manifest: '*.csproj' },
91
- { fact: 'has-pubspec', value: 'pub', manifest: 'pubspec.yaml' },
92
- { fact: 'has-swift-package', value: 'swift', manifest: 'Package.swift' },
93
- { fact: 'has-mix', value: 'mix', manifest: 'mix.exs' },
94
- { fact: 'has-uv-lock', value: 'uv', manifest: 'uv.lock' },
95
- { fact: 'has-bun-lock', value: 'bun', manifest: 'bun.lock' },
96
- { fact: 'has-devcontainer', value: 'devcontainers', manifest: 'devcontainer.json' },
97
- ];
98
67
 
99
68
  /**
100
69
  * The ecosystems this tree has a manifest for, in a stable order.
@@ -108,11 +77,11 @@ const ECOSYSTEMS = [
108
77
  * @param {Iterable<string>} [stack] emitted stack tags
109
78
  * @returns {{value: string, manifest: string}[]}
110
79
  */
111
- export function ecosystems(facts, stack = []) {
80
+ export function ecosystems(facts, stack = [], root = null) {
112
81
  const seenFacts = new Set(facts);
113
82
  const seenStack = new Set(stack);
114
83
  const out = [];
115
- for (const row of ECOSYSTEMS) {
84
+ for (const row of dependabotEntries(root)) {
116
85
  const found = row.fact ? seenFacts.has(row.fact) : seenStack.has(row.stack);
117
86
  if (!found || out.some((e) => e.value === row.value)) continue;
118
87
  out.push({ value: row.value, manifest: row.manifest });
@@ -174,8 +143,8 @@ function entry({ value }) {
174
143
  * @param {{stack?: Iterable<string>, own?: boolean}} [options]
175
144
  * @returns {string} YAML, LF-terminated
176
145
  */
177
- export function renderDependabot(facts, { stack = [], own = false } = {}) {
178
- const found = ecosystems(facts, stack);
146
+ export function renderDependabot(facts, { stack = [], own = false, root = null } = {}) {
147
+ const found = ecosystems(facts, stack, root);
179
148
  const entries = [entry({ value: 'github-actions' }), ...found.map(entry)];
180
149
  return `${header(own)}version: 2
181
150
  updates:
@@ -221,18 +190,19 @@ export async function planDependabot(root) {
221
190
  // "the file differs" instead of "the file differs from what this repo should get",
222
191
  // and those are different questions when a manifest appeared since.
223
192
  const { facts, stack } = await fingerprint(root);
224
- const watched = ecosystems(facts, stack).map((e) => e.value);
193
+ const watched = ecosystems(facts, stack, root).map((e) => e.value);
225
194
  // Carried on the plan so the writer renders from the same observation this
226
195
  // comparison used. Re-detecting at write time would make `ci` and `check`
227
196
  // disagree whenever a manifest appeared between the two calls, and the file
228
- // would be written from facts nobody ever saw in the plan.
229
- const detection = { facts, stack };
197
+ // would be written from facts nobody ever saw in the plan. `root` rides along so
198
+ // the writer renders the records a repository's own `.simt/ecosystems/` overrides.
199
+ const detection = { facts, stack, root };
230
200
 
231
201
  if (exists) {
232
202
  // Both sides of the comparison, for the same reason as the workflow plan: a reader
233
203
  // told the file differs can be shown how, and re-rendering for a diff would let
234
204
  // the two answers disagree.
235
- const desired = renderDependabot(facts, { stack, own: true });
205
+ const desired = renderDependabot(facts, { stack, own: true, root });
236
206
  const current = own ? text === desired : true;
237
207
  return {
238
208
  file: DEPENDABOT_FILE,
@@ -10,7 +10,6 @@
10
10
  // parse that can fail and no partial state, which is what makes this command
11
11
  // shorter than `agent-hooks install`.
12
12
 
13
- import { readFile } from 'node:fs/promises';
14
13
  import { join } from 'node:path';
15
14
 
16
15
  import { commitJournal } from '../journal.mjs';
@@ -28,6 +27,7 @@ import {
28
27
  writeAgentPlan,
29
28
  } from '../agents/render.mjs';
30
29
  import { commandRoot } from '../repo-root.mjs';
30
+ import { readIfExists } from '../util/fs.mjs';
31
31
 
32
32
  export async function agentsCommand(sub, args, flags) {
33
33
  const root = commandRoot(flags, args);
@@ -195,11 +195,3 @@ async function agentsStatus(root, flags) {
195
195
  emit(lines.join('\n'));
196
196
  return problems.length === 0 ? EXIT.OK : EXIT.FAIL;
197
197
  }
198
-
199
- async function readIfExists(path) {
200
- try {
201
- return await readFile(path, 'utf8');
202
- } catch {
203
- return null;
204
- }
205
- }
@@ -25,7 +25,8 @@
25
25
  import { existsSync } from 'node:fs';
26
26
  import { mkdir, readFile } from 'node:fs/promises';
27
27
  import { dirname, isAbsolute, join } from 'node:path';
28
- import { commitJournal, journaledWriteFile } from '../journal.mjs';
28
+ import { commitJournal } from '../journal.mjs';
29
+ import { applyPlan, change } from '../write/plan.mjs';
29
30
  import { AGENT_HOOK_SHIM, renderAgentHookShim } from '../agenthooks/shim.mjs';
30
31
  import { diffField, emitDiffs, readBefore } from '../diff.mjs';
31
32
 
@@ -179,18 +180,26 @@ export async function agentHooksCommand(sub, args, flags) {
179
180
  // `skipped` was an adapter the caller did not ask for (ADR-107).
180
181
  const OTHER_LABEL = { none: 'NO FILE ', other: 'NOT OURS', skipped: 'SKIPPED ' };
181
182
 
182
- async function installHooks(root, flags) {
183
- const mode = writeMode(flags);
183
+ /**
184
+ * The agent-hooks install, split from `installHooks` into the plan and the apply so
185
+ * `init` can run the same writes inside its own journal (ADR-002: a second real use).
186
+ *
187
+ * The plan is pure: it reads each documented config, renders the entry, and returns the
188
+ * shim the same way, so a preview and an apply describe the same set of files. A config
189
+ * this tool cannot parse lands in `refused` with its action set to `refused`, and the
190
+ * caller decides what that means — this command blocks on it, and `init` inherits the
191
+ * refusal because writing the other configs while one is unreadable would leave the
192
+ * repository in a state where the report it printed is no longer true.
193
+ */
194
+ export async function planAgentHooks(root, { harness }) {
184
195
  // `--harness` narrows the install to one reader (ADR-107). The id is validated
185
196
  // against the same table the loop reads, so a typo fails before anything is
186
197
  // planned; the remedy names the ids the way `agent-hook` does.
187
- const selected = flags.harness;
188
- if (selected !== undefined && adapter(selected) === null) {
189
- return fail(
190
- `unknown harness \`${selected}\``,
191
- EXIT.USAGE,
192
- `--harness <id>, one of: ${adapters().map((a) => a.id).join(', ')}`,
193
- );
198
+ if (harness !== undefined && adapter(harness) === null) {
199
+ return {
200
+ error: `unknown harness \`${harness}\``,
201
+ remedy: `--harness <id>, one of: ${adapters().map((a) => a.id).join(', ')}`,
202
+ };
194
203
  }
195
204
  // The delivery is a second entry in the same config, and a repository asks for it by
196
205
  // declaring a `brief` key rather than by having the entry. Read through a dynamic
@@ -202,7 +211,7 @@ async function installHooks(root, flags) {
202
211
  listPromotedLessons(root).catch(() => []),
203
212
  ]);
204
213
  const hasLessons = promotedLessons.length > 0;
205
- const rows = selected === undefined ? adapters() : adapters().filter((r) => r.id === selected);
214
+ const rows = harness === undefined ? adapters() : adapters().filter((r) => r.id === harness);
206
215
  const plan = [];
207
216
  for (const row of rows) {
208
217
  const path = hookPath(row.id);
@@ -232,7 +241,7 @@ async function installHooks(root, flags) {
232
241
  for (const r of hookRows()) {
233
242
  if (r.path === null) others.push({ id: r.id, path: null, state: 'none', note: r.note });
234
243
  else if (adapter(r.id) === null) others.push({ id: r.id, path: r.path, state: 'other', note: r.note });
235
- else if (selected !== undefined && r.id !== selected) {
244
+ else if (harness !== undefined && r.id !== harness) {
236
245
  others.push({ id: r.id, path: r.path, state: 'skipped', note: 'not requested; install without --harness to write every harness' });
237
246
  }
238
247
  }
@@ -240,13 +249,48 @@ async function installHooks(root, flags) {
240
249
  const refused = plan.filter((p) => p.error);
241
250
 
242
251
  // The shim every harness config points at: without it the entries above name a
243
- // binary no harness has on PATH (review 0003 Q21). Its remedy installs the
244
- // dependency the same way the git shims' does — a devDependency where the
245
- // repository has a package.json, the release binary where it does not.
252
+ // binary no harness has on PATH (review 0003 Q21).
246
253
  const shimText = renderAgentHookShim({ npm: (await readIfExists(join(root, 'package.json'))) !== null });
247
254
  const shimBefore = await readBefore(join(root, AGENT_HOOK_SHIM));
248
255
  const shimAction = shimBefore === null ? 'create' : shimBefore === shimText ? 'unchanged' : 'update';
249
- const shimRow = { id: 'shim', path: AGENT_HOOK_SHIM, action: shimAction };
256
+ const shim = { text: shimText, before: shimBefore, action: shimAction, row: { id: 'shim', path: AGENT_HOOK_SHIM, action: shimAction } };
257
+
258
+ return { plan, shim, others, promptLine, refused };
259
+ }
260
+
261
+ /**
262
+ * Write the planned agent-hooks entries and the shim into the caller's journal. Nothing
263
+ * is committed here: the caller commits its own journal under its own label, which is
264
+ * what separates this command's run (a standalone `agent-hooks install`) from `init`'s
265
+ * absorbed one (part of the adoption, committed with the rest of it).
266
+ */
267
+ export async function applyAgentHooks(root, journal, { plan, shim, owner }) {
268
+ const written = [];
269
+ const hookChanges = [];
270
+ for (const p of plan.filter((x) => x.action !== 'unchanged')) {
271
+ const path = join(root, p.path);
272
+ hookChanges.push(change({ path, before: await readBefore(path), after: p.text, owner, reason: p.action }));
273
+ written.push(p);
274
+ }
275
+ // The shim is a file like the configs: unchanged installs leave it alone, and a
276
+ // changed one is journaled so `undo` removes or restores it with the configs.
277
+ let shimWritten = false;
278
+ if (shim.action !== 'unchanged') {
279
+ const shimPath = join(root, AGENT_HOOK_SHIM);
280
+ hookChanges.push(change({ path: shimPath, before: await readBefore(shimPath), after: shim.text, owner, reason: shim.action, mode: 0o755 }));
281
+ shimWritten = true;
282
+ }
283
+ await applyPlan(root, hookChanges, { mode: { apply: true }, journal, commit: false });
284
+ return { written, shimWritten };
285
+ }
286
+
287
+ async function installHooks(root, flags) {
288
+ const mode = writeMode(flags);
289
+ const planned = await planAgentHooks(root, { harness: flags.harness });
290
+ if (planned.error) {
291
+ return fail(planned.error, EXIT.USAGE, planned.remedy);
292
+ }
293
+ const { plan, shim, others, promptLine, refused } = planned;
250
294
 
251
295
  // The `--diff` pairs, read before anything is written so the before side is
252
296
  // true in preview and in apply alike (R50).
@@ -256,7 +300,7 @@ async function installHooks(root, flags) {
256
300
  plan
257
301
  .filter((p) => p.action !== 'unchanged')
258
302
  .map(async (p) => ({ path: p.path, before: await readBefore(join(root, p.path)), after: p.text }))
259
- .concat(shimAction === 'unchanged' ? [] : [{ path: AGENT_HOOK_SHIM, before: shimBefore, after: shimText }]),
303
+ .concat(shim.action === 'unchanged' ? [] : [{ path: AGENT_HOOK_SHIM, before: shim.before, after: shim.text }]),
260
304
  )
261
305
  : [];
262
306
 
@@ -268,7 +312,7 @@ async function installHooks(root, flags) {
268
312
  root,
269
313
  dryRun: true,
270
314
  plan: plan.map(({ text, ...rest }) => rest),
271
- shim: shimRow,
315
+ shim: shim.row,
272
316
  notWritten: others,
273
317
  filesWritten: false,
274
318
  ...diffField(diffs, flags),
@@ -279,7 +323,7 @@ async function installHooks(root, flags) {
279
323
  [
280
324
  `repo ${root}`,
281
325
  ...plan.map((p) => `${p.action.toUpperCase().padEnd(9)} ${p.id.padEnd(12)} ${p.path}${p.error ? ` (${p.error})` : ''}`),
282
- `${shimAction.toUpperCase().padEnd(9)} shim ${AGENT_HOOK_SHIM}`,
326
+ `${shim.action.toUpperCase().padEnd(9)} shim ${AGENT_HOOK_SHIM}`,
283
327
  ...others.map((o) => `${OTHER_LABEL[o.state]} ${o.id.padEnd(12)} ${o.path ?? '(no hooks file is documented)'}`),
284
328
  ...promptLine,
285
329
  '',
@@ -302,20 +346,7 @@ async function installHooks(root, flags) {
302
346
 
303
347
  // Journaled so `undo` restores this run (R49).
304
348
  const journal = [];
305
- const written = [];
306
- for (const p of plan.filter((x) => x.action !== 'unchanged')) {
307
- await mkdir(join(root, dirname(p.path)), { recursive: true });
308
- await journaledWriteFile(root, journal, join(root, p.path), p.text);
309
- written.push(p);
310
- }
311
- // The shim is a file like the configs: unchanged installs leave it alone, and a
312
- // changed one is journaled so `undo` removes or restores it with the configs.
313
- let shimWritten = false;
314
- if (shimAction !== 'unchanged') {
315
- await mkdir(join(root, dirname(AGENT_HOOK_SHIM)), { recursive: true });
316
- await journaledWriteFile(root, journal, join(root, AGENT_HOOK_SHIM), shimText, { mode: 0o755 });
317
- shimWritten = true;
318
- }
349
+ const { written, shimWritten } = await applyAgentHooks(root, journal, { plan, shim, owner: 'agent-hooks install' });
319
350
  await commitJournal(root, 'agent-hooks install', journal);
320
351
 
321
352
  const notes = [];
@@ -331,7 +362,7 @@ async function installHooks(root, flags) {
331
362
  command: 'agent-hooks install',
332
363
  root,
333
364
  plan: plan.map(({ text, ...rest }) => rest),
334
- shim: { ...shimRow, written: shimWritten },
365
+ shim: { ...shim.row, written: shimWritten },
335
366
  notWritten: others,
336
367
  written: written.map((p) => p.path),
337
368
  filesWritten: true,
@@ -344,7 +375,7 @@ async function installHooks(root, flags) {
344
375
  [
345
376
  `repo ${root}`,
346
377
  ...plan.map((p) => `${p.action.toUpperCase().padEnd(9)} ${p.id.padEnd(12)} ${p.path}`),
347
- `${shimAction.toUpperCase().padEnd(9)} shim ${AGENT_HOOK_SHIM}`,
378
+ `${shim.action.toUpperCase().padEnd(9)} shim ${AGENT_HOOK_SHIM}`,
348
379
  ...others.map((o) => `${OTHER_LABEL[o.state]} ${o.id.padEnd(12)} ${o.path ?? '(no hooks file is documented)'}`),
349
380
  ...promptLine,
350
381
  '',
@@ -11,7 +11,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
11
11
  import { CLI } from '../name.mjs';
12
12
  import { EXIT, emit, emitJson, fail, isJson, writeMode } from '../output/index.mjs';
13
13
  import { DEFAULT_MINE_TARGET, mineTasks } from '../bench/mine.mjs';
14
- import { buildCorpusDocument, verifyCorpusDocument, writeCorpusDocument } from '../bench/corpus.mjs';
14
+ import { buildCorpusDocument, verifyCorpusDocument, writeCorpusDocument } from '../measure/corpus.mjs';
15
15
  import { regradeRun, runBench } from '../bench/run.mjs';
16
16
  import { buildBenchReport } from '../bench/report.mjs';
17
17
  import { formatRecall, measureRecall } from '../index/map.mjs';
@@ -233,7 +233,7 @@ export async function benchReportCommand(positional, flags) {
233
233
  );
234
234
  }
235
235
 
236
- const root = resolve(flags.root ?? pos[0] ?? process.cwd());
236
+ const root = resolve(commandRoot(flags, pos));
237
237
  const arms = listFlag(flags.arm);
238
238
 
239
239
  let report;