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
@@ -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
+ }
@@ -34,7 +34,10 @@
34
34
  // command and the tool leave it on. It is inert either way in a repository that declared no
35
35
  // servers.
36
36
 
37
+ import { join } from 'node:path';
38
+
37
39
  import { CLI, STATE_DIR } from '../name.mjs';
40
+ import { parse as parseRegion } from '../projection/markers.mjs';
38
41
  import { DEFAULT_BRIEF_BYTES, buildPacket, collect } from './packet.mjs';
39
42
  import { classifyTask, cardVerbs } from './kind.mjs';
40
43
  import { briefAdvice } from '../advice/advice.mjs';
@@ -42,15 +45,17 @@ import { commandCard } from '../kernel/card.mjs';
42
45
  import { decisionHits, scopeHits, sourceRoots } from '../index/govern.mjs';
43
46
  import { embedTaskQuery, rankFiles, renderMap } from '../index/map.mjs';
44
47
  import { searchFtsBm25 } from '../index/store.mjs';
45
- import { testEdges, testsFor } from '../index/tests.mjs';
48
+ import { coverage } from '../index/coverage.mjs';
46
49
  import { loadRegistry } from '../graph/load.mjs';
47
50
  import { fingerprint } from '../resolve/fingerprint.mjs';
48
- 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';
49
53
  import { loadGraph } from '../commands/map.mjs';
50
54
  import { dirtyPaths, parsePathList } from '../commands/skills.mjs';
51
55
  import { decisionRecords, lessonScopes } from '../commands/why.mjs';
52
56
  import { readFederation } from '../federation/config.mjs';
53
57
  import { consult } from '../federation/query.mjs';
58
+ import { gitText } from '../sys/git.mjs';
54
59
 
55
60
  /**
56
61
  * How many ranked files the packet may consider as candidates for other sections.
@@ -81,22 +86,32 @@ const MAP_FACTOR = 8;
81
86
  * space is never part of a task's scope.
82
87
  */
83
88
 
84
- /** Every file git tracks that carries a generated region, one `git grep` away. */
89
+ /**
90
+ * Every file git tracks that carries a generated region, one `git grep` away.
91
+ *
92
+ * A file that merely mentions the marker is not carrying a region: a rule file, a review
93
+ * and this test all quote `simt:begin`, and warning that they are generated sends a reader
94
+ * to edit a source file that has no generated bytes at all (review 0003 Q52). The grep is
95
+ * only the candidate list; the region parser decides.
96
+ */
85
97
  async function generatedRegions(root) {
86
- const { execFile } = await import('node:child_process');
87
- const { promisify } = await import('node:util');
98
+ const { readFile } = await import('node:fs/promises');
99
+ let candidates;
88
100
  try {
89
- const { stdout } = await promisify(execFile)(
90
- 'git',
91
- ['grep', '-lF', '--', `${'simt'}:begin`],
92
- { cwd: root, maxBuffer: 8 << 20 },
93
- );
94
- return 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();
95
103
  } catch {
96
104
  // No match is exit 1, and a directory that is not a repository fails too. Both mean
97
105
  // the same thing to a reader: no generated region was found to warn about.
98
106
  return [];
99
107
  }
108
+ const carrying = [];
109
+ for (const path of candidates) {
110
+ const text = await readFile(join(root, path), 'utf8').catch(() => null);
111
+ if (text === null) continue;
112
+ if (parseRegion(text, 'html').status === 'ok') carrying.push(path);
113
+ }
114
+ return carrying;
100
115
  }
101
116
 
102
117
  /**
@@ -113,6 +128,9 @@ async function generatedRegions(root) {
113
128
  * @param {boolean} [options.build] whether an empty index may be built here
114
129
  * @param {boolean} [options.federate] whether a declared external server may be asked one
115
130
  * question; a delivered packet turns it off to keep its 300 ms bar
131
+ * @param {string[]} [options.commands] the dispatchable command paths (`COMMANDS`). A
132
+ * decision's `Affects:` token is a command only when the tool has that command, so a
133
+ * packet that does not know them reports every one of them as unresolved
116
134
  * @returns a packet, or `{ error }` carrying the exit code a failure already reported, or
117
135
  * `{ cold: true }` when the index is empty and this caller may not build one
118
136
  */
@@ -124,12 +142,13 @@ export async function assembleBrief({
124
142
  flags = {},
125
143
  build = true,
126
144
  federate = true,
145
+ commands = [],
127
146
  } = {}) {
128
147
  const named = paths !== null;
129
148
  const dirty = (await dirtyPaths(root)).filter((path) => !path.startsWith(`${STATE_DIR}/`));
130
149
  const scope = named ? parsePathList(paths) : dirty;
131
150
 
132
- const graph = await loadGraph(root, { build });
151
+ const graph = await loadGraph(root, { build, task, embed: flags?.embed === true });
133
152
  if (graph.error) return { error: graph.error };
134
153
  if (graph.cold) return { cold: true };
135
154
  const graphStale = graph.stale === true;
@@ -187,6 +206,15 @@ export async function assembleBrief({
187
206
  })
188
207
  : null;
189
208
  const ranked = rankFiles({ files, symbols, edges, fts, bm25, embeddings, queryVector, task });
209
+ // An empty match is a different answer from a weak one: the ranking is then the graph
210
+ // hub, which is what a reader who asked about `zebracorn` must not read as a hit
211
+ // (review 0003 Q52).
212
+ const matched = ranked.filter((row) => (row.score ?? 0) > 0).length;
213
+ if (task.trim().length > 0 && matched === 0) {
214
+ packet.add('CAUTIONS', 'matched 0: no file or symbol mentions this task, so FILES is the graph hub rather than a match', {
215
+ why: ['an answer to a graph-shaped question is not an answer to this task'],
216
+ });
217
+ }
190
218
  const rendered = renderMap(ranked, symbols, { level: 'names', budget: budget > 0 ? budget * MAP_FACTOR : 0 });
191
219
  for (const line of rendered.text.split('\n')) {
192
220
  packet.add('FILES', line, { why: ['ranked by the task words against the import graph'] });
@@ -219,13 +247,15 @@ export async function assembleBrief({
219
247
  }
220
248
 
221
249
  const likely = ranked.slice(0, LIKELY_FILES).map((row) => row.path);
222
- const known = testEdges({ files, imports: edges.filter((edge) => edge.kind === 'import') });
223
250
  const untested = [];
224
251
  packet.add('TESTS', `the index knows direct importers; \`${CLI} tests <path>\` is the authority for multi-line and dynamic imports`, {
225
252
  why: ['a cheap packet and an exact answer are different questions'],
226
253
  });
227
254
  for (const path of likely) {
228
- 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
+ });
229
259
  if (found.length === 0) untested.push(path);
230
260
  else packet.add('TESTS', `${path} -> ${found.join(' ')}`, { why: ['the test imports it, or sits beside it'] });
231
261
  }
@@ -233,7 +263,10 @@ export async function assembleBrief({
233
263
  const records = await decisionRecords(root);
234
264
  const ctx = {
235
265
  files: filePaths,
236
- commands: new Set(card.lines.map((line) => line.command)),
266
+ // The card's lines are shell commands (`npm run test`); a decision's Affects token is
267
+ // a command of this tool. Passing only the card's list made every `simt …` token
268
+ // unresolved — 47 false cautions on this repository (review 0003 Q52).
269
+ commands: new Set([...card.lines.map((line) => line.command), ...commands]),
237
270
  roots: sourceRoots([...filePaths]),
238
271
  };
239
272
  const decisions = new Map();
@@ -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,
@@ -167,7 +167,7 @@ function checkoutFull() {
167
167
  * to. The own gate also varies the platforms and the Node versions, because this
168
168
  * package is the one whose floor and whose ADR-012 claim have to hold on more than
169
169
  * the minimum. */
170
- function skeleton(name, steps, { matrix = null } = {}) {
170
+ function skeleton(name, steps, { matrix = null, timeoutMinutes = 5, gateMain = false } = {}) {
171
171
  const axes = matrix
172
172
  ? Object.entries(matrix)
173
173
  .map(([axis, values]) => ` ${axis}: [${values.map((v) => `'${v}'`).join(', ')}]`)
@@ -182,9 +182,22 @@ function skeleton(name, steps, { matrix = null } = {}) {
182
182
  // it is written escaped, the same way the `concurrency` line below spells its own two.
183
183
  const runner = matrix?.os ? '\${{ matrix.os }}' : 'ubuntu-latest';
184
184
  const job = matrix?.node ? '\${{ matrix.os }} node \${{ matrix.node }}' : name;
185
- return `name: ${name}
186
-
187
- # Pull requests only, and that is a decision rather than a default.
185
+ // Two trigger blocks, because the two copies answer different questions. An
186
+ // adopting repository merges through pull requests, which is where its gate runs.
187
+ // This repository merges locally, so a merge never opens a pull request: without a
188
+ // push trigger the commit that lands on `main` is the one no gate ever ran on
189
+ // (review 0003 Q57). Each block ships only in the copy whose claim it states.
190
+ const trigger = gateMain
191
+ ? `# Pull requests ask whether a change may merge; a push to \`main\` asks whether
192
+ # what already merged is sound. This repository merges locally, so a merge never
193
+ # opens a pull request — without the second trigger the commit that lands on
194
+ # \`main\` is the one no gate ran on.
195
+ on:
196
+ pull_request:
197
+ push:
198
+ branches: [main]
199
+ `
200
+ : `# Pull requests only, and that is a decision rather than a default.
188
201
  #
189
202
  # A gate that runs on every push re-runs itself for every commit that pushes a
190
203
  # branch forward, including the ones nobody asked it about, and its verdict has to
@@ -194,7 +207,10 @@ function skeleton(name, steps, { matrix = null } = {}) {
194
207
  # on \`main\` is whatever CI the repository already has there.
195
208
  on:
196
209
  pull_request:
210
+ `;
211
+ return `name: ${name}
197
212
 
213
+ ${trigger}
198
214
  # Read-only is enough to check a repo, and a gate job does not need to write to it.
199
215
  permissions:
200
216
  contents: read
@@ -207,7 +223,7 @@ jobs:
207
223
  check:
208
224
  name: ${job}
209
225
  runs-on: ${runner}
210
- ${strategy} timeout-minutes: 5
226
+ ${strategy} timeout-minutes: ${timeoutMinutes}
211
227
  steps:
212
228
  ${steps}`;
213
229
  }
@@ -268,7 +284,7 @@ export function renderWorkflow({ own = false, npm = true, manager = null, versio
268
284
  ' - name: tests',
269
285
  ' run: npm test',
270
286
  ' - name: skill registry lint',
271
- ' run: node bin/simt.mjs graph lint',
287
+ ' run: npm run lint',
272
288
  ' - name: the gate copy in this repo is current',
273
289
  // The drift this exception exists to prevent, checked where it would be caught.
274
290
  // Without it a template change lands with the copy still describing the old
@@ -282,7 +298,14 @@ export function renderWorkflow({ own = false, npm = true, manager = null, versio
282
298
  // sentence was false in the one repository that could have made it true, and no
283
299
  // check read it - the package declares `os` for npm to enforce, and nothing here
284
300
  // ever ran a test on a second platform.
285
- { matrix: { os: ['ubuntu-latest', 'macos-latest'], node: [floor, '24', '26'] } },
301
+ // The slowest leg (macOS, full suite plus the registry lint) ran 4 min 28 s
302
+ // against a 5-minute timeout, so the timeout that was meant to bound a hang
303
+ // was about to fail a passing run instead. 15 leaves the hang bounded and the
304
+ // run alone; the sharding that would make it short again is Q92, not this row.
305
+ //
306
+ // `gateMain` because this repository merges locally: `pull_request` alone left
307
+ // the one commit that reached `main` ungated (review 0003 Q57).
308
+ { matrix: { os: ['ubuntu-latest', 'macos-latest'], node: [floor, '24', '26'] }, timeoutMinutes: 15, gateMain: true },
286
309
  )}\n`;
287
310
  }
288
311
 
package/src/cli.mjs ADDED
@@ -0,0 +1,45 @@
1
+ // The CLI proper, imported by `bin/simt.mjs` after the runtime floor is checked.
2
+ //
3
+ // It lives here rather than in `bin/` because a static import in the entry point is
4
+ // evaluated before the entry point's own code: the version check could not run first,
5
+ // and an old Node failed inside the module graph instead of saying which version it
6
+ // needed (review 0003 Q54).
7
+
8
+ import { parse, rawJsonFlag } from './argv.mjs';
9
+ import { EXIT, fail, jsonFailMode, jsonIndentDefault } from './output/index.mjs';
10
+
11
+ try {
12
+ const argv = process.argv.slice(2);
13
+ const parsed = parse(argv);
14
+ if (!parsed.ok) {
15
+ // The flags do not exist yet, so the JSON mode is read off the raw words:
16
+ // a parse failure under `--json` is a JSON document on stdout, not text on
17
+ // stderr with an empty stdout (Q11, ADR-070 ruling 4).
18
+ jsonFailMode(rawJsonFlag(argv));
19
+ process.exit(fail(parsed.error.message, parsed.error.code));
20
+ }
21
+ const { flags, positional } = parsed.value;
22
+
23
+ // Set before anything can fail: `no command given` under `--json` is a JSON
24
+ // document too, and a failure that prints as text with an empty stdout breaks
25
+ // the same ruling the parse failure above honours (Q11, ADR-070 ruling 4).
26
+ jsonIndentDefault(flags.pretty === true);
27
+ jsonFailMode(flags.json === true);
28
+
29
+ // No arguments at all is a usage error, not a silent help dump: an agent that
30
+ // ran the wrong thing should be told so rather than handed 40 lines of prose.
31
+ if (positional.length === 0 && !flags.help && !flags.version) {
32
+ process.exit(fail('no command given', EXIT.USAGE));
33
+ }
34
+
35
+ const { dispatch } = await import('./commands/index.mjs');
36
+ const code = (await dispatch(argv)) ?? EXIT.OK;
37
+ // Assigned, not forced: a large document on stdout (a `--json --diff` over a
38
+ // skill-sized body exceeds the 64 KiB pipe buffer) is still draining when the
39
+ // command resolves, and `process.exit()` cuts it — exit 0 with half a JSON
40
+ // document on stdout. Letting the loop empty first makes the exit honest
41
+ // (plan 0003 R50; the S10 contract asserts the document parses).
42
+ process.exitCode = code;
43
+ } catch (error) {
44
+ process.exit(fail(error?.message ?? String(error), EXIT.FAIL));
45
+ }
@@ -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';
@@ -27,9 +26,11 @@ import {
27
26
  rendered,
28
27
  writeAgentPlan,
29
28
  } from '../agents/render.mjs';
29
+ import { commandRoot } from '../repo-root.mjs';
30
+ import { readIfExists } from '../util/fs.mjs';
30
31
 
31
32
  export async function agentsCommand(sub, args, flags) {
32
- const root = flags.root ?? args[0] ?? process.cwd();
33
+ const root = commandRoot(flags, args);
33
34
  if (sub === 'install') return installAgents(root, flags);
34
35
  if (sub === 'status') return agentsStatus(root, flags);
35
36
  return fail(`unknown agents subcommand ${sub}`, EXIT.USAGE, `run: ${CLI} agents status`);
@@ -102,7 +103,6 @@ async function installAgents(root, flags) {
102
103
  '',
103
104
  `PLAN nothing written; apply with: ${CLI} agents install --yes`,
104
105
  ].join('\n'),
105
- { maxBytes: flags.maxBytes ?? 0 },
106
106
  );
107
107
  // After the report: the report says what, the diff shows it (R50).
108
108
  emitDiffs(diffs, flags);
@@ -135,7 +135,6 @@ async function installAgents(root, flags) {
135
135
  '',
136
136
  `WROTE ${written.length} agent file(s)`,
137
137
  ].join('\n'),
138
- { maxBytes: flags.maxBytes ?? 0 },
139
138
  );
140
139
  // After the report: the report says what, the diff shows it (R50).
141
140
  emitDiffs(diffs, flags);
@@ -193,14 +192,6 @@ async function agentsStatus(root, flags) {
193
192
  lines.push('', 'problems:');
194
193
  for (const p of problems) lines.push(` ${p}`);
195
194
  }
196
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
195
+ emit(lines.join('\n'));
197
196
  return problems.length === 0 ? EXIT.OK : EXIT.FAIL;
198
197
  }
199
-
200
- async function readIfExists(path) {
201
- try {
202
- return await readFile(path, 'utf8');
203
- } catch {
204
- return null;
205
- }
206
- }