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
@@ -12,10 +12,11 @@
12
12
  // DEFAULT location, which core.hooksPath being unset does not reveal, so a
13
13
  // foreign hook is found by asking git rather than by reading config.
14
14
 
15
- import { execFile } from 'node:child_process';
16
- import { chmod, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
15
+ import { mkdir, readFile, realpath } from 'node:fs/promises';
16
+ import { commitJournal, journaledGitConfig, journaledRemoveFile, journaledWriteFile } from '../journal.mjs';
17
+ import { diffField, emitDiffs, readBefore } from '../diff.mjs';
18
+ import { invalidateCheckCache } from '../status.mjs';
17
19
  import { isAbsolute, join, relative } from 'node:path';
18
- import { promisify } from 'node:util';
19
20
 
20
21
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
21
22
  import { MANIFEST, readManifest } from '../projection/manifest.mjs';
@@ -23,13 +24,11 @@ import { EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, SHIM_MARKER, fileFor, renderChainedS
23
24
  import { GATE_KEY, gateCount, readHooksPath, readHooksState, wantsShim } from '../compare/hooksstate.mjs';
24
25
  import { GATES } from '../hooks/gates.mjs';
25
26
  import { knownGates } from '../hooks/resolve.mjs';
26
- import { runGates } from '../hooks/dispatcher.mjs';
27
+ import { renderGateReport, runGates } from '../hooks/dispatcher.mjs';
27
28
  import { CLI } from '../name.mjs';
28
29
  import { MANIFEST_FILE } from '../name.mjs';
29
30
  import { packageVersion, hasNpm } from '../package-version.mjs';
30
31
 
31
- const exec = promisify(execFile);
32
-
33
32
  export async function hooksCommand(sub, args, flags) {
34
33
  const root = flags.root ?? args[0] ?? process.cwd();
35
34
  if (sub === 'install') return installHooks(root, flags);
@@ -51,7 +50,12 @@ async function runHook(args, flags) {
51
50
  return fail(`${root} has no ${MANIFEST_FILE}; the hook cannot know its gate list`, EXIT.CONFIG, `run: ${CLI} init --root ${root}`);
52
51
  }
53
52
 
54
- const result = await runGates(event, { root, manifest });
53
+ // git feeds pre-push its ref list on stdin, and the docs-only skip (ADR-113)
54
+ // needs it. A manual run from a terminal has a TTY and reads empty, which the
55
+ // dispatcher treats as "no push to classify" and answers with the full suite.
56
+ const pushed = event === 'pre-push' ? await readStdin() : undefined;
57
+
58
+ const result = await runGates(event, { root, manifest, pushed });
55
59
  if (result.error) return fail(result.error, EXIT.USAGE);
56
60
 
57
61
  if (isJson(flags)) {
@@ -60,17 +64,33 @@ async function runHook(args, flags) {
60
64
  }
61
65
 
62
66
  for (const n of result.notes ?? []) note(`note ${n}`);
63
- const lines = [`hooks ${event}`, `gates ${(manifest.gates[GATE_KEY[event]] ?? []).join(', ') || '(none declared)'}`];
64
- for (const g of result.gates) {
65
- lines.push(`${g.status.toUpperCase().padEnd(8)} ${g.name.padEnd(20)} ${g.detail}${g.adr ? ` [${g.adr}]` : ''}`);
66
- if (g.remedy) lines.push(` remedy: ${g.remedy}`);
67
- if (g.output) lines.push(...g.output.split('\n').map((l) => ` ${l}`));
68
- }
69
- lines.push(result.failed ? 'BLOCKED at least one gate failed' : 'PASSED');
70
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
67
+ emit(renderGateReport(event, manifest.gates[GATE_KEY[event]] ?? [], result).join('\n'), { maxBytes: flags.maxBytes ?? 0 });
71
68
  return result.failed ? EXIT.FAIL : EXIT.OK;
72
69
  }
73
70
 
71
+ /**
72
+ * Read all of stdin, or '' when there is none to read.
73
+ *
74
+ * The pre-push shim execs this command, so git's ref list arrives on the
75
+ * dispatcher's stdin. A TTY (a manual run from a terminal) has no ref list and
76
+ * reads empty - and empty is the fail-closed answer: the dispatcher cannot
77
+ * classify a push it was never told about, so the suite runs.
78
+ *
79
+ * @returns {Promise<string>}
80
+ */
81
+ function readStdin() {
82
+ return new Promise((resolve) => {
83
+ if (process.stdin.isTTY) {
84
+ resolve('');
85
+ return;
86
+ }
87
+ const chunks = [];
88
+ process.stdin.on('data', (chunk) => chunks.push(chunk));
89
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
90
+ process.stdin.resume();
91
+ });
92
+ }
93
+
74
94
  /**
75
95
  * The path to write into a chained shim: relative to the repository's top level
76
96
  * wherever the hook lives inside it.
@@ -101,33 +121,48 @@ async function relativeTarget(root, target) {
101
121
  return rel === '' || rel.startsWith('..') ? resolved : rel;
102
122
  }
103
123
 
104
- async function installHooks(root, flags) {
105
- const { manifest, exists, error } = await readManifest(root);
106
- if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG);
107
- if (!exists) {
108
- return fail(`${root} has no ${MANIFEST_FILE}; gates must be declared before hooks can run them`, EXIT.CONFIG, `run: ${CLI} init --root ${root}`);
109
- }
110
-
124
+ /**
125
+ * What `hooks install` would write, computed without writing or reporting (plan 0003 R47).
126
+ *
127
+ * `installHooks` and `doctor --fix` share this rather than each deriving the write
128
+ * set: the set nothing else here touches (obsolete shims, chained foreign hooks)
129
+ * is exactly the set a second derivation gets wrong, and a fix that writes bytes
130
+ * the command would not is a fix that lies about what it did. Reporting stays
131
+ * with the callers — the plan table belongs to the command, the finding line to
132
+ * the doctor — so this returns facts, never text.
133
+ *
134
+ * @returns the install inputs `applyHooksInstall` needs, plus the guards each
135
+ * caller enforces in its own voice (`takeover`, and the `wanted`/`obsolete`
136
+ * emptiness the command refuses on)
137
+ */
138
+ export async function planHooksInstall(root, { manifest, exists }) {
111
139
  const declaresGate = (event) => wantsShim(manifest, exists, event);
112
140
  const state = await readHooksState(root, { declaresGate });
113
141
  const current = state.current;
114
142
  const wants = HOOKS_DIR;
115
143
 
116
- // Read through `writeMode` like every other writer, and only now: this command
117
- // used to consult `flags.yes` and `flags.dryRun` directly and write when neither
118
- // said otherwise, which made it the last writer still writing by default after
119
- // ADR-035 and ADR-036 had settled that none of them may. Review 0002's S24
120
- // listed it among the writers that "all preview first"; that was wrong, and the
121
- // migration script in the same review tells an operator to run `hooks install`
122
- // for a plan and then `--yes` - advice that printed a plan and wrote anyway.
123
- const mode = writeMode(flags);
124
-
125
144
  // The release this install renders, and the kind of repository it installs into: the
126
145
  // remedy a shim prints when it cannot find the binary must name the install that
127
146
  // actually works here — a devDependency where package.json exists, a pinned global
128
147
  // install where there is no node_modules to pin in.
129
148
  const version = packageVersion();
130
149
  const npm = await hasNpm(root);
150
+ // Q31: installed shims name the repo's own installer in their remedy.
151
+ // Lockfile first, `packageManager` next, npm default — the one resolver.
152
+ let manager = null;
153
+ if (npm) {
154
+ const { resolveNodeRunner } = await import('../ecosystem/node-runner.mjs');
155
+ let packageManager = null;
156
+ try {
157
+ const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
158
+ if (typeof pkg.packageManager === 'string') packageManager = pkg.packageManager;
159
+ } catch {
160
+ // Unreadable here leaves the npm default; the manifest path reports it.
161
+ }
162
+ const { fingerprint } = await import('../resolve/fingerprint.mjs');
163
+ const detected = await fingerprint(root);
164
+ manager = resolveNodeRunner({ facts: detected.facts, packageManager }).id;
165
+ }
131
166
 
132
167
  // Only install a shim for an event that declares at least one gate. A shim with
133
168
  // an empty gate list enforces nothing, yet merely existing it REPLACES whatever
@@ -229,7 +264,7 @@ async function installHooks(root, flags) {
229
264
  const evidenceDeclared = manifest?.gates?.evidence === 'warn' || manifest?.gates?.evidence === 'block';
230
265
  const evidenceFile = fileFor('commit-msg');
231
266
  const evidenceExisting = await readIfExists(join(root, evidenceFile));
232
- const evidenceSource = evidenceDeclared ? renderEvidenceShim({ npm, version }) : null;
267
+ const evidenceSource = evidenceDeclared ? renderEvidenceShim({ npm, manager, version }) : null;
233
268
  const evidenceOurs = evidenceExisting !== null && evidenceExisting.includes(SHIM_MARKER);
234
269
  if (!evidenceDeclared && evidenceOurs) obsolete.push({ event: 'commit-msg' });
235
270
  // A foreign commit-msg would otherwise be chained onto the same path. The chain
@@ -240,6 +275,151 @@ async function installHooks(root, flags) {
240
275
  if (at !== -1) chained.splice(at, 1);
241
276
  }
242
277
 
278
+ const routed = wanted.length > 0 || chained.length > 0 || evidenceDeclared;
279
+ const takeover = (current !== null && current !== wants) || state.foreign.length > 0;
280
+ return {
281
+ manifest,
282
+ state,
283
+ current,
284
+ wants,
285
+ npm,
286
+ manager,
287
+ version,
288
+ wanted,
289
+ chained,
290
+ leftRunning,
291
+ obsolete,
292
+ evidenceDeclared,
293
+ evidenceSource,
294
+ evidenceFile,
295
+ // Returned so a caller can tell a current claim check from a stale one
296
+ // without re-reading the file: the evidence shim is not one of EVENTS, so
297
+ // neither the problems list nor the obsolete set names its staleness.
298
+ evidenceExisting,
299
+ routed,
300
+ takeover,
301
+ };
302
+ }
303
+
304
+ /**
305
+ * Write a planned install: shims, chains, removals, and the git routing (plan 0003 R47).
306
+ *
307
+ * The caller's journal records the file writes, and the caller commits it under
308
+ * its own command name — `hooks install` here, `doctor --fix` there — so `undo`
309
+ * names the run that actually changed something. Reporting stays with the
310
+ * caller for the same reason it does in `planHooksInstall`.
311
+ *
312
+ * @returns {{wrote: string[]}} the shim paths written, removed, or refreshed
313
+ */
314
+ export async function applyHooksInstall(root, planned, journal) {
315
+ const { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, manager, version, routed, wants, current } =
316
+ planned;
317
+ // Shim and evidence bytes are journaled so `undo` restores them (R49), with
318
+ // the executable bit they are written with: a shim the run chmodded is one
319
+ // change, and `undo` must restore the mode it found, not just the bytes.
320
+ // The `core.hooksPath` routing below is journaled as a `git-config` entry
321
+ // for the same reason — unwinding the shims while the config still points
322
+ // at their directory silently stops the repository's own hooks (review 0003
323
+ // Q22).
324
+ const wrote = [];
325
+ await mkdir(join(root, HOOKS_DIR), { recursive: true });
326
+ for (const event of wanted) {
327
+ const path = join(root, fileFor(event));
328
+ await journaledWriteFile(root, journal, path, renderShim(event, { npm, manager, version }), { mode: 0o755 });
329
+ wrote.push(fileFor(event));
330
+ }
331
+ for (const c of chained) {
332
+ const path = join(root, fileFor(c.event));
333
+ await journaledWriteFile(root, journal, path, renderChainedShim(c.event, c.target), { mode: 0o755 });
334
+ wrote.push(fileFor(c.event));
335
+ }
336
+ for (const h of obsolete) {
337
+ await journaledRemoveFile(root, journal, join(root, fileFor(h.event)));
338
+ wrote.push(fileFor(h.event));
339
+ }
340
+ // After the chain loop, so a chained commit-msg cannot replace the claim check.
341
+ if (evidenceSource) {
342
+ const path = join(root, evidenceFile);
343
+ await journaledWriteFile(root, journal, path, evidenceSource, { mode: 0o755 });
344
+ wrote.push(evidenceFile);
345
+ }
346
+ // `core.hooksPath` tells git to read ONE directory for EVERY event, so leaving it
347
+ // pointing at a directory of no shims stops the repository's own hooks - the ones in
348
+ // `.git/hooks` that predate this tool - from running, silently. Unsetting it is the
349
+ // only way back, and this is the only place that knows the last shim just left.
350
+ if (routed) {
351
+ await journaledGitConfig(root, journal, HOOKS_PATH_KEY, wants);
352
+ } else if (current === wants) {
353
+ await journaledGitConfig(root, journal, HOOKS_PATH_KEY, null);
354
+ }
355
+ return { wrote };
356
+ }
357
+
358
+ /**
359
+ * The `--diff` pairs for a planned install: current disk against what the
360
+ * install would write, re-rendered from the same inputs `applyHooksInstall`
361
+ * writes (plan 0003 R50). Read-only, so preview and apply share it, and
362
+ * `doctor --fix` reuses it rather than deriving a second answer.
363
+ */
364
+ export async function plannedDiffs(root, planned) {
365
+ const { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, manager, version } = planned;
366
+ const pairs = [];
367
+ for (const event of wanted) {
368
+ const path = fileFor(event);
369
+ pairs.push({ path, before: await readBefore(join(root, path)), after: renderShim(event, { npm, manager, version }) });
370
+ }
371
+ for (const c of chained) {
372
+ const path = fileFor(c.event);
373
+ pairs.push({ path, before: await readBefore(join(root, path)), after: renderChainedShim(c.event, c.target) });
374
+ }
375
+ for (const h of obsolete) {
376
+ const path = fileFor(h.event);
377
+ pairs.push({ path, before: await readBefore(join(root, path)), after: null });
378
+ }
379
+ if (evidenceSource) {
380
+ pairs.push({ path: evidenceFile, before: await readBefore(join(root, evidenceFile)), after: evidenceSource });
381
+ }
382
+ return pairs;
383
+ }
384
+
385
+ async function installHooks(root, flags) {
386
+ const { manifest, exists, error } = await readManifest(root);
387
+ if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG);
388
+ if (!exists) {
389
+ return fail(`${root} has no ${MANIFEST_FILE}; gates must be declared before hooks can run them`, EXIT.CONFIG, `run: ${CLI} init --root ${root}`);
390
+ }
391
+
392
+ // One computation, two callers (R47): the write set is derived once in
393
+ // `planHooksInstall` and shared with `doctor --fix`, so a fix applies exactly
394
+ // the bytes this command would write. Reporting stays here.
395
+ const {
396
+ state,
397
+ current,
398
+ wants,
399
+ npm,
400
+ manager,
401
+ version,
402
+ wanted,
403
+ chained,
404
+ leftRunning,
405
+ obsolete,
406
+ evidenceDeclared,
407
+ evidenceSource,
408
+ evidenceFile,
409
+ evidenceExisting,
410
+ routed,
411
+ takeover,
412
+ } = await planHooksInstall(root, { manifest, exists });
413
+
414
+ // Read through `writeMode` like every other writer, and only now: this command
415
+ // used to consult `flags.yes` and `flags.dryRun` directly and write when neither
416
+ // said otherwise, which made it the last writer still writing by default after
417
+ // ADR-035 and ADR-036 had settled that none of them may. Review 0002's S24
418
+ // listed it among the writers that "all preview first"; that was wrong, and the
419
+ // migration script in the same review tells an operator to run `hooks install`
420
+ // for a plan and then `--yes` - advice that printed a plan and wrote anyway.
421
+ const mode = writeMode(flags);
422
+
243
423
  // Nothing to write and nothing to take away is the only refusal. It sits after
244
424
  // `obsolete` because a repository whose last gate just left the manifest has
245
425
  // something to take away, and blocking that one left no way out except undoing
@@ -266,7 +446,7 @@ async function installHooks(root, flags) {
266
446
  const plan = [];
267
447
  for (const event of wanted) {
268
448
  const path = join(root, fileFor(event));
269
- const source = renderShim(event, { npm, version });
449
+ const source = renderShim(event, { npm, manager, version });
270
450
  const existing = await readIfExists(path);
271
451
  if (existing === source) {
272
452
  plan.push({ file: fileFor(event), action: 'unchanged' });
@@ -307,7 +487,6 @@ async function installHooks(root, flags) {
307
487
  note: 'commit-msg checks a verification claim against the ledger',
308
488
  });
309
489
  }
310
- const routed = wanted.length > 0 || chained.length > 0 || evidenceDeclared;
311
490
 
312
491
  // The routing this install leaves behind, which is not always `wants`. A repository
313
492
  // that removed its last gate has no shims to route to, and leaving `core.hooksPath`
@@ -324,7 +503,6 @@ async function installHooks(root, flags) {
324
503
  // being unset while a hook sits in the default location (4 KB of real logic in
325
504
  // .git/hooks/pre-commit). Reading unset as
326
505
  // "no hooks" is what let install replace a working gate with no warning.
327
- const takeover = (current !== null && current !== wants) || state.foreign.length > 0;
328
506
  if (takeover && !flags.yes) {
329
507
  const detail = state.foreign.length > 0
330
508
  ? state.foreign.map((f) => ` git runs ${f.event} at ${f.path} (${f.bytes} bytes)`)
@@ -348,12 +526,29 @@ async function installHooks(root, flags) {
348
526
  return EXIT.FAIL;
349
527
  }
350
528
 
529
+ // The `--diff` pairs, read before anything is written so the before side is
530
+ // true in preview and in apply alike (R50).
531
+ const diffs =
532
+ flags.diff === true
533
+ ? await plannedDiffs(root, { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, manager, version })
534
+ : [];
535
+
351
536
  if (!mode.apply) {
352
537
  if (isJson(flags)) {
353
538
  // `leaves`, not `wants`. A plan that would remove the last shim also un-routes
354
539
  // git, and a payload promising to set a directory the command is about to unset
355
540
  // is the same lie in a shape a machine reads.
356
- emitJson({ ok: true, command: 'hooks install', root, dryRun: true, currentHooksPath: current, wouldSet: leaves, plan, filesWritten: false });
541
+ emitJson({
542
+ ok: true,
543
+ command: 'hooks install',
544
+ root,
545
+ dryRun: true,
546
+ currentHooksPath: current,
547
+ wouldSet: leaves,
548
+ plan,
549
+ filesWritten: false,
550
+ ...diffField(diffs, flags),
551
+ });
357
552
  return EXIT.OK;
358
553
  }
359
554
  emit(
@@ -373,39 +568,23 @@ async function installHooks(root, flags) {
373
568
  ),
374
569
  { maxBytes: flags.maxBytes ?? 0 },
375
570
  );
571
+ // After the report: the report says what, the diff shows it (R50).
572
+ emitDiffs(diffs, flags);
376
573
  return EXIT.OK;
377
574
  }
378
575
 
379
- await mkdir(join(root, HOOKS_DIR), { recursive: true });
380
- for (const event of wanted) {
381
- const path = join(root, fileFor(event));
382
- await writeFile(path, renderShim(event, { npm, version }));
383
- await chmod(path, 0o755);
384
- }
385
- for (const c of chained) {
386
- const path = join(root, fileFor(c.event));
387
- await writeFile(path, renderChainedShim(c.event, c.target));
388
- await chmod(path, 0o755);
389
- }
390
- for (const h of obsolete) {
391
- await rm(join(root, fileFor(h.event)));
392
- }
393
- // After the chain loop, so a chained commit-msg cannot replace the claim check.
394
- if (evidenceSource) {
395
- const path = join(root, evidenceFile);
396
- await writeFile(path, evidenceSource);
397
- await chmod(path, 0o755);
398
- }
399
-
400
- // `core.hooksPath` tells git to read ONE directory for EVERY event, so leaving it
401
- // pointing at a directory of no shims stops the repository's own hooks - the ones in
402
- // `.git/hooks` that predate this tool - from running, silently. Unsetting it is the
403
- // only way back, and this is the only place that knows the last shim just left.
404
- if (routed) {
405
- await exec('git', ['config', HOOKS_PATH_KEY, wants], { cwd: root });
406
- } else if (current === wants) {
407
- await exec('git', ['config', '--unset', HOOKS_PATH_KEY], { cwd: root });
408
- }
576
+ // Same bytes, two callers (R47): the write itself lives in `applyHooksInstall`
577
+ // and is shared with `doctor --fix`, so a fix installs what the command would.
578
+ const journal = [];
579
+ await applyHooksInstall(
580
+ root,
581
+ { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, manager, version, routed, wants, current },
582
+ journal,
583
+ );
584
+ await commitJournal(root, 'hooks install', journal);
585
+ // Shims and routing changed, so the hooks slot of the last `check` is stale
586
+ // (R48). Gated on the journal: an already-current install changes nothing.
587
+ if (journal.length > 0) await invalidateCheckCache(root);
409
588
 
410
589
  const notes = [];
411
590
  const chainedEvents = new Set(chained.map((c) => c.event));
@@ -453,7 +632,17 @@ async function installHooks(root, flags) {
453
632
  }
454
633
 
455
634
  if (isJson(flags)) {
456
- emitJson({ ok: true, command: 'hooks install', root, hooksPath: leaves, previousHooksPath: current, plan, filesWritten: true, notes });
635
+ emitJson({
636
+ ok: true,
637
+ command: 'hooks install',
638
+ root,
639
+ hooksPath: leaves,
640
+ previousHooksPath: current,
641
+ plan,
642
+ filesWritten: true,
643
+ notes,
644
+ ...diffField(diffs, flags),
645
+ });
457
646
  return EXIT.OK;
458
647
  }
459
648
  emit(
@@ -468,6 +657,8 @@ async function installHooks(root, flags) {
468
657
  ),
469
658
  { maxBytes: flags.maxBytes ?? 0 },
470
659
  );
660
+ // After the report: the report says what, the diff shows it (R50).
661
+ emitDiffs(diffs, flags);
471
662
  for (const n of notes) note(`note ${n}`);
472
663
  note(`note never committed or pushed (ADR-001); review, then commit yourself`);
473
664
  return EXIT.OK;
@@ -0,0 +1,152 @@
1
+ // `simt ignore` — render the one ignore list into every reader's dialect
2
+ // (plan 0003 R52).
3
+ //
4
+ // Shape copied from `ci`: the plan is printed first, nothing is written
5
+ // without `--yes`, `--diff` shows the same plan as unified diffs and `undo`
6
+ // restores the set (R49). Three of the four targets are generated from the
7
+ // list; `.claude/settings.json` is merged, never replaced — see src/ignore.mjs
8
+ // for why that distinction exists.
9
+
10
+ import { join } from 'node:path';
11
+
12
+ import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
13
+ import { CLI } from '../name.mjs';
14
+ import { commitJournal, journaledWriteFile } from '../journal.mjs';
15
+ import { diffField, emitDiffs, readBefore } from '../diff.mjs';
16
+ import { CLAUDE_SETTINGS, IGNORE_FILE, parseIgnoreList, planIgnore } from '../ignore.mjs';
17
+
18
+ export async function ignoreCommand(args, flags) {
19
+ const root = flags.root ?? process.cwd();
20
+
21
+ let source;
22
+ try {
23
+ source = await readBefore(join(root, IGNORE_FILE));
24
+ } catch (error) {
25
+ return fail(`${IGNORE_FILE} could not be read: ${error.message}`, EXIT.FAIL, `fix the file's permissions, then re-run: ${CLI} ignore`);
26
+ }
27
+ if (source === null) {
28
+ // A missing list is a missing input, not an empty one: rendering four empty
29
+ // files from a file that does not exist would answer the request with
30
+ // silence the reader has no way to tell from success.
31
+ return fail(
32
+ `no ${IGNORE_FILE} in ${root}`,
33
+ EXIT.FAIL,
34
+ `create ${IGNORE_FILE} with one path or glob per line (# comments), then: ${CLI} ignore`,
35
+ );
36
+ }
37
+
38
+ const entries = parseIgnoreList(source);
39
+ if (entries.length === 0) {
40
+ return fail(
41
+ `${IGNORE_FILE} parses to zero entries`,
42
+ EXIT.FAIL,
43
+ `add one path or glob per line to ${IGNORE_FILE}, then: ${CLI} ignore`,
44
+ );
45
+ }
46
+
47
+ const plan = await planIgnore(entries, (path) => readBefore(join(root, path)));
48
+ if (!plan.ok) {
49
+ return fail(plan.error, EXIT.FAIL, `repair ${CLAUDE_SETTINGS} by hand, then re-run: ${CLI} ignore`);
50
+ }
51
+
52
+ const mode = writeMode(flags);
53
+ const changed = plan.targets.filter((t) => t.action !== 'unchanged');
54
+ const diffs =
55
+ flags.diff === true
56
+ ? changed.map((t) => ({ path: t.path, before: t.before, after: t.after }))
57
+ : [];
58
+
59
+ // Nothing to do: the report says so from the same plan the write would have
60
+ // used, so "unchanged" cannot disagree with what `--yes` would have written.
61
+ if (changed.length === 0) {
62
+ if (isJson(flags)) {
63
+ emitJson({
64
+ ok: true,
65
+ command: 'ignore',
66
+ root,
67
+ source: IGNORE_FILE,
68
+ entries: entries.length,
69
+ files: plan.targets.map((t) => ({ path: t.path, action: t.action, bytes: byteLen(t.after) })),
70
+ written: false,
71
+ dryRun: false,
72
+ });
73
+ } else {
74
+ emit(plan.targets.map((t) => `current ${t.path} ${byteLen(t.after)} bytes`).join('\n'));
75
+ note('every target already matches the list; nothing to write');
76
+ }
77
+ return EXIT.OK;
78
+ }
79
+
80
+ const lines = [
81
+ `repo ${root}`,
82
+ `source ${IGNORE_FILE} (${entries.length} entr${entries.length === 1 ? 'y' : 'ies'})`,
83
+ '',
84
+ ];
85
+ for (const t of plan.targets) {
86
+ lines.push(`file ${t.path}`, `action ${t.action}`);
87
+ if (t.action === 'update' && t.before !== null) {
88
+ // An update whose before side has lines the list does not: those lines
89
+ // are about to stop existing. Named here because a preview that only says
90
+ // "update" hides the deletion inside the diff the reader has to ask for.
91
+ const dropped = countDropped(t.before, t.after);
92
+ if (dropped > 0) lines.push(`replaces ${dropped} line(s) not in ${IGNORE_FILE} (shown by --diff)`);
93
+ }
94
+ }
95
+
96
+ // Exactly one JSON document per run, emitted once on whichever path ends it:
97
+ // a preview and its write are two phases of ONE answer, and a reader that
98
+ // parses the first document gets "written: false" while the run writes.
99
+ const payload = (written) => ({
100
+ ok: true,
101
+ command: 'ignore',
102
+ root,
103
+ source: IGNORE_FILE,
104
+ entries: entries.length,
105
+ files: plan.targets.map((t) => ({ path: t.path, action: t.action, bytes: byteLen(t.after) })),
106
+ written,
107
+ dryRun: !mode.apply,
108
+ ...diffField(diffs, flags),
109
+ });
110
+
111
+ if (!isJson(flags)) emit(lines.join('\n'));
112
+
113
+ if (!mode.apply) {
114
+ // After the report: the report says what, the diff shows it (R50).
115
+ emitDiffs(diffs, flags);
116
+ if (isJson(flags)) {
117
+ emitJson(payload(false));
118
+ } else {
119
+ note('');
120
+ note(mode.dryRun ? 'dry-run nothing written' : `dry-run nothing written; apply with: ${CLI} ignore --yes`);
121
+ }
122
+ return EXIT.OK;
123
+ }
124
+
125
+ // Journaled so `undo` restores this run (R49). Every note writes to stderr,
126
+ // so the `--json` document remains the only bytes on stdout.
127
+ const journal = [];
128
+ for (const t of changed) {
129
+ await journaledWriteFile(root, journal, join(root, t.path), t.after);
130
+ note(`wrote ${t.path} (${t.action})`);
131
+ }
132
+ await commitJournal(root, 'ignore', journal);
133
+
134
+ if (isJson(flags)) emitJson(payload(true));
135
+ // After the report: the report says what, the diff shows it (R50).
136
+ emitDiffs(diffs, flags);
137
+ return EXIT.OK;
138
+ }
139
+
140
+ function byteLen(text) {
141
+ return Buffer.byteLength(text, 'utf8');
142
+ }
143
+
144
+ /**
145
+ * How many lines the new bytes drop from the old: lines the current file has
146
+ * that the list does not produce. Used only for the plan's `replaces` note —
147
+ * the diff is the authoritative statement of what goes.
148
+ */
149
+ function countDropped(before, after) {
150
+ const kept = new Set(after.split('\n'));
151
+ return before.split('\n').filter((line) => line.trim() !== '' && !kept.has(line)).length;
152
+ }
@@ -191,8 +191,10 @@ function textExt(file) {
191
191
 
192
192
  async function tracked(root) {
193
193
  try {
194
- const { stdout } = await exec('git', ['ls-files'], { cwd: root, maxBuffer: 16 << 20 });
195
- return { files: stdout.split('\n').filter(Boolean) };
194
+ // `-z`: without it git C-quotes a non-ASCII path and the reader's real
195
+ // filename never matches it (review 0003 Q39).
196
+ const { stdout } = await exec('git', ['ls-files', '-z'], { cwd: root, maxBuffer: 16 << 20 });
197
+ return { files: stdout.split('\0').filter(Boolean) };
196
198
  } catch (error) {
197
199
  return {
198
200
  error: fail(