@mmnto/cli 2.2.1 → 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 (60) 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 +17 -0
  4. package/dist/commands/gate-install.d.ts.map +1 -1
  5. package/dist/commands/gate-install.js +23 -0
  6. package/dist/commands/gate-install.js.map +1 -1
  7. package/dist/commands/gate-install.test.js +417 -0
  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 +24 -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 +5 -5
  16. package/dist/commands/init-templates.d.ts.map +1 -1
  17. package/dist/commands/init-templates.js +730 -79
  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 +8 -1
  21. package/dist/commands/init.js.map +1 -1
  22. package/dist/commands/init.test.js +122 -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/sync-labels-forms.test.d.ts +7 -4
  47. package/dist/commands/sync-labels-forms.test.d.ts.map +1 -1
  48. package/dist/commands/sync-labels-forms.test.js +266 -18
  49. package/dist/commands/sync-labels-forms.test.js.map +1 -1
  50. package/dist/index.js +74 -5
  51. package/dist/index.js.map +1 -1
  52. package/dist/parsers/bot-identity-parity.test.d.ts +2 -0
  53. package/dist/parsers/bot-identity-parity.test.d.ts.map +1 -0
  54. package/dist/parsers/bot-identity-parity.test.js +175 -0
  55. package/dist/parsers/bot-identity-parity.test.js.map +1 -0
  56. package/dist/parsers/bot-review-parser.d.ts +8 -5
  57. package/dist/parsers/bot-review-parser.d.ts.map +1 -1
  58. package/dist/parsers/bot-review-parser.js +12 -34
  59. package/dist/parsers/bot-review-parser.js.map +1 -1
  60. package/package.json +2 -2
@@ -91,6 +91,7 @@ const TRANSPORT_SHIELD = {
91
91
  event: 'transport-shield',
92
92
  matcher: 'Bash|PowerShell',
93
93
  };
94
+ const MERGE_READY = { event: 'merge-ready', matcher: 'Bash|PowerShell' };
94
95
  /**
95
96
  * Every PreToolUse matcher an entry installing `event` currently appears under.
96
97
  * Matcher-AGNOSTIC by construction: a gate that leaked into a second matcher
@@ -237,6 +238,15 @@ describe('installGates per-gate matcher (mmnto-ai/totem#2799)', () => {
237
238
  },
238
239
  ],
239
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
+ };
240
250
  beforeEach(() => {
241
251
  cwd = makeTmpDir();
242
252
  originalCwd = process.cwd();
@@ -286,6 +296,27 @@ describe('installGates per-gate matcher (mmnto-ai/totem#2799)', () => {
286
296
  expect(cmd).toContain('--strict');
287
297
  expect(cmd).not.toContain('--pilot');
288
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
+ });
289
320
  it('after any sequence of INSTALLER writes, no gate appears under two matchers (hand edits are not enforced)', () => {
290
321
  // Interleave tiers and selections — the upsert must converge on exactly one
291
322
  // entry per gate, always under that gate's registry matcher.
@@ -427,8 +458,19 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
427
458
  function writeStubCli(opts) {
428
459
  const distDir = path.join(cwd, 'node_modules', '@mmnto', 'cli', 'dist');
429
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 });
430
468
  const verdictJson = opts.verdict === undefined ? '' : JSON.stringify(opts.verdict);
431
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 ?? '';
432
474
  // CommonJS stub (the wrapper invokes via `node <path>`); .js is fine here
433
475
  // because there is no package.json type:module in the temp dir. The record
434
476
  // is what lets a test assert BOTH that a spawn happened and exactly which
@@ -442,6 +484,8 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
442
484
  'let stdin = "";',
443
485
  'try { stdin = fs.readFileSync(0, "utf-8"); } catch { stdin = ""; }',
444
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");',
445
489
  'if (out) process.stdout.write(out + "\\n");',
446
490
  `process.exit(${exitCode});`,
447
491
  '',
@@ -626,6 +670,11 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
626
670
  writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
627
671
  const { status } = runWrapper(BASH_CMD, [], 'transport-shield');
628
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.
629
678
  expect(stubArgv()).toEqual(['gate', 'check', '--event', 'transport-shield', '--payload', '-']);
630
679
  expect(spawnedPayload()).toEqual({
631
680
  tool: 'Bash',
@@ -672,6 +721,51 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
672
721
  expect(stubArgv()).toEqual(['gate', 'check', '--event', 'freeze-check', '--payload', '-']);
673
722
  expect(spawnedPayload()).toEqual({ subsystem: 'rule-compilation' });
674
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
+ });
675
769
  it('an --event the wrapper cannot project → exit 2 fail-closed, never spawns', () => {
676
770
  writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
677
771
  const { status, stderr } = runWrapper(BASH_CMD, [], 'nope');
@@ -685,6 +779,304 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
685
779
  expect(status).toBe(2);
686
780
  expect(stubArgv()).toBeNull();
687
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
+ });
688
1080
  });
689
1081
  // ─── The PATH fallback arm (mmnto-ai/totem#2822) ───────────────────────
690
1082
  //
@@ -842,6 +1234,25 @@ describe('gate install discloses a Bash-matched gate applies to bootstrap', () =
842
1234
  expect(again).toContain('transport-shield matches Bash|PowerShell:');
843
1235
  expect(again).toContain('bootstrap a fresh clone from a terminal outside the harness');
844
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
+ });
845
1256
  });
846
1257
  // ─── eject parity ──────────────────────────────────────────────────────
847
1258
  describe('eject removes the gate entry (parity with install)', () => {
@@ -939,6 +1350,12 @@ describe('init --gates= routes through the shared installer', () => {
939
1350
  expect(freeze).not.toContain('bootstrap');
940
1351
  expect(freeze).not.toContain('matches Bash|PowerShell');
941
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
+ });
942
1359
  it('init --gates= with an unknown member fails loud', async () => {
943
1360
  await expect(initCommand({ bare: true, gates: 'made-up-gate' })).rejects.toThrow(/unknown gate/i);
944
1361
  });