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
@@ -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
 
@@ -44,6 +45,7 @@ import {
44
45
  installedIn,
45
46
  rendered,
46
47
  } from '../agenthooks/render.mjs';
48
+ import { commandRoot } from '../repo-root.mjs';
47
49
 
48
50
  export async function agentHookCommand(args, flags) {
49
51
  const event = args[0];
@@ -77,7 +79,7 @@ export async function agentHookCommand(args, flags) {
77
79
  // dynamic import that only this event opens.
78
80
  if (event === 'prompt-submit') {
79
81
  const { deliverPrompt } = await import('../agenthooks/prompt.mjs');
80
- const delivered = await deliverPrompt(row, payload);
82
+ const delivered = await deliverPrompt(row, payload, { root: flags.root });
81
83
  if (delivered.skipped) {
82
84
  note(`note ${id}: no packet delivered (${delivered.skipped})`);
83
85
  return EXIT.OK;
@@ -167,7 +169,7 @@ export async function agentHookCommand(args, flags) {
167
169
  }
168
170
 
169
171
  export async function agentHooksCommand(sub, args, flags) {
170
- const root = flags.root ?? args[0] ?? process.cwd();
172
+ const root = commandRoot(flags, args);
171
173
  if (sub === 'install') return installHooks(root, flags);
172
174
  if (sub === 'status') return hooksStatus(root, flags);
173
175
  return fail(`unknown agent-hooks subcommand ${sub}`, EXIT.USAGE, `run: ${CLI} agent-hooks status`);
@@ -178,18 +180,26 @@ export async function agentHooksCommand(sub, args, flags) {
178
180
  // `skipped` was an adapter the caller did not ask for (ADR-107).
179
181
  const OTHER_LABEL = { none: 'NO FILE ', other: 'NOT OURS', skipped: 'SKIPPED ' };
180
182
 
181
- async function installHooks(root, flags) {
182
- 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 }) {
183
195
  // `--harness` narrows the install to one reader (ADR-107). The id is validated
184
196
  // against the same table the loop reads, so a typo fails before anything is
185
197
  // planned; the remedy names the ids the way `agent-hook` does.
186
- const selected = flags.harness;
187
- if (selected !== undefined && adapter(selected) === null) {
188
- return fail(
189
- `unknown harness \`${selected}\``,
190
- EXIT.USAGE,
191
- `--harness <id>, one of: ${adapters().map((a) => a.id).join(', ')}`,
192
- );
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
+ };
193
203
  }
194
204
  // The delivery is a second entry in the same config, and a repository asks for it by
195
205
  // declaring a `brief` key rather than by having the entry. Read through a dynamic
@@ -201,7 +211,7 @@ async function installHooks(root, flags) {
201
211
  listPromotedLessons(root).catch(() => []),
202
212
  ]);
203
213
  const hasLessons = promotedLessons.length > 0;
204
- const rows = selected === undefined ? adapters() : adapters().filter((r) => r.id === selected);
214
+ const rows = harness === undefined ? adapters() : adapters().filter((r) => r.id === harness);
205
215
  const plan = [];
206
216
  for (const row of rows) {
207
217
  const path = hookPath(row.id);
@@ -231,7 +241,7 @@ async function installHooks(root, flags) {
231
241
  for (const r of hookRows()) {
232
242
  if (r.path === null) others.push({ id: r.id, path: null, state: 'none', note: r.note });
233
243
  else if (adapter(r.id) === null) others.push({ id: r.id, path: r.path, state: 'other', note: r.note });
234
- else if (selected !== undefined && r.id !== selected) {
244
+ else if (harness !== undefined && r.id !== harness) {
235
245
  others.push({ id: r.id, path: r.path, state: 'skipped', note: 'not requested; install without --harness to write every harness' });
236
246
  }
237
247
  }
@@ -239,13 +249,48 @@ async function installHooks(root, flags) {
239
249
  const refused = plan.filter((p) => p.error);
240
250
 
241
251
  // The shim every harness config points at: without it the entries above name a
242
- // binary no harness has on PATH (review 0003 Q21). Its remedy installs the
243
- // dependency the same way the git shims' does — a devDependency where the
244
- // repository has a package.json, the release binary where it does not.
252
+ // binary no harness has on PATH (review 0003 Q21).
245
253
  const shimText = renderAgentHookShim({ npm: (await readIfExists(join(root, 'package.json'))) !== null });
246
254
  const shimBefore = await readBefore(join(root, AGENT_HOOK_SHIM));
247
255
  const shimAction = shimBefore === null ? 'create' : shimBefore === shimText ? 'unchanged' : 'update';
248
- 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;
249
294
 
250
295
  // The `--diff` pairs, read before anything is written so the before side is
251
296
  // true in preview and in apply alike (R50).
@@ -255,7 +300,7 @@ async function installHooks(root, flags) {
255
300
  plan
256
301
  .filter((p) => p.action !== 'unchanged')
257
302
  .map(async (p) => ({ path: p.path, before: await readBefore(join(root, p.path)), after: p.text }))
258
- .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 }]),
259
304
  )
260
305
  : [];
261
306
 
@@ -267,7 +312,7 @@ async function installHooks(root, flags) {
267
312
  root,
268
313
  dryRun: true,
269
314
  plan: plan.map(({ text, ...rest }) => rest),
270
- shim: shimRow,
315
+ shim: shim.row,
271
316
  notWritten: others,
272
317
  filesWritten: false,
273
318
  ...diffField(diffs, flags),
@@ -278,7 +323,7 @@ async function installHooks(root, flags) {
278
323
  [
279
324
  `repo ${root}`,
280
325
  ...plan.map((p) => `${p.action.toUpperCase().padEnd(9)} ${p.id.padEnd(12)} ${p.path}${p.error ? ` (${p.error})` : ''}`),
281
- `${shimAction.toUpperCase().padEnd(9)} shim ${AGENT_HOOK_SHIM}`,
326
+ `${shim.action.toUpperCase().padEnd(9)} shim ${AGENT_HOOK_SHIM}`,
282
327
  ...others.map((o) => `${OTHER_LABEL[o.state]} ${o.id.padEnd(12)} ${o.path ?? '(no hooks file is documented)'}`),
283
328
  ...promptLine,
284
329
  '',
@@ -286,7 +331,6 @@ async function installHooks(root, flags) {
286
331
  ? `REFUSED ${refused.length} config file(s) could not be read; nothing written`
287
332
  : `PLAN nothing written; apply with: ${CLI} agent-hooks install --yes`,
288
333
  ].join('\n'),
289
- { maxBytes: flags.maxBytes ?? 0 },
290
334
  );
291
335
  // After the report: the report says what, the diff shows it (R50).
292
336
  emitDiffs(diffs, flags);
@@ -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,13 +375,12 @@ 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
  '',
351
382
  `WROTE ${written.length} hooks file(s)${shimWritten ? ' and the shim' : ''}; each one runs the ${AGENT_HOOK_SHIM} shim before the tool call`,
352
383
  ].join('\n'),
353
- { maxBytes: flags.maxBytes ?? 0 },
354
384
  );
355
385
  // After the report: the report says what, the diff shows it (R50).
356
386
  emitDiffs(diffs, flags);
@@ -448,7 +478,7 @@ async function hooksStatus(root, flags) {
448
478
  lines.push('', 'problems:');
449
479
  for (const p of problems) lines.push(` ${p}`);
450
480
  }
451
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
481
+ emit(lines.join('\n'));
452
482
  return problems.length === 0 ? EXIT.OK : EXIT.FAIL;
453
483
  }
454
484
 
@@ -5,11 +5,13 @@
5
5
  // default and write artifacts to `.simt/bench/` only when authorised with
6
6
  // `--yes` (and not `--dry-run`). `bench report` is read-only.
7
7
 
8
- import { resolve } from 'node:path';
8
+ import { dirname, resolve } from 'node:path';
9
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
9
10
 
10
11
  import { CLI } from '../name.mjs';
11
12
  import { EXIT, emit, emitJson, fail, isJson, writeMode } from '../output/index.mjs';
12
13
  import { DEFAULT_MINE_TARGET, mineTasks } from '../bench/mine.mjs';
14
+ import { buildCorpusDocument, verifyCorpusDocument, writeCorpusDocument } from '../measure/corpus.mjs';
13
15
  import { regradeRun, runBench } from '../bench/run.mjs';
14
16
  import { buildBenchReport } from '../bench/report.mjs';
15
17
  import { formatRecall, measureRecall } from '../index/map.mjs';
@@ -18,14 +20,33 @@ import { runBenchProbes } from '../bench/probes.mjs';
18
20
  import { selectRuleVariants } from '../bench/variants.mjs';
19
21
  import { criticBench } from '../review/critic.mjs';
20
22
  import { listFlag } from './skills.mjs';
23
+ import { commandRoot } from '../repo-root.mjs';
24
+
25
+ /**
26
+ * Refuse an authorised bench run that names no harness.
27
+ *
28
+ * The default harness is `gold`, which applies the reference patch instead of asking
29
+ * an agent: a `--yes` run with no harness measured the grader and stored the result as
30
+ * an arm's evidence (review 0003 Q49). A default cannot be mistaken for a measurement
31
+ * if there is no default.
32
+ */
33
+ function requireHarness(flags, command) {
34
+ if (typeof flags.harness === 'string' && flags.harness.length > 0) return null;
35
+ return fail(
36
+ `${command} --yes needs an explicit --harness`,
37
+ EXIT.USAGE,
38
+ `pass --harness <name>: an agent adapter such as \`--harness claude\`, or a calibration harness (\`gold\`, \`noop\`)`,
39
+ );
40
+ }
21
41
 
22
42
  export async function benchMineCommand(positional, flags) {
23
- const root = resolve(flags.root ?? positional[0] ?? process.cwd());
43
+ const root = resolve(commandRoot(flags, positional));
24
44
  const { dryRun, apply } = writeMode(flags);
25
45
 
26
46
  const result = await mineTasks(root, {
27
47
  apply,
28
48
  target: DEFAULT_MINE_TARGET,
49
+ replace: flags.replace === true,
29
50
  });
30
51
 
31
52
  if (!result.ok) {
@@ -64,7 +85,7 @@ export async function benchMineCommand(positional, flags) {
64
85
  for (const task of result.tasks) {
65
86
  const subject = task.prompt.split('\n')[0];
66
87
  lines.push(
67
- `task ${task.id} f2p=${task.f2p.length} p2p=${task.f2p.length} files=${task.gold.files} +${task.gold.linesAdded}/-${task.gold.linesRemoved} ${subject}`,
88
+ `task ${task.id} f2p=${task.f2p.length} p2p=${task.p2p.length} files=${task.gold.files} +${task.gold.linesAdded}/-${task.gold.linesRemoved} ${subject}`,
68
89
  );
69
90
  }
70
91
  } else {
@@ -75,12 +96,12 @@ export async function benchMineCommand(positional, flags) {
75
96
  lines.push(`remedy run: ${CLI} bench mine --yes`);
76
97
  }
77
98
 
78
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
99
+ emit(lines.join('\n'));
79
100
  return EXIT.OK;
80
101
  }
81
102
 
82
103
  export async function benchRunCommand(positional, flags) {
83
- const root = resolve(flags.root ?? positional[0] ?? process.cwd());
104
+ const root = resolve(commandRoot(flags, positional));
84
105
  const { dryRun, apply } = writeMode(flags);
85
106
 
86
107
  if (flags.regrade !== undefined) {
@@ -123,10 +144,15 @@ export async function benchRunCommand(positional, flags) {
123
144
  `run ${run.runId}/${run.task} arm=${run.arm} resolved=${run.resolved} gates=${run.gatesPassed} reproduced=${run.reproduced}`,
124
145
  );
125
146
  }
126
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
147
+ emit(lines.join('\n'));
127
148
  return code;
128
149
  }
129
150
 
151
+ if (apply) {
152
+ const missing = requireHarness(flags, 'bench run');
153
+ if (missing !== null) return missing;
154
+ }
155
+
130
156
  const arms = listFlag(flags.arm);
131
157
  const tasks = listFlag(flags.task);
132
158
  const harness = flags.harness ?? 'gold';
@@ -189,7 +215,7 @@ export async function benchRunCommand(positional, flags) {
189
215
  lines.push(`remedy run: ${CLI} bench run --yes`);
190
216
  }
191
217
 
192
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
218
+ emit(lines.join('\n'));
193
219
  return EXIT.OK;
194
220
  }
195
221
 
@@ -207,7 +233,7 @@ export async function benchReportCommand(positional, flags) {
207
233
  );
208
234
  }
209
235
 
210
- const root = resolve(flags.root ?? pos[0] ?? process.cwd());
236
+ const root = resolve(commandRoot(flags, pos));
211
237
  const arms = listFlag(flags.arm);
212
238
 
213
239
  let report;
@@ -243,7 +269,7 @@ export async function benchReportCommand(positional, flags) {
243
269
  return EXIT.OK;
244
270
  }
245
271
 
246
- emit(`${report.markdown}\n\n### Map\n\n${formatRecall(mapRecall)}`, { maxBytes: flags.maxBytes ?? 0 });
272
+ emit(`${report.markdown}\n\n### Map\n\n${formatRecall(mapRecall)}`);
247
273
  return EXIT.OK;
248
274
  }
249
275
 
@@ -256,8 +282,12 @@ async function recallOrUnmeasured(root) {
256
282
  }
257
283
 
258
284
  export async function benchAblateCommand(positional, flags) {
259
- const root = resolve(flags.root ?? positional[0] ?? process.cwd());
285
+ const root = resolve(commandRoot(flags, positional));
260
286
  const { dryRun, apply } = writeMode(flags);
287
+ if (apply) {
288
+ const missing = requireHarness(flags, 'bench ablate');
289
+ if (missing !== null) return missing;
290
+ }
261
291
  const harness = flags.harness ?? 'gold';
262
292
  const model = flags.model;
263
293
  const reps = flags.reps ?? 1;
@@ -302,13 +332,18 @@ export async function benchAblateCommand(positional, flags) {
302
332
  lines.push(`remedy run: ${CLI} bench ablate --yes`);
303
333
  }
304
334
 
305
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
335
+ emit(lines.join('\n'));
306
336
  return EXIT.OK;
307
337
  }
308
338
 
309
339
  export async function benchProbesCommand(positional, flags) {
310
- const root = resolve(flags.root ?? positional[0] ?? process.cwd());
340
+ const root = resolve(commandRoot(flags, positional));
311
341
  const { dryRun, apply } = writeMode(flags);
342
+ if (apply) {
343
+ const missing = requireHarness(flags, 'bench probes');
344
+ if (missing !== null) return missing;
345
+ }
346
+
312
347
  const arms = listFlag(flags.arm);
313
348
  const probes = listFlag(flags.task);
314
349
  const harness = flags.harness ?? 'gold';
@@ -353,13 +388,17 @@ export async function benchProbesCommand(positional, flags) {
353
388
  lines.push(`remedy run: ${CLI} bench probes --yes`);
354
389
  }
355
390
 
356
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
391
+ emit(lines.join('\n'));
357
392
  return EXIT.OK;
358
393
  }
359
394
 
360
395
  export async function benchVariantsCommand(positional, flags) {
361
- const root = resolve(flags.root ?? positional[0] ?? process.cwd());
396
+ const root = resolve(commandRoot(flags, positional));
362
397
  const { dryRun, apply } = writeMode(flags);
398
+ if (apply) {
399
+ const missing = requireHarness(flags, 'bench variants');
400
+ if (missing !== null) return missing;
401
+ }
363
402
  let result;
364
403
  try {
365
404
  result = await selectRuleVariants(root, {
@@ -390,7 +429,7 @@ export async function benchVariantsCommand(positional, flags) {
390
429
  ),
391
430
  ];
392
431
  if (!result.applied) lines.push(`remedy run: ${CLI} bench variants --yes`);
393
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
432
+ emit(lines.join('\n'));
394
433
  return EXIT.OK;
395
434
  }
396
435
 
@@ -405,13 +444,13 @@ export async function benchVariantsCommand(positional, flags) {
405
444
  // the row says precision >= 0.8 on >= 30 labelled diffs promotes it to a gate,
406
445
  // and until a bench here shows that, every report calls the critic advisory.
407
446
  export async function benchCriticCommand(positional, flags, commands = []) {
408
- const root = resolve(flags.root ?? positional[0] ?? process.cwd());
447
+ const root = resolve(commandRoot(flags, positional));
409
448
  const labelsPath = flags.labels ?? '.simt/local/critic-labels.jsonl';
410
449
  const result = await criticBench(root, { labelsPath, flags, commands });
411
450
  if (typeof result === 'number') return result;
412
451
 
413
452
  if (isJson(flags)) {
414
- emitJson(result, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
453
+ emitJson(result);
415
454
  return EXIT.OK;
416
455
  }
417
456
  const lines = [
@@ -424,6 +463,140 @@ export async function benchCriticCommand(positional, flags, commands = []) {
424
463
  ];
425
464
  if (result.labelsNote) lines.push(`labels ${result.labelsNote}`);
426
465
  for (const error of result.errors) lines.push(`error ${error}`);
427
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
466
+ emit(lines.join('\n'));
467
+ return EXIT.OK;
468
+ }
469
+
470
+ // `simt bench export` / `simt bench import` (review 0003 Q48).
471
+ //
472
+ // The vault is keyed on the repository's root commit and lives outside every
473
+ // checkout. That is what makes it safe as a hidden verifier, and it is also
474
+ // why a clone cannot read the corpus mined on another machine: the committed
475
+ // tasks failed with ENOENT from every other checkout. A sealed document
476
+ // carries the descriptors and both patches for every task in one file, so an
477
+ // export on one machine and an import on another reproduce the corpus
478
+ // exactly, and the seal is checked before a single byte is trusted.
479
+
480
+ export async function benchExportCommand(positional, flags) {
481
+ const out = positional[0];
482
+ if (!out) {
483
+ return fail(
484
+ 'bench export needs a target file: bench export <corpus.json>',
485
+ EXIT.USAGE,
486
+ `export the sealed corpus: ${CLI} bench export corpus.json --yes`,
487
+ );
488
+ }
489
+ const root = resolve(commandRoot(flags));
490
+ const { dryRun, apply } = writeMode(flags);
491
+
492
+ let doc;
493
+ try {
494
+ doc = await buildCorpusDocument(root);
495
+ } catch (error) {
496
+ return fail(
497
+ `${root}: ${error.message ?? error}`,
498
+ EXIT.CONFIG,
499
+ `mine tasks first: ${CLI} bench mine --yes`,
500
+ );
501
+ }
502
+
503
+ if (doc.taskCount === 0) {
504
+ return fail(
505
+ `${root}: no mined tasks to export`,
506
+ EXIT.USAGE,
507
+ `mine tasks first: ${CLI} bench mine --yes`,
508
+ );
509
+ }
510
+
511
+ if (apply) {
512
+ const absOut = resolve(out);
513
+ await mkdir(dirname(absOut), { recursive: true });
514
+ await writeFile(absOut, `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
515
+ }
516
+
517
+ if (isJson(flags)) {
518
+ emitJson({
519
+ ok: true,
520
+ command: 'bench export',
521
+ root,
522
+ dryRun,
523
+ applied: apply,
524
+ out: resolve(out),
525
+ taskCount: doc.taskCount,
526
+ seal: doc.seal,
527
+ });
528
+ return EXIT.OK;
529
+ }
530
+
531
+ const lines = [
532
+ `bench export ${resolve(out)}`,
533
+ `mode ${apply ? 'applied' : dryRun ? 'dry-run (writes nothing)' : 'preview (pass --yes to write)'}`,
534
+ `tasks ${doc.taskCount}`,
535
+ `seal ${doc.seal}`,
536
+ ];
537
+ if (!apply) lines.push(`remedy run: ${CLI} bench export ${out} --yes`);
538
+ emit(lines.join('\n'));
539
+ return EXIT.OK;
540
+ }
541
+
542
+ export async function benchImportCommand(positional, flags) {
543
+ const file = positional[0];
544
+ if (!file) {
545
+ return fail(
546
+ 'bench import needs a corpus file: bench import <corpus.json>',
547
+ EXIT.USAGE,
548
+ `import a sealed corpus: ${CLI} bench import corpus.json --yes`,
549
+ );
550
+ }
551
+ const root = resolve(commandRoot(flags));
552
+ const { dryRun, apply } = writeMode(flags);
553
+
554
+ let doc;
555
+ try {
556
+ doc = JSON.parse(await readFile(file, 'utf8'));
557
+ } catch (error) {
558
+ return fail(
559
+ `${file}: ${error.message ?? error}`,
560
+ EXIT.USAGE,
561
+ `export one with: ${CLI} bench export ${file} --yes`,
562
+ );
563
+ }
564
+
565
+ const problem = verifyCorpusDocument(doc);
566
+ if (problem !== null) {
567
+ return fail(
568
+ `${file}: ${problem}`,
569
+ EXIT.CONFIG,
570
+ `export a fresh corpus: ${CLI} bench export ${file} --yes`,
571
+ );
572
+ }
573
+
574
+ const result = await writeCorpusDocument(root, doc, { apply });
575
+
576
+ if (isJson(flags)) {
577
+ emitJson({
578
+ ok: true,
579
+ command: 'bench import',
580
+ root,
581
+ file: resolve(file),
582
+ dryRun,
583
+ applied: result.applied,
584
+ taskCount: result.tasks,
585
+ tasksDir: result.tasksDir,
586
+ vaultRoot: result.vaultRoot,
587
+ seal: doc.seal,
588
+ });
589
+ return EXIT.OK;
590
+ }
591
+
592
+ const lines = [
593
+ `bench import ${resolve(file)}`,
594
+ `mode ${apply ? 'applied' : dryRun ? 'dry-run (writes nothing)' : 'preview (pass --yes to write)'}`,
595
+ `imported ${result.tasks} task(s) seal=${doc.seal}`,
596
+ `tasks ${result.tasksDir}`,
597
+ `vault ${result.vaultRoot}`,
598
+ ];
599
+ if (!apply) lines.push(`remedy run: ${CLI} bench import ${file} --yes`);
600
+ emit(lines.join('\n'));
428
601
  return EXIT.OK;
429
602
  }
@@ -19,8 +19,9 @@ import { CLI } from '../name.mjs';
19
19
  import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
20
20
  import { DEFAULT_BRIEF_BYTES, explainPacket } from '../brief/packet.mjs';
21
21
  import { assembleBrief } from '../brief/assemble.mjs';
22
+ import { commandRoot } from '../repo-root.mjs';
22
23
 
23
- export async function briefCommand(args, flags) {
24
+ export async function briefCommand(args, flags, commands = []) {
24
25
  if (args.length > 1) {
25
26
  return fail(`brief takes one task, not ${args.length}`, EXIT.USAGE, `run: ${CLI} brief "<task>"`);
26
27
  }
@@ -33,11 +34,12 @@ export async function briefCommand(args, flags) {
33
34
  }
34
35
 
35
36
  const result = await assembleBrief({
36
- root: flags.root ?? process.cwd(),
37
+ root: commandRoot(flags),
37
38
  task: String(args[0] ?? flags.task ?? ''),
38
39
  paths: flags.paths === undefined ? null : flags.paths,
39
40
  budget,
40
41
  flags,
42
+ commands,
41
43
  });
42
44
  if (result.error) return result.error;
43
45
  // Unreachable from here — only a delivered brief asks for a cold index to be reported
@@ -67,12 +69,11 @@ export async function briefCommand(args, flags) {
67
69
  explain: flags.explain === true ? explainPacket(items, { cut }) : undefined,
68
70
  text: packed.text,
69
71
  },
70
- { pretty: flags.pretty === true, maxBytes: Number(flags.maxBytes ?? 0) || 0 },
71
72
  );
72
73
  return EXIT.OK;
73
74
  }
74
75
 
75
76
  const body = flags.explain === true ? [packed.text, '', 'EXPLAIN', ...explainPacket(items, { cut })] : [packed.text];
76
- emit(body.join('\n'), { maxBytes: Number(flags.maxBytes ?? 0) || 0 });
77
+ emit(body.join('\n'));
77
78
  return EXIT.OK;
78
79
  }
@@ -18,15 +18,15 @@
18
18
  // commands owns which file would be learning about ownership twice to learn nothing
19
19
  // new, and the next .github file would arrive with a third command to remember.
20
20
 
21
- import { mkdir, readFile } from 'node:fs/promises';
22
- import { dirname } from 'node:path';
23
- import { commitJournal, journaledWriteFile } from '../journal.mjs';
21
+ import { readFile } from 'node:fs/promises';
22
+ import { applyPlan, change } from '../write/plan.mjs';
24
23
  import { diffField, emitDiffs, readBefore } from '../diff.mjs';
25
24
 
26
25
  import { EXIT, emit, emitJson, isJson, note, writeMode } from '../output/index.mjs';
27
26
  import { planWorkflow, renderWorkflow, WORKFLOW_FILE } from '../ci/workflow.mjs';
28
27
  import { planDependabot, renderDependabot, DEPENDABOT_FILE } from '../ci/dependabot.mjs';
29
28
  import { CLI } from '../name.mjs';
29
+ import { commandRoot } from '../repo-root.mjs';
30
30
 
31
31
  /**
32
32
  * The two plans, with the render and the plan-time data each needs kept attached.
@@ -38,12 +38,12 @@ import { CLI } from '../name.mjs';
38
38
  function targets() {
39
39
  return [
40
40
  { key: 'workflow', file: WORKFLOW_FILE, plan: planWorkflow, render: () => renderWorkflow({ own: true }) },
41
- { key: 'dependabot', file: DEPENDABOT_FILE, plan: planDependabot, render: (plan) => renderDependabot(plan.facts ?? [], { stack: plan.stack ?? [], own: true }) },
41
+ { key: 'dependabot', file: DEPENDABOT_FILE, plan: planDependabot, render: (plan) => renderDependabot(plan.facts ?? [], { stack: plan.stack ?? [], own: true, root: plan.root }) },
42
42
  ];
43
43
  }
44
44
 
45
45
  export async function ciCommand(positional, flags) {
46
- const root = flags.root ?? positional[0] ?? process.cwd();
46
+ const root = commandRoot(flags, positional);
47
47
  const mode = writeMode(flags);
48
48
  const checkOnly = flags.check === true;
49
49
 
@@ -152,19 +152,18 @@ export async function ciCommand(positional, flags) {
152
152
  return EXIT.OK;
153
153
  }
154
154
 
155
- // Journaled so `undo` restores this run (R49).
156
- const journal = [];
155
+ // Journaled so `undo` restores this run (R49). The renderer takes the plan because
156
+ // detection-driven content is a function of this repo's facts: a dependabot config
157
+ // rendered without them would watch nothing, which is the same silent-nothing the
158
+ // detection ruling exists to stop.
159
+ const changes = [];
157
160
  for (const { key, plan, render } of targets()) {
158
161
  const p = plans.find((x) => x.key === key);
159
162
  if (p.action === 'unchanged') continue;
160
- await mkdir(dirname(p.path), { recursive: true });
161
- // The renderer takes the plan because detection-driven content is a function of
162
- // this repo's facts: a dependabot config rendered without them would watch
163
- // nothing, which is the same silent-nothing the detection ruling exists to stop.
164
- await journaledWriteFile(root, journal, p.path, render(p));
165
- note(`wrote ${p.file} (${p.action}; the template and this copy now agree)`);
163
+ changes.push(change({ path: p.path, before: await readBefore(p.path), after: render(p), owner: 'ci', reason: 'the template and this copy now agree' }));
166
164
  }
167
- await commitJournal(root, 'ci', journal);
165
+ await applyPlan(root, changes, { mode, label: 'ci' });
166
+ for (const c of changes) note(`wrote ${c.path.replace(`${root}/`, '')} (${c.action}; ${c.reason})`);
168
167
  if (isJson(flags)) {
169
168
  emitJson(
170
169
  {