@mmnto/cli 2.2.1 → 2.4.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 +467 -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 +758 -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 +472 -0
  39. package/dist/commands/resolve-threads.d.ts.map +1 -0
  40. package/dist/commands/resolve-threads.js +793 -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 +1123 -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 +229 -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,354 @@ 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
+ // The pilot-install round (greptile P1 on mmnto-ai/totem#2855): the
911
+ // executable spelled with an extension or a path is not the bare token
912
+ // the anchor reads — disclosed here and in the template's comment;
913
+ // widening the token is mmnto-ai/totem#2856, the strict tier's precondition.
914
+ 'gh.exe pr merge 5',
915
+ './gh pr merge 5',
916
+ // The same round's legs (mmnto-ai/totem#2857): two divergences from
917
+ // core's scanner that open a heredoc core does not, so the merge on a
918
+ // later line is blanked — a comment after `(` or an operator `)` (the
919
+ // template has no paren-boundary arms), and a bare delimiter carrying a
920
+ // character outside the template's word class (`<<E:F`) parsed as a
921
+ // prefix so the real terminator never matches. Locked as misses here;
922
+ // a fix flips these rows to spawnedPayload() rows.
923
+ '(true)#<<note\ngh pr merge 5',
924
+ 'cat <<E:F\nbody\nE:F\ngh pr merge 5',
925
+ ]) {
926
+ writeStubCli({
927
+ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} },
928
+ });
929
+ const { status } = runWrapper(bash(command), [], 'merge-ready');
930
+ expect(status, command).toBe(0);
931
+ expect(stubArgv(), command).toBeNull();
932
+ }
933
+ });
934
+ it('every gh pr merge at command position is judged, not only the first (PR round 1, greptile)', () => {
935
+ initGitRepo();
936
+ // The stub overwrites its record on every spawn, so the record names the
937
+ // LAST payload judged. An allowing stub: the second merge is reached.
938
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
939
+ const allowed = runWrapper(bash('gh pr merge 7; gh pr merge 8'), [], 'merge-ready');
940
+ expect(allowed.status).toBe(0);
941
+ expect(spawnedPayload()).toMatchObject({ pr: 8 });
942
+ // A denying stub under strict: the FIRST deny exits 2, so the second
943
+ // merge is never spawned and the record still names the first.
944
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'not ready', provenance: {} } });
945
+ const denied = runWrapper(bash('gh pr merge 7 && gh pr merge 8'), [], 'merge-ready');
946
+ expect(denied.status).toBe(2);
947
+ expect(spawnedPayload()).toMatchObject({ pr: 7 });
948
+ expect(denied.stderr.match(/merge-ready \(deny\)/g)).toHaveLength(1);
949
+ // Under --pilot a deny prints and the NEXT merge is still judged: two
950
+ // deny lines, the record names the second merge, exit 0.
951
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'not ready', provenance: {} } });
952
+ const pilot = runWrapper(bash('gh pr merge 7; gh pr merge 8'), ['--pilot'], 'merge-ready');
953
+ expect(pilot.status).toBe(0);
954
+ expect(spawnedPayload()).toMatchObject({ pr: 8 });
955
+ expect(pilot.stderr.match(/merge-ready \(deny\)/g)).toHaveLength(2);
956
+ });
957
+ it('an arithmetic shift or a comment does not swallow the merge that follows (fold round 2, F1)', () => {
958
+ // `$((1<<2))` is a SHIFT and `# see <<note` is a comment: neither opens a
959
+ // heredoc. Before the guards, each swallowed the rest of the command and
960
+ // the real merge after it went unjudged — a silent miss on the exact
961
+ // command this gate exists for.
962
+ initGitRepo();
963
+ for (const command of [
964
+ 'echo $((1<<2)); gh pr merge 5',
965
+ 'echo $(( 3<<1 )); gh pr merge 5',
966
+ '# see <<note\ngh pr merge 5',
967
+ '(( 1<<3 ))\ngh pr merge 5',
968
+ 'echo hi # <<EOF\ngh pr merge 5',
969
+ ]) {
970
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
971
+ runWrapper(bash(command), [], 'merge-ready');
972
+ expect(spawnedPayload(), command).toMatchObject({ pr: 5 });
973
+ }
974
+ });
975
+ it('a line continuation does not hide the anchor (round 3, F6)', () => {
976
+ // The shell removes a backslash-newline and joins the halves. Absorbing
977
+ // the newline into the token left the segment starting with something
978
+ // other than `gh`, so a real merge went unjudged.
979
+ initGitRepo();
980
+ for (const command of ['gh \\\npr merge 5', 'gh pr merge \\\n5', 'gh \\\r\npr merge 5']) {
981
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
982
+ runWrapper(bash(command), [], 'merge-ready');
983
+ expect(spawnedPayload(), JSON.stringify(command)).toMatchObject({ pr: 5 });
984
+ }
985
+ // A backslash before an ORDINARY character keeps its old meaning: it
986
+ // escapes that character into the token.
987
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
988
+ runWrapper(bash('gh pr merge 5 --body a\\ b'), [], 'merge-ready');
989
+ expect(spawnedPayload()).toMatchObject({ pr: 5 });
990
+ });
991
+ it('a PowerShell block comment is data, not commands (round 3 F8; round 4 F1, F8)', () => {
992
+ initGitRepo();
993
+ const pwsh = (command) => ({
994
+ tool_name: 'PowerShell',
995
+ tool_input: { command },
996
+ });
997
+ // THE CONTROL (round 4, F1): a MULTI-LINE block comment. Without the
998
+ // `<#` arm this fires with pr 9 — verified by stripping the arm from a
999
+ // rendered copy. The single-line row below behaves the same either way
1000
+ // (the `#` word-comment arm already covers it), so it is a companion, not
1001
+ // a control.
1002
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
1003
+ expect(runWrapper(pwsh('<#\ngh pr merge 9\n#>\necho hi'), [], 'merge-ready').status).toBe(0);
1004
+ expect(stubArgv()).toBeNull();
1005
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
1006
+ expect(runWrapper(pwsh('<# gh pr merge 9 #>\necho hi'), [], 'merge-ready').status).toBe(0);
1007
+ expect(stubArgv()).toBeNull();
1008
+ // The merge AFTER one is still judged — the blank must not eat it.
1009
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1010
+ runWrapper(pwsh('<# notes #>\ngh pr merge 4'), [], 'merge-ready');
1011
+ expect(spawnedPayload()).toMatchObject({ pr: 4 });
1012
+ // ROUND 4 F8: the blank is a POWERSHELL rule. In bash `<#tmp` is a
1013
+ // redirect from a file named `#tmp`, and blanking from it to a later `#>`
1014
+ // would swallow the real merge on the next line.
1015
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1016
+ runWrapper(bash('sort <#tmp\ngh pr merge 8'), [], 'merge-ready');
1017
+ expect(spawnedPayload()).toMatchObject({ pr: 8 });
1018
+ });
1019
+ it('a comment is not a command: a merge inside one never fires', () => {
1020
+ initGitRepo();
1021
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
1022
+ const { status } = runWrapper(bash('echo hi # gh pr merge 9'), [], 'merge-ready');
1023
+ expect(status).toBe(0);
1024
+ expect(stubArgv()).toBeNull();
1025
+ });
1026
+ it('EVERY heredoc queued on a line is read as data, not just the first (fold round 2, F2)', () => {
1027
+ // bash reads `cat <<A <<B` as two bodies in order, so a command sitting
1028
+ // in B's body is data too. Consuming only A's body left B's body as
1029
+ // commands — a false deny on text.
1030
+ initGitRepo();
1031
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
1032
+ const { status } = runWrapper(bash('cat <<A <<B\nfirst\nA\ngh pr merge 5\nB\n'), [], 'merge-ready');
1033
+ expect(status).toBe(0);
1034
+ expect(stubArgv()).toBeNull();
1035
+ // The complement: a heredoc as the merge's OWN operand still fires.
1036
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1037
+ runWrapper(bash('gh pr merge 5 <<EOF\nnotes\nEOF\n'), [], 'merge-ready');
1038
+ expect(spawnedPayload()).toMatchObject({ pr: 5 });
1039
+ });
1040
+ it('still fires on a real merge that FOLLOWS a heredoc (the blanker keeps the segments)', () => {
1041
+ // The heredoc blanker must not swallow the rest of the command: the
1042
+ // terminator line ends the body and the next segment is judged normally.
1043
+ initGitRepo();
1044
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1045
+ runWrapper(bash('cat <<EOF > body.md\nsome release notes\nEOF\ngh pr merge 21'), [], 'merge-ready');
1046
+ expect(spawnedPayload()).toMatchObject({ pr: 21 });
1047
+ });
1048
+ it('a here-string is not a heredoc: a merge on the line after `<<<` still fires (CodeRabbit on mmnto-ai/totem#2855)', () => {
1049
+ // `<<<` was excluded only at its FIRST `<`; the scanner then re-entered
1050
+ // at the second, read `bar` as a heredoc delimiter and blanked every
1051
+ // line after it — the merge below went unjudged, a fail-OPEN path. Core's
1052
+ // scanner (transport-shield.ts) carries the preceding-character guard the
1053
+ // template lacked; this row holds the two scanners equal on that shape.
1054
+ initGitRepo();
1055
+ // Three spellings, every one fail-open before the guard (the re-arm's R6):
1056
+ // spaced, glued, and a here-string that opens the command.
1057
+ for (const command of [
1058
+ 'grep x <<< bar\ngh pr merge 5',
1059
+ 'grep x <<<bar\ngh pr merge 5',
1060
+ '<<<bar\ngh pr merge 5',
1061
+ ]) {
1062
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1063
+ runWrapper(bash(command), [], 'merge-ready');
1064
+ expect(spawnedPayload(), command).toMatchObject({ pr: 5 });
1065
+ }
1066
+ // The complement (a non-regression row, not a falsifier): a here-string as
1067
+ // the merge's OWN operand still fires.
1068
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1069
+ runWrapper(bash('gh pr merge 6 <<< notes'), [], 'merge-ready');
1070
+ expect(spawnedPayload()).toMatchObject({ pr: 6 });
1071
+ });
1072
+ it('a # glued to $( is a comment, as in core: a <<word inside it never opens a heredoc (the pilot-install re-arm, R2)', () => {
1073
+ // Core's scanner sets the word boundary after a command substitution
1074
+ // opens, so `$(# …` reads the `#` as a comment. The template had no
1075
+ // substitution arm: the boundary stayed false, the `#` was text, and a
1076
+ // `<<note` inside it opened a heredoc whose unterminated body blanked the
1077
+ // merge on the next line — the same fail-open class as the here-string.
1078
+ initGitRepo();
1079
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1080
+ runWrapper(bash('echo $(# <<note\ngh pr merge 5\n)'), [], 'merge-ready');
1081
+ expect(spawnedPayload()).toMatchObject({ pr: 5 });
1082
+ });
1083
+ it('an unexpanded shell variable rides as unresolvedTarget, not as a branch (fold F13)', () => {
1084
+ // `gh pr merge $PR` names a target the hook cannot know — the shell
1085
+ // expands it after the gate has already decided. Reading "$PR" as a
1086
+ // branch would judge the wrong PR, or none.
1087
+ initGitRepo();
1088
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1089
+ runWrapper(bash('gh pr merge $PR --squash'), [], 'merge-ready');
1090
+ const payload = spawnedPayload();
1091
+ expect(payload.unresolvedTarget).toBe('$PR');
1092
+ expect(payload.pr).toBeNull();
1093
+ // NOT the current-branch fallback: that is for a command naming no target.
1094
+ expect(payload.branch).toBeUndefined();
1095
+ });
1096
+ it('a braced expansion keeps its braces in the evidence (round 2, F10)', () => {
1097
+ // `${PR}` used to reach the engine as bare "$", because the tokenizer
1098
+ // split on the braces. A `$( … )` deliberately still splits — a real
1099
+ // merge inside a command substitution must keep firing.
1100
+ initGitRepo();
1101
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1102
+ runWrapper(bash('gh pr merge ${PR}'), [], 'merge-ready');
1103
+ expect(spawnedPayload().unresolvedTarget).toBe('${PR}');
1104
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1105
+ runWrapper(bash('echo $(gh pr merge 8)'), [], 'merge-ready');
1106
+ expect(spawnedPayload()).toMatchObject({ pr: 8 });
1107
+ });
1108
+ it('a Write envelope and a PowerShell command are treated by tool, not by text', () => {
1109
+ initGitRepo();
1110
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
1111
+ const write = runWrapper({ tool_name: 'Write', tool_input: { file_path: 'notes.md' } }, [], 'merge-ready');
1112
+ expect(write.status).toBe(0);
1113
+ expect(stubArgv()).toBeNull();
1114
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1115
+ runWrapper({ tool_name: 'PowerShell', tool_input: { command: 'gh pr merge 11' } }, [], 'merge-ready');
1116
+ expect(spawnedPayload()).toMatchObject({ pr: 11 });
1117
+ });
1118
+ it('an evaluation failure on an APPLICABLE merge blocks (fail-closed), pilot exits 0', () => {
1119
+ initGitRepo();
1120
+ writeStubCli({ exit: 1 });
1121
+ const strict = runWrapper(bash('gh pr merge 2800'), [], 'merge-ready');
1122
+ expect(strict.status).toBe(2);
1123
+ expect(strict.stderr).toMatch(/fail-closed/i);
1124
+ writeStubCli({ verdict: { disposition: 'deny', reason: 'behind', provenance: {} }, exit: 0 });
1125
+ const pilot = runWrapper(bash('gh pr merge 2800'), ['--pilot'], 'merge-ready');
1126
+ expect(pilot.status).toBe(0);
1127
+ expect(pilot.stderr).toContain('behind');
1128
+ });
1129
+ });
688
1130
  });
689
1131
  // ─── The PATH fallback arm (mmnto-ai/totem#2822) ───────────────────────
690
1132
  //
@@ -842,6 +1284,25 @@ describe('gate install discloses a Bash-matched gate applies to bootstrap', () =
842
1284
  expect(again).toContain('transport-shield matches Bash|PowerShell:');
843
1285
  expect(again).toContain('bootstrap a fresh clone from a terminal outside the harness');
844
1286
  });
1287
+ // ─── Pilot-tier CLI floor (mmnto-ai/totem#2800 fold F3) ───────────────
1288
+ it('a --pilot install names the CLI floor its wrapper needs; a strict one does not', async () => {
1289
+ await gateInstallCommand({ name: 'merge-ready', pilot: true });
1290
+ const pilot = lines.join('\n');
1291
+ expect(pilot).toContain('gate check --tier pilot');
1292
+ expect(pilot).toContain('2.3.0');
1293
+ lines = [];
1294
+ await gateInstallCommand({ name: 'freeze-check' });
1295
+ const strict = lines.join('\n');
1296
+ // A strict install forwards no tier, so it carries no floor to disclose.
1297
+ expect(strict).not.toContain('2.3.0');
1298
+ expect(strict).not.toContain('--tier');
1299
+ });
1300
+ it('init --gates= discloses the same pilot floor (it prints its own rows)', async () => {
1301
+ await initCommand({ bare: true, gates: 'merge-ready', pilot: true });
1302
+ const out = lines.join('\n');
1303
+ expect(out).toContain('gate check --tier pilot');
1304
+ expect(out).toContain('2.3.0');
1305
+ });
845
1306
  });
846
1307
  // ─── eject parity ──────────────────────────────────────────────────────
847
1308
  describe('eject removes the gate entry (parity with install)', () => {
@@ -939,6 +1400,12 @@ describe('init --gates= routes through the shared installer', () => {
939
1400
  expect(freeze).not.toContain('bootstrap');
940
1401
  expect(freeze).not.toContain('matches Bash|PowerShell');
941
1402
  });
1403
+ it('init --gates=merge-ready installs it under Bash|PowerShell (mmnto-ai/totem#2800)', async () => {
1404
+ await initCommand({ bare: true, gates: 'merge-ready' });
1405
+ expect(gateEntryCount(cwd, 'merge-ready')).toBe(1);
1406
+ expect(matchersFor(cwd, 'merge-ready')).toEqual(['Bash|PowerShell']);
1407
+ expect(gateCommandFor(cwd, 'merge-ready')).toBe('node .claude/hooks/gate-wrapper.cjs --event merge-ready --strict');
1408
+ });
942
1409
  it('init --gates= with an unknown member fails loud', async () => {
943
1410
  await expect(initCommand({ bare: true, gates: 'made-up-gate' })).rejects.toThrow(/unknown gate/i);
944
1411
  });