@mmnto/cli 2.1.0 → 2.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 (58) 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 +55 -5
  25. package/dist/commands/gate-install.d.ts.map +1 -1
  26. package/dist/commands/gate-install.js +47 -34
  27. package/dist/commands/gate-install.js.map +1 -1
  28. package/dist/commands/gate-install.test.js +514 -56
  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 +50 -18
  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 +4 -4
  40. package/dist/commands/init-templates.d.ts.map +1 -1
  41. package/dist/commands/init-templates.js +243 -63
  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 +35 -17
  45. package/dist/commands/init.js.map +1 -1
  46. package/dist/commands/init.test.js +10 -0
  47. package/dist/commands/init.test.js.map +1 -1
  48. package/dist/commands/session-context-journal-select.test.d.ts +2 -0
  49. package/dist/commands/session-context-journal-select.test.d.ts.map +1 -0
  50. package/dist/commands/session-context-journal-select.test.js +95 -0
  51. package/dist/commands/session-context-journal-select.test.js.map +1 -0
  52. package/dist/commands/sync-labels-forms.test.d.ts +33 -0
  53. package/dist/commands/sync-labels-forms.test.d.ts.map +1 -0
  54. package/dist/commands/sync-labels-forms.test.js +325 -0
  55. package/dist/commands/sync-labels-forms.test.js.map +1 -0
  56. package/dist/index.js +2 -2
  57. package/dist/index.js.map +1 -1
  58. 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';
@@ -51,6 +51,26 @@ function makeTmpDir() {
51
51
  // `.native` expands Windows 8.3 short names so process.cwd()/realpath agree.
52
52
  return fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-gate-install-')));
53
53
  }
54
+ /**
55
+ * The parent env with EVERY case-variant of PATH dropped and exactly one
56
+ * `PATH` set (win32 stores it as `Path`; a plain spread would leave both keys
57
+ * in the child's block). Everything else is inherited so node still boots under
58
+ * an empty PATH.
59
+ *
60
+ * Load-bearing since the wrapper grew a PATH fallback arm (mmnto-ai/totem#2822):
61
+ * a wrapper test that means "no CLI is resolvable" must say so explicitly, or it
62
+ * silently reads the developer's global `@mmnto/cli` and passes/fails by machine.
63
+ */
64
+ function envWithPath(value) {
65
+ const env = {};
66
+ for (const [key, val] of Object.entries(process.env)) {
67
+ if (key.toUpperCase() === 'PATH')
68
+ continue;
69
+ env[key] = val;
70
+ }
71
+ env.PATH = value;
72
+ return env;
73
+ }
54
74
  function readSettings(cwd) {
55
75
  const raw = fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf-8');
56
76
  return JSON.parse(raw);
@@ -60,13 +80,41 @@ function preToolUseEntries(cwd) {
60
80
  const hooks = (parsed.hooks ?? {});
61
81
  return (hooks.PreToolUse ?? []);
62
82
  }
63
- /** Count Write|Edit PreToolUse entries whose command references a given gate. */
83
+ /**
84
+ * The `{event, matcher}` pairs the caller resolves from the core registry and
85
+ * hands `installGates` (mmnto-ai/totem#2799). Spelled literally here rather than
86
+ * read from `knownGates()` so a registry change that silently moved a gate to a
87
+ * different matcher would FAIL these tests instead of following them.
88
+ */
89
+ const FREEZE_CHECK = { event: 'freeze-check', matcher: 'Write|Edit' };
90
+ const TRANSPORT_SHIELD = {
91
+ event: 'transport-shield',
92
+ matcher: 'Bash|PowerShell',
93
+ };
94
+ /**
95
+ * Every PreToolUse matcher an entry installing `event` currently appears under.
96
+ * Matcher-AGNOSTIC by construction: a gate that leaked into a second matcher
97
+ * shows up here as two members, which is the invariant the #2799 tests assert.
98
+ */
99
+ function matchersFor(cwd, event) {
100
+ const found = [];
101
+ for (const e of preToolUseEntries(cwd)) {
102
+ if (!Array.isArray(e.hooks))
103
+ continue;
104
+ for (const h of e.hooks) {
105
+ const cmd = typeof h === 'string' ? h : (h?.command ?? '');
106
+ // Collision-safe exact --event match (tier-independent).
107
+ if (commandInstallsGate(cmd, event))
108
+ found.push(e.matcher ?? '');
109
+ }
110
+ }
111
+ return found;
112
+ }
113
+ /** Count PreToolUse entries (under ANY matcher) whose command references a given gate. */
64
114
  function gateEntryCount(cwd, event) {
65
- return preToolUseEntries(cwd).filter((e) => e.matcher === 'Write|Edit' &&
66
- Array.isArray(e.hooks) &&
115
+ return preToolUseEntries(cwd).filter((e) => Array.isArray(e.hooks) &&
67
116
  e.hooks.some((h) => {
68
117
  const cmd = typeof h === 'string' ? h : (h?.command ?? '');
69
- // Collision-safe exact --event match (tier-independent).
70
118
  return commandInstallsGate(cmd, event);
71
119
  })).length;
72
120
  }
@@ -74,7 +122,7 @@ function gateEntryCount(cwd, event) {
74
122
  function gateCommandFor(cwd, event) {
75
123
  const cmds = [];
76
124
  for (const e of preToolUseEntries(cwd)) {
77
- if (e.matcher !== 'Write|Edit' || !Array.isArray(e.hooks))
125
+ if (!Array.isArray(e.hooks))
78
126
  continue;
79
127
  for (const h of e.hooks) {
80
128
  const cmd = typeof h === 'string' ? h : (h?.command ?? '');
@@ -97,7 +145,7 @@ describe('installGates / gate install (settings merge)', () => {
97
145
  fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
98
146
  });
99
147
  it('scaffolds the wrapper and merges one PreToolUse entry per gate', () => {
100
- const results = installGates(cwd, ['freeze-check']);
148
+ const results = installGates(cwd, [FREEZE_CHECK]);
101
149
  // wrapper scaffold + one entry merge
102
150
  expect(results.some((r) => r.file === GATE_WRAPPER_REL && r.action === 'created')).toBe(true);
103
151
  expect(fs.existsSync(path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs'))).toBe(true);
@@ -108,7 +156,7 @@ describe('installGates / gate install (settings merge)', () => {
108
156
  const wrapperPath = path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs');
109
157
  fs.mkdirSync(path.dirname(wrapperPath), { recursive: true });
110
158
  fs.writeFileSync(wrapperPath, `${TOTEM_FILE_MARKER}\nstale\n${TOTEM_FILE_END}\n`, 'utf-8');
111
- const results = installGates(cwd, ['freeze-check']);
159
+ const results = installGates(cwd, [FREEZE_CHECK]);
112
160
  // gate install now threads the end marker, so scaffoldFile drift-repairs the bounded
113
161
  // wrapper (`refreshed`) and gate-install maps that to `merged` (a write happened).
114
162
  expect(results.find((r) => r.file === GATE_WRAPPER_REL).action).toBe('merged');
@@ -116,9 +164,9 @@ describe('installGates / gate install (settings merge)', () => {
116
164
  expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
117
165
  });
118
166
  it('is idempotent: a second install of the same gate is a no-op', () => {
119
- installGates(cwd, ['freeze-check']);
167
+ installGates(cwd, [FREEZE_CHECK]);
120
168
  const before = JSON.stringify(readSettings(cwd));
121
- const second = installGates(cwd, ['freeze-check']);
169
+ const second = installGates(cwd, [FREEZE_CHECK]);
122
170
  const entryResult = second.find((r) => r.event === 'freeze-check');
123
171
  expect(entryResult?.action).toBe('skipped');
124
172
  expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
@@ -127,8 +175,8 @@ describe('installGates / gate install (settings merge)', () => {
127
175
  it('keys idempotency on the per-gate command substring (distinct gates → distinct entries)', () => {
128
176
  // freeze-check installed; a hypothetical future gate keyed on a different
129
177
  // --event substring produces a SECOND entry rather than colliding.
130
- installGates(cwd, ['freeze-check']);
131
- installGates(cwd, ['some-future-gate']);
178
+ installGates(cwd, [FREEZE_CHECK]);
179
+ installGates(cwd, [{ event: 'some-future-gate', matcher: 'Write|Edit' }]);
132
180
  expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
133
181
  expect(gateEntryCount(cwd, 'some-future-gate')).toBe(1);
134
182
  // Two distinct Write|Edit gate entries under one matcher.
@@ -147,16 +195,110 @@ describe('installGates / gate install (settings merge)', () => {
147
195
  PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-hook' }] }],
148
196
  },
149
197
  }));
150
- installGates(cwd, ['freeze-check']);
198
+ installGates(cwd, [FREEZE_CHECK]);
151
199
  const entries = preToolUseEntries(cwd);
152
200
  expect(entries.some((e) => e.matcher === 'Bash')).toBe(true);
153
201
  expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
154
202
  });
155
- it('gateInstallCommand --all installs every known gate', async () => {
203
+ it('gateInstallCommand --all installs every known gate under ITS registry matcher', async () => {
156
204
  await gateInstallCommand({ all: true });
157
- for (const event of knownGateEvents()) {
205
+ for (const { event, matcher } of knownGates()) {
206
+ expect(gateEntryCount(cwd, event)).toBe(1);
207
+ // The installed matcher is the registry's, not a shared default.
208
+ expect(matchersFor(cwd, event)).toEqual([matcher]);
209
+ }
210
+ });
211
+ });
212
+ // ─── Per-gate matchers (mmnto-ai/totem#2799) ───────────────────────────
213
+ //
214
+ // Before #2799 every gate entry was written under one hardcoded `Write|Edit`
215
+ // matcher. The registry now carries each gate's own matcher, the caller
216
+ // resolves it (`knownGates()`), and `installGates` writes THAT — so
217
+ // `transport-shield` lands on `Bash|PowerShell` while `freeze-check` keeps the
218
+ // byte-identical `Write|Edit` entry it has always written.
219
+ describe('installGates per-gate matcher (mmnto-ai/totem#2799)', () => {
220
+ let cwd;
221
+ let originalCwd;
222
+ const STRICT_FREEZE_ENTRY = {
223
+ matcher: 'Write|Edit',
224
+ hooks: [
225
+ {
226
+ type: 'command',
227
+ command: 'node .claude/hooks/gate-wrapper.cjs --event freeze-check --strict',
228
+ },
229
+ ],
230
+ };
231
+ const STRICT_TRANSPORT_ENTRY = {
232
+ matcher: 'Bash|PowerShell',
233
+ hooks: [
234
+ {
235
+ type: 'command',
236
+ command: 'node .claude/hooks/gate-wrapper.cjs --event transport-shield --strict',
237
+ },
238
+ ],
239
+ };
240
+ beforeEach(() => {
241
+ cwd = makeTmpDir();
242
+ originalCwd = process.cwd();
243
+ process.chdir(cwd);
244
+ });
245
+ afterEach(() => {
246
+ process.chdir(originalCwd);
247
+ fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
248
+ });
249
+ it('transport-shield writes exactly ONE entry under Bash|PowerShell with the baked command', () => {
250
+ installGates(cwd, [TRANSPORT_SHIELD]);
251
+ expect(gateEntryCount(cwd, 'transport-shield')).toBe(1);
252
+ expect(matchersFor(cwd, 'transport-shield')).toEqual(['Bash|PowerShell']);
253
+ expect(gateCommandFor(cwd, 'transport-shield')).toBe('node .claude/hooks/gate-wrapper.cjs --event transport-shield --strict');
254
+ // The whole emitted entry, byte for byte.
255
+ expect(preToolUseEntries(cwd)).toEqual([STRICT_TRANSPORT_ENTRY]);
256
+ });
257
+ it('freeze-check writes the SAME Write|Edit entry it wrote before #2799, byte for byte', () => {
258
+ installGates(cwd, [FREEZE_CHECK]);
259
+ // The full emitted JSON entry — the regression guard on the gate whose
260
+ // matcher did NOT change.
261
+ expect(preToolUseEntries(cwd)).toEqual([STRICT_FREEZE_ENTRY]);
262
+ });
263
+ it('both gates together write both entries, each under its own matcher', () => {
264
+ installGates(cwd, [FREEZE_CHECK, TRANSPORT_SHIELD]);
265
+ expect(preToolUseEntries(cwd)).toEqual([STRICT_FREEZE_ENTRY, STRICT_TRANSPORT_ENTRY]);
266
+ expect(matchersFor(cwd, 'freeze-check')).toEqual(['Write|Edit']);
267
+ expect(matchersFor(cwd, 'transport-shield')).toEqual(['Bash|PowerShell']);
268
+ });
269
+ it('re-running both gates at the same tier is a byte-level no-op', () => {
270
+ installGates(cwd, [FREEZE_CHECK, TRANSPORT_SHIELD]);
271
+ const settingsPath = path.join(cwd, '.claude', 'settings.json');
272
+ const before = fs.readFileSync(settingsPath, 'utf-8');
273
+ const second = installGates(cwd, [FREEZE_CHECK, TRANSPORT_SHIELD]);
274
+ expect(second.find((r) => r.event === 'freeze-check')?.action).toBe('skipped');
275
+ expect(second.find((r) => r.event === 'transport-shield')?.action).toBe('skipped');
276
+ expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(before);
277
+ });
278
+ it('a tier switch updates the transport-shield entry IN PLACE under its own matcher', () => {
279
+ installGates(cwd, [TRANSPORT_SHIELD], 'pilot');
280
+ expect(gateCommandFor(cwd, 'transport-shield')).toContain('--pilot');
281
+ const switched = installGates(cwd, [TRANSPORT_SHIELD], 'strict');
282
+ expect(switched.find((r) => r.event === 'transport-shield')?.action).toBe('updated');
283
+ expect(gateEntryCount(cwd, 'transport-shield')).toBe(1);
284
+ expect(matchersFor(cwd, 'transport-shield')).toEqual(['Bash|PowerShell']);
285
+ const cmd = gateCommandFor(cwd, 'transport-shield');
286
+ expect(cmd).toContain('--strict');
287
+ expect(cmd).not.toContain('--pilot');
288
+ });
289
+ it('after any sequence of INSTALLER writes, no gate appears under two matchers (hand edits are not enforced)', () => {
290
+ // Interleave tiers and selections — the upsert must converge on exactly one
291
+ // entry per gate, always under that gate's registry matcher.
292
+ installGates(cwd, [FREEZE_CHECK], 'pilot');
293
+ installGates(cwd, [TRANSPORT_SHIELD]);
294
+ installGates(cwd, [TRANSPORT_SHIELD], 'pilot');
295
+ installGates(cwd, [FREEZE_CHECK, TRANSPORT_SHIELD], 'strict');
296
+ installGates(cwd, [TRANSPORT_SHIELD], 'strict');
297
+ for (const { event, matcher } of [FREEZE_CHECK, TRANSPORT_SHIELD]) {
298
+ expect(matchersFor(cwd, event)).toEqual([matcher]);
158
299
  expect(gateEntryCount(cwd, event)).toBe(1);
159
300
  }
301
+ expect(preToolUseEntries(cwd)).toEqual([STRICT_FREEZE_ENTRY, STRICT_TRANSPORT_ENTRY]);
160
302
  });
161
303
  });
162
304
  // ─── FIX A: tier-AWARE upsert (update in place, never silent no-op/dup) ──
@@ -179,17 +321,17 @@ describe('installGates tier-aware upsert (FIX A)', () => {
179
321
  fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
180
322
  });
181
323
  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');
324
+ installGates(cwd, [FREEZE_CHECK]); // default tier === strict
325
+ const second = installGates(cwd, [FREEZE_CHECK], 'strict');
184
326
  const entryResult = second.find((r) => r.event === 'freeze-check');
185
327
  expect(entryResult?.action).toBe('skipped');
186
328
  expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
187
329
  expect(gateCommandFor(cwd, 'freeze-check')).toContain('--strict');
188
330
  });
189
331
  it('--pilot then --strict → updated, ONE entry, command flips to --strict (not --pilot)', () => {
190
- installGates(cwd, ['freeze-check'], 'pilot');
332
+ installGates(cwd, [FREEZE_CHECK], 'pilot');
191
333
  expect(gateCommandFor(cwd, 'freeze-check')).toContain('--pilot');
192
- const switched = installGates(cwd, ['freeze-check'], 'strict');
334
+ const switched = installGates(cwd, [FREEZE_CHECK], 'strict');
193
335
  const entryResult = switched.find((r) => r.event === 'freeze-check');
194
336
  expect(entryResult?.action).toBe('updated');
195
337
  // Exactly one entry — no duplicate created by the tier switch.
@@ -199,8 +341,8 @@ describe('installGates tier-aware upsert (FIX A)', () => {
199
341
  expect(cmd).not.toContain('--pilot');
200
342
  });
201
343
  it('--strict then --pilot → updated back to --pilot, one entry', () => {
202
- installGates(cwd, ['freeze-check'], 'strict');
203
- const switched = installGates(cwd, ['freeze-check'], 'pilot');
344
+ installGates(cwd, [FREEZE_CHECK], 'strict');
345
+ const switched = installGates(cwd, [FREEZE_CHECK], 'pilot');
204
346
  const entryResult = switched.find((r) => r.event === 'freeze-check');
205
347
  expect(entryResult?.action).toBe('updated');
206
348
  expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
@@ -242,13 +384,13 @@ describe('installGates tier-aware upsert (FIX A)', () => {
242
384
  return c === shieldCommand;
243
385
  }));
244
386
  // Install the gate at pilot → a distinct second Write|Edit entry.
245
- installGates(cwd, ['freeze-check'], 'pilot');
387
+ installGates(cwd, [FREEZE_CHECK], 'pilot');
246
388
  expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
247
389
  expect(gateCommandFor(cwd, 'freeze-check')).toContain('--pilot');
248
390
  expect(hasShield()).toBe(true);
249
391
  expect(preToolUseEntries(cwd).filter((e) => e.matcher === 'Write|Edit').length).toBe(2);
250
392
  // Tier switch → updates ONLY the gate entry in place; PreWriteShield stays.
251
- const switched = installGates(cwd, ['freeze-check'], 'strict');
393
+ const switched = installGates(cwd, [FREEZE_CHECK], 'strict');
252
394
  expect(switched.find((r) => r.event === 'freeze-check')?.action).toBe('updated');
253
395
  expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
254
396
  const cmd2 = gateCommandFor(cwd, 'freeze-check');
@@ -258,21 +400,9 @@ describe('installGates tier-aware upsert (FIX A)', () => {
258
400
  expect(preToolUseEntries(cwd).filter((e) => e.matcher === 'Write|Edit').length).toBe(2);
259
401
  });
260
402
  });
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
- });
403
+ // `resolveGates` (the registry-driven `{event, matcher}` resolver that replaced
404
+ // `resolveGateEvents` in mmnto-ai/totem#2799) is covered in gate.test.ts, beside
405
+ // the other `./gate.js` command-seam tests.
276
406
  // ─── The parameterized wrapper's disposition → exit-code map ───────────
277
407
  //
278
408
  // We render the wrapper template to a temp dir and drive it via stdin with
@@ -291,36 +421,76 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
291
421
  afterEach(() => {
292
422
  fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
293
423
  });
294
- /** Install a stub local CLI that emits a controlled verdict / exit code. */
424
+ /** Where the stub CLI records the argv and stdin it was spawned with (absent ⇒ never spawned). */
425
+ const stubRecordPath = () => path.join(cwd, 'stub-record.json');
426
+ /** Install a stub local CLI that records its argv + stdin and emits a controlled verdict / exit code. */
295
427
  function writeStubCli(opts) {
296
428
  const distDir = path.join(cwd, 'node_modules', '@mmnto', 'cli', 'dist');
297
429
  fs.mkdirSync(distDir, { recursive: true });
298
430
  const verdictJson = opts.verdict === undefined ? '' : JSON.stringify(opts.verdict);
299
431
  const exitCode = opts.exit ?? 0;
300
432
  // 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.
433
+ // because there is no package.json type:module in the temp dir. The record
434
+ // is what lets a test assert BOTH that a spawn happened and exactly which
435
+ // `gate check --event … --payload -` the wrapper projected, argv AND the
436
+ // stdin the payload rides on (mmnto-ai/totem#2799); `JSON.stringify` on
437
+ // the path handles Windows separators without a hand-written escape.
302
438
  const stub = [
303
439
  '"use strict";',
440
+ 'const fs = require("fs");',
304
441
  `const out = ${JSON.stringify(verdictJson)};`,
442
+ 'let stdin = "";',
443
+ 'try { stdin = fs.readFileSync(0, "utf-8"); } catch { stdin = ""; }',
444
+ `fs.writeFileSync(${JSON.stringify(stubRecordPath())}, JSON.stringify({ argv: process.argv.slice(2), stdin }));`,
305
445
  'if (out) process.stdout.write(out + "\\n");',
306
446
  `process.exit(${exitCode});`,
307
447
  '',
308
448
  ].join('\n');
309
449
  fs.writeFileSync(path.join(distDir, 'index.js'), stub);
310
450
  }
311
- /** Run the rendered wrapper with the given envelope + baked extra args. */
312
- function runWrapper(envelope, extraArgs = []) {
451
+ /** What the stub CLI was spawned with, or null when the wrapper never spawned it. */
452
+ function stubRecord() {
453
+ if (!fs.existsSync(stubRecordPath()))
454
+ return null;
455
+ return JSON.parse(fs.readFileSync(stubRecordPath(), 'utf-8'));
456
+ }
457
+ /** The argv the stub CLI was spawned with, or null when the wrapper never spawned it. */
458
+ function stubArgv() {
459
+ return stubRecord()?.argv ?? null;
460
+ }
461
+ /**
462
+ * The payload JSON the wrapper projected, parsed (throws if it never
463
+ * spawned). The payload rides on the child's stdin under `--payload -` —
464
+ * never argv, whose win32 limit a long Bash command would exceed.
465
+ */
466
+ function spawnedPayload() {
467
+ const record = stubRecord();
468
+ expect(record, 'the wrapper never spawned the CLI').not.toBeNull();
469
+ const at = record.argv.indexOf('--payload');
470
+ expect(at).toBeGreaterThan(-1);
471
+ expect(record.argv[at + 1]).toBe('-');
472
+ return JSON.parse(record.stdin);
473
+ }
474
+ /**
475
+ * Run the rendered wrapper with the given envelope + baked extra args.
476
+ * `env` defaults to the parent's (every test here writes a repo-local stub,
477
+ * which the wrapper resolves FIRST, so the ambient PATH is inert for them);
478
+ * the no-CLI test passes an empty PATH explicitly.
479
+ */
480
+ function runWrapper(envelope, extraArgs = [], event = 'freeze-check', env = undefined) {
313
481
  const wrapperPath = path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs');
314
- const res = spawnSync(process.execPath, [wrapperPath, '--event', 'freeze-check', ...extraArgs], {
482
+ const res = spawnSync(process.execPath, [wrapperPath, '--event', event, ...extraArgs], {
315
483
  cwd,
316
484
  input: JSON.stringify(envelope),
317
485
  encoding: 'utf-8',
318
486
  timeout: 30000,
487
+ ...(env ? { env } : {}),
319
488
  });
320
489
  return { status: res.status, stderr: res.stderr ?? '' };
321
490
  }
322
491
  const EDIT_NO_SUBSYSTEM = { tool_name: 'Edit', tool_input: { file_path: 'src/foo.ts' } };
323
492
  const DECLARED = { tool_name: 'Edit', tool_input: { subsystem: 'rule-compilation' } };
493
+ const ALLOW_VERDICT = { disposition: 'allow', reason: 'ok', provenance: {} };
324
494
  it('allow → exit 0 (silent)', () => {
325
495
  writeStubCli({ verdict: { disposition: 'allow', reason: 'ok', provenance: {} }, exit: 0 });
326
496
  const { status } = runWrapper(DECLARED);
@@ -382,14 +552,17 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
382
552
  expect(res.status).toBe(0);
383
553
  });
384
554
  // ─── FIX 2: applicable gate but no local CLI dist → fail-closed ────────
385
- it('applicable gate but the local CLI dist is missing → exit 2 fail-closed (FIX 2)', () => {
386
- // Deliberately do NOT write the stub CLI. A declared subsystem makes the
387
- // gate APPLY, but @mmnto/cli is not resolvable. freeze-check has no
388
- // commit-time hard floor, so an applicable-but-unevaluable gate must fail
389
- // closed (exit 2) rather than silently allow (exit 0).
390
- const { status, stderr } = runWrapper(DECLARED);
555
+ it('applicable gate but NO CLI is resolvable (local absent, PATH empty) → exit 2 fail-closed (FIX 2)', () => {
556
+ // Deliberately do NOT write the stub CLI, and hand the wrapper an EMPTY
557
+ // PATH so neither resolution arm can answer (mmnto-ai/totem#2822 added the
558
+ // PATH fallback; without this the test would silently read a developer's
559
+ // global @mmnto/cli and stop testing fail-closed). A declared subsystem
560
+ // makes the gate APPLY; freeze-check has no commit-time hard floor, so an
561
+ // applicable-but-unevaluable gate must fail closed (exit 2), never silently
562
+ // allow (exit 0).
563
+ const { status, stderr } = runWrapper(DECLARED, [], 'freeze-check', envWithPath(''));
391
564
  expect(status).toBe(2);
392
- expect(stderr).toMatch(/not resolvable|not installed|failing closed/i);
565
+ expect(stderr).toMatch(/failing closed/i);
393
566
  });
394
567
  it('gate check exits 0 with unparseable stdout → exit 2 fail-closed', () => {
395
568
  // verdict undefined → stub emits nothing; the wrapper cannot parse a
@@ -417,6 +590,258 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
417
590
  });
418
591
  expect(res.status).toBe(0);
419
592
  });
593
+ // ─── Per-event payload projection (mmnto-ai/totem#2799) ────────────────
594
+ //
595
+ // The wrapper is --event-parameterized but used to build ONE freeze-check-
596
+ // shaped payload for every event. It now branches on the baked --event AFTER
597
+ // the envelope parse and the non-object guard: each gate owns its own
598
+ // NOT-APPLICABLE test and its own payload, and an event it cannot project
599
+ // fails CLOSED. The stub CLI records its argv, so "did not spawn" is an
600
+ // assertion about the filesystem, not an inference from the exit code.
601
+ const BASH_CMD = { tool_name: 'Bash', tool_input: { command: 'git status' } };
602
+ const PWSH_CMD = { tool_name: 'PowerShell', tool_input: { command: 'Get-ChildItem' } };
603
+ it('transport-shield on a Write envelope → exit 0, never spawns', () => {
604
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
605
+ const { status } = runWrapper({ tool_name: 'Write', tool_input: { file_path: 'src/foo.ts' } }, [], 'transport-shield');
606
+ expect(status).toBe(0);
607
+ expect(stubArgv()).toBeNull();
608
+ });
609
+ it('transport-shield on a Write envelope that DECLARES a subsystem still never spawns', () => {
610
+ // The falsifier for the projection swap: pre-#2799 the wrapper keyed
611
+ // applicability on `tool_input.subsystem` for EVERY event, so this envelope
612
+ // shelled out with a freeze-check-shaped `{subsystem}` payload under
613
+ // `--event transport-shield`. Applicability is now the gate's own.
614
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
615
+ const { status } = runWrapper({ tool_name: 'Write', tool_input: { subsystem: 'rule-compilation' } }, [], 'transport-shield');
616
+ expect(status).toBe(0);
617
+ expect(stubArgv()).toBeNull();
618
+ });
619
+ it('transport-shield on a Bash envelope with no string command → exit 0, never spawns', () => {
620
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
621
+ const { status } = runWrapper({ tool_name: 'Bash', tool_input: { description: 'no command field' } }, [], 'transport-shield');
622
+ expect(status).toBe(0);
623
+ expect(stubArgv()).toBeNull();
624
+ });
625
+ it('transport-shield on a Bash envelope with a command spawns {tool, command, platform}', () => {
626
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
627
+ const { status } = runWrapper(BASH_CMD, [], 'transport-shield');
628
+ expect(status).toBe(0);
629
+ expect(stubArgv()).toEqual(['gate', 'check', '--event', 'transport-shield', '--payload', '-']);
630
+ expect(spawnedPayload()).toEqual({
631
+ tool: 'Bash',
632
+ command: 'git status',
633
+ platform: process.platform,
634
+ });
635
+ });
636
+ it('a Bash command past the win32 command-line limit still reaches the gate on stdin (P3-F6)', () => {
637
+ // 40,000 characters: past win32's 32,767-character argv cap, where an argv
638
+ // payload failed the spawn with ENAMETOOLONG and fell into the fail-closed
639
+ // arm with nothing broken. On stdin the verdict comes back and maps to exit 0.
640
+ // Discriminates the fold on the windows-latest CI leg only: linux (128 KB per
641
+ // argument) and darwin admit a 40 KB argv, so there the pre-fold wrapper also
642
+ // exits 0; the stdin record assertion below is what holds on every platform.
643
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
644
+ /** One command-line past win32's 32,767-character cap (CreateProcess), where argv transport fails. */
645
+ const PAST_WIN32_ARGV_CAP = 40_000;
646
+ const long = 'x'.repeat(PAST_WIN32_ARGV_CAP);
647
+ const { status, stderr } = runWrapper({ tool_name: 'Bash', tool_input: { command: long } }, [], 'transport-shield');
648
+ expect(status).toBe(0);
649
+ expect(stderr).not.toContain('fail-closed');
650
+ expect(spawnedPayload()).toEqual({ tool: 'Bash', command: long, platform: process.platform });
651
+ });
652
+ it('transport-shield on a PowerShell envelope spawns the same shape with tool PowerShell', () => {
653
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
654
+ const { status } = runWrapper(PWSH_CMD, [], 'transport-shield');
655
+ expect(status).toBe(0);
656
+ expect(spawnedPayload()).toEqual({
657
+ tool: 'PowerShell',
658
+ command: 'Get-ChildItem',
659
+ platform: process.platform,
660
+ });
661
+ });
662
+ it('freeze-check is UNCHANGED: no subsystem → exit 0 without spawning', () => {
663
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
664
+ const { status } = runWrapper(EDIT_NO_SUBSYSTEM);
665
+ expect(status).toBe(0);
666
+ expect(stubArgv()).toBeNull();
667
+ });
668
+ it('freeze-check is UNCHANGED: a declared subsystem spawns the {subsystem} payload', () => {
669
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
670
+ const { status } = runWrapper(DECLARED);
671
+ expect(status).toBe(0);
672
+ expect(stubArgv()).toEqual(['gate', 'check', '--event', 'freeze-check', '--payload', '-']);
673
+ expect(spawnedPayload()).toEqual({ subsystem: 'rule-compilation' });
674
+ });
675
+ it('an --event the wrapper cannot project → exit 2 fail-closed, never spawns', () => {
676
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
677
+ const { status, stderr } = runWrapper(BASH_CMD, [], 'nope');
678
+ expect(status).toBe(2);
679
+ expect(stderr).toContain('no payload projection for event "nope"; failing closed.');
680
+ expect(stubArgv()).toBeNull();
681
+ });
682
+ it('an unprojectable --event fails closed even under --pilot (tier maps dispositions only)', () => {
683
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
684
+ const { status } = runWrapper(BASH_CMD, ['--pilot'], 'nope');
685
+ expect(status).toBe(2);
686
+ expect(stubArgv()).toBeNull();
687
+ });
688
+ });
689
+ // ─── The PATH fallback arm (mmnto-ai/totem#2822) ───────────────────────
690
+ //
691
+ // A `Bash|PowerShell`-matched gate applies to `pnpm install` / `pnpm build` —
692
+ // the very commands that CREATE the repo-local CLI — so a repo-local-ONLY
693
+ // resolution made the gate block its own bootstrap on a fresh clone, and block
694
+ // the cure its message named (`totem eject`, a Bash command). The wrapper now
695
+ // falls back to a `totem` on PATH (repo-local still FIRST) and fails closed
696
+ // only when NEITHER arm resolves — the #2799 ruling is kept, the self-block is
697
+ // not. Each test below drives the rendered wrapper with a CONTROLLED PATH and
698
+ // discriminates the fold: pre-fix, the first two exited through the no-CLI arm
699
+ // (there was no PATH probe at all) and the third's message named `totem eject`.
700
+ describe('gate-wrapper.cjs PATH fallback (mmnto-ai/totem#2822)', () => {
701
+ let cwd;
702
+ let pathDir;
703
+ /** The self-block case: a bootstrap command under the shell-matched gate. */
704
+ const BOOTSTRAP = { tool_name: 'Bash', tool_input: { command: 'pnpm install' } };
705
+ const ALLOW = { disposition: 'allow', reason: 'ok', provenance: {} };
706
+ beforeEach(() => {
707
+ cwd = makeTmpDir();
708
+ fs.mkdirSync(path.join(cwd, '.claude', 'hooks'), { recursive: true });
709
+ fs.writeFileSync(path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs'), CLAUDE_GATE_WRAPPER);
710
+ // A PATH entry shaped like an npm global prefix on win32: the `totem.cmd`
711
+ // shim's sibling `node_modules/@mmnto/cli/dist/index.js`. Deliberately NOT
712
+ // under `cwd/node_modules`, so the repo-local arm cannot resolve it.
713
+ pathDir = path.join(cwd, 'global-bin');
714
+ fs.mkdirSync(pathDir, { recursive: true });
715
+ });
716
+ afterEach(() => {
717
+ fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
718
+ });
719
+ /**
720
+ * A CLI stub at `<dir>/node_modules/@mmnto/cli/dist/index.js` emitting the
721
+ * given verdict and exit code; returns the entry path the wrapper should
722
+ * resolve. It drains stdin (the `--payload -` channel) before exiting so the
723
+ * parent's `input` write never races an EPIPE into `result.error`.
724
+ */
725
+ function writeCliUnder(dir, opts) {
726
+ const distDir = path.join(dir, 'node_modules', '@mmnto', 'cli', 'dist');
727
+ fs.mkdirSync(distDir, { recursive: true });
728
+ const out = opts.verdict === undefined ? '' : JSON.stringify(opts.verdict);
729
+ const stub = [
730
+ '"use strict";',
731
+ 'const fs = require("fs");',
732
+ `const out = ${JSON.stringify(out)};`,
733
+ 'try { fs.readFileSync(0, "utf-8"); } catch { /* no stdin */ }',
734
+ 'if (out) process.stdout.write(out + "\\n");',
735
+ `process.exit(${opts.exit ?? 0});`,
736
+ '',
737
+ ].join('\n');
738
+ const entry = path.join(distDir, 'index.js');
739
+ fs.writeFileSync(entry, stub);
740
+ return entry;
741
+ }
742
+ /** Run the rendered wrapper in `cwd` with an exact PATH value. */
743
+ function runWithPath(envelope, pathValue, event = 'transport-shield') {
744
+ const wrapperPath = path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs');
745
+ const res = spawnSync(process.execPath, [wrapperPath, '--event', event, '--strict'], {
746
+ cwd,
747
+ input: JSON.stringify(envelope),
748
+ encoding: 'utf-8',
749
+ timeout: 30000,
750
+ env: envWithPath(pathValue),
751
+ });
752
+ return { status: res.status, stderr: res.stderr ?? '' };
753
+ }
754
+ it('no repo-local CLI but a totem on PATH → the PATH arm evaluates (allow → exit 0)', () => {
755
+ writeCliUnder(pathDir, { verdict: ALLOW, exit: 0 });
756
+ const { status } = runWithPath(BOOTSTRAP, pathDir);
757
+ // Pre-fix this was exit 2: the wrapper never probed PATH, so a fresh clone's
758
+ // `pnpm install` was blocked by the gate it was about to install the CLI for.
759
+ expect(status).toBe(0);
760
+ });
761
+ it('the PATH CLI failing → exit 2 fail-closed, disclosing which CLI evaluated (basenames only)', () => {
762
+ // A CLI older than 2.2.0 has no `gate check --payload -` and lands exactly
763
+ // here (unknown option → non-zero exit). Fail-closed is UNCHANGED; the
764
+ // added line names the arm so the operator knows what to update.
765
+ const entry = writeCliUnder(pathDir, { exit: 1 });
766
+ const { status, stderr } = runWithPath(BOOTSTRAP, pathDir);
767
+ expect(status).toBe(2);
768
+ expect(stderr).toMatch(/fail-closed/i);
769
+ expect(stderr).toContain('evaluated by the PATH CLI at');
770
+ // The rendering is NON-resolvable: the PATH entry's basename plus the fixed
771
+ // package suffix. Hook stderr lands in transcripts that get pasted into
772
+ // issues, so a user-profile path must never ride along.
773
+ expect(stderr).toContain('evaluated by the PATH CLI at global-bin/node_modules/@mmnto/cli/dist/index.js');
774
+ expect(stderr).not.toContain(entry);
775
+ expect(stderr).not.toContain(cwd);
776
+ });
777
+ it('no CLI anywhere → exit 2 naming exits this gate does NOT block', () => {
778
+ const { status, stderr } = runWithPath(BOOTSTRAP, '');
779
+ expect(status).toBe(2);
780
+ expect(stderr).toMatch(/failing closed/i);
781
+ expect(stderr).toContain('terminal OUTSIDE the harness');
782
+ expect(stderr).toContain('.claude/settings.json');
783
+ // The cure must not be a command this very gate blocks (the pre-fix message
784
+ // said "Reinstall totem or run `totem eject`" — both Bash).
785
+ expect(stderr).not.toContain('totem eject');
786
+ });
787
+ it('the repo-local CLI still wins when both resolve (pinned beats ambient)', () => {
788
+ // ADR-072 §2 / Tenet 14: the PATH arm is a FALLBACK, never a preference.
789
+ // The PATH stub would fail closed if the cascade ever inverted.
790
+ writeCliUnder(cwd, { verdict: ALLOW, exit: 0 });
791
+ writeCliUnder(pathDir, { exit: 1 });
792
+ const { status, stderr } = runWithPath(BOOTSTRAP, pathDir);
793
+ expect(status).toBe(0);
794
+ expect(stderr).not.toContain('PATH CLI');
795
+ });
796
+ });
797
+ // ─── Install-time disclosure of the shell-matcher property (#2822) ─────
798
+ describe('gate install discloses a Bash-matched gate applies to bootstrap', () => {
799
+ let cwd;
800
+ let originalCwd;
801
+ let lines;
802
+ let errorSpy;
803
+ beforeEach(() => {
804
+ cwd = makeTmpDir();
805
+ originalCwd = process.cwd();
806
+ process.chdir(cwd);
807
+ lines = [];
808
+ // `log.*` writes through console.error (ui.ts); capture the rendered lines.
809
+ // Restored per-test rather than via restoreAllMocks, which would also clear
810
+ // the module-level install-hooks seam this file mocks.
811
+ errorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
812
+ lines.push(args.map((a) => String(a)).join(' '));
813
+ });
814
+ });
815
+ afterEach(() => {
816
+ errorSpy.mockRestore();
817
+ process.chdir(originalCwd);
818
+ fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
819
+ });
820
+ it('prints the line for a Bash|PowerShell gate and NOT for a Write|Edit gate', async () => {
821
+ await gateInstallCommand({ name: 'transport-shield' });
822
+ const shield = lines.join('\n');
823
+ expect(shield).toContain('transport-shield matches Bash|PowerShell:');
824
+ expect(shield).toContain("fresh clone's bootstrap commands (your package manager's install and build; here pnpm install and pnpm build)");
825
+ expect(shield).toContain('bootstrap a fresh clone from a terminal outside the harness');
826
+ lines = [];
827
+ await gateInstallCommand({ name: 'freeze-check' });
828
+ const freeze = lines.join('\n');
829
+ // It DID install (the control), and said nothing about bootstrap: the
830
+ // property is the shell matcher's, not every gate's.
831
+ expect(freeze).toContain('freeze-check');
832
+ expect(freeze).not.toContain('bootstrap');
833
+ expect(freeze).not.toContain('matches Bash|PowerShell');
834
+ // A third install in the SAME cwd: transport-shield is already present, so
835
+ // the merge is a no-op — and the disclosure still prints beside it. The
836
+ // property belongs to the installed gate, not to the write that installed
837
+ // it, and a re-install is where an operator most often reads the output.
838
+ lines = [];
839
+ await gateInstallCommand({ name: 'transport-shield' });
840
+ const again = lines.join('\n');
841
+ expect(again).toContain('already present — no change');
842
+ expect(again).toContain('transport-shield matches Bash|PowerShell:');
843
+ expect(again).toContain('bootstrap a fresh clone from a terminal outside the harness');
844
+ });
420
845
  });
421
846
  // ─── eject parity ──────────────────────────────────────────────────────
422
847
  describe('eject removes the gate entry (parity with install)', () => {
@@ -433,7 +858,7 @@ describe('eject removes the gate entry (parity with install)', () => {
433
858
  fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
434
859
  });
435
860
  it('scrubs the gate PreToolUse entry and the wrapper script on eject', async () => {
436
- installGates(cwd, ['freeze-check']);
861
+ installGates(cwd, [FREEZE_CHECK]);
437
862
  expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
438
863
  expect(fs.existsSync(path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs'))).toBe(true);
439
864
  await ejectCommand({ force: true });
@@ -449,7 +874,7 @@ describe('eject removes the gate entry (parity with install)', () => {
449
874
  fs.writeFileSync(path.join(cwd, '.claude', 'settings.json'), JSON.stringify({
450
875
  hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'mine' }] }] },
451
876
  }));
452
- installGates(cwd, ['freeze-check']);
877
+ installGates(cwd, [FREEZE_CHECK]);
453
878
  await ejectCommand({ force: true });
454
879
  expect(fs.existsSync(path.join(cwd, '.claude', 'settings.json'))).toBe(true);
455
880
  const entries = preToolUseEntries(cwd);
@@ -475,12 +900,45 @@ describe('init --gates= routes through the shared installer', () => {
475
900
  expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
476
901
  expect(fs.existsSync(path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs'))).toBe(true);
477
902
  });
478
- it('init --gates=all installs every known gate', async () => {
903
+ it('init --gates=all installs every known gate under ITS registry matcher', async () => {
479
904
  await initCommand({ bare: true, gates: 'all' });
480
- for (const event of knownGateEvents()) {
905
+ for (const { event, matcher } of knownGates()) {
481
906
  expect(gateEntryCount(cwd, event)).toBe(1);
907
+ expect(matchersFor(cwd, event)).toEqual([matcher]);
482
908
  }
483
909
  });
910
+ it('init --gates=transport-shield installs it under Bash|PowerShell (the pair, not a default)', async () => {
911
+ await initCommand({ bare: true, gates: 'transport-shield' });
912
+ expect(gateEntryCount(cwd, 'transport-shield')).toBe(1);
913
+ expect(matchersFor(cwd, 'transport-shield')).toEqual(['Bash|PowerShell']);
914
+ expect(gateCommandFor(cwd, 'transport-shield')).toBe('node .claude/hooks/gate-wrapper.cjs --event transport-shield --strict');
915
+ });
916
+ it('init --gates= discloses the bootstrap property for a Bash-matched gate, not for Write|Edit', async () => {
917
+ // The verb and init share `installGates` but NOT their output: init prints
918
+ // its own summary rows, so before mmnto-ai/totem#2822's shared helper the
919
+ // disclosure existed only on the verb and `--gates=` dropped it silently.
920
+ const shieldLines = [];
921
+ const shieldSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
922
+ shieldLines.push(args.map((a) => String(a)).join(' '));
923
+ });
924
+ await initCommand({ bare: true, gates: 'transport-shield' });
925
+ shieldSpy.mockRestore();
926
+ const shield = shieldLines.join('\n');
927
+ expect(shield).toContain('transport-shield matches Bash|PowerShell:');
928
+ expect(shield).toContain('bootstrap a fresh clone from a terminal outside the harness');
929
+ const freezeLines = [];
930
+ const freezeSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
931
+ freezeLines.push(args.map((a) => String(a)).join(' '));
932
+ });
933
+ await initCommand({ bare: true, gates: 'freeze-check' });
934
+ freezeSpy.mockRestore();
935
+ const freeze = freezeLines.join('\n');
936
+ // The control: freeze-check installs (a row names it) and says nothing
937
+ // about bootstrap — the property is the shell matcher's, not every gate's.
938
+ expect(freeze).toContain('freeze-check');
939
+ expect(freeze).not.toContain('bootstrap');
940
+ expect(freeze).not.toContain('matches Bash|PowerShell');
941
+ });
484
942
  it('init --gates= with an unknown member fails loud', async () => {
485
943
  await expect(initCommand({ bare: true, gates: 'made-up-gate' })).rejects.toThrow(/unknown gate/i);
486
944
  });