simt-dev 0.6.3 → 0.7.1

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 (187) hide show
  1. package/CHANGELOG.md +162 -841
  2. package/README.md +21 -135
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -55
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  7. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  8. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  9. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  10. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  11. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  12. 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
  13. 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
  14. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  15. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  16. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  17. 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
  18. 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
  19. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  20. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  21. 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
  22. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  23. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  24. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  25. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  26. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  27. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  28. package/package.json +13 -3
  29. package/rules/core.md +9 -2
  30. package/rules/mdc/slice-commit.mdc +57 -4
  31. package/rules/mdc/test-gate.mdc +2 -0
  32. package/rules/stacks/android.md +3 -3
  33. package/rules/stacks/connectiq.md +2 -2
  34. package/rules/stacks/dart.md +1 -1
  35. package/rules/stacks/deno.md +1 -1
  36. package/rules/stacks/dotnet.md +1 -1
  37. package/rules/stacks/elixir.md +1 -1
  38. package/rules/stacks/go.md +1 -1
  39. package/rules/stacks/jvm.md +3 -2
  40. package/rules/stacks/node.md +2 -2
  41. package/rules/stacks/python.md +7 -6
  42. package/rules/stacks/ruby.md +1 -1
  43. package/rules/stacks/rust.md +1 -1
  44. package/rules/stacks/swift.md +1 -1
  45. package/rules/stacks/typescript.md +2 -2
  46. package/rules/stacks/uv.md +3 -2
  47. package/skills/simt-commands/SKILL.md +70 -0
  48. package/src/advice/advice.mjs +7 -3
  49. package/src/agenthooks/decide.mjs +141 -9
  50. package/src/agenthooks/opencode.mjs +72 -0
  51. package/src/agenthooks/prompt.mjs +10 -3
  52. package/src/agenthooks/render.mjs +186 -19
  53. package/src/agenthooks/shim.mjs +55 -0
  54. package/src/agents/render.mjs +277 -0
  55. package/src/api.mjs +29 -0
  56. package/src/argv.mjs +37 -3
  57. package/src/bench/ablate.mjs +31 -14
  58. package/src/bench/adapters.json +16 -0
  59. package/src/bench/corpus.mjs +150 -0
  60. package/src/bench/grade.mjs +23 -49
  61. package/src/bench/mine.mjs +247 -36
  62. package/src/bench/probes.mjs +104 -68
  63. package/src/bench/process.mjs +128 -0
  64. package/src/bench/report.mjs +260 -84
  65. package/src/bench/run.mjs +251 -150
  66. package/src/bench/variants.mjs +112 -40
  67. package/src/brief/assemble.mjs +44 -3
  68. package/src/ci/workflow.mjs +46 -14
  69. package/src/cli.mjs +45 -0
  70. package/src/commands/agent.mjs +205 -0
  71. package/src/commands/agenthook.mjs +113 -20
  72. package/src/commands/bench.mjs +192 -18
  73. package/src/commands/brief.mjs +5 -4
  74. package/src/commands/ci.mjs +2 -1
  75. package/src/commands/compare.mjs +50 -49
  76. package/src/commands/completions.mjs +2 -6
  77. package/src/commands/decide.mjs +10 -9
  78. package/src/commands/distill.mjs +4 -3
  79. package/src/commands/doctor.mjs +11 -7
  80. package/src/commands/evidence.mjs +19 -12
  81. package/src/commands/explain.mjs +0 -1
  82. package/src/commands/gate.mjs +98 -28
  83. package/src/commands/graph.mjs +20 -8
  84. package/src/commands/harness.mjs +3 -2
  85. package/src/commands/help.mjs +160 -31
  86. package/src/commands/hooks.mjs +163 -50
  87. package/src/commands/ignore.mjs +2 -1
  88. package/src/commands/impact.mjs +8 -9
  89. package/src/commands/index.mjs +278 -180
  90. package/src/commands/init.mjs +171 -38
  91. package/src/commands/lease.mjs +206 -39
  92. package/src/commands/lessons.mjs +72 -15
  93. package/src/commands/local.mjs +3 -3
  94. package/src/commands/map.mjs +47 -27
  95. package/src/commands/mcp.mjs +0 -1
  96. package/src/commands/pack.mjs +9 -6
  97. package/src/commands/plan.mjs +6 -5
  98. package/src/commands/plugin.mjs +3 -3
  99. package/src/commands/prbody.mjs +3 -3
  100. package/src/commands/repoindex.mjs +5 -3
  101. package/src/commands/review.mjs +2 -2
  102. package/src/commands/rules.mjs +124 -33
  103. package/src/commands/shared.mjs +2 -3
  104. package/src/commands/skills.mjs +82 -45
  105. package/src/commands/skillscompile.mjs +12 -12
  106. package/src/commands/skillsinstall.mjs +14 -2
  107. package/src/commands/slice.mjs +396 -26
  108. package/src/commands/spec.mjs +151 -0
  109. package/src/commands/status.mjs +4 -7
  110. package/src/commands/sync.mjs +50 -33
  111. package/src/commands/tests.mjs +11 -11
  112. package/src/commands/tokens.mjs +2 -2
  113. package/src/commands/undo.mjs +29 -31
  114. package/src/commands/upgrade.mjs +40 -4
  115. package/src/commands/why.mjs +19 -12
  116. package/src/compare/hooksstate.mjs +23 -1
  117. package/src/compare/learn.mjs +82 -57
  118. package/src/compare/parallel.mjs +50 -0
  119. package/src/compare/row.mjs +26 -3
  120. package/src/completions.mjs +5 -0
  121. package/src/context.mjs +64 -0
  122. package/src/decisions/dir.mjs +84 -0
  123. package/src/decisions/index-doc.mjs +46 -0
  124. package/src/decisions/index.mjs +109 -11
  125. package/src/diff.mjs +1 -1
  126. package/src/distill/distill.mjs +17 -2
  127. package/src/distill/engine.mjs +5 -1
  128. package/src/ecosystem/node-runner.mjs +144 -0
  129. package/src/explain.mjs +18 -6
  130. package/src/graph/load.mjs +42 -20
  131. package/src/hooks/capability.mjs +17 -4
  132. package/src/hooks/dispatcher.mjs +513 -36
  133. package/src/hooks/gates.mjs +0 -43
  134. package/src/hooks/resolve.mjs +11 -10
  135. package/src/hooks/shim.mjs +17 -9
  136. package/src/identity.mjs +96 -15
  137. package/src/index/extract.mjs +192 -11
  138. package/src/index/persist.mjs +307 -47
  139. package/src/index/store.mjs +263 -60
  140. package/src/index/tests.mjs +6 -35
  141. package/src/journal.mjs +372 -84
  142. package/src/kernel/card.mjs +37 -12
  143. package/src/kernel/checks.mjs +44 -9
  144. package/src/kernel/variants.mjs +27 -16
  145. package/src/lease.mjs +97 -15
  146. package/src/ledger/index.mjs +120 -25
  147. package/src/local/client.mjs +4 -6
  148. package/src/locate.mjs +33 -9
  149. package/src/manifest/schema.mjs +213 -0
  150. package/src/mcp/resources.mjs +12 -13
  151. package/src/mcp/server.mjs +151 -38
  152. package/src/mcp/tools.mjs +90 -16
  153. package/src/measure/session.mjs +3 -2
  154. package/src/memory/decide.mjs +98 -43
  155. package/src/memory/lessons.mjs +168 -33
  156. package/src/name.mjs +9 -0
  157. package/src/node-version.mjs +37 -0
  158. package/src/output/index.mjs +111 -3
  159. package/src/packs/manage.mjs +17 -3
  160. package/src/packs.mjs +20 -2
  161. package/src/plan/index.mjs +31 -8
  162. package/src/plan/template.mjs +27 -5
  163. package/src/plugins.mjs +6 -2
  164. package/src/prbody.mjs +7 -5
  165. package/src/projection/baselines.mjs +1 -1
  166. package/src/projection/disclosure.mjs +2 -2
  167. package/src/projection/loaders.mjs +41 -1
  168. package/src/projection/lock.mjs +31 -14
  169. package/src/projection/manifest.mjs +89 -53
  170. package/src/projection/markers.mjs +43 -3
  171. package/src/projection/mdc.mjs +34 -7
  172. package/src/projection/project.mjs +162 -18
  173. package/src/projection/rules.mjs +58 -6
  174. package/src/projection/surfaces.mjs +9 -1
  175. package/src/repo-root.mjs +88 -0
  176. package/src/resolve/body.mjs +4 -0
  177. package/src/resolve/fingerprint.mjs +24 -15
  178. package/src/resolve/local.mjs +58 -0
  179. package/src/review/critic.mjs +6 -3
  180. package/src/secrets.mjs +165 -22
  181. package/src/state.mjs +76 -0
  182. package/src/status.mjs +9 -4
  183. package/src/toolwords.mjs +8 -0
  184. package/src/tsconfig.mjs +39 -99
  185. package/src/workspace.mjs +55 -10
  186. package/templates/plan/tracker.json +1 -1
  187. package/docs/decisions/README.md +0 -591
@@ -12,31 +12,32 @@
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 } from 'node:fs/promises';
17
- import { commitJournal, journaledRemoveFile, journaledWriteFile } from '../journal.mjs';
15
+ import { mkdir, readFile, readdir, realpath, rm, stat } from 'node:fs/promises';
16
+ import { repoContext } from '../context.mjs';
17
+ import { commitJournal, journaledGitConfig, journaledRemoveFile, journaledWriteFile } from '../journal.mjs';
18
18
  import { diffField, emitDiffs, readBefore } from '../diff.mjs';
19
19
  import { invalidateCheckCache } from '../status.mjs';
20
20
  import { isAbsolute, join, relative } from 'node:path';
21
- import { promisify } from 'node:util';
22
21
 
23
22
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
24
23
  import { MANIFEST, readManifest } from '../projection/manifest.mjs';
25
- import { EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, SHIM_MARKER, fileFor, renderChainedShim, renderEvidenceShim, renderShim } from '../hooks/shim.mjs';
24
+ import { CHAIN_MARKER, EVENTS, HOOKS_DIR, HOOKS_DIR_LEGACY, HOOKS_PATH_KEY, SHIM_MARKER, fileFor, renderChainedShim, renderEvidenceShim, renderShim } from '../hooks/shim.mjs';
26
25
  import { GATE_KEY, gateCount, readHooksPath, readHooksState, wantsShim } from '../compare/hooksstate.mjs';
27
26
  import { GATES } from '../hooks/gates.mjs';
28
- import { knownGates } from '../hooks/resolve.mjs';
27
+ import { gateNames } from '../hooks/gates.mjs';
29
28
  import { renderGateReport, runGates } from '../hooks/dispatcher.mjs';
30
29
  import { CLI } from '../name.mjs';
31
30
  import { MANIFEST_FILE } from '../name.mjs';
32
31
  import { packageVersion, hasNpm } from '../package-version.mjs';
33
-
34
- const exec = promisify(execFile);
32
+ import { commandRoot } from '../repo-root.mjs';
35
33
 
36
34
  export async function hooksCommand(sub, args, flags) {
37
- const root = flags.root ?? args[0] ?? process.cwd();
38
- if (sub === 'install') return installHooks(root, flags);
39
- if (sub === 'status') return hooksStatus(root, flags);
35
+ const root = commandRoot(flags, args);
36
+ // One context for the subcommand: install and status each ask about the same
37
+ // repository, and `doctor --fix` asks alongside them (review 0003 Q67).
38
+ const ctx = repoContext(root);
39
+ if (sub === 'install') return installHooks(root, flags, ctx);
40
+ if (sub === 'status') return hooksStatus(root, flags, ctx);
40
41
  if (sub === 'run') return runHook(args, flags);
41
42
  return fail(`unknown hooks subcommand ${sub}`, EXIT.USAGE, `run: ${CLI} hooks status`);
42
43
  }
@@ -44,17 +45,23 @@ export async function hooksCommand(sub, args, flags) {
44
45
  /** The shim execs this; the event name is baked into the shim, so it is arg 0. */
45
46
  async function runHook(args, flags) {
46
47
  const event = args[0];
47
- const root = flags.root ?? process.cwd();
48
+ const root = commandRoot(flags);
49
+ const ctx = repoContext(root);
48
50
  if (!EVENTS.includes(event)) {
49
51
  return fail(`unknown hook event ${event}`, EXIT.USAGE, `known events: ${EVENTS.join(', ')}`);
50
52
  }
51
- const { manifest, exists, error } = await readManifest(root);
53
+ const { manifest, exists, error } = await ctx.manifest();
52
54
  if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG);
53
55
  if (!exists) {
54
56
  return fail(`${root} has no ${MANIFEST_FILE}; the hook cannot know its gate list`, EXIT.CONFIG, `run: ${CLI} init --root ${root}`);
55
57
  }
56
58
 
57
- const result = await runGates(event, { root, manifest });
59
+ // git feeds pre-push its ref list on stdin, and the docs-only skip (ADR-113)
60
+ // needs it. A manual run from a terminal has a TTY and reads empty, which the
61
+ // dispatcher treats as "no push to classify" and answers with the full suite.
62
+ const pushed = event === 'pre-push' ? await readStdin() : undefined;
63
+
64
+ const result = await runGates(event, { root, manifest, pushed });
58
65
  if (result.error) return fail(result.error, EXIT.USAGE);
59
66
 
60
67
  if (isJson(flags)) {
@@ -63,10 +70,33 @@ async function runHook(args, flags) {
63
70
  }
64
71
 
65
72
  for (const n of result.notes ?? []) note(`note ${n}`);
66
- emit(renderGateReport(event, manifest.gates[GATE_KEY[event]] ?? [], result).join('\n'), { maxBytes: flags.maxBytes ?? 0 });
73
+ emit(renderGateReport(event, manifest.gates[GATE_KEY[event]] ?? [], result).join('\n'));
67
74
  return result.failed ? EXIT.FAIL : EXIT.OK;
68
75
  }
69
76
 
77
+ /**
78
+ * Read all of stdin, or '' when there is none to read.
79
+ *
80
+ * The pre-push shim execs this command, so git's ref list arrives on the
81
+ * dispatcher's stdin. A TTY (a manual run from a terminal) has no ref list and
82
+ * reads empty - and empty is the fail-closed answer: the dispatcher cannot
83
+ * classify a push it was never told about, so the suite runs.
84
+ *
85
+ * @returns {Promise<string>}
86
+ */
87
+ function readStdin() {
88
+ return new Promise((resolve) => {
89
+ if (process.stdin.isTTY) {
90
+ resolve('');
91
+ return;
92
+ }
93
+ const chunks = [];
94
+ process.stdin.on('data', (chunk) => chunks.push(chunk));
95
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
96
+ process.stdin.resume();
97
+ });
98
+ }
99
+
70
100
  /**
71
101
  * The path to write into a chained shim: relative to the repository's top level
72
102
  * wherever the hook lives inside it.
@@ -111,7 +141,7 @@ async function relativeTarget(root, target) {
111
141
  * caller enforces in its own voice (`takeover`, and the `wanted`/`obsolete`
112
142
  * emptiness the command refuses on)
113
143
  */
114
- export async function planHooksInstall(root, { manifest, exists }) {
144
+ export async function planHooksInstall(root, { manifest, exists }, ctx = null) {
115
145
  const declaresGate = (event) => wantsShim(manifest, exists, event);
116
146
  const state = await readHooksState(root, { declaresGate });
117
147
  const current = state.current;
@@ -123,6 +153,24 @@ export async function planHooksInstall(root, { manifest, exists }) {
123
153
  // install where there is no node_modules to pin in.
124
154
  const version = packageVersion();
125
155
  const npm = await hasNpm(root);
156
+ // Q31: installed shims name the repo's own installer in their remedy.
157
+ // Lockfile first, `packageManager` next, npm default — the one resolver.
158
+ let manager = null;
159
+ if (npm) {
160
+ const { resolveNodeRunner } = await import('../ecosystem/node-runner.mjs');
161
+ let packageManager = null;
162
+ try {
163
+ const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
164
+ if (typeof pkg.packageManager === 'string') packageManager = pkg.packageManager;
165
+ } catch {
166
+ // Unreadable here leaves the npm default; the manifest path reports it.
167
+ }
168
+ // The dynamic import stays: a hook run must not pull the fingerprint module in
169
+ // just by loading this one. A caller that already holds a context passes it and
170
+ // the walk is shared (review 0003 Q67).
171
+ const detected = ctx ? await ctx.fingerprint() : await (await import('../resolve/fingerprint.mjs')).fingerprint(root);
172
+ manager = resolveNodeRunner({ facts: detected.facts, packageManager }).id;
173
+ }
126
174
 
127
175
  // Only install a shim for an event that declares at least one gate. A shim with
128
176
  // an empty gate list enforces nothing, yet merely existing it REPLACES whatever
@@ -150,8 +198,8 @@ export async function planHooksInstall(root, { manifest, exists }) {
150
198
  //
151
199
  // `foreignHooks` lists every executable file in the directory git reads hooks from -
152
200
  // and when that directory is this tool's own, a file a person dropped in there is
153
- // listed too. Chaining it means writing a shim at `.simt-hooks/<name>` whose TARGET is
154
- // `.simt-hooks/<name>`: the same path. The shim overwrites the hook it was written to
201
+ // listed too. Chaining it means writing a shim at `.simt/hooks/<name>` whose TARGET is
202
+ // `.simt/hooks/<name>`: the same path. The shim overwrites the hook it was written to
155
203
  // preserve and then runs itself, so the hook stops working and git recurses in a loop
156
204
  // nothing reports. Confirmed at the previous release, which printed
157
205
  // `UPDATED .simt-hooks/mine (chained: runs .simt-hooks/mine)`.
@@ -224,7 +272,7 @@ export async function planHooksInstall(root, { manifest, exists }) {
224
272
  const evidenceDeclared = manifest?.gates?.evidence === 'warn' || manifest?.gates?.evidence === 'block';
225
273
  const evidenceFile = fileFor('commit-msg');
226
274
  const evidenceExisting = await readIfExists(join(root, evidenceFile));
227
- const evidenceSource = evidenceDeclared ? renderEvidenceShim({ npm, version }) : null;
275
+ const evidenceSource = evidenceDeclared ? renderEvidenceShim({ npm, manager, version }) : null;
228
276
  const evidenceOurs = evidenceExisting !== null && evidenceExisting.includes(SHIM_MARKER);
229
277
  if (!evidenceDeclared && evidenceOurs) obsolete.push({ event: 'commit-msg' });
230
278
  // A foreign commit-msg would otherwise be chained onto the same path. The chain
@@ -243,6 +291,7 @@ export async function planHooksInstall(root, { manifest, exists }) {
243
291
  current,
244
292
  wants,
245
293
  npm,
294
+ manager,
246
295
  version,
247
296
  wanted,
248
297
  chained,
@@ -257,9 +306,50 @@ export async function planHooksInstall(root, { manifest, exists }) {
257
306
  evidenceExisting,
258
307
  routed,
259
308
  takeover,
309
+ // The legacy directory's removal rides on the same planned write, so the
310
+ // scan is here and the deletion is in `applyHooksInstall` (ADR-122).
311
+ legacy: await legacyHooksScan(root),
260
312
  };
261
313
  }
262
314
 
315
+ /**
316
+ * Scan the legacy top-level shim directory (ADR-122). A repository installed
317
+ * before the move into the state directory still carries it, and it is
318
+ * removable only when every entry in it is a regular file that carries a
319
+ * simt-dev marker: the install re-routes git to the new directory, and a
320
+ * file this tool cannot recognise as its own is never deleted on its way.
321
+ *
322
+ * @returns {Promise<{path: string, removable: boolean}|null>} null when the
323
+ * directory is absent or empty; otherwise the directory and whether the
324
+ * install may take it down. `applyHooksInstall` acts on this, so the
325
+ * judgment is made once, in the plan. Module-local: one caller needs no
326
+ * export, and an export is a promise to a caller (deadcode).
327
+ */
328
+ async function legacyHooksScan(root) {
329
+ const dir = join(root, HOOKS_DIR_LEGACY);
330
+ let entries;
331
+ try {
332
+ entries = await readdir(dir);
333
+ } catch {
334
+ return null;
335
+ }
336
+ if (entries.length === 0) return null;
337
+ let removable = true;
338
+ for (const name of entries) {
339
+ const st = await stat(join(dir, name));
340
+ if (!st.isFile()) {
341
+ removable = false;
342
+ break;
343
+ }
344
+ const text = await readFile(join(dir, name), 'utf8');
345
+ if (!text.includes(SHIM_MARKER) && !text.includes(CHAIN_MARKER)) {
346
+ removable = false;
347
+ break;
348
+ }
349
+ }
350
+ return { path: HOOKS_DIR_LEGACY, removable };
351
+ }
352
+
263
353
  /**
264
354
  * Write a planned install: shims, chains, removals, and the git routing (plan 0003 R47).
265
355
  *
@@ -268,26 +358,30 @@ export async function planHooksInstall(root, { manifest, exists }) {
268
358
  * names the run that actually changed something. Reporting stays with the
269
359
  * caller for the same reason it does in `planHooksInstall`.
270
360
  *
271
- * @returns {{wrote: string[]}} the shim paths written, removed, or refreshed
361
+ * @returns {{wrote: string[], removed: string[]}} the shim paths written,
362
+ * removed or refreshed, and the legacy-directory files taken down
272
363
  */
273
364
  export async function applyHooksInstall(root, planned, journal) {
274
- const { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, version, routed, wants, current } =
365
+ const { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, manager, version, routed, wants, current, legacy } =
275
366
  planned;
276
- // Shim and evidence bytes are journaled so `undo` restores them (R49). The
277
- // `core.hooksPath` config below is deliberately not journaled: it is git
278
- // config, not a repo file, and `undo` restores files.
367
+ // Shim and evidence bytes are journaled so `undo` restores them (R49), with
368
+ // the executable bit they are written with: a shim the run chmodded is one
369
+ // change, and `undo` must restore the mode it found, not just the bytes.
370
+ // The `core.hooksPath` routing below is journaled as a `git-config` entry
371
+ // for the same reason — unwinding the shims while the config still points
372
+ // at their directory silently stops the repository's own hooks (review 0003
373
+ // Q22).
279
374
  const wrote = [];
375
+ const removed = [];
280
376
  await mkdir(join(root, HOOKS_DIR), { recursive: true });
281
377
  for (const event of wanted) {
282
378
  const path = join(root, fileFor(event));
283
- await journaledWriteFile(root, journal, path, renderShim(event, { npm, version }));
284
- await chmod(path, 0o755);
379
+ await journaledWriteFile(root, journal, path, renderShim(event, { npm, manager, version }), { mode: 0o755 });
285
380
  wrote.push(fileFor(event));
286
381
  }
287
382
  for (const c of chained) {
288
383
  const path = join(root, fileFor(c.event));
289
- await journaledWriteFile(root, journal, path, renderChainedShim(c.event, c.target));
290
- await chmod(path, 0o755);
384
+ await journaledWriteFile(root, journal, path, renderChainedShim(c.event, c.target), { mode: 0o755 });
291
385
  wrote.push(fileFor(c.event));
292
386
  }
293
387
  for (const h of obsolete) {
@@ -297,8 +391,7 @@ export async function applyHooksInstall(root, planned, journal) {
297
391
  // After the chain loop, so a chained commit-msg cannot replace the claim check.
298
392
  if (evidenceSource) {
299
393
  const path = join(root, evidenceFile);
300
- await journaledWriteFile(root, journal, path, evidenceSource);
301
- await chmod(path, 0o755);
394
+ await journaledWriteFile(root, journal, path, evidenceSource, { mode: 0o755 });
302
395
  wrote.push(evidenceFile);
303
396
  }
304
397
  // `core.hooksPath` tells git to read ONE directory for EVERY event, so leaving it
@@ -306,11 +399,23 @@ export async function applyHooksInstall(root, planned, journal) {
306
399
  // `.git/hooks` that predate this tool - from running, silently. Unsetting it is the
307
400
  // only way back, and this is the only place that knows the last shim just left.
308
401
  if (routed) {
309
- await exec('git', ['config', HOOKS_PATH_KEY, wants], { cwd: root });
402
+ await journaledGitConfig(root, journal, HOOKS_PATH_KEY, wants);
310
403
  } else if (current === wants) {
311
- await exec('git', ['config', '--unset', HOOKS_PATH_KEY], { cwd: root });
404
+ await journaledGitConfig(root, journal, HOOKS_PATH_KEY, null);
405
+ }
406
+ // The move into the state directory (ADR-122): the routing now points
407
+ // elsewhere, so a legacy directory left behind is the only place that looks
408
+ // like a hooks directory while holding nothing git reads. The scan in the
409
+ // plan already judged it removable — every file a simt-dev marker — so
410
+ // deletion is journaled per file, and `undo` restores the directory.
411
+ if (legacy?.removable) {
412
+ for (const name of await readdir(join(root, HOOKS_DIR_LEGACY))) {
413
+ await journaledRemoveFile(root, journal, join(root, HOOKS_DIR_LEGACY, name));
414
+ removed.push(`${HOOKS_DIR_LEGACY}/${name}`);
415
+ }
416
+ await rm(join(root, HOOKS_DIR_LEGACY), { recursive: true });
312
417
  }
313
- return { wrote };
418
+ return { wrote, removed };
314
419
  }
315
420
 
316
421
  /**
@@ -320,11 +425,11 @@ export async function applyHooksInstall(root, planned, journal) {
320
425
  * `doctor --fix` reuses it rather than deriving a second answer.
321
426
  */
322
427
  export async function plannedDiffs(root, planned) {
323
- const { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, version } = planned;
428
+ const { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, manager, version } = planned;
324
429
  const pairs = [];
325
430
  for (const event of wanted) {
326
431
  const path = fileFor(event);
327
- pairs.push({ path, before: await readBefore(join(root, path)), after: renderShim(event, { npm, version }) });
432
+ pairs.push({ path, before: await readBefore(join(root, path)), after: renderShim(event, { npm, manager, version }) });
328
433
  }
329
434
  for (const c of chained) {
330
435
  const path = fileFor(c.event);
@@ -340,8 +445,8 @@ export async function plannedDiffs(root, planned) {
340
445
  return pairs;
341
446
  }
342
447
 
343
- async function installHooks(root, flags) {
344
- const { manifest, exists, error } = await readManifest(root);
448
+ async function installHooks(root, flags, ctx = repoContext(root)) {
449
+ const { manifest, exists, error } = await ctx.manifest();
345
450
  if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG);
346
451
  if (!exists) {
347
452
  return fail(`${root} has no ${MANIFEST_FILE}; gates must be declared before hooks can run them`, EXIT.CONFIG, `run: ${CLI} init --root ${root}`);
@@ -355,6 +460,7 @@ async function installHooks(root, flags) {
355
460
  current,
356
461
  wants,
357
462
  npm,
463
+ manager,
358
464
  version,
359
465
  wanted,
360
466
  chained,
@@ -366,7 +472,8 @@ async function installHooks(root, flags) {
366
472
  evidenceExisting,
367
473
  routed,
368
474
  takeover,
369
- } = await planHooksInstall(root, { manifest, exists });
475
+ legacy,
476
+ } = await planHooksInstall(root, { manifest, exists }, ctx);
370
477
 
371
478
  // Read through `writeMode` like every other writer, and only now: this command
372
479
  // used to consult `flags.yes` and `flags.dryRun` directly and write when neither
@@ -395,7 +502,6 @@ async function installHooks(root, flags) {
395
502
  ' and replace it with a gate that checks nothing.',
396
503
  `REMEDY add the gates this repo can actually run to ${MANIFEST_FILE}, then re-run.`,
397
504
  ].join('\n'),
398
- { maxBytes: flags.maxBytes ?? 0 },
399
505
  );
400
506
  return EXIT.FAIL;
401
507
  }
@@ -403,7 +509,7 @@ async function installHooks(root, flags) {
403
509
  const plan = [];
404
510
  for (const event of wanted) {
405
511
  const path = join(root, fileFor(event));
406
- const source = renderShim(event, { npm, version });
512
+ const source = renderShim(event, { npm, manager, version });
407
513
  const existing = await readIfExists(path);
408
514
  if (existing === source) {
409
515
  plan.push({ file: fileFor(event), action: 'unchanged' });
@@ -444,11 +550,21 @@ async function installHooks(root, flags) {
444
550
  note: 'commit-msg checks a verification claim against the ledger',
445
551
  });
446
552
  }
553
+ if (legacy?.removable) {
554
+ plan.push({
555
+ file: HOOKS_DIR_LEGACY,
556
+ action: 'removed',
557
+ // Named in the plan for the same reason the obsolete shims are: the
558
+ // reader approving this write is about to lose a directory, and the
559
+ // routing line above does not say the old one goes too (ADR-122).
560
+ note: `legacy shim directory; the shims now live in ${HOOKS_DIR}`,
561
+ });
562
+ }
447
563
 
448
564
  // The routing this install leaves behind, which is not always `wants`. A repository
449
565
  // that removed its last gate has no shims to route to, and leaving `core.hooksPath`
450
566
  // pointed at the directory would stop its own hooks running - the plan has to say so,
451
- // because a plan that prints "core.hooksPath updated .simt-hooks -> .simt-hooks" while
567
+ // because a plan that prints "core.hooksPath updated .simt/hooks -> .simt/hooks" while
452
568
  // the command unsets it is worse than no plan.
453
569
  const leaves = routed ? wants : null;
454
570
  const pathAction =
@@ -478,7 +594,6 @@ async function installHooks(root, flags) {
478
594
  ' Taking over replaces that routing. Inspect the existing hooks first,',
479
595
  ' then re-run with --yes to accept, or set core.hooksPath yourself.',
480
596
  ].join('\n'),
481
- { maxBytes: flags.maxBytes ?? 0 },
482
597
  );
483
598
  return EXIT.FAIL;
484
599
  }
@@ -487,7 +602,7 @@ async function installHooks(root, flags) {
487
602
  // true in preview and in apply alike (R50).
488
603
  const diffs =
489
604
  flags.diff === true
490
- ? await plannedDiffs(root, { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, version })
605
+ ? await plannedDiffs(root, { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, manager, version })
491
606
  : [];
492
607
 
493
608
  if (!mode.apply) {
@@ -523,7 +638,6 @@ async function installHooks(root, flags) {
523
638
  : `PLAN nothing written; apply with: ${CLI} hooks install --root ${root} --yes`,
524
639
  manifest,
525
640
  ),
526
- { maxBytes: flags.maxBytes ?? 0 },
527
641
  );
528
642
  // After the report: the report says what, the diff shows it (R50).
529
643
  emitDiffs(diffs, flags);
@@ -535,7 +649,7 @@ async function installHooks(root, flags) {
535
649
  const journal = [];
536
650
  await applyHooksInstall(
537
651
  root,
538
- { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, version, routed, wants, current },
652
+ { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, manager, version, routed, wants, current, legacy },
539
653
  journal,
540
654
  );
541
655
  await commitJournal(root, 'hooks install', journal);
@@ -612,7 +726,6 @@ async function installHooks(root, flags) {
612
726
  routed ? 'WROTE shims and set core.hooksPath' : 'WROTE no shims remain; core.hooksPath unset',
613
727
  manifest,
614
728
  ),
615
- { maxBytes: flags.maxBytes ?? 0 },
616
729
  );
617
730
  // After the report: the report says what, the diff shows it (R50).
618
731
  emitDiffs(diffs, flags);
@@ -641,8 +754,8 @@ function describeGates(manifest, key) {
641
754
  }).join(', ');
642
755
  }
643
756
 
644
- async function hooksStatus(root, flags) {
645
- const { manifest, exists, error } = await readManifest(root);
757
+ async function hooksStatus(root, flags, ctx = repoContext(root)) {
758
+ const { manifest, exists, error } = await ctx.manifest();
646
759
  if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG);
647
760
 
648
761
  const state = await readHooksState(root, { declaresGate: (e) => wantsShim(manifest, exists, e) });
@@ -727,14 +840,14 @@ async function hooksStatus(root, flags) {
727
840
  const unknown = [...(manifest?.gates?.preCommit ?? []), ...(manifest?.gates?.prePush ?? [])].filter((n) => !GATES[n]);
728
841
  if (unknown.length > 0) {
729
842
  lines.push('', `unknown gates (the hook will fail): ${unknown.join(', ')}`);
730
- lines.push(`known gates: ${knownGates().join(', ')}`);
843
+ lines.push(`known gates: ${gateNames().join(', ')}`);
731
844
  }
732
845
  }
733
846
  if (problems.length > 0) {
734
847
  lines.push('', 'problems:');
735
848
  for (const p of problems) lines.push(` ${p}`);
736
849
  }
737
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
850
+ emit(lines.join('\n'));
738
851
  return code;
739
852
  }
740
853
 
@@ -14,9 +14,10 @@ import { CLI } from '../name.mjs';
14
14
  import { commitJournal, journaledWriteFile } from '../journal.mjs';
15
15
  import { diffField, emitDiffs, readBefore } from '../diff.mjs';
16
16
  import { CLAUDE_SETTINGS, IGNORE_FILE, parseIgnoreList, planIgnore } from '../ignore.mjs';
17
+ import { commandRoot } from '../repo-root.mjs';
17
18
 
18
19
  export async function ignoreCommand(args, flags) {
19
- const root = flags.root ?? process.cwd();
20
+ const root = commandRoot(flags);
20
21
 
21
22
  let source;
22
23
  try {
@@ -9,7 +9,7 @@ import { readFile } from 'node:fs/promises';
9
9
  import { basename, join, resolve } from 'node:path';
10
10
  import { promisify } from 'node:util';
11
11
 
12
- import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
12
+ import { EXIT, fail, present } from '../output/index.mjs';
13
13
  import { CLI } from '../name.mjs';
14
14
  import { discover } from '../compare/discover.mjs';
15
15
  import { extract } from '../index/extract.mjs';
@@ -25,6 +25,7 @@ import {
25
25
  import { decisionHits, scopeHits, sortHits, sourceRoots } from '../index/govern.mjs';
26
26
  import { decisionRecords, ruleScopes } from './why.mjs';
27
27
  import { resolveWorkspace } from '../workspace.mjs';
28
+ import { commandRoot } from '../repo-root.mjs';
28
29
 
29
30
  const exec = promisify(execFile);
30
31
  const TEXT_EXT = new Set(['js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'json', 'yaml', 'yml', 'md', 'toml', 'xml', 'py', 'go', 'kt', 'java', 'rs']);
@@ -35,7 +36,7 @@ export async function impactCommand(args, flags, commands = []) {
35
36
  if (args.length > 1) return fail(`unknown impact argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} impact <file|symbol>`);
36
37
  const target = args[0];
37
38
  if (!target) return fail(`a file or a symbol is required`, EXIT.USAGE, `run: ${CLI} impact <file|symbol>`);
38
- const root = flags.root ?? process.cwd();
39
+ const root = commandRoot(flags);
39
40
  const listed = await tracked(root);
40
41
  if (listed.error) return listed.error;
41
42
  const files = new Set(listed.files);
@@ -63,11 +64,7 @@ export async function impactCommand(args, flags, commands = []) {
63
64
  consumers: consumers.references,
64
65
  truncated,
65
66
  };
66
- if (isJson(flags)) {
67
- emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
68
- return EXIT.OK;
69
- }
70
- emit(render(payload), { maxBytes: flags.maxBytes ?? 0 });
67
+ present(payload, () => render(payload), flags);
71
68
  return EXIT.OK;
72
69
  }
73
70
 
@@ -191,8 +188,10 @@ function textExt(file) {
191
188
 
192
189
  async function tracked(root) {
193
190
  try {
194
- const { stdout } = await exec('git', ['ls-files'], { cwd: root, maxBuffer: 16 << 20 });
195
- return { files: stdout.split('\n').filter(Boolean) };
191
+ // `-z`: without it git C-quotes a non-ASCII path and the reader's real
192
+ // filename never matches it (review 0003 Q39).
193
+ const { stdout } = await exec('git', ['ls-files', '-z'], { cwd: root, maxBuffer: 16 << 20 });
194
+ return { files: stdout.split('\0').filter(Boolean) };
196
195
  } catch (error) {
197
196
  return {
198
197
  error: fail(