@mmnto/cli 2.1.0 → 2.2.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 (48) hide show
  1. package/dist/commands/doctor-parity-fetch.d.ts +93 -2
  2. package/dist/commands/doctor-parity-fetch.d.ts.map +1 -1
  3. package/dist/commands/doctor-parity-fetch.js +332 -11
  4. package/dist/commands/doctor-parity-fetch.js.map +1 -1
  5. package/dist/commands/doctor-parity-fetch.test.d.ts +6 -0
  6. package/dist/commands/doctor-parity-fetch.test.d.ts.map +1 -1
  7. package/dist/commands/doctor-parity-fetch.test.js +405 -1
  8. package/dist/commands/doctor-parity-fetch.test.js.map +1 -1
  9. package/dist/commands/doctor-parity.d.ts.map +1 -1
  10. package/dist/commands/doctor-parity.js +27 -1
  11. package/dist/commands/doctor-parity.js.map +1 -1
  12. package/dist/commands/doctor-parity.test.js +67 -0
  13. package/dist/commands/doctor-parity.test.js.map +1 -1
  14. package/dist/commands/eject-gate-needle.test.d.ts +2 -0
  15. package/dist/commands/eject-gate-needle.test.d.ts.map +1 -0
  16. package/dist/commands/eject-gate-needle.test.js +48 -0
  17. package/dist/commands/eject-gate-needle.test.js.map +1 -0
  18. package/dist/commands/eject.d.ts +10 -0
  19. package/dist/commands/eject.d.ts.map +1 -1
  20. package/dist/commands/eject.js +67 -12
  21. package/dist/commands/eject.js.map +1 -1
  22. package/dist/commands/eject.test.js +113 -0
  23. package/dist/commands/eject.test.js.map +1 -1
  24. package/dist/commands/gate-install.d.ts +37 -5
  25. package/dist/commands/gate-install.d.ts.map +1 -1
  26. package/dist/commands/gate-install.js +26 -38
  27. package/dist/commands/gate-install.js.map +1 -1
  28. package/dist/commands/gate-install.test.js +294 -48
  29. package/dist/commands/gate-install.test.js.map +1 -1
  30. package/dist/commands/gate.d.ts +24 -9
  31. package/dist/commands/gate.d.ts.map +1 -1
  32. package/dist/commands/gate.js +36 -17
  33. package/dist/commands/gate.js.map +1 -1
  34. package/dist/commands/gate.test.js +74 -3
  35. package/dist/commands/gate.test.js.map +1 -1
  36. package/dist/commands/host-hooks.d.ts +8 -2
  37. package/dist/commands/host-hooks.d.ts.map +1 -1
  38. package/dist/commands/host-hooks.js.map +1 -1
  39. package/dist/commands/init-templates.d.ts +1 -1
  40. package/dist/commands/init-templates.d.ts.map +1 -1
  41. package/dist/commands/init-templates.js +105 -46
  42. package/dist/commands/init-templates.js.map +1 -1
  43. package/dist/commands/init.d.ts.map +1 -1
  44. package/dist/commands/init.js +20 -16
  45. package/dist/commands/init.js.map +1 -1
  46. package/dist/index.js +2 -2
  47. package/dist/index.js.map +1 -1
  48. package/package.json +2 -2
@@ -3,10 +3,10 @@ import * as fs from 'node:fs';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
- import { knownGateEvents, TotemError } from '@mmnto/totem';
6
+ import { knownGates, TotemError } from '@mmnto/totem';
7
7
  import { ejectCommand } from './eject.js';
8
- import { gateInstallCommand, resolveGateEvents } from './gate.js';
9
- import { commandInstallsGate, GATE_WRAPPER_REL, installGates } from './gate-install.js';
8
+ import { gateInstallCommand } from './gate.js';
9
+ import { commandInstallsGate, GATE_WRAPPER_REL, installGates, } from './gate-install.js';
10
10
  import { initCommand } from './init.js';
11
11
  import { CLAUDE_GATE_WRAPPER, TOTEM_FILE_END, TOTEM_FILE_MARKER } from './init-templates.js';
12
12
  import { resolveGitRootForHookPath, resolveHooksDir } from './install-hooks.js';
@@ -60,13 +60,41 @@ function preToolUseEntries(cwd) {
60
60
  const hooks = (parsed.hooks ?? {});
61
61
  return (hooks.PreToolUse ?? []);
62
62
  }
63
- /** Count Write|Edit PreToolUse entries whose command references a given gate. */
63
+ /**
64
+ * The `{event, matcher}` pairs the caller resolves from the core registry and
65
+ * hands `installGates` (mmnto-ai/totem#2799). Spelled literally here rather than
66
+ * read from `knownGates()` so a registry change that silently moved a gate to a
67
+ * different matcher would FAIL these tests instead of following them.
68
+ */
69
+ const FREEZE_CHECK = { event: 'freeze-check', matcher: 'Write|Edit' };
70
+ const TRANSPORT_SHIELD = {
71
+ event: 'transport-shield',
72
+ matcher: 'Bash|PowerShell',
73
+ };
74
+ /**
75
+ * Every PreToolUse matcher an entry installing `event` currently appears under.
76
+ * Matcher-AGNOSTIC by construction: a gate that leaked into a second matcher
77
+ * shows up here as two members, which is the invariant the #2799 tests assert.
78
+ */
79
+ function matchersFor(cwd, event) {
80
+ const found = [];
81
+ for (const e of preToolUseEntries(cwd)) {
82
+ if (!Array.isArray(e.hooks))
83
+ continue;
84
+ for (const h of e.hooks) {
85
+ const cmd = typeof h === 'string' ? h : (h?.command ?? '');
86
+ // Collision-safe exact --event match (tier-independent).
87
+ if (commandInstallsGate(cmd, event))
88
+ found.push(e.matcher ?? '');
89
+ }
90
+ }
91
+ return found;
92
+ }
93
+ /** Count PreToolUse entries (under ANY matcher) whose command references a given gate. */
64
94
  function gateEntryCount(cwd, event) {
65
- return preToolUseEntries(cwd).filter((e) => e.matcher === 'Write|Edit' &&
66
- Array.isArray(e.hooks) &&
95
+ return preToolUseEntries(cwd).filter((e) => Array.isArray(e.hooks) &&
67
96
  e.hooks.some((h) => {
68
97
  const cmd = typeof h === 'string' ? h : (h?.command ?? '');
69
- // Collision-safe exact --event match (tier-independent).
70
98
  return commandInstallsGate(cmd, event);
71
99
  })).length;
72
100
  }
@@ -74,7 +102,7 @@ function gateEntryCount(cwd, event) {
74
102
  function gateCommandFor(cwd, event) {
75
103
  const cmds = [];
76
104
  for (const e of preToolUseEntries(cwd)) {
77
- if (e.matcher !== 'Write|Edit' || !Array.isArray(e.hooks))
105
+ if (!Array.isArray(e.hooks))
78
106
  continue;
79
107
  for (const h of e.hooks) {
80
108
  const cmd = typeof h === 'string' ? h : (h?.command ?? '');
@@ -97,7 +125,7 @@ describe('installGates / gate install (settings merge)', () => {
97
125
  fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
98
126
  });
99
127
  it('scaffolds the wrapper and merges one PreToolUse entry per gate', () => {
100
- const results = installGates(cwd, ['freeze-check']);
128
+ const results = installGates(cwd, [FREEZE_CHECK]);
101
129
  // wrapper scaffold + one entry merge
102
130
  expect(results.some((r) => r.file === GATE_WRAPPER_REL && r.action === 'created')).toBe(true);
103
131
  expect(fs.existsSync(path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs'))).toBe(true);
@@ -108,7 +136,7 @@ describe('installGates / gate install (settings merge)', () => {
108
136
  const wrapperPath = path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs');
109
137
  fs.mkdirSync(path.dirname(wrapperPath), { recursive: true });
110
138
  fs.writeFileSync(wrapperPath, `${TOTEM_FILE_MARKER}\nstale\n${TOTEM_FILE_END}\n`, 'utf-8');
111
- const results = installGates(cwd, ['freeze-check']);
139
+ const results = installGates(cwd, [FREEZE_CHECK]);
112
140
  // gate install now threads the end marker, so scaffoldFile drift-repairs the bounded
113
141
  // wrapper (`refreshed`) and gate-install maps that to `merged` (a write happened).
114
142
  expect(results.find((r) => r.file === GATE_WRAPPER_REL).action).toBe('merged');
@@ -116,9 +144,9 @@ describe('installGates / gate install (settings merge)', () => {
116
144
  expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
117
145
  });
118
146
  it('is idempotent: a second install of the same gate is a no-op', () => {
119
- installGates(cwd, ['freeze-check']);
147
+ installGates(cwd, [FREEZE_CHECK]);
120
148
  const before = JSON.stringify(readSettings(cwd));
121
- const second = installGates(cwd, ['freeze-check']);
149
+ const second = installGates(cwd, [FREEZE_CHECK]);
122
150
  const entryResult = second.find((r) => r.event === 'freeze-check');
123
151
  expect(entryResult?.action).toBe('skipped');
124
152
  expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
@@ -127,8 +155,8 @@ describe('installGates / gate install (settings merge)', () => {
127
155
  it('keys idempotency on the per-gate command substring (distinct gates → distinct entries)', () => {
128
156
  // freeze-check installed; a hypothetical future gate keyed on a different
129
157
  // --event substring produces a SECOND entry rather than colliding.
130
- installGates(cwd, ['freeze-check']);
131
- installGates(cwd, ['some-future-gate']);
158
+ installGates(cwd, [FREEZE_CHECK]);
159
+ installGates(cwd, [{ event: 'some-future-gate', matcher: 'Write|Edit' }]);
132
160
  expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
133
161
  expect(gateEntryCount(cwd, 'some-future-gate')).toBe(1);
134
162
  // Two distinct Write|Edit gate entries under one matcher.
@@ -147,16 +175,110 @@ describe('installGates / gate install (settings merge)', () => {
147
175
  PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-hook' }] }],
148
176
  },
149
177
  }));
150
- installGates(cwd, ['freeze-check']);
178
+ installGates(cwd, [FREEZE_CHECK]);
151
179
  const entries = preToolUseEntries(cwd);
152
180
  expect(entries.some((e) => e.matcher === 'Bash')).toBe(true);
153
181
  expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
154
182
  });
155
- it('gateInstallCommand --all installs every known gate', async () => {
183
+ it('gateInstallCommand --all installs every known gate under ITS registry matcher', async () => {
156
184
  await gateInstallCommand({ all: true });
157
- for (const event of knownGateEvents()) {
185
+ for (const { event, matcher } of knownGates()) {
186
+ expect(gateEntryCount(cwd, event)).toBe(1);
187
+ // The installed matcher is the registry's, not a shared default.
188
+ expect(matchersFor(cwd, event)).toEqual([matcher]);
189
+ }
190
+ });
191
+ });
192
+ // ─── Per-gate matchers (mmnto-ai/totem#2799) ───────────────────────────
193
+ //
194
+ // Before #2799 every gate entry was written under one hardcoded `Write|Edit`
195
+ // matcher. The registry now carries each gate's own matcher, the caller
196
+ // resolves it (`knownGates()`), and `installGates` writes THAT — so
197
+ // `transport-shield` lands on `Bash|PowerShell` while `freeze-check` keeps the
198
+ // byte-identical `Write|Edit` entry it has always written.
199
+ describe('installGates per-gate matcher (mmnto-ai/totem#2799)', () => {
200
+ let cwd;
201
+ let originalCwd;
202
+ const STRICT_FREEZE_ENTRY = {
203
+ matcher: 'Write|Edit',
204
+ hooks: [
205
+ {
206
+ type: 'command',
207
+ command: 'node .claude/hooks/gate-wrapper.cjs --event freeze-check --strict',
208
+ },
209
+ ],
210
+ };
211
+ const STRICT_TRANSPORT_ENTRY = {
212
+ matcher: 'Bash|PowerShell',
213
+ hooks: [
214
+ {
215
+ type: 'command',
216
+ command: 'node .claude/hooks/gate-wrapper.cjs --event transport-shield --strict',
217
+ },
218
+ ],
219
+ };
220
+ beforeEach(() => {
221
+ cwd = makeTmpDir();
222
+ originalCwd = process.cwd();
223
+ process.chdir(cwd);
224
+ });
225
+ afterEach(() => {
226
+ process.chdir(originalCwd);
227
+ fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
228
+ });
229
+ it('transport-shield writes exactly ONE entry under Bash|PowerShell with the baked command', () => {
230
+ installGates(cwd, [TRANSPORT_SHIELD]);
231
+ expect(gateEntryCount(cwd, 'transport-shield')).toBe(1);
232
+ expect(matchersFor(cwd, 'transport-shield')).toEqual(['Bash|PowerShell']);
233
+ expect(gateCommandFor(cwd, 'transport-shield')).toBe('node .claude/hooks/gate-wrapper.cjs --event transport-shield --strict');
234
+ // The whole emitted entry, byte for byte.
235
+ expect(preToolUseEntries(cwd)).toEqual([STRICT_TRANSPORT_ENTRY]);
236
+ });
237
+ it('freeze-check writes the SAME Write|Edit entry it wrote before #2799, byte for byte', () => {
238
+ installGates(cwd, [FREEZE_CHECK]);
239
+ // The full emitted JSON entry — the regression guard on the gate whose
240
+ // matcher did NOT change.
241
+ expect(preToolUseEntries(cwd)).toEqual([STRICT_FREEZE_ENTRY]);
242
+ });
243
+ it('both gates together write both entries, each under its own matcher', () => {
244
+ installGates(cwd, [FREEZE_CHECK, TRANSPORT_SHIELD]);
245
+ expect(preToolUseEntries(cwd)).toEqual([STRICT_FREEZE_ENTRY, STRICT_TRANSPORT_ENTRY]);
246
+ expect(matchersFor(cwd, 'freeze-check')).toEqual(['Write|Edit']);
247
+ expect(matchersFor(cwd, 'transport-shield')).toEqual(['Bash|PowerShell']);
248
+ });
249
+ it('re-running both gates at the same tier is a byte-level no-op', () => {
250
+ installGates(cwd, [FREEZE_CHECK, TRANSPORT_SHIELD]);
251
+ const settingsPath = path.join(cwd, '.claude', 'settings.json');
252
+ const before = fs.readFileSync(settingsPath, 'utf-8');
253
+ const second = installGates(cwd, [FREEZE_CHECK, TRANSPORT_SHIELD]);
254
+ expect(second.find((r) => r.event === 'freeze-check')?.action).toBe('skipped');
255
+ expect(second.find((r) => r.event === 'transport-shield')?.action).toBe('skipped');
256
+ expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(before);
257
+ });
258
+ it('a tier switch updates the transport-shield entry IN PLACE under its own matcher', () => {
259
+ installGates(cwd, [TRANSPORT_SHIELD], 'pilot');
260
+ expect(gateCommandFor(cwd, 'transport-shield')).toContain('--pilot');
261
+ const switched = installGates(cwd, [TRANSPORT_SHIELD], 'strict');
262
+ expect(switched.find((r) => r.event === 'transport-shield')?.action).toBe('updated');
263
+ expect(gateEntryCount(cwd, 'transport-shield')).toBe(1);
264
+ expect(matchersFor(cwd, 'transport-shield')).toEqual(['Bash|PowerShell']);
265
+ const cmd = gateCommandFor(cwd, 'transport-shield');
266
+ expect(cmd).toContain('--strict');
267
+ expect(cmd).not.toContain('--pilot');
268
+ });
269
+ it('after any sequence of INSTALLER writes, no gate appears under two matchers (hand edits are not enforced)', () => {
270
+ // Interleave tiers and selections — the upsert must converge on exactly one
271
+ // entry per gate, always under that gate's registry matcher.
272
+ installGates(cwd, [FREEZE_CHECK], 'pilot');
273
+ installGates(cwd, [TRANSPORT_SHIELD]);
274
+ installGates(cwd, [TRANSPORT_SHIELD], 'pilot');
275
+ installGates(cwd, [FREEZE_CHECK, TRANSPORT_SHIELD], 'strict');
276
+ installGates(cwd, [TRANSPORT_SHIELD], 'strict');
277
+ for (const { event, matcher } of [FREEZE_CHECK, TRANSPORT_SHIELD]) {
278
+ expect(matchersFor(cwd, event)).toEqual([matcher]);
158
279
  expect(gateEntryCount(cwd, event)).toBe(1);
159
280
  }
281
+ expect(preToolUseEntries(cwd)).toEqual([STRICT_FREEZE_ENTRY, STRICT_TRANSPORT_ENTRY]);
160
282
  });
161
283
  });
162
284
  // ─── FIX A: tier-AWARE upsert (update in place, never silent no-op/dup) ──
@@ -179,17 +301,17 @@ describe('installGates tier-aware upsert (FIX A)', () => {
179
301
  fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
180
302
  });
181
303
  it('default(strict) then re-install --strict → skipped, one entry, command keeps --strict', () => {
182
- installGates(cwd, ['freeze-check']); // default tier === strict
183
- const second = installGates(cwd, ['freeze-check'], 'strict');
304
+ installGates(cwd, [FREEZE_CHECK]); // default tier === strict
305
+ const second = installGates(cwd, [FREEZE_CHECK], 'strict');
184
306
  const entryResult = second.find((r) => r.event === 'freeze-check');
185
307
  expect(entryResult?.action).toBe('skipped');
186
308
  expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
187
309
  expect(gateCommandFor(cwd, 'freeze-check')).toContain('--strict');
188
310
  });
189
311
  it('--pilot then --strict → updated, ONE entry, command flips to --strict (not --pilot)', () => {
190
- installGates(cwd, ['freeze-check'], 'pilot');
312
+ installGates(cwd, [FREEZE_CHECK], 'pilot');
191
313
  expect(gateCommandFor(cwd, 'freeze-check')).toContain('--pilot');
192
- const switched = installGates(cwd, ['freeze-check'], 'strict');
314
+ const switched = installGates(cwd, [FREEZE_CHECK], 'strict');
193
315
  const entryResult = switched.find((r) => r.event === 'freeze-check');
194
316
  expect(entryResult?.action).toBe('updated');
195
317
  // Exactly one entry — no duplicate created by the tier switch.
@@ -199,8 +321,8 @@ describe('installGates tier-aware upsert (FIX A)', () => {
199
321
  expect(cmd).not.toContain('--pilot');
200
322
  });
201
323
  it('--strict then --pilot → updated back to --pilot, one entry', () => {
202
- installGates(cwd, ['freeze-check'], 'strict');
203
- const switched = installGates(cwd, ['freeze-check'], 'pilot');
324
+ installGates(cwd, [FREEZE_CHECK], 'strict');
325
+ const switched = installGates(cwd, [FREEZE_CHECK], 'pilot');
204
326
  const entryResult = switched.find((r) => r.event === 'freeze-check');
205
327
  expect(entryResult?.action).toBe('updated');
206
328
  expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
@@ -242,13 +364,13 @@ describe('installGates tier-aware upsert (FIX A)', () => {
242
364
  return c === shieldCommand;
243
365
  }));
244
366
  // Install the gate at pilot → a distinct second Write|Edit entry.
245
- installGates(cwd, ['freeze-check'], 'pilot');
367
+ installGates(cwd, [FREEZE_CHECK], 'pilot');
246
368
  expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
247
369
  expect(gateCommandFor(cwd, 'freeze-check')).toContain('--pilot');
248
370
  expect(hasShield()).toBe(true);
249
371
  expect(preToolUseEntries(cwd).filter((e) => e.matcher === 'Write|Edit').length).toBe(2);
250
372
  // Tier switch → updates ONLY the gate entry in place; PreWriteShield stays.
251
- const switched = installGates(cwd, ['freeze-check'], 'strict');
373
+ const switched = installGates(cwd, [FREEZE_CHECK], 'strict');
252
374
  expect(switched.find((r) => r.event === 'freeze-check')?.action).toBe('updated');
253
375
  expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
254
376
  const cmd2 = gateCommandFor(cwd, 'freeze-check');
@@ -258,21 +380,9 @@ describe('installGates tier-aware upsert (FIX A)', () => {
258
380
  expect(preToolUseEntries(cwd).filter((e) => e.matcher === 'Write|Edit').length).toBe(2);
259
381
  });
260
382
  });
261
- describe('resolveGateEvents (registry-driven validation)', () => {
262
- it('--all enumerates knownGateEvents()', async () => {
263
- expect(await resolveGateEvents({ all: true })).toEqual(knownGateEvents());
264
- });
265
- it('a known --<name> resolves to itself', async () => {
266
- expect(await resolveGateEvents({ name: 'freeze-check' })).toEqual(['freeze-check']);
267
- });
268
- it('an unknown --<name> fails loud (never default-install)', async () => {
269
- await expect(resolveGateEvents({ name: 'made-up-gate' })).rejects.toBeInstanceOf(TotemError);
270
- await expect(resolveGateEvents({ name: 'made-up-gate' })).rejects.toThrow(/unknown gate/i);
271
- });
272
- it('no --all and no --<name> fails loud (no default-install)', async () => {
273
- await expect(resolveGateEvents({})).rejects.toThrow(/no gate selected/i);
274
- });
275
- });
383
+ // `resolveGates` (the registry-driven `{event, matcher}` resolver that replaced
384
+ // `resolveGateEvents` in mmnto-ai/totem#2799) is covered in gate.test.ts, beside
385
+ // the other `./gate.js` command-seam tests.
276
386
  // ─── The parameterized wrapper's disposition → exit-code map ───────────
277
387
  //
278
388
  // We render the wrapper template to a temp dir and drive it via stdin with
@@ -291,27 +401,60 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
291
401
  afterEach(() => {
292
402
  fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
293
403
  });
294
- /** Install a stub local CLI that emits a controlled verdict / exit code. */
404
+ /** Where the stub CLI records the argv and stdin it was spawned with (absent ⇒ never spawned). */
405
+ const stubRecordPath = () => path.join(cwd, 'stub-record.json');
406
+ /** Install a stub local CLI that records its argv + stdin and emits a controlled verdict / exit code. */
295
407
  function writeStubCli(opts) {
296
408
  const distDir = path.join(cwd, 'node_modules', '@mmnto', 'cli', 'dist');
297
409
  fs.mkdirSync(distDir, { recursive: true });
298
410
  const verdictJson = opts.verdict === undefined ? '' : JSON.stringify(opts.verdict);
299
411
  const exitCode = opts.exit ?? 0;
300
412
  // CommonJS stub (the wrapper invokes via `node <path>`); .js is fine here
301
- // because there is no package.json type:module in the temp dir.
413
+ // because there is no package.json type:module in the temp dir. The record
414
+ // is what lets a test assert BOTH that a spawn happened and exactly which
415
+ // `gate check --event … --payload -` the wrapper projected, argv AND the
416
+ // stdin the payload rides on (mmnto-ai/totem#2799); `JSON.stringify` on
417
+ // the path handles Windows separators without a hand-written escape.
302
418
  const stub = [
303
419
  '"use strict";',
420
+ 'const fs = require("fs");',
304
421
  `const out = ${JSON.stringify(verdictJson)};`,
422
+ 'let stdin = "";',
423
+ 'try { stdin = fs.readFileSync(0, "utf-8"); } catch { stdin = ""; }',
424
+ `fs.writeFileSync(${JSON.stringify(stubRecordPath())}, JSON.stringify({ argv: process.argv.slice(2), stdin }));`,
305
425
  'if (out) process.stdout.write(out + "\\n");',
306
426
  `process.exit(${exitCode});`,
307
427
  '',
308
428
  ].join('\n');
309
429
  fs.writeFileSync(path.join(distDir, 'index.js'), stub);
310
430
  }
431
+ /** What the stub CLI was spawned with, or null when the wrapper never spawned it. */
432
+ function stubRecord() {
433
+ if (!fs.existsSync(stubRecordPath()))
434
+ return null;
435
+ return JSON.parse(fs.readFileSync(stubRecordPath(), 'utf-8'));
436
+ }
437
+ /** The argv the stub CLI was spawned with, or null when the wrapper never spawned it. */
438
+ function stubArgv() {
439
+ return stubRecord()?.argv ?? null;
440
+ }
441
+ /**
442
+ * The payload JSON the wrapper projected, parsed (throws if it never
443
+ * spawned). The payload rides on the child's stdin under `--payload -` —
444
+ * never argv, whose win32 limit a long Bash command would exceed.
445
+ */
446
+ function spawnedPayload() {
447
+ const record = stubRecord();
448
+ expect(record, 'the wrapper never spawned the CLI').not.toBeNull();
449
+ const at = record.argv.indexOf('--payload');
450
+ expect(at).toBeGreaterThan(-1);
451
+ expect(record.argv[at + 1]).toBe('-');
452
+ return JSON.parse(record.stdin);
453
+ }
311
454
  /** Run the rendered wrapper with the given envelope + baked extra args. */
312
- function runWrapper(envelope, extraArgs = []) {
455
+ function runWrapper(envelope, extraArgs = [], event = 'freeze-check') {
313
456
  const wrapperPath = path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs');
314
- const res = spawnSync(process.execPath, [wrapperPath, '--event', 'freeze-check', ...extraArgs], {
457
+ const res = spawnSync(process.execPath, [wrapperPath, '--event', event, ...extraArgs], {
315
458
  cwd,
316
459
  input: JSON.stringify(envelope),
317
460
  encoding: 'utf-8',
@@ -321,6 +464,7 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
321
464
  }
322
465
  const EDIT_NO_SUBSYSTEM = { tool_name: 'Edit', tool_input: { file_path: 'src/foo.ts' } };
323
466
  const DECLARED = { tool_name: 'Edit', tool_input: { subsystem: 'rule-compilation' } };
467
+ const ALLOW_VERDICT = { disposition: 'allow', reason: 'ok', provenance: {} };
324
468
  it('allow → exit 0 (silent)', () => {
325
469
  writeStubCli({ verdict: { disposition: 'allow', reason: 'ok', provenance: {} }, exit: 0 });
326
470
  const { status } = runWrapper(DECLARED);
@@ -417,6 +561,101 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
417
561
  });
418
562
  expect(res.status).toBe(0);
419
563
  });
564
+ // ─── Per-event payload projection (mmnto-ai/totem#2799) ────────────────
565
+ //
566
+ // The wrapper is --event-parameterized but used to build ONE freeze-check-
567
+ // shaped payload for every event. It now branches on the baked --event AFTER
568
+ // the envelope parse and the non-object guard: each gate owns its own
569
+ // NOT-APPLICABLE test and its own payload, and an event it cannot project
570
+ // fails CLOSED. The stub CLI records its argv, so "did not spawn" is an
571
+ // assertion about the filesystem, not an inference from the exit code.
572
+ const BASH_CMD = { tool_name: 'Bash', tool_input: { command: 'git status' } };
573
+ const PWSH_CMD = { tool_name: 'PowerShell', tool_input: { command: 'Get-ChildItem' } };
574
+ it('transport-shield on a Write envelope → exit 0, never spawns', () => {
575
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
576
+ const { status } = runWrapper({ tool_name: 'Write', tool_input: { file_path: 'src/foo.ts' } }, [], 'transport-shield');
577
+ expect(status).toBe(0);
578
+ expect(stubArgv()).toBeNull();
579
+ });
580
+ it('transport-shield on a Write envelope that DECLARES a subsystem still never spawns', () => {
581
+ // The falsifier for the projection swap: pre-#2799 the wrapper keyed
582
+ // applicability on `tool_input.subsystem` for EVERY event, so this envelope
583
+ // shelled out with a freeze-check-shaped `{subsystem}` payload under
584
+ // `--event transport-shield`. Applicability is now the gate's own.
585
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
586
+ const { status } = runWrapper({ tool_name: 'Write', tool_input: { subsystem: 'rule-compilation' } }, [], 'transport-shield');
587
+ expect(status).toBe(0);
588
+ expect(stubArgv()).toBeNull();
589
+ });
590
+ it('transport-shield on a Bash envelope with no string command → exit 0, never spawns', () => {
591
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
592
+ const { status } = runWrapper({ tool_name: 'Bash', tool_input: { description: 'no command field' } }, [], 'transport-shield');
593
+ expect(status).toBe(0);
594
+ expect(stubArgv()).toBeNull();
595
+ });
596
+ it('transport-shield on a Bash envelope with a command spawns {tool, command, platform}', () => {
597
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
598
+ const { status } = runWrapper(BASH_CMD, [], 'transport-shield');
599
+ expect(status).toBe(0);
600
+ expect(stubArgv()).toEqual(['gate', 'check', '--event', 'transport-shield', '--payload', '-']);
601
+ expect(spawnedPayload()).toEqual({
602
+ tool: 'Bash',
603
+ command: 'git status',
604
+ platform: process.platform,
605
+ });
606
+ });
607
+ it('a Bash command past the win32 command-line limit still reaches the gate on stdin (P3-F6)', () => {
608
+ // 40,000 characters: past win32's 32,767-character argv cap, where an argv
609
+ // payload failed the spawn with ENAMETOOLONG and fell into the fail-closed
610
+ // arm with nothing broken. On stdin the verdict comes back and maps to exit 0.
611
+ // Discriminates the fold on the windows-latest CI leg only: linux (128 KB per
612
+ // argument) and darwin admit a 40 KB argv, so there the pre-fold wrapper also
613
+ // exits 0; the stdin record assertion below is what holds on every platform.
614
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
615
+ /** One command-line past win32's 32,767-character cap (CreateProcess), where argv transport fails. */
616
+ const PAST_WIN32_ARGV_CAP = 40_000;
617
+ const long = 'x'.repeat(PAST_WIN32_ARGV_CAP);
618
+ const { status, stderr } = runWrapper({ tool_name: 'Bash', tool_input: { command: long } }, [], 'transport-shield');
619
+ expect(status).toBe(0);
620
+ expect(stderr).not.toContain('fail-closed');
621
+ expect(spawnedPayload()).toEqual({ tool: 'Bash', command: long, platform: process.platform });
622
+ });
623
+ it('transport-shield on a PowerShell envelope spawns the same shape with tool PowerShell', () => {
624
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
625
+ const { status } = runWrapper(PWSH_CMD, [], 'transport-shield');
626
+ expect(status).toBe(0);
627
+ expect(spawnedPayload()).toEqual({
628
+ tool: 'PowerShell',
629
+ command: 'Get-ChildItem',
630
+ platform: process.platform,
631
+ });
632
+ });
633
+ it('freeze-check is UNCHANGED: no subsystem → exit 0 without spawning', () => {
634
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
635
+ const { status } = runWrapper(EDIT_NO_SUBSYSTEM);
636
+ expect(status).toBe(0);
637
+ expect(stubArgv()).toBeNull();
638
+ });
639
+ it('freeze-check is UNCHANGED: a declared subsystem spawns the {subsystem} payload', () => {
640
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
641
+ const { status } = runWrapper(DECLARED);
642
+ expect(status).toBe(0);
643
+ expect(stubArgv()).toEqual(['gate', 'check', '--event', 'freeze-check', '--payload', '-']);
644
+ expect(spawnedPayload()).toEqual({ subsystem: 'rule-compilation' });
645
+ });
646
+ it('an --event the wrapper cannot project → exit 2 fail-closed, never spawns', () => {
647
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
648
+ const { status, stderr } = runWrapper(BASH_CMD, [], 'nope');
649
+ expect(status).toBe(2);
650
+ expect(stderr).toContain('no payload projection for event "nope"; failing closed.');
651
+ expect(stubArgv()).toBeNull();
652
+ });
653
+ it('an unprojectable --event fails closed even under --pilot (tier maps dispositions only)', () => {
654
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
655
+ const { status } = runWrapper(BASH_CMD, ['--pilot'], 'nope');
656
+ expect(status).toBe(2);
657
+ expect(stubArgv()).toBeNull();
658
+ });
420
659
  });
421
660
  // ─── eject parity ──────────────────────────────────────────────────────
422
661
  describe('eject removes the gate entry (parity with install)', () => {
@@ -433,7 +672,7 @@ describe('eject removes the gate entry (parity with install)', () => {
433
672
  fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
434
673
  });
435
674
  it('scrubs the gate PreToolUse entry and the wrapper script on eject', async () => {
436
- installGates(cwd, ['freeze-check']);
675
+ installGates(cwd, [FREEZE_CHECK]);
437
676
  expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
438
677
  expect(fs.existsSync(path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs'))).toBe(true);
439
678
  await ejectCommand({ force: true });
@@ -449,7 +688,7 @@ describe('eject removes the gate entry (parity with install)', () => {
449
688
  fs.writeFileSync(path.join(cwd, '.claude', 'settings.json'), JSON.stringify({
450
689
  hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'mine' }] }] },
451
690
  }));
452
- installGates(cwd, ['freeze-check']);
691
+ installGates(cwd, [FREEZE_CHECK]);
453
692
  await ejectCommand({ force: true });
454
693
  expect(fs.existsSync(path.join(cwd, '.claude', 'settings.json'))).toBe(true);
455
694
  const entries = preToolUseEntries(cwd);
@@ -475,12 +714,19 @@ describe('init --gates= routes through the shared installer', () => {
475
714
  expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
476
715
  expect(fs.existsSync(path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs'))).toBe(true);
477
716
  });
478
- it('init --gates=all installs every known gate', async () => {
717
+ it('init --gates=all installs every known gate under ITS registry matcher', async () => {
479
718
  await initCommand({ bare: true, gates: 'all' });
480
- for (const event of knownGateEvents()) {
719
+ for (const { event, matcher } of knownGates()) {
481
720
  expect(gateEntryCount(cwd, event)).toBe(1);
721
+ expect(matchersFor(cwd, event)).toEqual([matcher]);
482
722
  }
483
723
  });
724
+ it('init --gates=transport-shield installs it under Bash|PowerShell (the pair, not a default)', async () => {
725
+ await initCommand({ bare: true, gates: 'transport-shield' });
726
+ expect(gateEntryCount(cwd, 'transport-shield')).toBe(1);
727
+ expect(matchersFor(cwd, 'transport-shield')).toEqual(['Bash|PowerShell']);
728
+ expect(gateCommandFor(cwd, 'transport-shield')).toBe('node .claude/hooks/gate-wrapper.cjs --event transport-shield --strict');
729
+ });
484
730
  it('init --gates= with an unknown member fails loud', async () => {
485
731
  await expect(initCommand({ bare: true, gates: 'made-up-gate' })).rejects.toThrow(/unknown gate/i);
486
732
  });