simt-dev 0.2.0 → 0.2.2

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 (90) hide show
  1. package/CHANGELOG.md +375 -7
  2. package/README.md +4 -4
  3. package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +27 -0
  4. package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
  5. package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
  6. package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
  7. package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
  8. package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
  9. package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
  10. package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
  11. package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
  12. package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
  13. package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
  14. package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
  15. package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
  16. package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
  17. package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
  18. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
  19. package/docs/decisions/README.md +17 -0
  20. package/package.json +1 -2
  21. package/rules/core.md +1 -0
  22. package/rules/stacks/android.md +2 -0
  23. package/rules/stacks/cloudflare-workers.md +2 -0
  24. package/rules/stacks/connectiq.md +1 -0
  25. package/rules/stacks/container.md +2 -0
  26. package/rules/stacks/dart.md +40 -0
  27. package/rules/stacks/deno.md +38 -0
  28. package/rules/stacks/dotnet.md +37 -0
  29. package/rules/stacks/elixir.md +41 -0
  30. package/rules/stacks/go.md +36 -0
  31. package/rules/stacks/jvm.md +2 -0
  32. package/rules/stacks/node.md +2 -0
  33. package/rules/stacks/python.md +2 -0
  34. package/rules/stacks/react-native.md +2 -0
  35. package/rules/stacks/react-web.md +2 -0
  36. package/rules/stacks/ruby.md +38 -0
  37. package/rules/stacks/rust.md +36 -0
  38. package/rules/stacks/swift.md +39 -0
  39. package/rules/stacks/typescript.md +2 -0
  40. package/rules/stacks/uv.md +2 -0
  41. package/src/argv.mjs +54 -6
  42. package/src/ci/dependabot.mjs +1 -1
  43. package/src/ci/workflow.mjs +33 -11
  44. package/src/commands/compare.mjs +15 -0
  45. package/src/commands/graph.mjs +58 -25
  46. package/src/commands/harness.mjs +150 -0
  47. package/src/commands/help.mjs +37 -5
  48. package/src/commands/hooks.mjs +173 -57
  49. package/src/commands/index.mjs +95 -69
  50. package/src/commands/init.mjs +100 -20
  51. package/src/commands/mcp.mjs +20 -5
  52. package/src/commands/rules.mjs +96 -60
  53. package/src/commands/skills.mjs +189 -86
  54. package/src/commands/skillsinstall.mjs +20 -5
  55. package/src/commands/sync.mjs +213 -15
  56. package/src/commands/tokens.mjs +138 -0
  57. package/src/compare/hooksstate.mjs +96 -9
  58. package/src/compare/row.mjs +107 -7
  59. package/src/decisions/index.mjs +21 -6
  60. package/src/graph/load.mjs +125 -14
  61. package/src/graph/spec.mjs +227 -0
  62. package/src/graph/speccheck.mjs +68 -0
  63. package/src/hooks/capability.mjs +48 -4
  64. package/src/hooks/dispatcher.mjs +84 -7
  65. package/src/hooks/gates.mjs +0 -2
  66. package/src/hooks/resolve.mjs +24 -25
  67. package/src/hooks/shim.mjs +29 -5
  68. package/src/identity.mjs +0 -11
  69. package/src/mcp/schema.mjs +139 -0
  70. package/src/mcp/server.mjs +197 -20
  71. package/src/mcp/tools.mjs +290 -3
  72. package/src/measure/context.mjs +371 -0
  73. package/src/monorepo.mjs +339 -0
  74. package/src/output/index.mjs +86 -9
  75. package/src/package-version.mjs +74 -0
  76. package/src/packs.mjs +182 -0
  77. package/src/projection/conflicts.mjs +56 -5
  78. package/src/projection/manifest.mjs +335 -25
  79. package/src/projection/mdc.mjs +4 -13
  80. package/src/projection/project.mjs +139 -8
  81. package/src/projection/rules.mjs +308 -27
  82. package/src/projection/surfaces.mjs +133 -15
  83. package/src/projection/targets.mjs +90 -7
  84. package/src/resolve/eval.mjs +64 -0
  85. package/src/resolve/fingerprint.mjs +1 -0
  86. package/src/resolve/near.mjs +26 -7
  87. package/src/resolve/resolve.mjs +1 -1
  88. package/src/resolve/source.mjs +1 -1
  89. package/src/workspace.mjs +0 -9
  90. package/graph/skills.graph.json +0 -1622
@@ -13,19 +13,20 @@
13
13
  // foreign hook is found by asking git rather than by reading config.
14
14
 
15
15
  import { execFile } from 'node:child_process';
16
- import { chmod, mkdir, readFile, realpath, writeFile } from 'node:fs/promises';
16
+ import { chmod, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
17
17
  import { isAbsolute, join, relative } from 'node:path';
18
18
  import { promisify } from 'node:util';
19
19
 
20
20
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
21
21
  import { MANIFEST, readManifest } from '../projection/manifest.mjs';
22
22
  import { EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, fileFor, renderChainedShim, renderShim } from '../hooks/shim.mjs';
23
- import { readHooksPath, readHooksState } from '../compare/hooksstate.mjs';
23
+ import { GATE_KEY, gateCount, readHooksPath, readHooksState, wantsShim } from '../compare/hooksstate.mjs';
24
24
  import { GATES } from '../hooks/gates.mjs';
25
25
  import { knownGates } from '../hooks/resolve.mjs';
26
26
  import { runGates } from '../hooks/dispatcher.mjs';
27
27
  import { CLI } from '../name.mjs';
28
28
  import { MANIFEST_FILE } from '../name.mjs';
29
+ import { packageVersion, hasNpm } from '../package-version.mjs';
29
30
 
30
31
  const exec = promisify(execFile);
31
32
 
@@ -70,12 +71,6 @@ async function runHook(args, flags) {
70
71
  return result.failed ? EXIT.FAIL : EXIT.OK;
71
72
  }
72
73
 
73
- const GATE_KEY = { 'pre-commit': 'preCommit', 'pre-push': 'prePush' };
74
-
75
- function gateCount(manifest, event) {
76
- return (manifest?.gates?.[GATE_KEY[event]] ?? []).length;
77
- }
78
-
79
74
  /**
80
75
  * The path to write into a chained shim: relative to the repository's top level
81
76
  * wherever the hook lives inside it.
@@ -106,19 +101,6 @@ async function relativeTarget(root, target) {
106
101
  return rel === '' || rel.startsWith('..') ? resolved : rel;
107
102
  }
108
103
 
109
- /**
110
- * Whether a missing shim for this event is a defect.
111
- *
112
- * A repo that declares no gate for an event has no reason to want a shim for it,
113
- * so its absence is the correct end state. A repo with NO manifest is different:
114
- * we do not know what it wants, so we cannot rule the shim out - absence of
115
- * knowledge is not a decision.
116
- */
117
- function wantsShim(manifest, exists, event) {
118
- if (!exists) return true;
119
- return gateCount(manifest, event) > 0;
120
- }
121
-
122
104
  async function installHooks(root, flags) {
123
105
  const { manifest, exists, error } = await readManifest(root);
124
106
  if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG);
@@ -126,7 +108,8 @@ async function installHooks(root, flags) {
126
108
  return fail(`${root} has no ${MANIFEST_FILE}; gates must be declared before hooks can run them`, EXIT.CONFIG, `run: ${CLI} init --root ${root}`);
127
109
  }
128
110
 
129
- const state = await readHooksState(root, { declaresGate: (e) => wantsShim(manifest, exists, e) });
111
+ const declaresGate = (event) => wantsShim(manifest, exists, event);
112
+ const state = await readHooksState(root, { declaresGate });
130
113
  const current = state.current;
131
114
  const wants = HOOKS_DIR;
132
115
 
@@ -139,30 +122,19 @@ async function installHooks(root, flags) {
139
122
  // for a plan and then `--yes` - advice that printed a plan and wrote anyway.
140
123
  const mode = writeMode(flags);
141
124
 
125
+ // The release this install renders, and the kind of repository it installs into: the
126
+ // remedy a shim prints when it cannot find the binary must name the install that
127
+ // actually works here — a devDependency where package.json exists, a pinned global
128
+ // install where there is no node_modules to pin in.
129
+ const version = packageVersion();
130
+ const npm = await hasNpm(root);
131
+
142
132
  // Only install a shim for an event that declares at least one gate. A shim with
143
133
  // an empty gate list enforces nothing, yet merely existing it REPLACES whatever
144
134
  // git was running - so installing one over a working hand-written hook is a
145
135
  // downgrade dressed as an upgrade. `init` already says this in prose; install
146
136
  // has to agree, or the two commands describe different tools.
147
137
  const wanted = EVENTS.filter((e) => gateCount(manifest, e) > 0);
148
- if (wanted.length === 0) {
149
- if (isJson(flags)) {
150
- emitJson({ ok: false, command: 'hooks install', root, nothingToEnforce: true, filesWritten: false });
151
- return EXIT.FAIL;
152
- }
153
- emit(
154
- [
155
- `repo ${root}`,
156
- '',
157
- `BLOCKED ${root} declares no gate it can run, so there is nothing to enforce.`,
158
- ' Installing shims anyway would stop running whatever git runs now',
159
- ' and replace it with a gate that checks nothing.',
160
- `REMEDY add the gates this repo can actually run to ${MANIFEST_FILE}, then re-run.`,
161
- ].join('\n'),
162
- { maxBytes: flags.maxBytes ?? 0 },
163
- );
164
- return EXIT.FAIL;
165
- }
166
138
 
167
139
  // Foreign hooks on events this tool does NOT gate. Routing replaces the whole
168
140
  // directory git reads from, so these have to be carried across explicitly or they
@@ -179,7 +151,21 @@ async function installHooks(root, flags) {
179
151
  // Keying this on the event vocabulary instead of on the manifest cost a repository
180
152
  // git-lfs hook: pre-push is a gated event in general, but this repo declares no
181
153
  // pre-push gate, so nothing replaces its hook and routing would have dropped it.
182
- const chained = [
154
+ // A chained shim and the hook it chains must be two different files.
155
+ //
156
+ // `foreignHooks` lists every executable file in the directory git reads hooks from -
157
+ // and when that directory is this tool's own, a file a person dropped in there is
158
+ // listed too. Chaining it means writing a shim at `.simt-hooks/<name>` whose TARGET is
159
+ // `.simt-hooks/<name>`: the same path. The shim overwrites the hook it was written to
160
+ // preserve and then runs itself, so the hook stops working and git recurses in a loop
161
+ // nothing reports. Confirmed at the previous release, which printed
162
+ // `UPDATED .simt-hooks/mine (chained: runs .simt-hooks/mine)`.
163
+ //
164
+ // So such a hook is not chained. It is not stopped either - nothing overwrites it, so
165
+ // git keeps running it - and it is reported as what it is below.
166
+ const chainable = (event, target) => target !== fileFor(event);
167
+
168
+ const candidates = [
183
169
  // Already chained by an earlier install, read back off disk: re-rendered from the
184
170
  // file's own contents so the bytes cannot drift on a second run.
185
171
  ...(await Promise.all(
@@ -198,12 +184,71 @@ async function installHooks(root, flags) {
198
184
  .filter((f) => gateCount(manifest, f.event) === 0)
199
185
  .map(async (f) => ({ event: f.event, target: await relativeTarget(root, f.path) })),
200
186
  )),
201
- ].reduce((all, c) => (all.some((x) => x.event === c.event) ? all : [...all, c]), []);
187
+ ];
188
+
189
+ const chained = candidates
190
+ .filter((c) => chainable(c.event, c.target))
191
+ .reduce((all, c) => (all.some((x) => x.event === c.event) ? all : [...all, c]), []);
192
+
193
+ // Hooks that keep running untouched because chaining them was impossible. Separate
194
+ // from `chained` because the notes below say opposite things about the two: one runs
195
+ // the hook for you, the other leaves it alone and it still runs. Reporting both as
196
+ // "stopped running" - which is what this did - was false for this one and would push an
197
+ // operator to restore a hook that is already running.
198
+ const leftRunning = candidates.filter((c) => !chainable(c.event, c.target));
199
+
200
+ // Shims this tool wrote for an event the manifest no longer gates.
201
+ //
202
+ // `install` only ever ADDED files, so dropping a gate from the manifest left its shim
203
+ // on disk: still routed through `core.hooksPath`, still `PRESENT` in `hooks status`,
204
+ // and enforcing nothing. That is the exact harm the refusal below is written to
205
+ // prevent - reached by the sanctioned path rather than by the blocked one.
206
+ //
207
+ // It was a dead end rather than only a defect. With the LAST gate removed there is
208
+ // nothing to install, so `install` refused and wrote nothing, leaving the shim, the
209
+ // routing, and a remedy - "add the gates this repo can actually run" - telling a
210
+ // reader to undo the change they had just made, for a gate that may be gone because
211
+ // the script it ran was deleted. The only way out was to re-declare the gate and
212
+ // delete the file by hand.
213
+ //
214
+ // Read from `state.installed`, which describes every shim this install is responsible
215
+ // for, and not from `state.staleOurs`: that loop skips a shim the moment it matches
216
+ // what this run would render, so a *current* shim for an ungated event never reaches
217
+ // it - which is the common case here and the one that matters. `ours` on each entry is
218
+ // what keeps a file a person wrote in this directory from being deleted on their
219
+ // behalf; `wanted` and `chained` are what this run writes, so what is left is exactly
220
+ // the set nothing else here touches.
221
+ const obsolete = state.installed.filter(
222
+ (i) => i.present && i.ours && !wanted.includes(i.event) && !chained.some((c) => c.event === i.event),
223
+ );
224
+
225
+ // Nothing to write and nothing to take away is the only refusal. It sits after
226
+ // `obsolete` because a repository whose last gate just left the manifest has
227
+ // something to take away, and blocking that one left no way out except undoing
228
+ // the change by hand - see the note on `obsolete`.
229
+ if (wanted.length === 0 && obsolete.length === 0) {
230
+ if (isJson(flags)) {
231
+ emitJson({ ok: false, command: 'hooks install', root, nothingToEnforce: true, filesWritten: false });
232
+ return EXIT.FAIL;
233
+ }
234
+ emit(
235
+ [
236
+ `repo ${root}`,
237
+ '',
238
+ `BLOCKED ${root} declares no gate it can run, so there is nothing to enforce.`,
239
+ ' Installing shims anyway would stop running whatever git runs now',
240
+ ' and replace it with a gate that checks nothing.',
241
+ `REMEDY add the gates this repo can actually run to ${MANIFEST_FILE}, then re-run.`,
242
+ ].join('\n'),
243
+ { maxBytes: flags.maxBytes ?? 0 },
244
+ );
245
+ return EXIT.FAIL;
246
+ }
202
247
 
203
248
  const plan = [];
204
249
  for (const event of wanted) {
205
250
  const path = join(root, fileFor(event));
206
- const source = renderShim(event);
251
+ const source = renderShim(event, { npm, version });
207
252
  const existing = await readIfExists(path);
208
253
  if (existing === source) {
209
254
  plan.push({ file: fileFor(event), action: 'unchanged' });
@@ -224,7 +269,26 @@ async function installHooks(root, flags) {
224
269
  note: `chained: runs ${c.target}`,
225
270
  });
226
271
  }
227
- const pathAction = current === wants ? 'unchanged' : current === null ? 'created' : 'updated';
272
+ for (const h of obsolete) {
273
+ plan.push({
274
+ file: fileFor(h.event),
275
+ action: 'removed',
276
+ // Named in the plan because the reader approving this write is about to lose a
277
+ // file, and "REMOVED pre-push" does not say why - which is the difference between
278
+ // agreeing with it and discovering later that a gate had been dropped by accident.
279
+ note: `no gate for ${h.event} in ${MANIFEST_FILE}; the shim enforced nothing`,
280
+ });
281
+ }
282
+ const routed = wanted.length > 0 || chained.length > 0;
283
+
284
+ // The routing this install leaves behind, which is not always `wants`. A repository
285
+ // that removed its last gate has no shims to route to, and leaving `core.hooksPath`
286
+ // pointed at the directory would stop its own hooks running - the plan has to say so,
287
+ // because a plan that prints "core.hooksPath updated .simt-hooks -> .simt-hooks" while
288
+ // the command unsets it is worse than no plan.
289
+ const leaves = routed ? wants : null;
290
+ const pathAction =
291
+ leaves === current ? 'unchanged' : leaves === null ? 'removed' : current === null ? 'created' : 'updated';
228
292
 
229
293
  // Refuse to take over routing git is already using without explicit consent.
230
294
  // Two cases, and the second one is the one that bites: core.hooksPath already
@@ -238,7 +302,7 @@ async function installHooks(root, flags) {
238
302
  ? state.foreign.map((f) => ` git runs ${f.event} at ${f.path} (${f.bytes} bytes)`)
239
303
  : [` existing ${HOOKS_PATH_KEY} = ${current}`];
240
304
  if (isJson(flags)) {
241
- emitJson({ ok: false, command: 'hooks install', root, currentHooksPath: current, wouldSet: wants, takeover: true, foreign: state.foreign, plan, filesWritten: false });
305
+ emitJson({ ok: false, command: 'hooks install', root, currentHooksPath: current, wouldSet: leaves, takeover: true, foreign: state.foreign, plan, filesWritten: false });
242
306
  return EXIT.FAIL;
243
307
  }
244
308
  emit(
@@ -258,14 +322,20 @@ async function installHooks(root, flags) {
258
322
 
259
323
  if (!mode.apply) {
260
324
  if (isJson(flags)) {
261
- emitJson({ ok: true, command: 'hooks install', root, dryRun: true, currentHooksPath: current, wouldSet: wants, plan, filesWritten: false });
325
+ // `leaves`, not `wants`. A plan that would remove the last shim also un-routes
326
+ // git, and a payload promising to set a directory the command is about to unset
327
+ // is the same lie in a shape a machine reads.
328
+ emitJson({ ok: true, command: 'hooks install', root, dryRun: true, currentHooksPath: current, wouldSet: leaves, plan, filesWritten: false });
262
329
  return EXIT.OK;
263
330
  }
264
331
  emit(
265
332
  renderPlan(
266
333
  root,
267
334
  current,
268
- wants,
335
+ // `leaves`, for the same reason the `--json` payload uses it: this plan says
336
+ // REMOVED beside the last shim, and a reader of it is entitled to read that
337
+ // the routing is going too, rather than being told it is unchanged.
338
+ leaves,
269
339
  pathAction,
270
340
  plan,
271
341
  mode.dryRun
@@ -281,7 +351,7 @@ async function installHooks(root, flags) {
281
351
  await mkdir(join(root, HOOKS_DIR), { recursive: true });
282
352
  for (const event of wanted) {
283
353
  const path = join(root, fileFor(event));
284
- await writeFile(path, renderShim(event));
354
+ await writeFile(path, renderShim(event, { npm, version }));
285
355
  await chmod(path, 0o755);
286
356
  }
287
357
  for (const c of chained) {
@@ -289,7 +359,19 @@ async function installHooks(root, flags) {
289
359
  await writeFile(path, renderChainedShim(c.event, c.target));
290
360
  await chmod(path, 0o755);
291
361
  }
292
- await exec('git', ['config', HOOKS_PATH_KEY, wants], { cwd: root });
362
+ for (const h of obsolete) {
363
+ await rm(join(root, fileFor(h.event)));
364
+ }
365
+
366
+ // `core.hooksPath` tells git to read ONE directory for EVERY event, so leaving it
367
+ // pointing at a directory of no shims stops the repository's own hooks - the ones in
368
+ // `.git/hooks` that predate this tool - from running, silently. Unsetting it is the
369
+ // only way back, and this is the only place that knows the last shim just left.
370
+ if (routed) {
371
+ await exec('git', ['config', HOOKS_PATH_KEY, wants], { cwd: root });
372
+ } else if (current === wants) {
373
+ await exec('git', ['config', '--unset', HOOKS_PATH_KEY], { cwd: root });
374
+ }
293
375
 
294
376
  const notes = [];
295
377
  const chainedEvents = new Set(chained.map((c) => c.event));
@@ -299,28 +381,62 @@ async function installHooks(root, flags) {
299
381
  // chained and genuinely stopped running. Saying "stopped running" about both would
300
382
  // be wrong about the first, and it would push an operator to restore a hook that is
301
383
  // already being run for them.
302
- notes.push(
303
- chainedEvents.has(f.event)
304
- ? `chained ${f.event}: ${fileFor(f.event)} runs ${f.path} (${f.bytes} bytes), so it keeps running`
305
- : `stopped running ${f.event} at ${f.path} (${f.bytes} bytes); the file is still on disk, untouched - declare it in ${MANIFEST_FILE} gates.commands to have the dispatcher run it`,
306
- );
384
+ if (chainedEvents.has(f.event)) {
385
+ notes.push(
386
+ `chained ${f.event}: ${fileFor(f.event)} runs ${f.path} (${f.bytes} bytes), so it keeps running`,
387
+ );
388
+ } else if (leftRunning.some((c) => c.event === f.event)) {
389
+ // A third case, and the one that was misreported: a hook this tool could not chain
390
+ // because the shim would have had to run itself. Nothing overwrote it, so it still
391
+ // runs - which is the opposite of what the branch below says.
392
+ notes.push(
393
+ `${f.event} at ${f.path} (${f.bytes} bytes) is in this tool's own hooks directory, so it cannot be chained to itself and was left untouched; it keeps running`,
394
+ );
395
+ } else {
396
+ notes.push(
397
+ `stopped running ${f.event} at ${f.path} (${f.bytes} bytes); the file is still on disk, untouched - declare it in ${MANIFEST_FILE} gates.commands to have the dispatcher run it`,
398
+ );
399
+ }
307
400
  }
308
401
  if (state.foreign.length === 0 && takeover) {
309
402
  notes.push(`replaced an existing core.${HOOKS_PATH_KEY} (${current}); the old hooks are still on disk, untouched`);
310
403
  }
404
+ for (const h of obsolete) {
405
+ notes.push(
406
+ `removed ${fileFor(h.event)}: ${MANIFEST_FILE} declares no ${h.event} gate, so the shim enforced nothing and still replaced git's own hook`,
407
+ );
408
+ }
409
+ if (leaves === null && current !== null) {
410
+ notes.push(
411
+ `unset ${HOOKS_PATH_KEY}; git reads its hooks from .git/hooks again, so the repository's own hooks run`,
412
+ );
413
+ }
311
414
 
312
415
  if (isJson(flags)) {
313
- emitJson({ ok: true, command: 'hooks install', root, hooksPath: wants, previousHooksPath: current, plan, filesWritten: true, notes });
416
+ emitJson({ ok: true, command: 'hooks install', root, hooksPath: leaves, previousHooksPath: current, plan, filesWritten: true, notes });
314
417
  return EXIT.OK;
315
418
  }
316
- emit(renderPlan(root, current, wants, pathAction, plan, 'WROTE shims and set core.hooksPath', manifest), { maxBytes: flags.maxBytes ?? 0 });
419
+ emit(
420
+ renderPlan(
421
+ root,
422
+ current,
423
+ leaves,
424
+ pathAction,
425
+ plan,
426
+ routed ? 'WROTE shims and set core.hooksPath' : 'WROTE no shims remain; core.hooksPath unset',
427
+ manifest,
428
+ ),
429
+ { maxBytes: flags.maxBytes ?? 0 },
430
+ );
317
431
  for (const n of notes) note(`note ${n}`);
318
432
  note(`note never committed or pushed (ADR-001); review, then commit yourself`);
319
433
  return EXIT.OK;
320
434
  }
321
435
 
436
+ // `wants` is null when this install removes the last shim, and "null" is not what a
437
+ // reader should read in a plan that says REMOVED next to it.
322
438
  function renderPlan(root, current, wants, pathAction, plan, headline, manifest) {
323
- const lines = [`repo ${root}`, `${HOOKS_PATH_KEY.padEnd(15)} ${pathAction} ${current ?? '(unset)'} -> ${wants}`, ''];
439
+ const lines = [`repo ${root}`, `${HOOKS_PATH_KEY.padEnd(15)} ${pathAction} ${current ?? '(unset)'} -> ${wants ?? '(unset)'}`, ''];
324
440
  for (const p of plan) lines.push(`${p.action.toUpperCase().padEnd(9)} ${p.file}${p.note ? ` (${p.note})` : ''}`);
325
441
  lines.push('', headline);
326
442
  lines.push(`gates preCommit = ${describeGates(manifest, 'preCommit')}`);
@@ -6,7 +6,15 @@
6
6
  // succeeds. An agent that calls `simt-dev compare` on an unreleased build must get a
7
7
  // failure, not an empty result it will read as "no drift".
8
8
 
9
- import { EXIT, emit, fail, isJson } from '../output/index.mjs';
9
+ import {
10
+ EXIT,
11
+ emit,
12
+ emitJson,
13
+ fail,
14
+ isJson,
15
+ jsonFailMode,
16
+ jsonIndentDefault,
17
+ } from '../output/index.mjs';
10
18
  import { help } from './help.mjs';
11
19
  import { lintCommand } from './graph.mjs';
12
20
  import {
@@ -15,6 +23,7 @@ import {
15
23
  briefCommand as skillsBrief,
16
24
  showCommand as skillsShow,
17
25
  whyCommand as skillsWhy,
26
+ evalCommand as skillsEval,
18
27
  } from './skills.mjs';
19
28
  import { syncCommand, checkCommand } from './sync.mjs';
20
29
  import { initCommand } from './init.mjs';
@@ -25,6 +34,8 @@ import { upgradeCommand } from './upgrade.mjs';
25
34
  import { mcpCommand } from './mcp.mjs';
26
35
  import { installCommand } from './skillsinstall.mjs';
27
36
  import { listCommand as rulesList, showCommand as rulesShow, whyCommand as rulesWhy } from './rules.mjs';
37
+ import { tokensCommand } from './tokens.mjs';
38
+ import { harnessCommand } from './harness.mjs';
28
39
  import { parse } from '../argv.mjs';
29
40
  import { fingerprint } from '../resolve/fingerprint.mjs';
30
41
  import { resolveWorkspace } from '../workspace.mjs';
@@ -42,11 +53,69 @@ const GROUPS = new Map([
42
53
  ['graph', 'lint'],
43
54
  ['rules', 'list'],
44
55
  ['hooks', 'status'],
56
+ ['harness', 'detect'],
45
57
  ]);
46
58
 
59
+ /**
60
+ * Every command this binary dispatches, in one place.
61
+ *
62
+ * It was a literal inside `dispatch`, which is why `help` and the CLI could disagree:
63
+ * nothing could enumerate the real list, so `--help` was a hand-maintained template and
64
+ * three working commands (`graph lint`, `hooks status`, `hooks run`) were never written
65
+ * into it. A list that cannot be read cannot be checked, and the reader of `--help` is
66
+ * an agent choosing a command — the one consumer with no way to discover the omission
67
+ * except by guessing.
68
+ *
69
+ * Module scope so `test/cli.test.mjs` can assert help against it. Module scope is also
70
+ * just correct: nothing in the map depends on the call.
71
+ */
72
+ export const IMPLEMENTED = {
73
+ doctor: runDoctor,
74
+ 'graph lint': lintCommand,
75
+ 'skills index': skillsIndex,
76
+ 'skills find': skillsFind,
77
+ 'skills brief': skillsBrief,
78
+ 'skills show': skillsShow,
79
+ 'skills why': skillsWhy,
80
+ 'skills eval': skillsEval,
81
+ 'skills install': installCommand,
82
+ 'rules list': rulesList,
83
+ 'rules show': rulesShow,
84
+ 'rules why': rulesWhy,
85
+ sync: syncCommand,
86
+ check: checkCommand,
87
+ init: initCommand,
88
+ ci: ciCommand,
89
+ 'hooks install': (args, flags) => hooksCommand('install', args, flags),
90
+ 'hooks status': (args, flags) => hooksCommand('status', args, flags),
91
+ 'hooks run': (args, flags) => hooksCommand('run', args, flags),
92
+ compare: compareCommand,
93
+ tokens: tokensCommand,
94
+ 'harness detect': harnessCommand,
95
+ upgrade: upgradeCommand,
96
+ mcp: mcpCommand,
97
+ };
98
+
99
+ /**
100
+ * The dispatchable command paths, sorted. The authoritative answer to "what can this
101
+ * binary do", as opposed to the answer `--help` gives.
102
+ */
103
+ export const COMMANDS = Object.keys(IMPLEMENTED).sort();
104
+
47
105
  export async function dispatch(argv) {
48
106
  const { flags, positional } = parseOrExit(argv);
49
107
 
108
+ // The JSON shape is decided once, here, and every command inherits it. Threading a
109
+ // `--pretty` through twenty-five call sites would mean twenty-five places to forget,
110
+ // and the one that forgot would be the one a reader pipes into a script.
111
+ //
112
+ // Both halves are set before anything can fail, because a command that fails under
113
+ // `--json` has still produced a result: the reason it failed. Set here, an unknown
114
+ // command answers in the same shape a working one does, and the caller's parse
115
+ // never sees an empty document.
116
+ jsonIndentDefault(flags.pretty === true);
117
+ jsonFailMode(flags.json === true);
118
+
50
119
  // Help is shown only when explicitly asked for. An unknown command must fail with
51
120
  // a usage code, not print 40 lines of prose and exit 0 — an agent that mistyped
52
121
  // needs to be told so, not handed a wall of text it may misread as output.
@@ -76,29 +145,6 @@ export async function dispatch(argv) {
76
145
  );
77
146
  }
78
147
  const args = positional.slice(offset);
79
- const IMPLEMENTED = {
80
- doctor: runDoctor,
81
- 'graph lint': lintCommand,
82
- 'skills index': skillsIndex,
83
- 'skills find': skillsFind,
84
- 'skills brief': skillsBrief,
85
- 'skills show': skillsShow,
86
- 'skills why': skillsWhy,
87
- 'skills install': installCommand,
88
- 'rules list': rulesList,
89
- 'rules show': rulesShow,
90
- 'rules why': rulesWhy,
91
- sync: syncCommand,
92
- check: checkCommand,
93
- init: initCommand,
94
- ci: ciCommand,
95
- 'hooks install': (args, flags) => hooksCommand('install', args, flags),
96
- 'hooks status': (args, flags) => hooksCommand('status', args, flags),
97
- 'hooks run': (args, flags) => hooksCommand('run', args, flags),
98
- compare: compareCommand,
99
- upgrade: upgradeCommand,
100
- mcp: mcpCommand,
101
- };
102
148
  const run = IMPLEMENTED[path];
103
149
  if (run) return run(args, flags);
104
150
  return fail(`unknown command \`${path}\``, EXIT.USAGE);
@@ -117,34 +163,20 @@ function commandPath(first, second) {
117
163
  if (second === undefined) return `${first} ${group}`;
118
164
  // Reject a second token that is not a subcommand of this group, rather than
119
165
  // silently routing `simt-dev skills nonsense` to the default subcommand.
166
+ //
167
+ // Read from IMPLEMENTED rather than from a second list. The list existed because
168
+ // there was no way to enumerate what dispatch could run — `IMPLEMENTED` was an
169
+ // object literal inside `dispatch()` — and a hand-kept copy of it had already
170
+ // drifted: it carried `doctor`, which `commandPath` can never match, because
171
+ // `doctor` is not a group and this branch is only reached when `GROUPS.has(first)`.
172
+ // Two lists of the same fact is the defect S15 was about in the help text.
120
173
  const key = `${first} ${second}`;
121
- if (!STEPS.has(key) && !KNOWN_SUBS.has(key)) {
174
+ if (!STEPS.has(key) && !(key in IMPLEMENTED)) {
122
175
  return null;
123
176
  }
124
177
  return key;
125
178
  }
126
179
 
127
- /**
128
- * Subcommands implemented now, so they must not be rejected as unknown.
129
- * Implemented paths live here; declared-but-unimplemented ones live in STEPS.
130
- */
131
- const KNOWN_SUBS = new Set([
132
- 'doctor',
133
- 'graph lint',
134
- 'skills index',
135
- 'skills find',
136
- 'skills brief',
137
- 'skills show',
138
- 'skills why',
139
- 'skills install',
140
- 'rules list',
141
- 'rules show',
142
- 'rules why',
143
- 'hooks install',
144
- 'hooks status',
145
- 'hooks run',
146
- ]);
147
-
148
180
  function parseOrExit(argv) {
149
181
  const parsed = parse(argv);
150
182
  if (!parsed.ok) return { flags: {}, positional: [] };
@@ -183,29 +215,23 @@ async function runDoctor(positional, flags) {
183
215
  const code = failure ? EXIT.CONFIG : EXIT.OK;
184
216
 
185
217
  if (isJson(flags)) {
186
- emit(
187
- `${JSON.stringify(
188
- {
189
- ok: code === EXIT.OK,
190
- command: 'doctor',
191
- root,
192
- ...result,
193
- // The workspace answer is a fact about which directory the tool would
194
- // act on next (ADR-014). It is printed in the text report; leaving it
195
- // out of the JSON meant a machine-readable caller could not see it at
196
- // all, which is the one-question-two-answers defect again.
197
- workspace: {
198
- root: ws.root ?? null,
199
- source: ws.source,
200
- detail: ws.detail,
201
- error: ws.error ?? null,
202
- },
203
- ...(failure ?? {}),
204
- },
205
- null,
206
- 2,
207
- )}\n`,
208
- );
218
+ emitJson({
219
+ ok: code === EXIT.OK,
220
+ command: 'doctor',
221
+ root,
222
+ ...result,
223
+ // The workspace answer is a fact about which directory the tool would
224
+ // act on next (ADR-014). It is printed in the text report; leaving it
225
+ // out of the JSON meant a machine-readable caller could not see it at
226
+ // all, which is the one-question-two-answers defect again.
227
+ workspace: {
228
+ root: ws.root ?? null,
229
+ source: ws.source,
230
+ detail: ws.detail,
231
+ error: ws.error ?? null,
232
+ },
233
+ ...(failure ?? {}),
234
+ });
209
235
  return code;
210
236
  }
211
237
  if (result.error) {