simt-dev 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/CHANGELOG.md +191 -870
  2. package/README.md +188 -213
  3. package/bin/simt.mjs +19 -55
  4. package/docs/decisions/ADR-014-workspace-is-configuration.md +2 -2
  5. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  7. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  8. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  9. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  10. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  11. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  12. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  13. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  14. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  15. package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
  16. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  17. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  18. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  19. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  20. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  21. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  22. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  23. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  24. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  25. package/ecosystems/bun.json +6 -0
  26. package/ecosystems/cargo.json +25 -0
  27. package/ecosystems/composer.json +6 -0
  28. package/ecosystems/dart.json +24 -0
  29. package/ecosystems/deno.json +21 -0
  30. package/ecosystems/devcontainers.json +6 -0
  31. package/ecosystems/docker-compose.json +6 -0
  32. package/ecosystems/docker.json +6 -0
  33. package/ecosystems/dotnet.json +25 -0
  34. package/ecosystems/elixir.json +23 -0
  35. package/ecosystems/gitsubmodule.json +6 -0
  36. package/ecosystems/go.json +25 -0
  37. package/ecosystems/gradle.json +13 -0
  38. package/ecosystems/maven.json +23 -0
  39. package/ecosystems/node.json +25 -0
  40. package/ecosystems/python.json +20 -0
  41. package/ecosystems/ruby.json +23 -0
  42. package/ecosystems/swift.json +25 -0
  43. package/ecosystems/terraform.json +6 -0
  44. package/ecosystems/uv.json +6 -0
  45. package/harnesses/antigravity.json +162 -0
  46. package/harnesses/claude.json +206 -0
  47. package/harnesses/codex.json +136 -0
  48. package/harnesses/copilot.json +88 -0
  49. package/harnesses/cursor.json +140 -0
  50. package/harnesses/deepseek.json +99 -0
  51. package/harnesses/gemini.json +122 -0
  52. package/harnesses/opencode.json +173 -0
  53. package/package.json +12 -2
  54. package/rules/core.md +7 -0
  55. package/rules/mdc/intake-questions.mdc +8 -0
  56. package/rules/mdc/slice-commit.mdc +57 -4
  57. package/src/advice/advice.mjs +3 -2
  58. package/src/agenthooks/prompt.mjs +5 -2
  59. package/src/agenthooks/render.mjs +86 -180
  60. package/src/agenthooks/shim.mjs +1 -1
  61. package/src/agents/render.mjs +15 -18
  62. package/src/api.mjs +31 -0
  63. package/src/argv.mjs +48 -3
  64. package/src/bench/ablate.mjs +3 -4
  65. package/src/bench/adapters.json +163 -105
  66. package/src/bench/adapters.mjs +466 -0
  67. package/src/bench/arms.mjs +333 -0
  68. package/src/bench/grade.mjs +33 -73
  69. package/src/bench/graders.mjs +42 -0
  70. package/src/bench/mine.mjs +121 -475
  71. package/src/bench/probe-ledger.mjs +149 -0
  72. package/src/bench/probes.catalog.mjs +918 -0
  73. package/src/bench/probes.mjs +79 -1064
  74. package/src/bench/render.mjs +111 -0
  75. package/src/bench/report.mjs +120 -962
  76. package/src/bench/run.mjs +185 -802
  77. package/src/bench/variants.mjs +4 -5
  78. package/src/bench/workspace.mjs +160 -0
  79. package/src/brief/assemble.mjs +48 -15
  80. package/src/ci/dependabot.mjs +25 -55
  81. package/src/ci/workflow.mjs +30 -7
  82. package/src/cli.mjs +45 -0
  83. package/src/commands/agent.mjs +4 -13
  84. package/src/commands/agenthook.mjs +70 -40
  85. package/src/commands/bench.mjs +191 -18
  86. package/src/commands/brief.mjs +5 -4
  87. package/src/commands/ci.mjs +13 -14
  88. package/src/commands/compare.mjs +42 -42
  89. package/src/commands/completions.mjs +2 -6
  90. package/src/commands/decide.mjs +4 -4
  91. package/src/commands/distill.mjs +4 -3
  92. package/src/commands/docs.mjs +88 -0
  93. package/src/commands/doctor.mjs +11 -7
  94. package/src/commands/evidence.mjs +6 -9
  95. package/src/commands/explain.mjs +0 -1
  96. package/src/commands/gate.mjs +26 -25
  97. package/src/commands/graph.mjs +2 -3
  98. package/src/commands/harness.mjs +6 -5
  99. package/src/commands/help.mjs +168 -19
  100. package/src/commands/hooks.mjs +119 -49
  101. package/src/commands/ignore.mjs +13 -8
  102. package/src/commands/impact.mjs +8 -15
  103. package/src/commands/index.mjs +318 -193
  104. package/src/commands/init-scan.mjs +90 -0
  105. package/src/commands/init.mjs +124 -105
  106. package/src/commands/lease.mjs +73 -25
  107. package/src/commands/lessons.mjs +10 -9
  108. package/src/commands/local.mjs +3 -3
  109. package/src/commands/map.mjs +24 -14
  110. package/src/commands/mcp.mjs +2 -18
  111. package/src/commands/pack.mjs +9 -6
  112. package/src/commands/plan.mjs +6 -5
  113. package/src/commands/plugin.mjs +2 -2
  114. package/src/commands/prbody.mjs +3 -3
  115. package/src/commands/repoindex.mjs +12 -11
  116. package/src/commands/review.mjs +2 -2
  117. package/src/commands/rules.mjs +37 -16
  118. package/src/commands/shared.mjs +14 -19
  119. package/src/commands/skills.mjs +33 -32
  120. package/src/commands/skillscompile.mjs +7 -8
  121. package/src/commands/skillsinstall.mjs +7 -6
  122. package/src/commands/slice.mjs +220 -74
  123. package/src/commands/spec.mjs +167 -0
  124. package/src/commands/status.mjs +27 -18
  125. package/src/commands/sync.mjs +27 -25
  126. package/src/commands/tests.mjs +18 -21
  127. package/src/commands/tokens.mjs +21 -26
  128. package/src/commands/undo.mjs +5 -15
  129. package/src/commands/upgrade.mjs +15 -7
  130. package/src/commands/why.mjs +15 -21
  131. package/src/compare/hooksstate.mjs +2 -6
  132. package/src/compare/learn.mjs +3 -1
  133. package/src/compare/row.mjs +0 -2
  134. package/src/context.mjs +64 -0
  135. package/src/decisions/index-doc.mjs +46 -0
  136. package/src/decisions/index.mjs +3 -2
  137. package/src/diff.mjs +1 -1
  138. package/src/distill/distill.mjs +14 -1
  139. package/src/distill/engine.mjs +5 -1
  140. package/src/docs/commands.mjs +65 -0
  141. package/src/ecosystem/records.mjs +219 -0
  142. package/src/explain.mjs +31 -0
  143. package/src/gates/index.mjs +385 -0
  144. package/src/graph/compile.mjs +8 -4
  145. package/src/graph/load.mjs +6 -2
  146. package/src/graph/spec.mjs +10 -8
  147. package/src/harness/records.mjs +143 -0
  148. package/src/hooks/capability.mjs +7 -5
  149. package/src/hooks/dispatcher.mjs +209 -60
  150. package/src/hooks/gates.mjs +10 -43
  151. package/src/hooks/resolve.mjs +4 -14
  152. package/src/hooks/shim.mjs +5 -2
  153. package/src/hooks/where.mjs +2 -7
  154. package/src/identity.mjs +31 -12
  155. package/src/index/coverage.mjs +58 -0
  156. package/src/index/extract.mjs +6 -16
  157. package/src/index/gate.mjs +1 -1
  158. package/src/index/govern.mjs +26 -9
  159. package/src/index/impact.mjs +4 -8
  160. package/src/index/map.mjs +14 -15
  161. package/src/index/open.mjs +173 -0
  162. package/src/index/persist.mjs +224 -57
  163. package/src/index/store.mjs +82 -39
  164. package/src/index/tests.mjs +2 -6
  165. package/src/journal.mjs +94 -20
  166. package/src/kernel/card.mjs +37 -63
  167. package/src/kernel/checks.mjs +4 -10
  168. package/src/lease.mjs +102 -7
  169. package/src/ledger/index.mjs +150 -14
  170. package/src/local/client.mjs +4 -6
  171. package/src/locate.mjs +33 -9
  172. package/src/manifest/schema.mjs +213 -0
  173. package/src/mcp/paginate.mjs +33 -0
  174. package/src/mcp/resources.mjs +7 -10
  175. package/src/mcp/schemas.mjs +512 -0
  176. package/src/mcp/server.mjs +119 -25
  177. package/src/mcp/tools-facts.mjs +326 -0
  178. package/src/mcp/tools-read.mjs +221 -0
  179. package/src/mcp/tools.mjs +4 -967
  180. package/src/measure/corpus.mjs +150 -0
  181. package/src/measure/metrics.mjs +236 -0
  182. package/src/measure/records.mjs +211 -0
  183. package/src/measure/session.mjs +3 -2
  184. package/src/measure/stats.mjs +538 -0
  185. package/src/measure/task-schema.mjs +125 -0
  186. package/src/measure/tasks.mjs +69 -0
  187. package/src/memory/decide.mjs +6 -15
  188. package/src/memory/lesson-text.mjs +61 -0
  189. package/src/memory/lessons.mjs +29 -82
  190. package/src/name.mjs +7 -0
  191. package/src/node-version.mjs +37 -0
  192. package/src/output/index.mjs +209 -10
  193. package/src/packs/manage.mjs +32 -12
  194. package/src/packs.mjs +20 -2
  195. package/src/plan/index.mjs +1 -1
  196. package/src/plugins.mjs +22 -16
  197. package/src/prbody.mjs +7 -11
  198. package/src/projection/loaders.mjs +7 -692
  199. package/src/projection/lock.mjs +11 -1
  200. package/src/projection/manifest.mjs +29 -468
  201. package/src/projection/mdc.mjs +7 -8
  202. package/src/projection/project.mjs +126 -221
  203. package/src/projection/region.mjs +561 -0
  204. package/src/projection/rules.mjs +20 -550
  205. package/src/projection/surfaces.mjs +43 -210
  206. package/src/projection/targets.mjs +5 -5
  207. package/src/repo-root.mjs +31 -0
  208. package/src/resolve/eval.mjs +2 -1
  209. package/src/resolve/fingerprint.mjs +19 -22
  210. package/src/resolve/resolve.mjs +1 -41
  211. package/src/review/critic.mjs +10 -13
  212. package/src/run.mjs +98 -0
  213. package/src/secrets.mjs +14 -22
  214. package/src/state/events.mjs +129 -0
  215. package/src/status-snapshot.mjs +233 -0
  216. package/src/status.mjs +2 -6
  217. package/src/sys/git.mjs +82 -0
  218. package/src/sys/proc.mjs +151 -0
  219. package/src/testing/parsers.mjs +265 -0
  220. package/src/testing/patches.mjs +23 -0
  221. package/src/testing/paths.mjs +49 -0
  222. package/src/testing/runners.mjs +186 -0
  223. package/src/testing/vault.mjs +68 -0
  224. package/src/text/frontmatter.mjs +49 -0
  225. package/src/text/tokens.mjs +52 -0
  226. package/src/toolwords.mjs +3 -0
  227. package/src/util/fs.mjs +20 -0
  228. package/src/util/sort.mjs +12 -0
  229. package/src/workspace.mjs +58 -11
  230. package/src/write/plan.mjs +82 -0
  231. package/templates/plan/tracker.json +1 -0
  232. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  233. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  234. package/docs/decisions/README.md +0 -600
  235. package/src/bench/tasks.mjs +0 -36
@@ -1,8 +1,12 @@
1
- import { emit, fail, jsonFailMode } from '../output/index.mjs';
1
+ import { emit, emitJson, fail, jsonFailMode } from '../output/index.mjs';
2
+ import { SPEC, SPEC_PATHS, tierOf } from './spec.mjs';
2
3
  import { parse, rawJsonFlag } from '../argv.mjs';
3
4
  import { CLI, MANIFEST_FILE, STATE_DIR } from '../name.mjs';
4
5
  import { SESSION_ENV } from '../agenthooks/policy.mjs';
5
6
  import { DEFAULT_DIRECTORIES, DEFAULT_TEMPLATE } from '../plan/template.mjs';
7
+ import { commandRoot } from '../repo-root.mjs';
8
+ import { readManifest } from '../projection/manifest.mjs';
9
+ import { assertVersionPin } from '../package-version.mjs';
6
10
 
7
11
  const COMMANDS = `${CLI} init [path] adopt or scaffold a repo into the ecosystem
8
12
  (dry-run unless --yes; conflicts block without
@@ -145,7 +149,7 @@ ${CLI} lessons list deterministic lesson candidates in .simt/local/le
145
149
  and promoted lessons in docs/lessons/*.md (reads nothing else)
146
150
  ${CLI} lessons capture scan the ledger and git history for new candidates (the write half of list)
147
151
  ${CLI} lessons review [<id>] promote a lesson candidate to docs/lessons/NNNN-<slug>.md
148
- (dry-run unless --yes; writes Appendix A frontmatter)
152
+ (dry-run unless --yes; writes the lesson frontmatter)
149
153
  ${CLI} explain <CODE> what a check finding means: the decision behind it, how
150
154
  to fix it, and a concrete trigger (DRIFT, UNMANAGED,
151
155
  PACK-DIGEST-MISMATCH, ...; case-insensitive)
@@ -168,7 +172,7 @@ ${CLI} distill <skill|doc> a budgeted proposal summary of a skill body or do
168
172
  from the configured chat model instead; the cache is
169
173
  keyed by source digest and budget, --refresh regenerates)
170
174
  ${CLI} review [--local] an ADR-008 verdict skeleton for the staged diff,
171
- the brief listing everything that governs it (R25)
175
+ the brief listing everything that governs it
172
176
  (deterministic without --local: the brief and its
173
177
  governing ids only; --local proposes from the
174
178
  configured chat model and writes
@@ -219,6 +223,16 @@ ${CLI} bench critic precision and recall of the local critic over a lab
219
223
  overrides --labels; labels are human-written verdicts
220
224
  under ADR-008; the critic is advisory until precision
221
225
  reaches 0.8 on 30 labelled diffs)
226
+ ${CLI} bench export <file> [--yes]
227
+ sealed export of the mined corpus: every task
228
+ descriptor and both hidden patches in one signed
229
+ JSON file, for import on another machine
230
+ (dry-run unless --yes; --root selects the repository)
231
+ ${CLI} bench import <file> [--yes]
232
+ import a sealed corpus: verify the seal, then write
233
+ the task descriptors and hidden patches into the
234
+ repository's vault (dry-run unless --yes; --root
235
+ selects the repository)
222
236
  ${CLI} pack new <name> --source <spec>
223
237
  scaffold a versioned pack: pack.json, an unmeasured
224
238
  bench.json, and one stack rule (dry-run unless --yes;
@@ -231,6 +245,7 @@ ${CLI} pack publish [dir] [--yes]
231
245
  publish an npm-sourced pack to the registry
232
246
  (dry-run unless --yes; skips a version already live;
233
247
  a git-sourced pack names the tag-and-push instead)
248
+ ${CLI} docs readme --yes render the README command list from the spec
234
249
  ${CLI} mcp the read-only surface as MCP tools over stdio
235
250
  (--list prints it without reading stdin; init, sync,
236
251
  upgrade and the installers stay CLI-only by design)`;
@@ -283,6 +298,8 @@ const FLAGS = `--json structured output (compact; one document,
283
298
  --accept-conflicts init: adopt rules that collide with hand-written ones
284
299
  --backup init: tag the pre-adoption commit and branch onto
285
300
  simt-dev/adopt before writing (the undo path)
301
+ --no-agents init: skip the agent files and the agent-hooks in one flag;
302
+ the standalone agents / agent-hooks install commands remain
286
303
  --spec <name> graph lint: also check every skill against a named
287
304
  specification (one exists: agentskills)
288
305
  --user harness detect: also report the directories in your home
@@ -302,7 +319,9 @@ const FLAGS = `--json structured output (compact; one document,
302
319
  --harness <id> bench run: adapter to invoke (claude, codex, gemini, opencode,
303
320
  cursor, aider, gold, noop; default: gold);
304
321
  agent-hook: which dialect to answer in (claude, cursor,
305
- codex, antigravity)
322
+ codex, antigravity); agent-hooks / agents install: which
323
+ reader to write (one, not all); init: narrows the agent
324
+ files and the agent-hooks to one reader
306
325
  --model <id> bench run: model identifier recorded and passed to the adapter
307
326
  --reps <n> bench run: repetitions per task and arm (default: 3)
308
327
  --regrade <run> bench run: re-grade a stored run from its archived agent patch
@@ -310,6 +329,8 @@ const FLAGS = `--json structured output (compact; one document,
310
329
  --test <cmd> plan done / slice finish: the test command that closes the row
311
330
  --state <name> plan done: target state (default: Complete)
312
331
  --type <name> slice finish: the Conventional Commits type (default: feat)
332
+ --id <name> slice start: the slice id when -- starts one from a prompt
333
+ (default: a slug of the prompt text)
313
334
  --ttl <seconds> lease: how long a claim lasts (default: 3600)
314
335
  --level <name> map: detail level (outline, names, signatures)
315
336
  --supersedes <spec> decide: superseded ADR or ruling (ADR-NNN or ADR-NNN:<ruling>[:<kind>]; repeatable)
@@ -322,8 +343,14 @@ const FLAGS = `--json structured output (compact; one document,
322
343
  --embed index / map / brief: compute or query optional local vector embeddings
323
344
  (off by default unless recall@10 gains at least 10 points)
324
345
  --labels <path> bench critic: the labelled set of historical diffs to score
346
+ --cross-group bench ablate: compare across harness, model and experiment
347
+ groups instead of refusing the comparison
348
+ --expires <when> lessons capture: how long a captured candidate stays eligible
325
349
  (default .simt/critic-labels.jsonl; one human-written
326
350
  {sha, verdict} pair per line)
351
+ --replace bench mine: delete task files the current mine does not
352
+ reproduce (off by default: mining is append-only; a
353
+ re-mine adds, it does not replace the corpus)
327
354
  --refresh distill / review: regenerate a proposal that is already cached
328
355
  --local distill / review: propose the answer from the configured
329
356
  local chat model (local.chat); off by default, which
@@ -338,42 +365,164 @@ const PRINCIPLES = `design rules:
338
365
  zero dependencies a DX tool that needs an install step fails on a fresh machine
339
366
  bounded output one fact per line, fixed field order, explicit TRUNCATED lines`;
340
367
 
341
- export async function help(argv) {
342
- const parsed = parse(argv);
368
+ export async function help(argv, dispatched) {
369
+ // `dispatched` is what `dispatch` already parsed. Called from a test or another
370
+ // command with only argv, help parses it itself.
371
+ const parsed =
372
+ dispatched === undefined
373
+ ? parse(argv)
374
+ : dispatched.parseError === undefined
375
+ ? { ok: true, value: { flags: dispatched.flags, positional: dispatched.positional } }
376
+ : { ok: false, error: dispatched.parseError };
343
377
  if (!parsed.ok) {
344
378
  jsonFailMode(rawJsonFlag(argv));
345
379
  return fail(parsed.error.message, parsed.error.code);
346
380
  }
347
- const { flags } = parsed.value;
381
+ const { flags, positional } = parsed.value;
348
382
  if (flags.version) {
349
383
  emit((await version()).trim());
350
384
  return 0;
351
385
  }
386
+ // A reader who names one command gets that command, not the whole surface (review
387
+ // 0003 Q61): `simt help gate` and `simt gate --help` both printed 23 KB, so the one
388
+ // fact being asked for was 40 screens down.
389
+ const asked = commandFromHelp(positional);
390
+ if (asked !== null) {
391
+ emit(commandPage(asked));
392
+ return 0;
393
+ }
394
+ if (flags.json === true) {
395
+ emitJson(SPEC.map(specRow));
396
+ return 0;
397
+ }
398
+ // The standard page is where a reader asks "what can this do, and where do I start".
399
+ // A repository that has not been adopted, or that pins a version this binary is not,
400
+ // has a prerequisite worth stating before the catalogue: no manifest means init is the
401
+ // first move, a stale pin means sync is the fix. Fully ready, the page stands on its
402
+ // own and the banner is nothing.
403
+ const banner = await prerequisiteBanner(flags, positional);
404
+ const bannerLines = banner === null ? [] : ['', banner, ''];
405
+ // `--all` prints every command and every flag; the default page lists the core tier and
406
+ // says where the rest is. A reader asking "what can this do" needs the shape, not 23 KB
407
+ // of flag reference (review 0003 Q61).
408
+ if (flags.all === true) {
409
+ emit(
410
+ [
411
+ 'simt-dev - shared agent rules, skills and gates for a repo ecosystem',
412
+ '',
413
+ `usage: ${CLI} <command> [args] [flags]`,
414
+ ...bannerLines,
415
+ 'commands:',
416
+ COMMANDS,
417
+ '',
418
+ 'flags:',
419
+ FLAGS,
420
+ '',
421
+ PRINCIPLES,
422
+ '',
423
+ `rulings ${CLI} rules list the decisions behind the rules you just read`,
424
+ ].join('\n'),
425
+ );
426
+ return 0;
427
+ }
428
+ // One line per group and one per bare command: listing every subcommand with its
429
+ // summary does not fit in the 2 KB a reader will actually read (review 0003 Q61).
430
+ const groups = new Map();
431
+ const bare = [];
432
+ for (const row of SPEC) {
433
+ if (tierOf(row.path) !== 'core') continue;
434
+ const [head, sub] = row.path.split(' ');
435
+ if (sub === undefined) bare.push(row);
436
+ else groups.set(head, [...(groups.get(head) ?? []), sub]);
437
+ }
352
438
  emit(
353
439
  [
354
440
  'simt-dev - shared agent rules, skills and gates for a repo ecosystem',
355
441
  '',
356
442
  `usage: ${CLI} <command> [args] [flags]`,
357
- '',
443
+ ...bannerLines,
358
444
  'commands:',
359
- COMMANDS,
360
- '',
361
- 'flags:',
362
- FLAGS,
363
- '',
364
- PRINCIPLES,
445
+ ...[...groups].map(([group, subs]) => ` ${CLI} ${group} <${subs.join('|')}>`),
446
+ ...bare.map((row) => ` ${CLI} ${row.path.padEnd(8)} ${row.summary.length > 46 ? `${row.summary.slice(0, 43)}...` : row.summary}`),
365
447
  '',
366
- // The plans ship inside the installed package but have no command that surfaces
367
- // them, so naming the path would again be advice the reader cannot take. They
368
- // are an implementation record; the rulings are the part a reader can act on.
369
- `rulings ${CLI} rules list the decisions behind the rules you just read`,
448
+ `more ${CLI} help --all (every command and flag), ${CLI} help <command>`,
449
+ `rulings ${CLI} rules list (the decisions behind the rules)`,
370
450
  ].join('\n'),
371
- { maxBytes: flags.maxBytes ?? 0 },
372
451
  );
373
452
  return 0;
374
453
  }
375
454
 
455
+ /**
456
+ * The prerequisite line for the standard help page, or null when the repository is fully
457
+ * ready. No manifest (or one that cannot be read) means the adoption has not happened and
458
+ * `init` is the first move; a manifest that pins a version the running binary is not means
459
+ * `sync` is the fix. These are the two states in which a reader opening help is about to
460
+ * run a command that would fail in a predictable way, so the page says so before it lists
461
+ * the commands.
462
+ */
463
+ async function prerequisiteBanner(flags, positional) {
464
+ const root = commandRoot(flags, positional);
465
+ const { manifest, exists, error } = await readManifest(root);
466
+ if (!exists) {
467
+ return `prerequisite no ${MANIFEST_FILE} here; run: ${CLI} init <path>`;
468
+ }
469
+ if (error !== null) {
470
+ return `prerequisite ${MANIFEST_FILE} cannot be read (${error}); run: ${CLI} init <path>`;
471
+ }
472
+ const stale = assertVersionPin(manifest);
473
+ if (stale !== null) {
474
+ return `prerequisite ${stale}; run: ${CLI} sync`;
475
+ }
476
+ return null;
477
+ }
478
+
376
479
  async function version() {
377
480
  const { describeVersion } = await import('../package-version.mjs');
378
481
  return `${describeVersion()}\n`;
379
482
  }
483
+
484
+ /** The command a help request names, or null when it names none. */
485
+ function commandFromHelp(positional) {
486
+ const words = positional[0] === 'help' ? positional.slice(1) : positional;
487
+ const two = words.slice(0, 2).join(' ');
488
+ if (SPEC_PATHS.includes(two)) return two;
489
+ return SPEC_PATHS.includes(words[0]) ? words[0] : null;
490
+ }
491
+
492
+ /** One spec row as the JSON a UI or a harness reads. */
493
+ function specRow(row) {
494
+ return { path: row.path, summary: row.summary, ...(row.defaultFor === undefined ? {} : { defaultFor: row.defaultFor }) };
495
+ }
496
+
497
+ const PAGE_WIDTH = 78;
498
+
499
+ /** The summary broken to the page width, so a narrow terminal keeps it readable. */
500
+ function wrapped(text) {
501
+ const lines = [];
502
+ let line = '';
503
+ for (const word of text.split(' ')) {
504
+ if (line !== '' && line.length + 1 + word.length > PAGE_WIDTH) {
505
+ lines.push(line);
506
+ line = word;
507
+ } else {
508
+ line = line === '' ? word : `${line} ${word}`;
509
+ }
510
+ }
511
+ if (line !== '') lines.push(line);
512
+ return lines;
513
+ }
514
+
515
+ /**
516
+ * One command's page: what it is, and where the rest of the answers are. The flags a
517
+ * single command declares arrive with the spec's `flags` field (Q62); until then the
518
+ * page points at the global list rather than inventing one per command.
519
+ */
520
+ export function commandPage(path) {
521
+ const row = SPEC.find((r) => r.path === path);
522
+ if (row === undefined) return '';
523
+ const lines = [`${CLI} ${path} [args] [flags]`, '', ...wrapped(row.summary), ''];
524
+ if (row.defaultFor !== undefined) lines.push(`default ${CLI} ${row.defaultFor} runs this command`);
525
+ lines.push(`flags --json --pretty --root --max-bytes, and every global flag`);
526
+ lines.push(`all ${CLI} --help every command, and every flag it takes`);
527
+ return `${lines.join('\n')}\n`;
528
+ }
@@ -12,27 +12,33 @@
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 { mkdir, readFile, realpath } from 'node:fs/promises';
16
- import { commitJournal, journaledGitConfig, 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 } from '../journal.mjs';
18
+ import { applyPlan, change } from '../write/plan.mjs';
17
19
  import { diffField, emitDiffs, readBefore } from '../diff.mjs';
18
20
  import { invalidateCheckCache } from '../status.mjs';
19
21
  import { isAbsolute, join, relative } from 'node:path';
20
22
 
21
23
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
22
24
  import { MANIFEST, readManifest } from '../projection/manifest.mjs';
23
- import { EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, SHIM_MARKER, fileFor, renderChainedShim, renderEvidenceShim, renderShim } from '../hooks/shim.mjs';
25
+ import { CHAIN_MARKER, EVENTS, HOOKS_DIR, HOOKS_DIR_LEGACY, HOOKS_PATH_KEY, SHIM_MARKER, fileFor, renderChainedShim, renderEvidenceShim, renderShim } from '../hooks/shim.mjs';
24
26
  import { GATE_KEY, gateCount, readHooksPath, readHooksState, wantsShim } from '../compare/hooksstate.mjs';
25
- import { GATES } from '../hooks/gates.mjs';
26
- import { knownGates } from '../hooks/resolve.mjs';
27
+ import { gateNames, getGate } from '../hooks/gates.mjs';
27
28
  import { renderGateReport, runGates } from '../hooks/dispatcher.mjs';
28
29
  import { CLI } from '../name.mjs';
29
30
  import { MANIFEST_FILE } from '../name.mjs';
30
31
  import { packageVersion, hasNpm } from '../package-version.mjs';
32
+ import { commandRoot } from '../repo-root.mjs';
33
+ import { readIfExists } from '../util/fs.mjs';
31
34
 
32
35
  export async function hooksCommand(sub, args, flags) {
33
- const root = flags.root ?? args[0] ?? process.cwd();
34
- if (sub === 'install') return installHooks(root, flags);
35
- if (sub === 'status') return hooksStatus(root, flags);
36
+ const root = commandRoot(flags, args);
37
+ // One context for the subcommand: install and status each ask about the same
38
+ // repository, and `doctor --fix` asks alongside them (review 0003 Q67).
39
+ const ctx = repoContext(root);
40
+ if (sub === 'install') return installHooks(root, flags, ctx);
41
+ if (sub === 'status') return hooksStatus(root, flags, ctx);
36
42
  if (sub === 'run') return runHook(args, flags);
37
43
  return fail(`unknown hooks subcommand ${sub}`, EXIT.USAGE, `run: ${CLI} hooks status`);
38
44
  }
@@ -40,11 +46,12 @@ export async function hooksCommand(sub, args, flags) {
40
46
  /** The shim execs this; the event name is baked into the shim, so it is arg 0. */
41
47
  async function runHook(args, flags) {
42
48
  const event = args[0];
43
- const root = flags.root ?? process.cwd();
49
+ const root = commandRoot(flags);
50
+ const ctx = repoContext(root);
44
51
  if (!EVENTS.includes(event)) {
45
52
  return fail(`unknown hook event ${event}`, EXIT.USAGE, `known events: ${EVENTS.join(', ')}`);
46
53
  }
47
- const { manifest, exists, error } = await readManifest(root);
54
+ const { manifest, exists, error } = await ctx.manifest();
48
55
  if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG);
49
56
  if (!exists) {
50
57
  return fail(`${root} has no ${MANIFEST_FILE}; the hook cannot know its gate list`, EXIT.CONFIG, `run: ${CLI} init --root ${root}`);
@@ -64,7 +71,7 @@ async function runHook(args, flags) {
64
71
  }
65
72
 
66
73
  for (const n of result.notes ?? []) note(`note ${n}`);
67
- emit(renderGateReport(event, manifest.gates[GATE_KEY[event]] ?? [], result).join('\n'), { maxBytes: flags.maxBytes ?? 0 });
74
+ emit(renderGateReport(event, manifest.gates[GATE_KEY[event]] ?? [], result).join('\n'));
68
75
  return result.failed ? EXIT.FAIL : EXIT.OK;
69
76
  }
70
77
 
@@ -135,7 +142,7 @@ async function relativeTarget(root, target) {
135
142
  * caller enforces in its own voice (`takeover`, and the `wanted`/`obsolete`
136
143
  * emptiness the command refuses on)
137
144
  */
138
- export async function planHooksInstall(root, { manifest, exists }) {
145
+ export async function planHooksInstall(root, { manifest, exists }, ctx = null) {
139
146
  const declaresGate = (event) => wantsShim(manifest, exists, event);
140
147
  const state = await readHooksState(root, { declaresGate });
141
148
  const current = state.current;
@@ -159,8 +166,10 @@ export async function planHooksInstall(root, { manifest, exists }) {
159
166
  } catch {
160
167
  // Unreadable here leaves the npm default; the manifest path reports it.
161
168
  }
162
- const { fingerprint } = await import('../resolve/fingerprint.mjs');
163
- const detected = await fingerprint(root);
169
+ // The dynamic import stays: a hook run must not pull the fingerprint module in
170
+ // just by loading this one. A caller that already holds a context passes it and
171
+ // the walk is shared (review 0003 Q67).
172
+ const detected = ctx ? await ctx.fingerprint() : await (await import('../resolve/fingerprint.mjs')).fingerprint(root);
164
173
  manager = resolveNodeRunner({ facts: detected.facts, packageManager }).id;
165
174
  }
166
175
 
@@ -190,8 +199,8 @@ export async function planHooksInstall(root, { manifest, exists }) {
190
199
  //
191
200
  // `foreignHooks` lists every executable file in the directory git reads hooks from -
192
201
  // and when that directory is this tool's own, a file a person dropped in there is
193
- // listed too. Chaining it means writing a shim at `.simt-hooks/<name>` whose TARGET is
194
- // `.simt-hooks/<name>`: the same path. The shim overwrites the hook it was written to
202
+ // listed too. Chaining it means writing a shim at `.simt/hooks/<name>` whose TARGET is
203
+ // `.simt/hooks/<name>`: the same path. The shim overwrites the hook it was written to
195
204
  // preserve and then runs itself, so the hook stops working and git recurses in a loop
196
205
  // nothing reports. Confirmed at the previous release, which printed
197
206
  // `UPDATED .simt-hooks/mine (chained: runs .simt-hooks/mine)`.
@@ -298,9 +307,50 @@ export async function planHooksInstall(root, { manifest, exists }) {
298
307
  evidenceExisting,
299
308
  routed,
300
309
  takeover,
310
+ // The legacy directory's removal rides on the same planned write, so the
311
+ // scan is here and the deletion is in `applyHooksInstall` (ADR-122).
312
+ legacy: await legacyHooksScan(root),
301
313
  };
302
314
  }
303
315
 
316
+ /**
317
+ * Scan the legacy top-level shim directory (ADR-122). A repository installed
318
+ * before the move into the state directory still carries it, and it is
319
+ * removable only when every entry in it is a regular file that carries a
320
+ * simt-dev marker: the install re-routes git to the new directory, and a
321
+ * file this tool cannot recognise as its own is never deleted on its way.
322
+ *
323
+ * @returns {Promise<{path: string, removable: boolean}|null>} null when the
324
+ * directory is absent or empty; otherwise the directory and whether the
325
+ * install may take it down. `applyHooksInstall` acts on this, so the
326
+ * judgment is made once, in the plan. Module-local: one caller needs no
327
+ * export, and an export is a promise to a caller (deadcode).
328
+ */
329
+ async function legacyHooksScan(root) {
330
+ const dir = join(root, HOOKS_DIR_LEGACY);
331
+ let entries;
332
+ try {
333
+ entries = await readdir(dir);
334
+ } catch {
335
+ return null;
336
+ }
337
+ if (entries.length === 0) return null;
338
+ let removable = true;
339
+ for (const name of entries) {
340
+ const st = await stat(join(dir, name));
341
+ if (!st.isFile()) {
342
+ removable = false;
343
+ break;
344
+ }
345
+ const text = await readFile(join(dir, name), 'utf8');
346
+ if (!text.includes(SHIM_MARKER) && !text.includes(CHAIN_MARKER)) {
347
+ removable = false;
348
+ break;
349
+ }
350
+ }
351
+ return { path: HOOKS_DIR_LEGACY, removable };
352
+ }
353
+
304
354
  /**
305
355
  * Write a planned install: shims, chains, removals, and the git routing (plan 0003 R47).
306
356
  *
@@ -309,10 +359,11 @@ export async function planHooksInstall(root, { manifest, exists }) {
309
359
  * names the run that actually changed something. Reporting stays with the
310
360
  * caller for the same reason it does in `planHooksInstall`.
311
361
  *
312
- * @returns {{wrote: string[]}} the shim paths written, removed, or refreshed
362
+ * @returns {{wrote: string[], removed: string[]}} the shim paths written,
363
+ * removed or refreshed, and the legacy-directory files taken down
313
364
  */
314
365
  export async function applyHooksInstall(root, planned, journal) {
315
- const { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, manager, version, routed, wants, current } =
366
+ const { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, manager, version, routed, wants, current, legacy } =
316
367
  planned;
317
368
  // Shim and evidence bytes are journaled so `undo` restores them (R49), with
318
369
  // the executable bit they are written with: a shim the run chmodded is one
@@ -322,27 +373,33 @@ export async function applyHooksInstall(root, planned, journal) {
322
373
  // at their directory silently stops the repository's own hooks (review 0003
323
374
  // Q22).
324
375
  const wrote = [];
376
+ const removed = [];
325
377
  await mkdir(join(root, HOOKS_DIR), { recursive: true });
378
+ const hookChanges = [];
326
379
  for (const event of wanted) {
327
380
  const path = join(root, fileFor(event));
328
- await journaledWriteFile(root, journal, path, renderShim(event, { npm, manager, version }), { mode: 0o755 });
381
+ hookChanges.push(change({ path, before: await readBefore(path), after: renderShim(event, { npm, manager, version }), owner: 'hooks install', reason: event, mode: 0o755 }));
329
382
  wrote.push(fileFor(event));
330
383
  }
331
384
  for (const c of chained) {
332
385
  const path = join(root, fileFor(c.event));
333
- await journaledWriteFile(root, journal, path, renderChainedShim(c.event, c.target), { mode: 0o755 });
386
+ hookChanges.push(change({ path, before: await readBefore(path), after: renderChainedShim(c.event, c.target), owner: 'hooks install', reason: c.event, mode: 0o755 }));
334
387
  wrote.push(fileFor(c.event));
335
388
  }
336
389
  for (const h of obsolete) {
337
- await journaledRemoveFile(root, journal, join(root, fileFor(h.event)));
390
+ const obsoletePath = join(root, fileFor(h.event));
391
+ hookChanges.push(
392
+ change({ path: obsoletePath, kind: 'remove', before: await readBefore(obsoletePath), after: null, owner: 'hooks install', reason: h.event }),
393
+ );
338
394
  wrote.push(fileFor(h.event));
339
395
  }
340
396
  // After the chain loop, so a chained commit-msg cannot replace the claim check.
341
397
  if (evidenceSource) {
342
398
  const path = join(root, evidenceFile);
343
- await journaledWriteFile(root, journal, path, evidenceSource, { mode: 0o755 });
399
+ hookChanges.push(change({ path, before: await readBefore(path), after: evidenceSource, owner: 'hooks install', reason: 'the evidence shim', mode: 0o755 }));
344
400
  wrote.push(evidenceFile);
345
401
  }
402
+ await applyPlan(root, hookChanges, { mode: { apply: true }, journal, commit: false });
346
403
  // `core.hooksPath` tells git to read ONE directory for EVERY event, so leaving it
347
404
  // pointing at a directory of no shims stops the repository's own hooks - the ones in
348
405
  // `.git/hooks` that predate this tool - from running, silently. Unsetting it is the
@@ -352,7 +409,20 @@ export async function applyHooksInstall(root, planned, journal) {
352
409
  } else if (current === wants) {
353
410
  await journaledGitConfig(root, journal, HOOKS_PATH_KEY, null);
354
411
  }
355
- return { wrote };
412
+ // The move into the state directory (ADR-122): the routing now points
413
+ // elsewhere, so a legacy directory left behind is the only place that looks
414
+ // like a hooks directory while holding nothing git reads. The scan in the
415
+ // plan already judged it removable — every file a simt-dev marker — so
416
+ // deletion is journaled per file, and `undo` restores the directory.
417
+ if (legacy?.removable) {
418
+ for (const name of await readdir(join(root, HOOKS_DIR_LEGACY))) {
419
+ await journaledRemoveFile(root, journal, join(root, HOOKS_DIR_LEGACY, name));
420
+ removed.push(`${HOOKS_DIR_LEGACY}/${name}`);
421
+ }
422
+ await rm(join(root, HOOKS_DIR_LEGACY), { recursive: true });
423
+ }
424
+ return { wrote, removed };
425
+
356
426
  }
357
427
 
358
428
  /**
@@ -382,8 +452,8 @@ export async function plannedDiffs(root, planned) {
382
452
  return pairs;
383
453
  }
384
454
 
385
- async function installHooks(root, flags) {
386
- const { manifest, exists, error } = await readManifest(root);
455
+ async function installHooks(root, flags, ctx = repoContext(root)) {
456
+ const { manifest, exists, error } = await ctx.manifest();
387
457
  if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG);
388
458
  if (!exists) {
389
459
  return fail(`${root} has no ${MANIFEST_FILE}; gates must be declared before hooks can run them`, EXIT.CONFIG, `run: ${CLI} init --root ${root}`);
@@ -409,7 +479,8 @@ async function installHooks(root, flags) {
409
479
  evidenceExisting,
410
480
  routed,
411
481
  takeover,
412
- } = await planHooksInstall(root, { manifest, exists });
482
+ legacy,
483
+ } = await planHooksInstall(root, { manifest, exists }, ctx);
413
484
 
414
485
  // Read through `writeMode` like every other writer, and only now: this command
415
486
  // used to consult `flags.yes` and `flags.dryRun` directly and write when neither
@@ -438,7 +509,6 @@ async function installHooks(root, flags) {
438
509
  ' and replace it with a gate that checks nothing.',
439
510
  `REMEDY add the gates this repo can actually run to ${MANIFEST_FILE}, then re-run.`,
440
511
  ].join('\n'),
441
- { maxBytes: flags.maxBytes ?? 0 },
442
512
  );
443
513
  return EXIT.FAIL;
444
514
  }
@@ -487,11 +557,21 @@ async function installHooks(root, flags) {
487
557
  note: 'commit-msg checks a verification claim against the ledger',
488
558
  });
489
559
  }
560
+ if (legacy?.removable) {
561
+ plan.push({
562
+ file: HOOKS_DIR_LEGACY,
563
+ action: 'removed',
564
+ // Named in the plan for the same reason the obsolete shims are: the
565
+ // reader approving this write is about to lose a directory, and the
566
+ // routing line above does not say the old one goes too (ADR-122).
567
+ note: `legacy shim directory; the shims now live in ${HOOKS_DIR}`,
568
+ });
569
+ }
490
570
 
491
571
  // The routing this install leaves behind, which is not always `wants`. A repository
492
572
  // that removed its last gate has no shims to route to, and leaving `core.hooksPath`
493
573
  // pointed at the directory would stop its own hooks running - the plan has to say so,
494
- // because a plan that prints "core.hooksPath updated .simt-hooks -> .simt-hooks" while
574
+ // because a plan that prints "core.hooksPath updated .simt/hooks -> .simt/hooks" while
495
575
  // the command unsets it is worse than no plan.
496
576
  const leaves = routed ? wants : null;
497
577
  const pathAction =
@@ -521,7 +601,6 @@ async function installHooks(root, flags) {
521
601
  ' Taking over replaces that routing. Inspect the existing hooks first,',
522
602
  ' then re-run with --yes to accept, or set core.hooksPath yourself.',
523
603
  ].join('\n'),
524
- { maxBytes: flags.maxBytes ?? 0 },
525
604
  );
526
605
  return EXIT.FAIL;
527
606
  }
@@ -566,7 +645,6 @@ async function installHooks(root, flags) {
566
645
  : `PLAN nothing written; apply with: ${CLI} hooks install --root ${root} --yes`,
567
646
  manifest,
568
647
  ),
569
- { maxBytes: flags.maxBytes ?? 0 },
570
648
  );
571
649
  // After the report: the report says what, the diff shows it (R50).
572
650
  emitDiffs(diffs, flags);
@@ -578,7 +656,7 @@ async function installHooks(root, flags) {
578
656
  const journal = [];
579
657
  await applyHooksInstall(
580
658
  root,
581
- { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, manager, version, routed, wants, current },
659
+ { wanted, chained, obsolete, evidenceSource, evidenceFile, npm, manager, version, routed, wants, current, legacy },
582
660
  journal,
583
661
  );
584
662
  await commitJournal(root, 'hooks install', journal);
@@ -655,7 +733,6 @@ async function installHooks(root, flags) {
655
733
  routed ? 'WROTE shims and set core.hooksPath' : 'WROTE no shims remain; core.hooksPath unset',
656
734
  manifest,
657
735
  ),
658
- { maxBytes: flags.maxBytes ?? 0 },
659
736
  );
660
737
  // After the report: the report says what, the diff shows it (R50).
661
738
  emitDiffs(diffs, flags);
@@ -679,13 +756,13 @@ function describeGates(manifest, key) {
679
756
  const list = manifest?.gates?.[key];
680
757
  if (!Array.isArray(list) || list.length === 0) return '(none declared)';
681
758
  return list.map((name) => {
682
- const gate = GATES[name];
759
+ const gate = getGate(name, manifest?.gates?.commands);
683
760
  return gate ? name : `${name} (UNKNOWN - the hook will fail)`;
684
761
  }).join(', ');
685
762
  }
686
763
 
687
- async function hooksStatus(root, flags) {
688
- const { manifest, exists, error } = await readManifest(root);
764
+ async function hooksStatus(root, flags, ctx = repoContext(root)) {
765
+ const { manifest, exists, error } = await ctx.manifest();
689
766
  if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG);
690
767
 
691
768
  const state = await readHooksState(root, { declaresGate: (e) => wantsShim(manifest, exists, e) });
@@ -748,7 +825,7 @@ async function hooksStatus(root, flags) {
748
825
  lines.push('', 'would run:');
749
826
  for (const key of ['preCommit', 'prePush']) {
750
827
  for (const name of manifest?.gates?.[key] ?? []) {
751
- const known = GATES[name];
828
+ const known = getGate(name, commands);
752
829
  // A declared command wins for every kind, so the status must say so too —
753
830
  // printing "run script" for a gate the dispatcher will shell out to `./gradlew`
754
831
  // for is the same one-question-two-answers defect ADR-018 was written about.
@@ -767,17 +844,17 @@ async function hooksStatus(root, flags) {
767
844
  if (undeclared.length > 0) {
768
845
  lines.push('', `declared but never run: ${undeclared.join(', ')}`);
769
846
  }
770
- const unknown = [...(manifest?.gates?.preCommit ?? []), ...(manifest?.gates?.prePush ?? [])].filter((n) => !GATES[n]);
847
+ const unknown = [...(manifest?.gates?.preCommit ?? []), ...(manifest?.gates?.prePush ?? [])].filter((n) => !getGate(n, commands));
771
848
  if (unknown.length > 0) {
772
849
  lines.push('', `unknown gates (the hook will fail): ${unknown.join(', ')}`);
773
- lines.push(`known gates: ${knownGates().join(', ')}`);
850
+ lines.push(`known gates: ${gateNames().join(', ')}`);
774
851
  }
775
852
  }
776
853
  if (problems.length > 0) {
777
854
  lines.push('', 'problems:');
778
855
  for (const p of problems) lines.push(` ${p}`);
779
856
  }
780
- emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
857
+ emit(lines.join('\n'));
781
858
  return code;
782
859
  }
783
860
 
@@ -799,8 +876,9 @@ function collectProblems({ exists, current, installed, manifest, foreign = [] })
799
876
  if (exists) {
800
877
  for (const key of ['preCommit', 'prePush']) {
801
878
  for (const name of manifest?.gates?.[key] ?? []) {
802
- if (!GATES[name]) problems.push(`gate "${name}" in ${key} is not a known gate`);
803
- else if (GATES[name].kind === 'command' && !manifest?.gates?.commands?.[name]) {
879
+ const gate = getGate(name, manifest?.gates?.commands);
880
+ if (!gate) problems.push(`gate "${name}" in ${key} is not a known gate`);
881
+ else if (gate.kind === 'command' && !manifest?.gates?.commands?.[name]) {
804
882
  problems.push(`gate "${name}" in ${key} is a command gate with no declared command; the hook will fail`);
805
883
  }
806
884
  }
@@ -808,11 +886,3 @@ function collectProblems({ exists, current, installed, manifest, foreign = [] })
808
886
  }
809
887
  return problems;
810
888
  }
811
-
812
- async function readIfExists(path) {
813
- try {
814
- return await readFile(path, 'utf8');
815
- } catch {
816
- return null;
817
- }
818
- }