simt-dev 0.2.0 → 0.2.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 (56) hide show
  1. package/CHANGELOG.md +173 -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/README.md +17 -0
  10. package/package.json +1 -2
  11. package/rules/stacks/dart.md +38 -0
  12. package/rules/stacks/deno.md +36 -0
  13. package/rules/stacks/dotnet.md +35 -0
  14. package/rules/stacks/elixir.md +39 -0
  15. package/rules/stacks/go.md +34 -0
  16. package/rules/stacks/ruby.md +36 -0
  17. package/rules/stacks/rust.md +34 -0
  18. package/rules/stacks/swift.md +37 -0
  19. package/src/argv.mjs +35 -6
  20. package/src/ci/dependabot.mjs +1 -1
  21. package/src/ci/workflow.mjs +33 -11
  22. package/src/commands/graph.mjs +13 -4
  23. package/src/commands/help.mjs +22 -4
  24. package/src/commands/hooks.mjs +173 -57
  25. package/src/commands/index.mjs +53 -45
  26. package/src/commands/init.mjs +89 -13
  27. package/src/commands/rules.mjs +54 -3
  28. package/src/commands/skills.mjs +143 -15
  29. package/src/commands/skillsinstall.mjs +3 -3
  30. package/src/commands/sync.mjs +153 -11
  31. package/src/compare/hooksstate.mjs +96 -9
  32. package/src/compare/row.mjs +34 -5
  33. package/src/decisions/index.mjs +21 -6
  34. package/src/graph/load.mjs +125 -14
  35. package/src/hooks/capability.mjs +48 -4
  36. package/src/hooks/dispatcher.mjs +50 -5
  37. package/src/hooks/gates.mjs +0 -2
  38. package/src/hooks/resolve.mjs +24 -25
  39. package/src/hooks/shim.mjs +29 -5
  40. package/src/identity.mjs +0 -11
  41. package/src/mcp/tools.mjs +2 -2
  42. package/src/output/index.mjs +8 -7
  43. package/src/package-version.mjs +74 -0
  44. package/src/projection/conflicts.mjs +56 -5
  45. package/src/projection/manifest.mjs +247 -25
  46. package/src/projection/mdc.mjs +4 -13
  47. package/src/projection/project.mjs +1 -1
  48. package/src/projection/rules.mjs +21 -8
  49. package/src/projection/targets.mjs +53 -7
  50. package/src/resolve/eval.mjs +64 -0
  51. package/src/resolve/fingerprint.mjs +1 -0
  52. package/src/resolve/near.mjs +26 -7
  53. package/src/resolve/resolve.mjs +1 -1
  54. package/src/resolve/source.mjs +1 -1
  55. package/src/workspace.mjs +0 -9
  56. package/graph/skills.graph.json +0 -1622
@@ -50,6 +50,7 @@ import { PACKAGE_ROOT } from '../workspace.mjs';
50
50
  import { PACKAGE_NAME, WORKFLOW_FILE_NAME } from '../name.mjs';
51
51
  import { renderLocator } from '../locate.mjs';
52
52
  import { CLI } from '../name.mjs';
53
+ import { packageVersion } from '../package-version.mjs';
53
54
 
54
55
  /** Directory whose presence means this repo runs GitHub Actions. */
55
56
  export const WORKFLOWS_DIR = '.github/workflows';
@@ -169,12 +170,16 @@ function skeleton(name, steps, { matrix = null } = {}) {
169
170
  const job = matrix?.node ? '\${{ matrix.os }} node \${{ matrix.node }}' : name;
170
171
  return `name: ${name}
171
172
 
173
+ # Pull requests only, and that is a decision rather than a default.
174
+ #
175
+ # A gate that runs on every push re-runs itself for every commit that pushes a
176
+ # branch forward, including the ones nobody asked it about, and its verdict has to
177
+ # be cleared before the next one lands. \`pull_request\` asks one question — may this
178
+ # merge — and answers it once. \`main\` here is the branch protection list, not a
179
+ # trigger: the gate that has to pass is the one the pull request runs, and the one
180
+ # on \`main\` is whatever CI the repository already has there.
172
181
  on:
173
182
  pull_request:
174
- push:
175
- branches:
176
- - main
177
- - master
178
183
 
179
184
  # Read-only is enough to check a repo, and a gate job does not need to write to it.
180
185
  permissions:
@@ -235,7 +240,7 @@ function indent(text, spaces) {
235
240
  * @param {{own?: boolean}} [options]
236
241
  * @returns {string} YAML, LF-terminated
237
242
  */
238
- export function renderWorkflow({ own = false } = {}) {
243
+ export function renderWorkflow({ own = false, npm = true, version } = {}) {
239
244
  if (own) {
240
245
  const floor = nodeFloor();
241
246
  return `${header(true)}${skeleton(
@@ -267,17 +272,34 @@ export function renderWorkflow({ own = false } = {}) {
267
272
  )}\n`;
268
273
  }
269
274
 
270
- const locator = renderLocator('check --root "$ROOT"', {
271
- remedy: `REMEDY run: npm i -D ${PACKAGE_NAME} (the gate runs from the pinned dependency)`,
272
- });
275
+ const v = version ?? packageVersion();
276
+ // The install step is the whole of S35's consumer CI: a repository with npm pins
277
+ // simt-dev as a devDependency and `npm ci` restores it; one without (Gradle,
278
+ // Connect IQ) has no node_modules to pin in, so CI installs the same pinned global
279
+ // binary the machine remedy names. The two must agree, or the gate passes in one
280
+ // place and cannot start in the other.
281
+ const install = npm
282
+ ? [
283
+ ' - name: install dependencies',
284
+ ' # `npm ci` needs a lockfile; a repo without one is still gated, not skipped.',
285
+ ' run: npm ci || npm install',
286
+ ]
287
+ : [
288
+ ' - name: install the pinned gate binary',
289
+ ' # No node_modules to pin in, so a pinned global install: the same bytes',
290
+ ' # the machine remedy names, so the gate cannot pass locally and fail here.',
291
+ ` run: npm i -g ${PACKAGE_NAME}@${v}`,
292
+ ];
293
+ const remedy = npm
294
+ ? `REMEDY run: npm i -D ${PACKAGE_NAME} (the gate runs from the pinned dependency)`
295
+ : `REMEDY run: npm i -g ${PACKAGE_NAME}@${v} (the gate runs from a pinned global install)`;
296
+ const locator = renderLocator('check --root "$ROOT"', { remedy });
273
297
  return `${header(false)}${skeleton(
274
298
  `${CLI} check`,
275
299
  [
276
300
  uses('checkout'),
277
301
  setupNode(nodeFloor()),
278
- ' - name: install dependencies',
279
- ' # \`npm ci\` needs a lockfile; a repo without one is still gated, not skipped.',
280
- ' run: npm ci || npm install',
302
+ ...install,
281
303
  ` - name: ${CLI} check`,
282
304
  ' run: |',
283
305
  indent(`set -eu\nROOT="\${GITHUB_WORKSPACE}"\n${locator}`, 10),
@@ -7,11 +7,17 @@
7
7
  // the offending node, the file it disagrees with, and both numbers.
8
8
 
9
9
  import { EXIT, emit, fail, isJson } from '../output/index.mjs';
10
- import { loadRegistry, REGISTRY_PATH } from '../graph/load.mjs';
10
+ import { loadRegistry } from '../graph/load.mjs';
11
+ import { CLI } from '../name.mjs';
11
12
 
12
13
  export async function lintCommand(positional, flags) {
13
- const path = positional[0] ?? REGISTRY_PATH;
14
- const { ok, registry, errors, warnings, verified, workspace } = await loadRegistry(path, flags);
14
+ // The resolved path comes back from the loader, because when no positional is given it
15
+ // falls back to the member that holds the registry in this workspace (ADR-061) - and the
16
+ // reader should see where it actually looked, not a constant that no longer exists.
17
+ const { ok, registry, errors, warnings, verified, workspace, path } = await loadRegistry(
18
+ positional[0] ?? null,
19
+ flags,
20
+ );
15
21
 
16
22
  if (isJson(flags)) {
17
23
  emit(
@@ -40,7 +46,10 @@ export async function lintCommand(positional, flags) {
40
46
  }
41
47
 
42
48
  if (!registry) {
43
- return fail(errors[0] ?? `${path}: unreadable`, EXIT.REGISTRY);
49
+ // The default remedy for this exit code is `run: simt-dev graph lint`, which from
50
+ // inside `graph lint` is circular: the reader just ran it and got this. Name the
51
+ // argument the command takes, because an explicit path is the way out (ADR-061).
52
+ return fail(errors[0] ?? `${path}: unreadable`, EXIT.REGISTRY, `run: ${CLI} graph lint <path-to-registry>`);
44
53
  }
45
54
 
46
55
  const lines = [
@@ -4,7 +4,8 @@ import { CLI } from '../name.mjs';
4
4
 
5
5
  const COMMANDS = `${CLI} init [path] adopt or scaffold a repo into the ecosystem
6
6
  (dry-run unless --yes; conflicts block without
7
- --accept-conflicts; never commits or pushes)
7
+ --accept-conflicts; in place unless --backup;
8
+ never commits or pushes)
8
9
  ${CLI} doctor classify a repo and check config health
9
10
  ${CLI} check the gate: projection drift, manifest validity, policy
10
11
  ${CLI} sync [--yes] regenerate the projection (idempotent)
@@ -19,6 +20,10 @@ ${CLI} skills brief --task tier-1 pre-assembled brief <- the common case
19
20
  ${CLI} skills show <name> tier-2 body (respects --max-bytes)
20
21
  ${CLI} skills why <name> the signal chain that selected it
21
22
  ${CLI} skills find <query> search across capability and keyword
23
+ ${CLI} skills eval [corpus] measure the resolver against a gold corpus
24
+ (reports precision and recall; exits non-zero on
25
+ regression; without an argument, the corpus in this
26
+ checkout)
22
27
  ${CLI} skills install <name> pin a skill into the repo as a local override
23
28
  (dry-run unless --yes; a multi-variant skill needs
24
29
  --variant or --from rather than a guess)
@@ -26,9 +31,15 @@ ${CLI} skills install <name> pin a skill into the repo as a local override
26
31
  ${CLI} rules list the rule catalogue (--root marks what applies to a repo)
27
32
  ${CLI} rules show <id> one rule body
28
33
  ${CLI} rules why <id|ADR-NNN> the decision that settled it, and whether it is still in force
34
+ (--all prints every ruling, not the first two)
29
35
  ${CLI} hooks install [--yes] install the shared dispatcher; report what it would run
30
36
  (dry-run unless --yes; refuses to take over routing git
31
37
  already uses, and --yes is that consent)
38
+ ${CLI} hooks status what each git event routes to, and whether it is wired
39
+ ${CLI} hooks run <event> run one event's gates now, as git would
40
+ (pre-commit, pre-push, post-merge, ...)
41
+ ${CLI} graph lint check the skill registry against the skills on disk
42
+ (byte counts, dangling edges, unresolvable variants)
32
43
  ${CLI} ci simt-dev's own .github files: create or refresh them
33
44
  (dry-run unless --yes; refreshes the templates' own copies
34
45
  and no repository else's)
@@ -55,9 +66,16 @@ const FLAGS = `--json structured output
55
66
  file, then the sibling heuristic if it verifies)
56
67
  --dry-run report what would change, change nothing
57
68
  --yes assume yes; required in automation, never prompts
58
- --all ignore the budget and resolve everything
69
+ --check report drift and write nothing (sync, ci)
70
+ --all ignore a cap: every ruling of a record, or the skills budget
59
71
  --variant <id> which body of a multi-variant skill to use
60
- --from <repo/path> install from a declared source by name`;
72
+ --from <repo/path> install from a declared source by name
73
+ --list print the MCP tool surface without reading stdin (mcp)
74
+ --accept-conflicts init: adopt rules that collide with hand-written ones
75
+ --backup init: tag the pre-adoption commit and branch onto
76
+ simt-dev/adopt before writing (the undo path)
77
+ --help this text
78
+ --version the version, and the SHA it was built from`;
61
79
 
62
80
  const PRINCIPLES = `design rules:
63
81
  deterministic same repo + same task = same answer, forever
@@ -78,7 +96,7 @@ export async function help(argv) {
78
96
  [
79
97
  'simt-dev - shared agent rules, skills and gates for a repo ecosystem',
80
98
  '',
81
- 'usage: simt-dev <command> [args] [flags]',
99
+ `usage: ${CLI} <command> [args] [flags]`,
82
100
  '',
83
101
  'commands:',
84
102
  COMMANDS,
@@ -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')}`);
@@ -15,6 +15,7 @@ import {
15
15
  briefCommand as skillsBrief,
16
16
  showCommand as skillsShow,
17
17
  whyCommand as skillsWhy,
18
+ evalCommand as skillsEval,
18
19
  } from './skills.mjs';
19
20
  import { syncCommand, checkCommand } from './sync.mjs';
20
21
  import { initCommand } from './init.mjs';
@@ -44,6 +45,50 @@ const GROUPS = new Map([
44
45
  ['hooks', 'status'],
45
46
  ]);
46
47
 
48
+ /**
49
+ * Every command this binary dispatches, in one place.
50
+ *
51
+ * It was a literal inside `dispatch`, which is why `help` and the CLI could disagree:
52
+ * nothing could enumerate the real list, so `--help` was a hand-maintained template and
53
+ * three working commands (`graph lint`, `hooks status`, `hooks run`) were never written
54
+ * into it. A list that cannot be read cannot be checked, and the reader of `--help` is
55
+ * an agent choosing a command — the one consumer with no way to discover the omission
56
+ * except by guessing.
57
+ *
58
+ * Module scope so `test/cli.test.mjs` can assert help against it. Module scope is also
59
+ * just correct: nothing in the map depends on the call.
60
+ */
61
+ const IMPLEMENTED = {
62
+ doctor: runDoctor,
63
+ 'graph lint': lintCommand,
64
+ 'skills index': skillsIndex,
65
+ 'skills find': skillsFind,
66
+ 'skills brief': skillsBrief,
67
+ 'skills show': skillsShow,
68
+ 'skills why': skillsWhy,
69
+ 'skills eval': skillsEval,
70
+ 'skills install': installCommand,
71
+ 'rules list': rulesList,
72
+ 'rules show': rulesShow,
73
+ 'rules why': rulesWhy,
74
+ sync: syncCommand,
75
+ check: checkCommand,
76
+ init: initCommand,
77
+ ci: ciCommand,
78
+ 'hooks install': (args, flags) => hooksCommand('install', args, flags),
79
+ 'hooks status': (args, flags) => hooksCommand('status', args, flags),
80
+ 'hooks run': (args, flags) => hooksCommand('run', args, flags),
81
+ compare: compareCommand,
82
+ upgrade: upgradeCommand,
83
+ mcp: mcpCommand,
84
+ };
85
+
86
+ /**
87
+ * The dispatchable command paths, sorted. The authoritative answer to "what can this
88
+ * binary do", as opposed to the answer `--help` gives.
89
+ */
90
+ export const COMMANDS = Object.keys(IMPLEMENTED).sort();
91
+
47
92
  export async function dispatch(argv) {
48
93
  const { flags, positional } = parseOrExit(argv);
49
94
 
@@ -76,29 +121,6 @@ export async function dispatch(argv) {
76
121
  );
77
122
  }
78
123
  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
124
  const run = IMPLEMENTED[path];
103
125
  if (run) return run(args, flags);
104
126
  return fail(`unknown command \`${path}\``, EXIT.USAGE);
@@ -117,34 +139,20 @@ function commandPath(first, second) {
117
139
  if (second === undefined) return `${first} ${group}`;
118
140
  // Reject a second token that is not a subcommand of this group, rather than
119
141
  // silently routing `simt-dev skills nonsense` to the default subcommand.
142
+ //
143
+ // Read from IMPLEMENTED rather than from a second list. The list existed because
144
+ // there was no way to enumerate what dispatch could run — `IMPLEMENTED` was an
145
+ // object literal inside `dispatch()` — and a hand-kept copy of it had already
146
+ // drifted: it carried `doctor`, which `commandPath` can never match, because
147
+ // `doctor` is not a group and this branch is only reached when `GROUPS.has(first)`.
148
+ // Two lists of the same fact is the defect S15 was about in the help text.
120
149
  const key = `${first} ${second}`;
121
- if (!STEPS.has(key) && !KNOWN_SUBS.has(key)) {
150
+ if (!STEPS.has(key) && !(key in IMPLEMENTED)) {
122
151
  return null;
123
152
  }
124
153
  return key;
125
154
  }
126
155
 
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
156
  function parseOrExit(argv) {
149
157
  const parsed = parse(argv);
150
158
  if (!parsed.ok) return { flags: {}, positional: [] };