@mmnto/cli 2.2.0 → 2.3.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 (64) hide show
  1. package/dist/commands/config-drift.test.js +4 -1
  2. package/dist/commands/config-drift.test.js.map +1 -1
  3. package/dist/commands/gate-install.d.ts +35 -0
  4. package/dist/commands/gate-install.d.ts.map +1 -1
  5. package/dist/commands/gate-install.js +48 -0
  6. package/dist/commands/gate-install.js.map +1 -1
  7. package/dist/commands/gate-install.test.js +638 -9
  8. package/dist/commands/gate-install.test.js.map +1 -1
  9. package/dist/commands/gate.d.ts +9 -0
  10. package/dist/commands/gate.d.ts.map +1 -1
  11. package/dist/commands/gate.js +37 -2
  12. package/dist/commands/gate.js.map +1 -1
  13. package/dist/commands/gate.test.js +42 -1
  14. package/dist/commands/gate.test.js.map +1 -1
  15. package/dist/commands/init-templates.d.ts +8 -8
  16. package/dist/commands/init-templates.d.ts.map +1 -1
  17. package/dist/commands/init-templates.js +866 -94
  18. package/dist/commands/init-templates.js.map +1 -1
  19. package/dist/commands/init.d.ts.map +1 -1
  20. package/dist/commands/init.js +22 -1
  21. package/dist/commands/init.js.map +1 -1
  22. package/dist/commands/init.test.js +132 -0
  23. package/dist/commands/init.test.js.map +1 -1
  24. package/dist/commands/mail-cli-wiring.test.js +58 -2
  25. package/dist/commands/mail-cli-wiring.test.js.map +1 -1
  26. package/dist/commands/mail-derive-seat.test.d.ts +28 -0
  27. package/dist/commands/mail-derive-seat.test.d.ts.map +1 -0
  28. package/dist/commands/mail-derive-seat.test.js +557 -0
  29. package/dist/commands/mail-derive-seat.test.js.map +1 -0
  30. package/dist/commands/mail.d.ts +112 -0
  31. package/dist/commands/mail.d.ts.map +1 -1
  32. package/dist/commands/mail.js +189 -0
  33. package/dist/commands/mail.js.map +1 -1
  34. package/dist/commands/resolve-threads-cli-wiring.test.d.ts +19 -0
  35. package/dist/commands/resolve-threads-cli-wiring.test.d.ts.map +1 -0
  36. package/dist/commands/resolve-threads-cli-wiring.test.js +103 -0
  37. package/dist/commands/resolve-threads-cli-wiring.test.js.map +1 -0
  38. package/dist/commands/resolve-threads.d.ts +468 -0
  39. package/dist/commands/resolve-threads.d.ts.map +1 -0
  40. package/dist/commands/resolve-threads.js +794 -0
  41. package/dist/commands/resolve-threads.js.map +1 -0
  42. package/dist/commands/resolve-threads.test.d.ts +2 -0
  43. package/dist/commands/resolve-threads.test.d.ts.map +1 -0
  44. package/dist/commands/resolve-threads.test.js +1121 -0
  45. package/dist/commands/resolve-threads.test.js.map +1 -0
  46. package/dist/commands/session-context-journal-select.test.d.ts +2 -0
  47. package/dist/commands/session-context-journal-select.test.d.ts.map +1 -0
  48. package/dist/commands/session-context-journal-select.test.js +95 -0
  49. package/dist/commands/session-context-journal-select.test.js.map +1 -0
  50. package/dist/commands/sync-labels-forms.test.d.ts +36 -0
  51. package/dist/commands/sync-labels-forms.test.d.ts.map +1 -0
  52. package/dist/commands/sync-labels-forms.test.js +573 -0
  53. package/dist/commands/sync-labels-forms.test.js.map +1 -0
  54. package/dist/index.js +74 -5
  55. package/dist/index.js.map +1 -1
  56. package/dist/parsers/bot-identity-parity.test.d.ts +2 -0
  57. package/dist/parsers/bot-identity-parity.test.d.ts.map +1 -0
  58. package/dist/parsers/bot-identity-parity.test.js +175 -0
  59. package/dist/parsers/bot-identity-parity.test.js.map +1 -0
  60. package/dist/parsers/bot-review-parser.d.ts +8 -5
  61. package/dist/parsers/bot-review-parser.d.ts.map +1 -1
  62. package/dist/parsers/bot-review-parser.js +12 -34
  63. package/dist/parsers/bot-review-parser.js.map +1 -1
  64. package/package.json +2 -2
@@ -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);
@@ -71,6 +91,7 @@ const TRANSPORT_SHIELD = {
71
91
  event: 'transport-shield',
72
92
  matcher: 'Bash|PowerShell',
73
93
  };
94
+ const MERGE_READY = { event: 'merge-ready', matcher: 'Bash|PowerShell' };
74
95
  /**
75
96
  * Every PreToolUse matcher an entry installing `event` currently appears under.
76
97
  * Matcher-AGNOSTIC by construction: a gate that leaked into a second matcher
@@ -217,6 +238,15 @@ describe('installGates per-gate matcher (mmnto-ai/totem#2799)', () => {
217
238
  },
218
239
  ],
219
240
  };
241
+ const STRICT_MERGE_READY_ENTRY = {
242
+ matcher: 'Bash|PowerShell',
243
+ hooks: [
244
+ {
245
+ type: 'command',
246
+ command: 'node .claude/hooks/gate-wrapper.cjs --event merge-ready --strict',
247
+ },
248
+ ],
249
+ };
220
250
  beforeEach(() => {
221
251
  cwd = makeTmpDir();
222
252
  originalCwd = process.cwd();
@@ -266,6 +296,27 @@ describe('installGates per-gate matcher (mmnto-ai/totem#2799)', () => {
266
296
  expect(cmd).toContain('--strict');
267
297
  expect(cmd).not.toContain('--pilot');
268
298
  });
299
+ it('merge-ready writes ONE entry under Bash|PowerShell, beside transport-shield (mmnto-ai/totem#2800)', () => {
300
+ installGates(cwd, [MERGE_READY]);
301
+ expect(gateEntryCount(cwd, 'merge-ready')).toBe(1);
302
+ expect(matchersFor(cwd, 'merge-ready')).toEqual(['Bash|PowerShell']);
303
+ expect(gateCommandFor(cwd, 'merge-ready')).toBe('node .claude/hooks/gate-wrapper.cjs --event merge-ready --strict');
304
+ expect(preToolUseEntries(cwd)).toEqual([STRICT_MERGE_READY_ENTRY]);
305
+ // A second Bash|PowerShell gate is its OWN entry, never folded into the
306
+ // first (each gate's --event is its identity).
307
+ installGates(cwd, [TRANSPORT_SHIELD]);
308
+ expect(gateEntryCount(cwd, 'merge-ready')).toBe(1);
309
+ expect(gateEntryCount(cwd, 'transport-shield')).toBe(1);
310
+ expect(preToolUseEntries(cwd)).toEqual([STRICT_MERGE_READY_ENTRY, STRICT_TRANSPORT_ENTRY]);
311
+ });
312
+ it('a merge-ready tier switch updates the one entry in place', () => {
313
+ installGates(cwd, [MERGE_READY], 'pilot');
314
+ expect(gateCommandFor(cwd, 'merge-ready')).toContain('--pilot');
315
+ const switched = installGates(cwd, [MERGE_READY], 'strict');
316
+ expect(switched.find((r) => r.event === 'merge-ready')?.action).toBe('updated');
317
+ expect(gateEntryCount(cwd, 'merge-ready')).toBe(1);
318
+ expect(gateCommandFor(cwd, 'merge-ready')).toBe('node .claude/hooks/gate-wrapper.cjs --event merge-ready --strict');
319
+ });
269
320
  it('after any sequence of INSTALLER writes, no gate appears under two matchers (hand edits are not enforced)', () => {
270
321
  // Interleave tiers and selections — the upsert must converge on exactly one
271
322
  // entry per gate, always under that gate's registry matcher.
@@ -407,8 +458,19 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
407
458
  function writeStubCli(opts) {
408
459
  const distDir = path.join(cwd, 'node_modules', '@mmnto', 'cli', 'dist');
409
460
  fs.mkdirSync(distDir, { recursive: true });
461
+ // A fresh stub starts with NO record: the record is written only by a
462
+ // spawn, so `stubArgv() === null` after a run means "never spawned" and a
463
+ // `spawnedPayload()` after a run names THAT run's payload. Without this
464
+ // reset a loop of firing shapes that all name the same PR passed on the
465
+ // record left by its FIRST iteration — the PR-round-2 leg proved every
466
+ // later row green against the pre-fold wrapper (F2).
467
+ fs.rmSync(stubRecordPath(), { force: true });
410
468
  const verdictJson = opts.verdict === undefined ? '' : JSON.stringify(opts.verdict);
411
469
  const exitCode = opts.exit ?? 0;
470
+ // `stderr` stands in for the ENGINE's own agent-facing lines (merge-ready's
471
+ // override audit, its zero-checks fact): the wrapper must pass them through
472
+ // in every arm, allow included (mmnto-ai/totem#2800 fold F1).
473
+ const stderrText = opts.stderr ?? '';
412
474
  // CommonJS stub (the wrapper invokes via `node <path>`); .js is fine here
413
475
  // because there is no package.json type:module in the temp dir. The record
414
476
  // is what lets a test assert BOTH that a spawn happened and exactly which
@@ -422,6 +484,8 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
422
484
  'let stdin = "";',
423
485
  'try { stdin = fs.readFileSync(0, "utf-8"); } catch { stdin = ""; }',
424
486
  `fs.writeFileSync(${JSON.stringify(stubRecordPath())}, JSON.stringify({ argv: process.argv.slice(2), stdin }));`,
487
+ `const err = ${JSON.stringify(stderrText)};`,
488
+ 'if (err) process.stderr.write(err + "\\n");',
425
489
  'if (out) process.stdout.write(out + "\\n");',
426
490
  `process.exit(${exitCode});`,
427
491
  '',
@@ -451,14 +515,20 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
451
515
  expect(record.argv[at + 1]).toBe('-');
452
516
  return JSON.parse(record.stdin);
453
517
  }
454
- /** Run the rendered wrapper with the given envelope + baked extra args. */
455
- function runWrapper(envelope, extraArgs = [], event = 'freeze-check') {
518
+ /**
519
+ * Run the rendered wrapper with the given envelope + baked extra args.
520
+ * `env` defaults to the parent's (every test here writes a repo-local stub,
521
+ * which the wrapper resolves FIRST, so the ambient PATH is inert for them);
522
+ * the no-CLI test passes an empty PATH explicitly.
523
+ */
524
+ function runWrapper(envelope, extraArgs = [], event = 'freeze-check', env = undefined) {
456
525
  const wrapperPath = path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs');
457
526
  const res = spawnSync(process.execPath, [wrapperPath, '--event', event, ...extraArgs], {
458
527
  cwd,
459
528
  input: JSON.stringify(envelope),
460
529
  encoding: 'utf-8',
461
530
  timeout: 30000,
531
+ ...(env ? { env } : {}),
462
532
  });
463
533
  return { status: res.status, stderr: res.stderr ?? '' };
464
534
  }
@@ -526,14 +596,17 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
526
596
  expect(res.status).toBe(0);
527
597
  });
528
598
  // ─── FIX 2: applicable gate but no local CLI dist → fail-closed ────────
529
- it('applicable gate but the local CLI dist is missing → exit 2 fail-closed (FIX 2)', () => {
530
- // Deliberately do NOT write the stub CLI. A declared subsystem makes the
531
- // gate APPLY, but @mmnto/cli is not resolvable. freeze-check has no
532
- // commit-time hard floor, so an applicable-but-unevaluable gate must fail
533
- // closed (exit 2) rather than silently allow (exit 0).
534
- const { status, stderr } = runWrapper(DECLARED);
599
+ it('applicable gate but NO CLI is resolvable (local absent, PATH empty) → exit 2 fail-closed (FIX 2)', () => {
600
+ // Deliberately do NOT write the stub CLI, and hand the wrapper an EMPTY
601
+ // PATH so neither resolution arm can answer (mmnto-ai/totem#2822 added the
602
+ // PATH fallback; without this the test would silently read a developer's
603
+ // global @mmnto/cli and stop testing fail-closed). A declared subsystem
604
+ // makes the gate APPLY; freeze-check has no commit-time hard floor, so an
605
+ // applicable-but-unevaluable gate must fail closed (exit 2), never silently
606
+ // allow (exit 0).
607
+ const { status, stderr } = runWrapper(DECLARED, [], 'freeze-check', envWithPath(''));
535
608
  expect(status).toBe(2);
536
- expect(stderr).toMatch(/not resolvable|not installed|failing closed/i);
609
+ expect(stderr).toMatch(/failing closed/i);
537
610
  });
538
611
  it('gate check exits 0 with unparseable stdout → exit 2 fail-closed', () => {
539
612
  // verdict undefined → stub emits nothing; the wrapper cannot parse a
@@ -597,6 +670,11 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
597
670
  writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
598
671
  const { status } = runWrapper(BASH_CMD, [], 'transport-shield');
599
672
  expect(status).toBe(0);
673
+ // The baked tier rides along since mmnto-ai/totem#2800 (R1): the engine owns
674
+ // the strict/pilot split for a gate's own unevaluable class.
675
+ // A STRICT wrapper forwards NO `--tier` (fold F3): strict is the engine's
676
+ // default, and an option a 2.2.x CLI cannot parse would fail the check
677
+ // closed — re-entering the mmnto-ai/totem#2822 bootstrap self-block.
600
678
  expect(stubArgv()).toEqual(['gate', 'check', '--event', 'transport-shield', '--payload', '-']);
601
679
  expect(spawnedPayload()).toEqual({
602
680
  tool: 'Bash',
@@ -643,6 +721,51 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
643
721
  expect(stubArgv()).toEqual(['gate', 'check', '--event', 'freeze-check', '--payload', '-']);
644
722
  expect(spawnedPayload()).toEqual({ subsystem: 'rule-compilation' });
645
723
  });
724
+ it('a PILOT install forwards --tier pilot; a strict one forwards none (fold F3)', () => {
725
+ // The tier the ENGINE needs is the non-default one. Forwarding `--tier` on
726
+ // every entry made a 2.2.x CLI exit "unknown option" — the fail-closed arm —
727
+ // for every gated command, so only pilot pays that coupling and its
728
+ // install-time disclosure names the 2.3.0 floor.
729
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
730
+ runWrapper(DECLARED, ['--pilot']);
731
+ expect(stubArgv()).toEqual([
732
+ 'gate',
733
+ 'check',
734
+ '--event',
735
+ 'freeze-check',
736
+ '--tier',
737
+ 'pilot',
738
+ '--payload',
739
+ '-',
740
+ ]);
741
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
742
+ runWrapper(DECLARED, ['--strict']);
743
+ expect(stubArgv()).not.toContain('--tier');
744
+ });
745
+ it('the child CLI stderr reaches the transcript on ALLOW, not just on failure (fold F1)', () => {
746
+ // merge-ready writes its audited-override line and its zero-checks fact to
747
+ // stderr with an `allow` verdict. Printing the child's stderr only in the
748
+ // failure arm silently dropped exactly the lines that must never be silent.
749
+ writeStubCli({
750
+ verdict: ALLOW_VERDICT,
751
+ exit: 0,
752
+ stderr: '[totem merge-ready] OVERRIDE (TOTEM_MERGE_GATE_OVERRIDE=1): allowing repo#1',
753
+ });
754
+ const { status, stderr } = runWrapper(DECLARED);
755
+ expect(status).toBe(0);
756
+ expect(stderr).toContain('[totem merge-ready] OVERRIDE');
757
+ });
758
+ it('a warn verdict carries the child stderr through as well (fold F1)', () => {
759
+ writeStubCli({
760
+ verdict: { disposition: 'warn', reason: 'heads up', provenance: {} },
761
+ exit: 0,
762
+ stderr: '[totem merge-ready] ZERO status checks — predicate 1 passes as a fact',
763
+ });
764
+ const { status, stderr } = runWrapper(DECLARED);
765
+ expect(status).toBe(0);
766
+ expect(stderr).toContain('ZERO status checks');
767
+ expect(stderr).toContain('heads up');
768
+ });
646
769
  it('an --event the wrapper cannot project → exit 2 fail-closed, never spawns', () => {
647
770
  writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
648
771
  const { status, stderr } = runWrapper(BASH_CMD, [], 'nope');
@@ -656,6 +779,480 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
656
779
  expect(status).toBe(2);
657
780
  expect(stubArgv()).toBeNull();
658
781
  });
782
+ // ─── merge-ready projection (mmnto-ai/totem#2800) ──────────────────────
783
+ //
784
+ // The gate installs under Bash|PowerShell, so this branch sees EVERY shell
785
+ // command: the load-bearing half is what it does NOT do — a command that is
786
+ // not `gh pr merge` at command position must pass through (exit 0) without
787
+ // ever spawning the CLI. `stubArgv() === null` is the filesystem record of
788
+ // "never spawned", not an inference from the exit code.
789
+ describe('merge-ready', () => {
790
+ /** Give the temp cwd a git identity so the projection can read repo/branch/head. */
791
+ function initGitRepo() {
792
+ spawnSync('git', ['init', '-b', 'feat/demo'], { cwd, encoding: 'utf-8' });
793
+ spawnSync('git', ['remote', 'add', 'origin', 'https://github.com/mmnto-ai/totem.git'], {
794
+ cwd,
795
+ });
796
+ fs.writeFileSync(path.join(cwd, 'file.txt'), 'x');
797
+ spawnSync('git', ['add', '-A'], { cwd });
798
+ spawnSync('git', ['-c', 'user.email=t@example.com', '-c', 'user.name=t', 'commit', '-m', 'init'], { cwd });
799
+ return spawnSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf-8' }).stdout.trim();
800
+ }
801
+ const bash = (command) => ({
802
+ tool_name: 'Bash',
803
+ tool_input: { command },
804
+ });
805
+ it('projects { repo, pr, headSha } from `gh pr merge <number>`', () => {
806
+ const head = initGitRepo();
807
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
808
+ const { status } = runWrapper(bash('gh pr merge 2800 --squash'), [], 'merge-ready');
809
+ expect(status).toBe(0);
810
+ // No `--tier` at the default strict tier (fold F3).
811
+ expect(stubArgv()).toEqual(['gate', 'check', '--event', 'merge-ready', '--payload', '-']);
812
+ expect(spawnedPayload()).toEqual({ repo: 'mmnto-ai/totem', pr: 2800, headSha: head });
813
+ });
814
+ it('projects pr: null + the CURRENT branch when the command names no PR', () => {
815
+ initGitRepo();
816
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
817
+ runWrapper(bash('gh pr merge'), [], 'merge-ready');
818
+ const payload = spawnedPayload();
819
+ expect(payload.pr).toBeNull();
820
+ expect(payload.branch).toBe('feat/demo');
821
+ expect(payload.repo).toBe('mmnto-ai/totem');
822
+ });
823
+ it('reads the repo from -R / --repo= and the PR from a URL, over the git remote', () => {
824
+ initGitRepo();
825
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
826
+ runWrapper(bash('gh pr merge --squash -R mmnto-ai/liquid-city 363'), [], 'merge-ready');
827
+ expect(spawnedPayload()).toMatchObject({ repo: 'mmnto-ai/liquid-city', pr: 363 });
828
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
829
+ runWrapper(bash('gh pr merge https://github.com/mmnto-ai/totem-strategy/pull/1251 --merge'), [], 'merge-ready');
830
+ expect(spawnedPayload()).toMatchObject({ repo: 'mmnto-ai/totem-strategy', pr: 1251 });
831
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
832
+ runWrapper(bash('gh pr merge 12 --repo=mmnto-ai/other'), [], 'merge-ready');
833
+ expect(spawnedPayload()).toMatchObject({ repo: 'mmnto-ai/other', pr: 12 });
834
+ });
835
+ it('a named branch is projected as the branch, not as a PR number', () => {
836
+ initGitRepo();
837
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
838
+ runWrapper(bash('gh pr merge feat/other-branch'), [], 'merge-ready');
839
+ expect(spawnedPayload()).toMatchObject({ pr: null, branch: 'feat/other-branch' });
840
+ });
841
+ it('a value-taking flag does not swallow the PR target (`-b "…" 42`)', () => {
842
+ initGitRepo();
843
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
844
+ runWrapper(bash('gh pr merge -b "merge this now" 42'), [], 'merge-ready');
845
+ expect(spawnedPayload()).toMatchObject({ pr: 42 });
846
+ });
847
+ it("fires at command position after a separator, after the shell's command-position words, and behind an assignment prefix", () => {
848
+ initGitRepo();
849
+ for (const command of [
850
+ 'git status && gh pr merge 7',
851
+ 'git fetch; gh pr merge 7',
852
+ 'for x in 1; do gh pr merge 7; done',
853
+ 'if true; then gh pr merge 7; fi',
854
+ 'git log |\ngh pr merge 7',
855
+ // PR round 1 (greptile): a merge used AS the condition, and one behind
856
+ // an assignment prefix, each left something other than `gh` at the
857
+ // segment's front and went unjudged.
858
+ 'if gh pr merge 7; then echo merged; fi',
859
+ 'if false; then :; elif gh pr merge 7; then :; fi',
860
+ 'while gh pr merge 7; do break; done',
861
+ 'until gh pr merge 7; do sleep 1; done',
862
+ 'GH_TOKEN=x gh pr merge 7',
863
+ 'GH_REPO=mmnto-ai/totem GH_TOKEN="a b" gh pr merge 7',
864
+ 'exec gh pr merge 7',
865
+ 'command gh pr merge 7',
866
+ // Round 2 (the leg's F1): two more reserved words and the builtin that
867
+ // runs an unquoted operand as the command.
868
+ 'time gh pr merge 7',
869
+ 'coproc gh pr merge 7',
870
+ 'eval gh pr merge 7',
871
+ ]) {
872
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
873
+ runWrapper(bash(command), [], 'merge-ready');
874
+ expect(spawnedPayload(), command).toMatchObject({ pr: 7 });
875
+ }
876
+ });
877
+ it('does NOT fire inside a quoted string, a heredoc body, or on another gh verb — and never spawns', () => {
878
+ initGitRepo();
879
+ for (const command of [
880
+ 'echo "gh pr merge 5"',
881
+ "echo 'gh pr merge 5'",
882
+ 'gh pr list',
883
+ 'gh pr view 3 | grep merge',
884
+ 'git commit -m "gh pr merge"',
885
+ // A heredoc body is DATA, not commands (fold F4): firing here was a
886
+ // false deny — the direction this projection must not have.
887
+ 'cat <<EOF\ngh pr merge 5\nEOF',
888
+ "cat <<'EOF'\ngh pr merge 5\nEOF",
889
+ 'cat <<-EOF\n\tgh pr merge 5\n\tEOF',
890
+ 'cat <<EOF > notes.txt\ngh pr merge 5\nEOF\necho done',
891
+ // An UNTERMINATED body runs to the end of the command and is still data.
892
+ 'cat <<EOF\ngh pr merge 5',
893
+ // DISCLOSED misses (the gate does not fire — the safe direction): a
894
+ // wrapper PROGRAM takes the first token, so the position anchor never
895
+ // sees `gh` (an assignment prefix no longer hides it — PR round 1).
896
+ 'sudo gh pr merge 5',
897
+ 'timeout 30 gh pr merge 5',
898
+ 'env GH_TOKEN=x gh pr merge 5',
899
+ // Round 2 (the leg's F1/F3), disclosed in the template's comment: a
900
+ // merge handed over as ONE quoted word, a builtin with a flag before
901
+ // `gh`, a backtick substitution, a leading redirection.
902
+ 'eval "gh pr merge 5"',
903
+ 'command -p gh pr merge 5',
904
+ 'exec -a x gh pr merge 5',
905
+ // Round 3 (the leg's F1): the reserved word carrying its own flag.
906
+ 'time -p gh pr merge 5',
907
+ 'time -- gh pr merge 5',
908
+ 'echo `gh pr merge 5`',
909
+ '> out.txt gh pr merge 5',
910
+ ]) {
911
+ writeStubCli({
912
+ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} },
913
+ });
914
+ const { status } = runWrapper(bash(command), [], 'merge-ready');
915
+ expect(status, command).toBe(0);
916
+ expect(stubArgv(), command).toBeNull();
917
+ }
918
+ });
919
+ it('every gh pr merge at command position is judged, not only the first (PR round 1, greptile)', () => {
920
+ initGitRepo();
921
+ // The stub overwrites its record on every spawn, so the record names the
922
+ // LAST payload judged. An allowing stub: the second merge is reached.
923
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
924
+ const allowed = runWrapper(bash('gh pr merge 7; gh pr merge 8'), [], 'merge-ready');
925
+ expect(allowed.status).toBe(0);
926
+ expect(spawnedPayload()).toMatchObject({ pr: 8 });
927
+ // A denying stub under strict: the FIRST deny exits 2, so the second
928
+ // merge is never spawned and the record still names the first.
929
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'not ready', provenance: {} } });
930
+ const denied = runWrapper(bash('gh pr merge 7 && gh pr merge 8'), [], 'merge-ready');
931
+ expect(denied.status).toBe(2);
932
+ expect(spawnedPayload()).toMatchObject({ pr: 7 });
933
+ expect(denied.stderr.match(/merge-ready \(deny\)/g)).toHaveLength(1);
934
+ // Under --pilot a deny prints and the NEXT merge is still judged: two
935
+ // deny lines, the record names the second merge, exit 0.
936
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'not ready', provenance: {} } });
937
+ const pilot = runWrapper(bash('gh pr merge 7; gh pr merge 8'), ['--pilot'], 'merge-ready');
938
+ expect(pilot.status).toBe(0);
939
+ expect(spawnedPayload()).toMatchObject({ pr: 8 });
940
+ expect(pilot.stderr.match(/merge-ready \(deny\)/g)).toHaveLength(2);
941
+ });
942
+ it('an arithmetic shift or a comment does not swallow the merge that follows (fold round 2, F1)', () => {
943
+ // `$((1<<2))` is a SHIFT and `# see <<note` is a comment: neither opens a
944
+ // heredoc. Before the guards, each swallowed the rest of the command and
945
+ // the real merge after it went unjudged — a silent miss on the exact
946
+ // command this gate exists for.
947
+ initGitRepo();
948
+ for (const command of [
949
+ 'echo $((1<<2)); gh pr merge 5',
950
+ 'echo $(( 3<<1 )); gh pr merge 5',
951
+ '# see <<note\ngh pr merge 5',
952
+ '(( 1<<3 ))\ngh pr merge 5',
953
+ 'echo hi # <<EOF\ngh pr merge 5',
954
+ ]) {
955
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
956
+ runWrapper(bash(command), [], 'merge-ready');
957
+ expect(spawnedPayload(), command).toMatchObject({ pr: 5 });
958
+ }
959
+ });
960
+ it('a line continuation does not hide the anchor (round 3, F6)', () => {
961
+ // The shell removes a backslash-newline and joins the halves. Absorbing
962
+ // the newline into the token left the segment starting with something
963
+ // other than `gh`, so a real merge went unjudged.
964
+ initGitRepo();
965
+ for (const command of ['gh \\\npr merge 5', 'gh pr merge \\\n5', 'gh \\\r\npr merge 5']) {
966
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
967
+ runWrapper(bash(command), [], 'merge-ready');
968
+ expect(spawnedPayload(), JSON.stringify(command)).toMatchObject({ pr: 5 });
969
+ }
970
+ // A backslash before an ORDINARY character keeps its old meaning: it
971
+ // escapes that character into the token.
972
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
973
+ runWrapper(bash('gh pr merge 5 --body a\\ b'), [], 'merge-ready');
974
+ expect(spawnedPayload()).toMatchObject({ pr: 5 });
975
+ });
976
+ it('a PowerShell block comment is data, not commands (round 3 F8; round 4 F1, F8)', () => {
977
+ initGitRepo();
978
+ const pwsh = (command) => ({
979
+ tool_name: 'PowerShell',
980
+ tool_input: { command },
981
+ });
982
+ // THE CONTROL (round 4, F1): a MULTI-LINE block comment. Without the
983
+ // `<#` arm this fires with pr 9 — verified by stripping the arm from a
984
+ // rendered copy. The single-line row below behaves the same either way
985
+ // (the `#` word-comment arm already covers it), so it is a companion, not
986
+ // a control.
987
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
988
+ expect(runWrapper(pwsh('<#\ngh pr merge 9\n#>\necho hi'), [], 'merge-ready').status).toBe(0);
989
+ expect(stubArgv()).toBeNull();
990
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
991
+ expect(runWrapper(pwsh('<# gh pr merge 9 #>\necho hi'), [], 'merge-ready').status).toBe(0);
992
+ expect(stubArgv()).toBeNull();
993
+ // The merge AFTER one is still judged — the blank must not eat it.
994
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
995
+ runWrapper(pwsh('<# notes #>\ngh pr merge 4'), [], 'merge-ready');
996
+ expect(spawnedPayload()).toMatchObject({ pr: 4 });
997
+ // ROUND 4 F8: the blank is a POWERSHELL rule. In bash `<#tmp` is a
998
+ // redirect from a file named `#tmp`, and blanking from it to a later `#>`
999
+ // would swallow the real merge on the next line.
1000
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1001
+ runWrapper(bash('sort <#tmp\ngh pr merge 8'), [], 'merge-ready');
1002
+ expect(spawnedPayload()).toMatchObject({ pr: 8 });
1003
+ });
1004
+ it('a comment is not a command: a merge inside one never fires', () => {
1005
+ initGitRepo();
1006
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
1007
+ const { status } = runWrapper(bash('echo hi # gh pr merge 9'), [], 'merge-ready');
1008
+ expect(status).toBe(0);
1009
+ expect(stubArgv()).toBeNull();
1010
+ });
1011
+ it('EVERY heredoc queued on a line is read as data, not just the first (fold round 2, F2)', () => {
1012
+ // bash reads `cat <<A <<B` as two bodies in order, so a command sitting
1013
+ // in B's body is data too. Consuming only A's body left B's body as
1014
+ // commands — a false deny on text.
1015
+ initGitRepo();
1016
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
1017
+ const { status } = runWrapper(bash('cat <<A <<B\nfirst\nA\ngh pr merge 5\nB\n'), [], 'merge-ready');
1018
+ expect(status).toBe(0);
1019
+ expect(stubArgv()).toBeNull();
1020
+ // The complement: a heredoc as the merge's OWN operand still fires.
1021
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1022
+ runWrapper(bash('gh pr merge 5 <<EOF\nnotes\nEOF\n'), [], 'merge-ready');
1023
+ expect(spawnedPayload()).toMatchObject({ pr: 5 });
1024
+ });
1025
+ it('still fires on a real merge that FOLLOWS a heredoc (the blanker keeps the segments)', () => {
1026
+ // The heredoc blanker must not swallow the rest of the command: the
1027
+ // terminator line ends the body and the next segment is judged normally.
1028
+ initGitRepo();
1029
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1030
+ runWrapper(bash('cat <<EOF > body.md\nsome release notes\nEOF\ngh pr merge 21'), [], 'merge-ready');
1031
+ expect(spawnedPayload()).toMatchObject({ pr: 21 });
1032
+ });
1033
+ it('an unexpanded shell variable rides as unresolvedTarget, not as a branch (fold F13)', () => {
1034
+ // `gh pr merge $PR` names a target the hook cannot know — the shell
1035
+ // expands it after the gate has already decided. Reading "$PR" as a
1036
+ // branch would judge the wrong PR, or none.
1037
+ initGitRepo();
1038
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1039
+ runWrapper(bash('gh pr merge $PR --squash'), [], 'merge-ready');
1040
+ const payload = spawnedPayload();
1041
+ expect(payload.unresolvedTarget).toBe('$PR');
1042
+ expect(payload.pr).toBeNull();
1043
+ // NOT the current-branch fallback: that is for a command naming no target.
1044
+ expect(payload.branch).toBeUndefined();
1045
+ });
1046
+ it('a braced expansion keeps its braces in the evidence (round 2, F10)', () => {
1047
+ // `${PR}` used to reach the engine as bare "$", because the tokenizer
1048
+ // split on the braces. A `$( … )` deliberately still splits — a real
1049
+ // merge inside a command substitution must keep firing.
1050
+ initGitRepo();
1051
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1052
+ runWrapper(bash('gh pr merge ${PR}'), [], 'merge-ready');
1053
+ expect(spawnedPayload().unresolvedTarget).toBe('${PR}');
1054
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1055
+ runWrapper(bash('echo $(gh pr merge 8)'), [], 'merge-ready');
1056
+ expect(spawnedPayload()).toMatchObject({ pr: 8 });
1057
+ });
1058
+ it('a Write envelope and a PowerShell command are treated by tool, not by text', () => {
1059
+ initGitRepo();
1060
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
1061
+ const write = runWrapper({ tool_name: 'Write', tool_input: { file_path: 'notes.md' } }, [], 'merge-ready');
1062
+ expect(write.status).toBe(0);
1063
+ expect(stubArgv()).toBeNull();
1064
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1065
+ runWrapper({ tool_name: 'PowerShell', tool_input: { command: 'gh pr merge 11' } }, [], 'merge-ready');
1066
+ expect(spawnedPayload()).toMatchObject({ pr: 11 });
1067
+ });
1068
+ it('an evaluation failure on an APPLICABLE merge blocks (fail-closed), pilot exits 0', () => {
1069
+ initGitRepo();
1070
+ writeStubCli({ exit: 1 });
1071
+ const strict = runWrapper(bash('gh pr merge 2800'), [], 'merge-ready');
1072
+ expect(strict.status).toBe(2);
1073
+ expect(strict.stderr).toMatch(/fail-closed/i);
1074
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'behind', provenance: {} }, exit: 0 });
1075
+ const pilot = runWrapper(bash('gh pr merge 2800'), ['--pilot'], 'merge-ready');
1076
+ expect(pilot.status).toBe(0);
1077
+ expect(pilot.stderr).toContain('behind');
1078
+ });
1079
+ });
1080
+ });
1081
+ // ─── The PATH fallback arm (mmnto-ai/totem#2822) ───────────────────────
1082
+ //
1083
+ // A `Bash|PowerShell`-matched gate applies to `pnpm install` / `pnpm build` —
1084
+ // the very commands that CREATE the repo-local CLI — so a repo-local-ONLY
1085
+ // resolution made the gate block its own bootstrap on a fresh clone, and block
1086
+ // the cure its message named (`totem eject`, a Bash command). The wrapper now
1087
+ // falls back to a `totem` on PATH (repo-local still FIRST) and fails closed
1088
+ // only when NEITHER arm resolves — the #2799 ruling is kept, the self-block is
1089
+ // not. Each test below drives the rendered wrapper with a CONTROLLED PATH and
1090
+ // discriminates the fold: pre-fix, the first two exited through the no-CLI arm
1091
+ // (there was no PATH probe at all) and the third's message named `totem eject`.
1092
+ describe('gate-wrapper.cjs PATH fallback (mmnto-ai/totem#2822)', () => {
1093
+ let cwd;
1094
+ let pathDir;
1095
+ /** The self-block case: a bootstrap command under the shell-matched gate. */
1096
+ const BOOTSTRAP = { tool_name: 'Bash', tool_input: { command: 'pnpm install' } };
1097
+ const ALLOW = { disposition: 'allow', reason: 'ok', provenance: {} };
1098
+ beforeEach(() => {
1099
+ cwd = makeTmpDir();
1100
+ fs.mkdirSync(path.join(cwd, '.claude', 'hooks'), { recursive: true });
1101
+ fs.writeFileSync(path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs'), CLAUDE_GATE_WRAPPER);
1102
+ // A PATH entry shaped like an npm global prefix on win32: the `totem.cmd`
1103
+ // shim's sibling `node_modules/@mmnto/cli/dist/index.js`. Deliberately NOT
1104
+ // under `cwd/node_modules`, so the repo-local arm cannot resolve it.
1105
+ pathDir = path.join(cwd, 'global-bin');
1106
+ fs.mkdirSync(pathDir, { recursive: true });
1107
+ });
1108
+ afterEach(() => {
1109
+ fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
1110
+ });
1111
+ /**
1112
+ * A CLI stub at `<dir>/node_modules/@mmnto/cli/dist/index.js` emitting the
1113
+ * given verdict and exit code; returns the entry path the wrapper should
1114
+ * resolve. It drains stdin (the `--payload -` channel) before exiting so the
1115
+ * parent's `input` write never races an EPIPE into `result.error`.
1116
+ */
1117
+ function writeCliUnder(dir, opts) {
1118
+ const distDir = path.join(dir, 'node_modules', '@mmnto', 'cli', 'dist');
1119
+ fs.mkdirSync(distDir, { recursive: true });
1120
+ const out = opts.verdict === undefined ? '' : JSON.stringify(opts.verdict);
1121
+ const stub = [
1122
+ '"use strict";',
1123
+ 'const fs = require("fs");',
1124
+ `const out = ${JSON.stringify(out)};`,
1125
+ 'try { fs.readFileSync(0, "utf-8"); } catch { /* no stdin */ }',
1126
+ 'if (out) process.stdout.write(out + "\\n");',
1127
+ `process.exit(${opts.exit ?? 0});`,
1128
+ '',
1129
+ ].join('\n');
1130
+ const entry = path.join(distDir, 'index.js');
1131
+ fs.writeFileSync(entry, stub);
1132
+ return entry;
1133
+ }
1134
+ /** Run the rendered wrapper in `cwd` with an exact PATH value. */
1135
+ function runWithPath(envelope, pathValue, event = 'transport-shield') {
1136
+ const wrapperPath = path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs');
1137
+ const res = spawnSync(process.execPath, [wrapperPath, '--event', event, '--strict'], {
1138
+ cwd,
1139
+ input: JSON.stringify(envelope),
1140
+ encoding: 'utf-8',
1141
+ timeout: 30000,
1142
+ env: envWithPath(pathValue),
1143
+ });
1144
+ return { status: res.status, stderr: res.stderr ?? '' };
1145
+ }
1146
+ it('no repo-local CLI but a totem on PATH → the PATH arm evaluates (allow → exit 0)', () => {
1147
+ writeCliUnder(pathDir, { verdict: ALLOW, exit: 0 });
1148
+ const { status } = runWithPath(BOOTSTRAP, pathDir);
1149
+ // Pre-fix this was exit 2: the wrapper never probed PATH, so a fresh clone's
1150
+ // `pnpm install` was blocked by the gate it was about to install the CLI for.
1151
+ expect(status).toBe(0);
1152
+ });
1153
+ it('the PATH CLI failing → exit 2 fail-closed, disclosing which CLI evaluated (basenames only)', () => {
1154
+ // A CLI older than 2.2.0 has no `gate check --payload -` and lands exactly
1155
+ // here (unknown option → non-zero exit). Fail-closed is UNCHANGED; the
1156
+ // added line names the arm so the operator knows what to update.
1157
+ const entry = writeCliUnder(pathDir, { exit: 1 });
1158
+ const { status, stderr } = runWithPath(BOOTSTRAP, pathDir);
1159
+ expect(status).toBe(2);
1160
+ expect(stderr).toMatch(/fail-closed/i);
1161
+ expect(stderr).toContain('evaluated by the PATH CLI at');
1162
+ // The rendering is NON-resolvable: the PATH entry's basename plus the fixed
1163
+ // package suffix. Hook stderr lands in transcripts that get pasted into
1164
+ // issues, so a user-profile path must never ride along.
1165
+ expect(stderr).toContain('evaluated by the PATH CLI at global-bin/node_modules/@mmnto/cli/dist/index.js');
1166
+ expect(stderr).not.toContain(entry);
1167
+ expect(stderr).not.toContain(cwd);
1168
+ });
1169
+ it('no CLI anywhere → exit 2 naming exits this gate does NOT block', () => {
1170
+ const { status, stderr } = runWithPath(BOOTSTRAP, '');
1171
+ expect(status).toBe(2);
1172
+ expect(stderr).toMatch(/failing closed/i);
1173
+ expect(stderr).toContain('terminal OUTSIDE the harness');
1174
+ expect(stderr).toContain('.claude/settings.json');
1175
+ // The cure must not be a command this very gate blocks (the pre-fix message
1176
+ // said "Reinstall totem or run `totem eject`" — both Bash).
1177
+ expect(stderr).not.toContain('totem eject');
1178
+ });
1179
+ it('the repo-local CLI still wins when both resolve (pinned beats ambient)', () => {
1180
+ // ADR-072 §2 / Tenet 14: the PATH arm is a FALLBACK, never a preference.
1181
+ // The PATH stub would fail closed if the cascade ever inverted.
1182
+ writeCliUnder(cwd, { verdict: ALLOW, exit: 0 });
1183
+ writeCliUnder(pathDir, { exit: 1 });
1184
+ const { status, stderr } = runWithPath(BOOTSTRAP, pathDir);
1185
+ expect(status).toBe(0);
1186
+ expect(stderr).not.toContain('PATH CLI');
1187
+ });
1188
+ });
1189
+ // ─── Install-time disclosure of the shell-matcher property (#2822) ─────
1190
+ describe('gate install discloses a Bash-matched gate applies to bootstrap', () => {
1191
+ let cwd;
1192
+ let originalCwd;
1193
+ let lines;
1194
+ let errorSpy;
1195
+ beforeEach(() => {
1196
+ cwd = makeTmpDir();
1197
+ originalCwd = process.cwd();
1198
+ process.chdir(cwd);
1199
+ lines = [];
1200
+ // `log.*` writes through console.error (ui.ts); capture the rendered lines.
1201
+ // Restored per-test rather than via restoreAllMocks, which would also clear
1202
+ // the module-level install-hooks seam this file mocks.
1203
+ errorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
1204
+ lines.push(args.map((a) => String(a)).join(' '));
1205
+ });
1206
+ });
1207
+ afterEach(() => {
1208
+ errorSpy.mockRestore();
1209
+ process.chdir(originalCwd);
1210
+ fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
1211
+ });
1212
+ it('prints the line for a Bash|PowerShell gate and NOT for a Write|Edit gate', async () => {
1213
+ await gateInstallCommand({ name: 'transport-shield' });
1214
+ const shield = lines.join('\n');
1215
+ expect(shield).toContain('transport-shield matches Bash|PowerShell:');
1216
+ expect(shield).toContain("fresh clone's bootstrap commands (your package manager's install and build; here pnpm install and pnpm build)");
1217
+ expect(shield).toContain('bootstrap a fresh clone from a terminal outside the harness');
1218
+ lines = [];
1219
+ await gateInstallCommand({ name: 'freeze-check' });
1220
+ const freeze = lines.join('\n');
1221
+ // It DID install (the control), and said nothing about bootstrap: the
1222
+ // property is the shell matcher's, not every gate's.
1223
+ expect(freeze).toContain('freeze-check');
1224
+ expect(freeze).not.toContain('bootstrap');
1225
+ expect(freeze).not.toContain('matches Bash|PowerShell');
1226
+ // A third install in the SAME cwd: transport-shield is already present, so
1227
+ // the merge is a no-op — and the disclosure still prints beside it. The
1228
+ // property belongs to the installed gate, not to the write that installed
1229
+ // it, and a re-install is where an operator most often reads the output.
1230
+ lines = [];
1231
+ await gateInstallCommand({ name: 'transport-shield' });
1232
+ const again = lines.join('\n');
1233
+ expect(again).toContain('already present — no change');
1234
+ expect(again).toContain('transport-shield matches Bash|PowerShell:');
1235
+ expect(again).toContain('bootstrap a fresh clone from a terminal outside the harness');
1236
+ });
1237
+ // ─── Pilot-tier CLI floor (mmnto-ai/totem#2800 fold F3) ───────────────
1238
+ it('a --pilot install names the CLI floor its wrapper needs; a strict one does not', async () => {
1239
+ await gateInstallCommand({ name: 'merge-ready', pilot: true });
1240
+ const pilot = lines.join('\n');
1241
+ expect(pilot).toContain('gate check --tier pilot');
1242
+ expect(pilot).toContain('2.3.0');
1243
+ lines = [];
1244
+ await gateInstallCommand({ name: 'freeze-check' });
1245
+ const strict = lines.join('\n');
1246
+ // A strict install forwards no tier, so it carries no floor to disclose.
1247
+ expect(strict).not.toContain('2.3.0');
1248
+ expect(strict).not.toContain('--tier');
1249
+ });
1250
+ it('init --gates= discloses the same pilot floor (it prints its own rows)', async () => {
1251
+ await initCommand({ bare: true, gates: 'merge-ready', pilot: true });
1252
+ const out = lines.join('\n');
1253
+ expect(out).toContain('gate check --tier pilot');
1254
+ expect(out).toContain('2.3.0');
1255
+ });
659
1256
  });
660
1257
  // ─── eject parity ──────────────────────────────────────────────────────
661
1258
  describe('eject removes the gate entry (parity with install)', () => {
@@ -727,6 +1324,38 @@ describe('init --gates= routes through the shared installer', () => {
727
1324
  expect(matchersFor(cwd, 'transport-shield')).toEqual(['Bash|PowerShell']);
728
1325
  expect(gateCommandFor(cwd, 'transport-shield')).toBe('node .claude/hooks/gate-wrapper.cjs --event transport-shield --strict');
729
1326
  });
1327
+ it('init --gates= discloses the bootstrap property for a Bash-matched gate, not for Write|Edit', async () => {
1328
+ // The verb and init share `installGates` but NOT their output: init prints
1329
+ // its own summary rows, so before mmnto-ai/totem#2822's shared helper the
1330
+ // disclosure existed only on the verb and `--gates=` dropped it silently.
1331
+ const shieldLines = [];
1332
+ const shieldSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
1333
+ shieldLines.push(args.map((a) => String(a)).join(' '));
1334
+ });
1335
+ await initCommand({ bare: true, gates: 'transport-shield' });
1336
+ shieldSpy.mockRestore();
1337
+ const shield = shieldLines.join('\n');
1338
+ expect(shield).toContain('transport-shield matches Bash|PowerShell:');
1339
+ expect(shield).toContain('bootstrap a fresh clone from a terminal outside the harness');
1340
+ const freezeLines = [];
1341
+ const freezeSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
1342
+ freezeLines.push(args.map((a) => String(a)).join(' '));
1343
+ });
1344
+ await initCommand({ bare: true, gates: 'freeze-check' });
1345
+ freezeSpy.mockRestore();
1346
+ const freeze = freezeLines.join('\n');
1347
+ // The control: freeze-check installs (a row names it) and says nothing
1348
+ // about bootstrap — the property is the shell matcher's, not every gate's.
1349
+ expect(freeze).toContain('freeze-check');
1350
+ expect(freeze).not.toContain('bootstrap');
1351
+ expect(freeze).not.toContain('matches Bash|PowerShell');
1352
+ });
1353
+ it('init --gates=merge-ready installs it under Bash|PowerShell (mmnto-ai/totem#2800)', async () => {
1354
+ await initCommand({ bare: true, gates: 'merge-ready' });
1355
+ expect(gateEntryCount(cwd, 'merge-ready')).toBe(1);
1356
+ expect(matchersFor(cwd, 'merge-ready')).toEqual(['Bash|PowerShell']);
1357
+ expect(gateCommandFor(cwd, 'merge-ready')).toBe('node .claude/hooks/gate-wrapper.cjs --event merge-ready --strict');
1358
+ });
730
1359
  it('init --gates= with an unknown member fails loud', async () => {
731
1360
  await expect(initCommand({ bare: true, gates: 'made-up-gate' })).rejects.toThrow(/unknown gate/i);
732
1361
  });