simt-dev 0.6.2 → 0.7.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 (170) hide show
  1. package/CHANGELOG.md +83 -1
  2. package/README.md +46 -17
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -6
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  7. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  8. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  9. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  10. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  11. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  12. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  13. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  14. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  15. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  16. package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
  17. package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
  18. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  19. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  20. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  21. package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
  22. package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
  23. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  24. package/docs/decisions/README.md +53 -0
  25. package/package.json +4 -2
  26. package/rules/core.md +2 -2
  27. package/rules/mdc/preview-postflight.mdc +5 -6
  28. package/rules/mdc/test-gate.mdc +2 -0
  29. package/rules/stacks/android.md +10 -11
  30. package/rules/stacks/cloudflare-workers.md +1 -1
  31. package/rules/stacks/connectiq.md +2 -2
  32. package/rules/stacks/container.md +2 -0
  33. package/rules/stacks/dart.md +1 -1
  34. package/rules/stacks/deno.md +1 -1
  35. package/rules/stacks/dotnet.md +1 -1
  36. package/rules/stacks/elixir.md +1 -1
  37. package/rules/stacks/go.md +1 -1
  38. package/rules/stacks/jvm.md +4 -3
  39. package/rules/stacks/node.md +2 -2
  40. package/rules/stacks/python.md +7 -6
  41. package/rules/stacks/react-native.md +3 -5
  42. package/rules/stacks/react-web.md +9 -9
  43. package/rules/stacks/ruby.md +1 -1
  44. package/rules/stacks/rust.md +1 -1
  45. package/rules/stacks/swift.md +1 -1
  46. package/rules/stacks/typescript.md +2 -2
  47. package/rules/stacks/uv.md +3 -2
  48. package/skills/simt-commands/SKILL.md +70 -0
  49. package/src/advice/advice.mjs +7 -3
  50. package/src/agenthooks/decide.mjs +238 -15
  51. package/src/agenthooks/opencode.mjs +72 -0
  52. package/src/agenthooks/policy.mjs +81 -5
  53. package/src/agenthooks/prompt.mjs +6 -2
  54. package/src/agenthooks/render.mjs +186 -19
  55. package/src/agenthooks/shell.mjs +523 -0
  56. package/src/agenthooks/shim.mjs +55 -0
  57. package/src/agents/render.mjs +277 -0
  58. package/src/argv.mjs +62 -9
  59. package/src/bench/ablate.mjs +31 -14
  60. package/src/bench/mine.mjs +48 -1
  61. package/src/bench/probes.mjs +5 -6
  62. package/src/bench/report.mjs +243 -80
  63. package/src/bench/run.mjs +12 -11
  64. package/src/bench/variants.mjs +112 -40
  65. package/src/brief/assemble.mjs +6 -0
  66. package/src/ci/workflow.mjs +16 -7
  67. package/src/commands/agent.mjs +206 -0
  68. package/src/commands/agenthook.mjs +133 -17
  69. package/src/commands/bench.mjs +2 -1
  70. package/src/commands/ci.mjs +28 -3
  71. package/src/commands/compare.mjs +8 -7
  72. package/src/commands/completions.mjs +27 -0
  73. package/src/commands/decide.mjs +20 -6
  74. package/src/commands/doctor.mjs +367 -0
  75. package/src/commands/evidence.mjs +13 -3
  76. package/src/commands/explain.mjs +52 -0
  77. package/src/commands/gate.mjs +85 -19
  78. package/src/commands/graph.mjs +18 -5
  79. package/src/commands/help.mjs +96 -26
  80. package/src/commands/hooks.mjs +258 -67
  81. package/src/commands/ignore.mjs +152 -0
  82. package/src/commands/impact.mjs +4 -2
  83. package/src/commands/index.mjs +69 -102
  84. package/src/commands/init.mjs +239 -37
  85. package/src/commands/lease.mjs +304 -0
  86. package/src/commands/lessons.mjs +77 -9
  87. package/src/commands/map.mjs +33 -18
  88. package/src/commands/mcp.mjs +2 -2
  89. package/src/commands/pack.mjs +9 -0
  90. package/src/commands/plan.mjs +13 -1
  91. package/src/commands/plugin.mjs +17 -2
  92. package/src/commands/prbody.mjs +45 -0
  93. package/src/commands/repoindex.mjs +4 -0
  94. package/src/commands/rules.mjs +219 -105
  95. package/src/commands/shared.mjs +36 -5
  96. package/src/commands/skills.mjs +329 -206
  97. package/src/commands/skillscompile.mjs +8 -7
  98. package/src/commands/skillsinstall.mjs +112 -30
  99. package/src/commands/slice.mjs +712 -0
  100. package/src/commands/status.mjs +62 -0
  101. package/src/commands/sync.mjs +146 -36
  102. package/src/commands/tests.mjs +5 -2
  103. package/src/commands/undo.mjs +114 -0
  104. package/src/commands/upgrade.mjs +103 -9
  105. package/src/commands/why.mjs +10 -4
  106. package/src/compare/hooksstate.mjs +23 -1
  107. package/src/compare/learn.mjs +82 -57
  108. package/src/compare/parallel.mjs +50 -0
  109. package/src/compare/row.mjs +26 -1
  110. package/src/completions.mjs +196 -0
  111. package/src/decisions/dir.mjs +84 -0
  112. package/src/decisions/index.mjs +109 -11
  113. package/src/diff.mjs +254 -0
  114. package/src/distill/distill.mjs +3 -1
  115. package/src/ecosystem/node-runner.mjs +144 -0
  116. package/src/explain.mjs +270 -0
  117. package/src/federation/config.mjs +44 -0
  118. package/src/graph/load.mjs +52 -24
  119. package/src/hooks/capability.mjs +14 -1
  120. package/src/hooks/dispatcher.mjs +441 -9
  121. package/src/hooks/gates.mjs +14 -3
  122. package/src/hooks/resolve.mjs +23 -3
  123. package/src/hooks/shim.mjs +12 -7
  124. package/src/identity.mjs +67 -14
  125. package/src/ignore.mjs +133 -0
  126. package/src/index/extract.mjs +192 -11
  127. package/src/index/persist.mjs +372 -55
  128. package/src/index/store.mjs +263 -60
  129. package/src/index/tests.mjs +6 -35
  130. package/src/journal.mjs +545 -0
  131. package/src/kernel/card.mjs +37 -12
  132. package/src/kernel/checks.mjs +44 -9
  133. package/src/kernel/variants.mjs +27 -16
  134. package/src/lease.mjs +195 -0
  135. package/src/ledger/index.mjs +91 -25
  136. package/src/mcp/resources.mjs +5 -3
  137. package/src/mcp/server.mjs +86 -13
  138. package/src/mcp/tools.mjs +340 -264
  139. package/src/memory/decide.mjs +98 -38
  140. package/src/memory/lessons.mjs +180 -33
  141. package/src/name.mjs +9 -0
  142. package/src/output/index.mjs +82 -12
  143. package/src/package-version.mjs +49 -1
  144. package/src/packs/manage.mjs +13 -4
  145. package/src/plan/index.mjs +37 -8
  146. package/src/plan/template.mjs +27 -5
  147. package/src/plugins.mjs +33 -8
  148. package/src/prbody.mjs +253 -0
  149. package/src/projection/baselines.mjs +1 -1
  150. package/src/projection/disclosure.mjs +2 -2
  151. package/src/projection/loaders.mjs +41 -1
  152. package/src/projection/lock.mjs +21 -14
  153. package/src/projection/manifest.mjs +82 -21
  154. package/src/projection/markers.mjs +43 -3
  155. package/src/projection/mdc.mjs +34 -7
  156. package/src/projection/project.mjs +162 -18
  157. package/src/projection/rules.mjs +54 -3
  158. package/src/projection/shared.mjs +4 -1
  159. package/src/projection/surfaces.mjs +9 -1
  160. package/src/repo-root.mjs +69 -0
  161. package/src/resolve/body.mjs +4 -0
  162. package/src/resolve/fingerprint.mjs +5 -0
  163. package/src/resolve/local.mjs +58 -0
  164. package/src/review/critic.mjs +4 -2
  165. package/src/secrets.mjs +374 -0
  166. package/src/state.mjs +76 -0
  167. package/src/status.mjs +157 -0
  168. package/src/toolwords.mjs +5 -0
  169. package/src/tsconfig.mjs +39 -99
  170. package/templates/plan/tracker.json +1 -1
@@ -22,13 +22,18 @@
22
22
  // tool call. Failures of this command exit 1, which a harness reports as a failed hook,
23
23
  // and that is the right blast radius for a hook that is broken rather than refusing.
24
24
 
25
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
26
- import { dirname, join } from 'node:path';
25
+ import { existsSync } from 'node:fs';
26
+ import { mkdir, readFile } from 'node:fs/promises';
27
+ import { dirname, isAbsolute, join } from 'node:path';
28
+ import { commitJournal, journaledWriteFile } from '../journal.mjs';
29
+ import { AGENT_HOOK_SHIM, renderAgentHookShim } from '../agenthooks/shim.mjs';
30
+ import { diffField, emitDiffs, readBefore } from '../diff.mjs';
27
31
 
28
32
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
29
- import { CLI } from '../name.mjs';
30
- import { ALLOW_ENV, EVENTS, POLICY_IDS } from '../agenthooks/policy.mjs';
33
+ import { CLI, MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
34
+ import { ALLOW_ENV, EVENTS, POLICY_IDS, SESSION_ENV } from '../agenthooks/policy.mjs';
31
35
  import { decide, factsFor } from '../agenthooks/decide.mjs';
36
+ import { repoRoot } from '../repo-root.mjs';
32
37
  import {
33
38
  adapter,
34
39
  adapters,
@@ -95,10 +100,33 @@ export async function agentHookCommand(args, flags) {
95
100
  // `--root` outranks the payload: a packaged hook runs from the plugin directory and the
96
101
  // harness's environment variable names the project, so a payload cwd that is the plugin's
97
102
  // own working directory must not win. The repository-install path passes no flag, and the
98
- // payload's cwd is what the harness reports.
99
- const { root, command, writes } = factsFrom(row, payload, { root: flags.root });
100
- const facts = await factsFor(root, { command, writes });
101
- const decision = decide({ event, facts, env: process.env });
103
+ // payload's cwd is what the harness reports. The answer is then resolved to the
104
+ // repository's top level: a harness reports the directory it happens to be in, and an
105
+ // agent works from subdirectories, so every repository-relative fact (the lease file,
106
+ // `.gitmodules`) must resolve against the root git itself uses, not the cwd (review
107
+ // 0003 Q16).
108
+ const { root: payloadRoot, command, writes, session: payloadSession } = factsFrom(row, payload, { root: flags.root });
109
+ const root = repoRoot(payloadRoot);
110
+ // A relative write path is relative to the directory the harness is in, not to the
111
+ // repository root the facts resolve against: an agent editing `keep.mjs` from `src/`
112
+ // means `src/keep.mjs`, and a path joined to the wrong base is a path the lease file
113
+ // will never meet (review 0003 Q16).
114
+ // Writes are `{path, ...edit}` rows (Q17): the path resolves against the harness
115
+ // cwd, the edit detail travels untouched so the region check sees the old_string.
116
+ const writesAbsolute = writes.map((w) => {
117
+ const path = typeof w === 'string' ? w : w?.path;
118
+ const abs = typeof path === 'string' && isAbsolute(path) ? path : join(payloadRoot, path);
119
+ return typeof w === 'string' ? abs : { ...w, path: abs };
120
+ });
121
+ const facts = await factsFor(root, { command, writes: writesAbsolute });
122
+ // Harness hook processes rarely carry SIMT_SESSION, so the payload's own
123
+ // session id is the fallback: without it an agent is blocked by its own
124
+ // lease (review 0003 Q20). The environment still wins when it names one.
125
+ const session =
126
+ typeof process.env[SESSION_ENV] === 'string' && process.env[SESSION_ENV] !== ''
127
+ ? process.env[SESSION_ENV]
128
+ : (payloadSession ?? '');
129
+ const decision = decide({ event, facts, env: { ...process.env, [SESSION_ENV]: session } });
102
130
 
103
131
  // Both notes are about a conclusion the author is about to draw: a lift that did
104
132
  // nothing because of a typo, and a lift that is the reason the command they expected to
@@ -113,7 +141,11 @@ export async function agentHookCommand(args, flags) {
113
141
  const json = decisionJson(row, decision);
114
142
  if (json === null) return EXIT.OK;
115
143
 
116
- if (root) {
144
+ // A denial is recorded only for a repository that adopted the tool: hook
145
+ // denials in a never-adopted repo are noise about someone else's policy, and
146
+ // that is how an unadopted checkout accumulated 23 candidates nobody would
147
+ // ever promote (review 0003 Q36). The manifest is the adoption mark.
148
+ if (root && (existsSync(join(root, MANIFEST_FILE)) || existsSync(join(root, MANIFEST_LEGACY)))) {
117
149
  try {
118
150
  const { recordHookDenial } = await import('../memory/lessons.mjs');
119
151
  await recordHookDenial(root, {
@@ -141,8 +173,24 @@ export async function agentHooksCommand(sub, args, flags) {
141
173
  return fail(`unknown agent-hooks subcommand ${sub}`, EXIT.USAGE, `run: ${CLI} agent-hooks status`);
142
174
  }
143
175
 
176
+ // The label for a reader the run did not write, by the state that names why:
177
+ // `none` documents no hooks file, `other` is a reader this tool does not write,
178
+ // `skipped` was an adapter the caller did not ask for (ADR-107).
179
+ const OTHER_LABEL = { none: 'NO FILE ', other: 'NOT OURS', skipped: 'SKIPPED ' };
180
+
144
181
  async function installHooks(root, flags) {
145
182
  const mode = writeMode(flags);
183
+ // `--harness` narrows the install to one reader (ADR-107). The id is validated
184
+ // against the same table the loop reads, so a typo fails before anything is
185
+ // 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
+ );
193
+ }
146
194
  // The delivery is a second entry in the same config, and a repository asks for it by
147
195
  // declaring a `brief` key rather than by having the entry. Read through a dynamic
148
196
  // import for the same reason the hook delivers through one.
@@ -153,8 +201,9 @@ async function installHooks(root, flags) {
153
201
  listPromotedLessons(root).catch(() => []),
154
202
  ]);
155
203
  const hasLessons = promotedLessons.length > 0;
204
+ const rows = selected === undefined ? adapters() : adapters().filter((r) => r.id === selected);
156
205
  const plan = [];
157
- for (const row of adapters()) {
206
+ for (const row of rows) {
158
207
  const path = hookPath(row.id);
159
208
  const existing = await readIfExists(join(root, path));
160
209
  const result = rendered(row, existing, { prompt: optIn.optedIn, post: hasLessons });
@@ -176,15 +225,40 @@ async function installHooks(root, flags) {
176
225
 
177
226
  // The readers this tool does not write an entry for, reported rather than skipped in
178
227
  // silence: a command that writes four files and says nothing about the fifth harness
179
- // in the repository reads as "that one is covered too".
228
+ // in the repository reads as "that one is covered too". A narrowed install adds a
229
+ // third state: an adapter that is this tool's but was not requested (ADR-107).
180
230
  const others = [];
181
231
  for (const r of hookRows()) {
182
232
  if (r.path === null) others.push({ id: r.id, path: null, state: 'none', note: r.note });
183
233
  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) {
235
+ others.push({ id: r.id, path: r.path, state: 'skipped', note: 'not requested; install without --harness to write every harness' });
236
+ }
184
237
  }
185
238
 
186
239
  const refused = plan.filter((p) => p.error);
187
240
 
241
+ // 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.
245
+ const shimText = renderAgentHookShim({ npm: (await readIfExists(join(root, 'package.json'))) !== null });
246
+ const shimBefore = await readBefore(join(root, AGENT_HOOK_SHIM));
247
+ const shimAction = shimBefore === null ? 'create' : shimBefore === shimText ? 'unchanged' : 'update';
248
+ const shimRow = { id: 'shim', path: AGENT_HOOK_SHIM, action: shimAction };
249
+
250
+ // The `--diff` pairs, read before anything is written so the before side is
251
+ // true in preview and in apply alike (R50).
252
+ const diffs =
253
+ flags.diff === true
254
+ ? await Promise.all(
255
+ plan
256
+ .filter((p) => p.action !== 'unchanged')
257
+ .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 }]),
259
+ )
260
+ : [];
261
+
188
262
  if (!mode.apply) {
189
263
  if (isJson(flags)) {
190
264
  emitJson({
@@ -193,8 +267,10 @@ async function installHooks(root, flags) {
193
267
  root,
194
268
  dryRun: true,
195
269
  plan: plan.map(({ text, ...rest }) => rest),
270
+ shim: shimRow,
196
271
  notWritten: others,
197
272
  filesWritten: false,
273
+ ...diffField(diffs, flags),
198
274
  });
199
275
  return refused.length > 0 ? EXIT.FAIL : EXIT.OK;
200
276
  }
@@ -202,15 +278,18 @@ async function installHooks(root, flags) {
202
278
  [
203
279
  `repo ${root}`,
204
280
  ...plan.map((p) => `${p.action.toUpperCase().padEnd(9)} ${p.id.padEnd(12)} ${p.path}${p.error ? ` (${p.error})` : ''}`),
205
- ...others.map((o) => `${o.state === 'none' ? 'NO FILE ' : 'NOT OURS'} ${o.id.padEnd(12)} ${o.path ?? '(no hooks file is documented)'}`),
281
+ `${shimAction.toUpperCase().padEnd(9)} shim ${AGENT_HOOK_SHIM}`,
282
+ ...others.map((o) => `${OTHER_LABEL[o.state]} ${o.id.padEnd(12)} ${o.path ?? '(no hooks file is documented)'}`),
206
283
  ...promptLine,
207
284
  '',
208
285
  refused.length > 0
209
286
  ? `REFUSED ${refused.length} config file(s) could not be read; nothing written`
210
- : `PLAN nothing written; apply with: ${CLI} agent-hooks install --yes`,
287
+ : `PLAN nothing written; apply with: ${CLI} agent-hooks install --yes`,
211
288
  ].join('\n'),
212
289
  { maxBytes: flags.maxBytes ?? 0 },
213
290
  );
291
+ // After the report: the report says what, the diff shows it (R50).
292
+ emitDiffs(diffs, flags);
214
293
  return refused.length > 0 ? EXIT.FAIL : EXIT.OK;
215
294
  }
216
295
 
@@ -221,12 +300,23 @@ async function installHooks(root, flags) {
221
300
  return fail(`refused to write ${refused.map((p) => `${p.path} (${p.error})`).join('; ')}`, EXIT.FAIL);
222
301
  }
223
302
 
303
+ // Journaled so `undo` restores this run (R49).
304
+ const journal = [];
224
305
  const written = [];
225
306
  for (const p of plan.filter((x) => x.action !== 'unchanged')) {
226
307
  await mkdir(join(root, dirname(p.path)), { recursive: true });
227
- await writeFile(join(root, p.path), p.text);
308
+ await journaledWriteFile(root, journal, join(root, p.path), p.text);
228
309
  written.push(p);
229
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
+ }
319
+ await commitJournal(root, 'agent-hooks install', journal);
230
320
 
231
321
  const notes = [];
232
322
  for (const p of written) {
@@ -241,10 +331,12 @@ async function installHooks(root, flags) {
241
331
  command: 'agent-hooks install',
242
332
  root,
243
333
  plan: plan.map(({ text, ...rest }) => rest),
334
+ shim: { ...shimRow, written: shimWritten },
244
335
  notWritten: others,
245
336
  written: written.map((p) => p.path),
246
337
  filesWritten: true,
247
338
  notes,
339
+ ...diffField(diffs, flags),
248
340
  });
249
341
  return EXIT.OK;
250
342
  }
@@ -252,13 +344,16 @@ async function installHooks(root, flags) {
252
344
  [
253
345
  `repo ${root}`,
254
346
  ...plan.map((p) => `${p.action.toUpperCase().padEnd(9)} ${p.id.padEnd(12)} ${p.path}`),
255
- ...others.map((o) => `${o.state === 'none' ? 'NO FILE ' : 'NOT OURS'} ${o.id.padEnd(12)} ${o.path ?? '(no hooks file is documented)'}`),
347
+ `${shimAction.toUpperCase().padEnd(9)} shim ${AGENT_HOOK_SHIM}`,
348
+ ...others.map((o) => `${OTHER_LABEL[o.state]} ${o.id.padEnd(12)} ${o.path ?? '(no hooks file is documented)'}`),
256
349
  ...promptLine,
257
350
  '',
258
- `WROTE ${written.length} hooks file(s); each one runs ${CLI} agent-hook before the tool call`,
351
+ `WROTE ${written.length} hooks file(s)${shimWritten ? ' and the shim' : ''}; each one runs the ${AGENT_HOOK_SHIM} shim before the tool call`,
259
352
  ].join('\n'),
260
353
  { maxBytes: flags.maxBytes ?? 0 },
261
354
  );
355
+ // After the report: the report says what, the diff shows it (R50).
356
+ emitDiffs(diffs, flags);
262
357
  for (const o of others) note(`note ${o.id}: not written - ${o.note}`);
263
358
  for (const n of notes) note(`note ${n}`);
264
359
  note(`note never committed or pushed (ADR-001); review, then commit yourself`);
@@ -268,10 +363,31 @@ async function installHooks(root, flags) {
268
363
  async function hooksStatus(root, flags) {
269
364
  const rows = [];
270
365
  const problems = [];
366
+ // The plugin file this tool owns whole is compared against what `install` would write
367
+ // right now, so status reads the same opt-in install reads: a file that matches the
368
+ // preview is present, anything else is drift an install will rewrite.
369
+ const { promptOptIn } = await import('../agenthooks/prompt.mjs');
370
+ const optIn = await promptOptIn(root);
271
371
 
272
372
  for (const row of adapters()) {
273
373
  const path = hookPath(row.id);
274
374
  const existing = await readIfExists(join(root, path));
375
+ if (row.file) {
376
+ if (existing === null) {
377
+ rows.push({ id: row.id, path, state: 'absent', entries: 0, prompt: row.prompt ? 'absent' : null, note: null });
378
+ } else {
379
+ const present = rendered(row, existing, { prompt: optIn.optedIn }).action === 'unchanged';
380
+ rows.push({
381
+ id: row.id,
382
+ path,
383
+ state: present ? 'present' : 'drift',
384
+ entries: present ? 1 : 0,
385
+ prompt: row.prompt ? (present && optIn.optedIn ? 'present' : 'absent') : null,
386
+ note: present ? null : 'differs from what install would write; run install to restore it',
387
+ });
388
+ }
389
+ continue;
390
+ }
275
391
  if (existing === null) {
276
392
  rows.push({ id: row.id, path, state: 'absent', entries: 0, prompt: row.prompt ? 'absent' : null, note: null });
277
393
  continue;
@@ -319,7 +435,7 @@ async function hooksStatus(root, flags) {
319
435
 
320
436
  const lines = [`repo ${root}`];
321
437
  for (const r of rows) {
322
- const state = { present: 'PRESENT ', absent: 'ABSENT ', unreadable: 'INVALID ', none: 'NO FILE ', other: 'NOT OURS' }[r.state];
438
+ const state = { present: 'PRESENT ', absent: 'ABSENT ', unreadable: 'INVALID ', drift: 'DRIFT ', none: 'NO FILE ', other: 'NOT OURS' }[r.state];
323
439
  lines.push(`${state} ${r.id.padEnd(12)} ${r.path ?? '(no hooks file is documented)'}`);
324
440
  if (r.state === 'absent') lines.push(` ${' '.repeat(12)} install: ${CLI} agent-hooks install --yes`);
325
441
  // The second event is a separate answer to a separate question: a harness can carry
@@ -217,6 +217,7 @@ export async function benchReportCommand(positional, flags) {
217
217
  harness: flags.harness,
218
218
  model: flags.model,
219
219
  compare: compareItems.length === 2 ? compareItems : null,
220
+ crossGroup: Boolean(flags.crossGroup),
220
221
  });
221
222
  } catch (error) {
222
223
  return fail(
@@ -405,7 +406,7 @@ export async function benchVariantsCommand(positional, flags) {
405
406
  // and until a bench here shows that, every report calls the critic advisory.
406
407
  export async function benchCriticCommand(positional, flags, commands = []) {
407
408
  const root = resolve(flags.root ?? positional[0] ?? process.cwd());
408
- const labelsPath = flags.labels ?? '.simt/critic-labels.jsonl';
409
+ const labelsPath = flags.labels ?? '.simt/local/critic-labels.jsonl';
409
410
  const result = await criticBench(root, { labelsPath, flags, commands });
410
411
  if (typeof result === 'number') return result;
411
412
 
@@ -18,8 +18,10 @@
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, writeFile } from 'node:fs/promises';
21
+ import { mkdir, readFile } from 'node:fs/promises';
22
22
  import { dirname } from 'node:path';
23
+ import { commitJournal, journaledWriteFile } from '../journal.mjs';
24
+ import { diffField, emitDiffs, readBefore } from '../diff.mjs';
23
25
 
24
26
  import { EXIT, emit, emitJson, isJson, note, writeMode } from '../output/index.mjs';
25
27
  import { planWorkflow, renderWorkflow, WORKFLOW_FILE } from '../ci/workflow.mjs';
@@ -121,18 +123,37 @@ export async function ciCommand(positional, flags) {
121
123
  return EXIT.FAIL;
122
124
  }
123
125
 
126
+ // The `--diff` pairs, read before anything is written so the before side is
127
+ // true in preview and in apply alike (R50).
128
+ const renders = new Map(targets().map(({ key, render }) => [key, render]));
129
+ const diffs =
130
+ flags.diff === true
131
+ ? await Promise.all(
132
+ plans
133
+ .filter((p) => p.action !== 'unchanged')
134
+ .map(async (p) => ({ path: p.file, before: await readBefore(p.path), after: renders.get(p.key)(p) })),
135
+ )
136
+ : [];
137
+
124
138
  if (isJson(flags)) {
125
- emitJson({ ok: true, command: 'ci', root, files: todo.map(publicPlan), written: false, dryRun: !mode.apply }, {});
139
+ emitJson(
140
+ { ok: true, command: 'ci', root, files: todo.map(publicPlan), written: false, dryRun: !mode.apply, ...diffField(diffs, flags) },
141
+ {},
142
+ );
126
143
  } else {
127
144
  emit(lines.join('\n'));
128
145
  }
129
146
 
130
147
  if (!mode.apply) {
148
+ // After the report: the report says what, the diff shows it (R50).
149
+ emitDiffs(diffs, flags);
131
150
  note('');
132
151
  note(mode.dryRun ? 'dry-run nothing written' : `dry-run nothing written; apply with: ${CLI} ci --yes`);
133
152
  return EXIT.OK;
134
153
  }
135
154
 
155
+ // Journaled so `undo` restores this run (R49).
156
+ const journal = [];
136
157
  for (const { key, plan, render } of targets()) {
137
158
  const p = plans.find((x) => x.key === key);
138
159
  if (p.action === 'unchanged') continue;
@@ -140,9 +161,10 @@ export async function ciCommand(positional, flags) {
140
161
  // The renderer takes the plan because detection-driven content is a function of
141
162
  // this repo's facts: a dependabot config rendered without them would watch
142
163
  // nothing, which is the same silent-nothing the detection ruling exists to stop.
143
- await writeFile(p.path, render(p));
164
+ await journaledWriteFile(root, journal, p.path, render(p));
144
165
  note(`wrote ${p.file} (${p.action}; the template and this copy now agree)`);
145
166
  }
167
+ await commitJournal(root, 'ci', journal);
146
168
  if (isJson(flags)) {
147
169
  emitJson(
148
170
  {
@@ -151,10 +173,13 @@ export async function ciCommand(positional, flags) {
151
173
  root,
152
174
  files: plans.map((p) => ({ file: p.file, action: p.action, bytes: p.bytes })),
153
175
  written: true,
176
+ ...diffField(diffs, flags),
154
177
  },
155
178
  {},
156
179
  );
157
180
  }
181
+ // After the report: the report says what, the diff shows it (R50).
182
+ emitDiffs(diffs, flags);
158
183
  return EXIT.OK;
159
184
  }
160
185
 
@@ -24,7 +24,7 @@ import { basename, resolve } from 'node:path';
24
24
  import { EXIT, emit, emitJson, fail, isJson, note } from '../output/index.mjs';
25
25
  import { provenanceDigest } from '../projection/project.mjs';
26
26
  import { GATES } from '../hooks/gates.mjs';
27
- import { buildRow, summarise } from '../compare/row.mjs';
27
+ import { buildRows, resolveConcurrency, summarise } from '../compare/row.mjs';
28
28
  import { discover } from '../compare/discover.mjs';
29
29
  import { learnAcrossEstate, renderLearningLines } from '../compare/learn.mjs';
30
30
  import { targetRoot, WORKSPACE_REMEDY } from '../workspace.mjs';
@@ -60,12 +60,13 @@ export async function compareCommand(positional, flags) {
60
60
  const expectedSha = await provenanceDigest();
61
61
  const context = { expectedSha, gates: new Set(Object.keys(GATES)), gateKinds: GATE_KINDS };
62
62
 
63
- // Sequential on purpose. 16 fingerprint+region computations each spawn a git
64
- // process; running them concurrently produces a burst of subprocesses and no
65
- // measurable speedup, because the work is process-spawn bound rather than CPU
66
- // bound. Determinism of the output matters more than wall clock on a dashboard.
67
- const rows = [];
68
- for (const repo of found.repos) rows.push(await buildRow(repo, context));
63
+ // Parallel with a cap, in discovery order. 16 fingerprint+region computations
64
+ // each spawn a git process; unbounded parallelism is a burst of subprocesses
65
+ // with no speedup, and sequential builds are what took 48 s over MCP for 17
66
+ // repos (review 0003 Q38). The cap keeps the burst bounded while overlapping
67
+ // the spawn wait; the rows still come back in input order, so two runs diff
68
+ // cleanly and determinism of the output is untouched.
69
+ const rows = await buildRows(found.repos, context, { concurrency: resolveConcurrency(flags) });
69
70
  const learning = await learnAcrossEstate(found.repos);
70
71
 
71
72
  const summary = summarise(rows, {
@@ -0,0 +1,27 @@
1
+ // `simt completions <shell>` — print a completion script (plan 0003 R51).
2
+ //
3
+ // The script goes to stdout and nothing else does, because the install is a
4
+ // redirect: `source <(simt completions bash)`, or the zsh file dropped onto
5
+ // `$fpath`. A report line above it would be a report line in the user's shell
6
+ // config.
7
+
8
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
9
+ import { SHELLS, renderCompletions } from '../completions.mjs';
10
+
11
+ export async function completionsCommand(args, flags, commands) {
12
+ const shell = args[0];
13
+ if (shell === undefined) {
14
+ return fail('completions needs a shell', EXIT.USAGE, `one of: ${SHELLS.join(', ')}`);
15
+ }
16
+ if (!SHELLS.includes(shell)) {
17
+ return fail(`unknown shell \`${shell}\``, EXIT.USAGE, `one of: ${SHELLS.join(', ')}`);
18
+ }
19
+
20
+ const script = renderCompletions(shell, commands);
21
+ if (isJson(flags)) {
22
+ emitJson({ ok: true, command: 'completions', shell, script });
23
+ return EXIT.OK;
24
+ }
25
+ emit(script, { maxBytes: flags.maxBytes ?? 0 });
26
+ return EXIT.OK;
27
+ }
@@ -2,20 +2,23 @@
2
2
  // a rulings skeleton, and structured `simt-affects` + `simt-supersedes` frontmatter
3
3
  // (plan 0003 R34).
4
4
 
5
- import { join, resolve } from 'node:path';
5
+ import { join } from 'node:path';
6
6
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
7
7
  import { CLI } from '../name.mjs';
8
- import { DECISIONS_DIR } from '../decisions/index.mjs';
8
+ import { decisionsCutoff, decisionsDir } from '../decisions/dir.mjs';
9
9
  import { lintDecisions, scaffoldDecision } from '../memory/decide.mjs';
10
+ import { commitJournal } from '../journal.mjs';
11
+ import { diffField, emitDiffs } from '../diff.mjs';
10
12
  import { parsePathList } from '../argv.mjs';
11
13
 
12
14
  export async function decideCommand(args, flags) {
13
15
  const root = flags.root ?? process.cwd();
14
16
 
15
17
  if (flags.check === true) {
16
- const decisionsDir = join(root, 'docs', 'decisions');
17
- const allowHistorical = resolve(decisionsDir) === DECISIONS_DIR;
18
- const res = await lintDecisions(decisionsDir, { allowHistorical });
18
+ // Q55: one resolution — the manifest key, then the detected directory —
19
+ // and the cutoff from the same config, never a hard-coded number or path.
20
+ const relDir = await decisionsDir(root);
21
+ const res = await lintDecisions(join(root, relDir), { historicalCutoff: await decisionsCutoff(root) });
19
22
 
20
23
  if (isJson(flags)) {
21
24
  emitJson({
@@ -49,12 +52,17 @@ export async function decideCommand(args, flags) {
49
52
  const parsedPaths = parsePathList(flags.paths);
50
53
  const explicitPaths = flags.paths !== undefined ? parsedPaths : undefined;
51
54
 
55
+ // Journaled so `undo` restores the scaffold (R49); committed only when the
56
+ // run actually wrote, which commitJournal already guarantees.
57
+ const journal = [];
52
58
  const res = await scaffoldDecision(root, {
53
59
  title,
54
60
  affects: explicitPaths,
55
61
  supersedes: flags.supersedes,
56
62
  write: mode.apply,
63
+ journal: mode.apply ? journal : undefined,
57
64
  });
65
+ if (mode.apply && res.ok) await commitJournal(root, 'decide', journal);
58
66
 
59
67
  if (!res.ok) {
60
68
  return fail(
@@ -70,6 +78,10 @@ export async function decideCommand(args, flags) {
70
78
  note('dry-run no files written');
71
79
  }
72
80
 
81
+ // A scaffold always creates: the number is free by construction, so the
82
+ // before side is empty and the pair is the whole file (R50).
83
+ const diffs = flags.diff === true ? [{ path: res.relPath, before: null, after: res.text }] : [];
84
+
73
85
  if (isJson(flags)) {
74
86
  emitJson({
75
87
  ok: true,
@@ -84,10 +96,10 @@ export async function decideCommand(args, flags) {
84
96
  supersedes: res.supersedes,
85
97
  written: res.written,
86
98
  dryRun: !mode.apply,
99
+ ...diffField(diffs, flags),
87
100
  });
88
101
  return EXIT.OK;
89
102
  }
90
-
91
103
  const supersedesLabel =
92
104
  res.supersedes.length > 0
93
105
  ? res.supersedes
@@ -112,5 +124,7 @@ export async function decideCommand(args, flags) {
112
124
  ];
113
125
 
114
126
  emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
127
+ // After the report: the report says what, the diff shows it (R50).
128
+ emitDiffs(diffs, flags);
115
129
  return EXIT.OK;
116
130
  }