@mmnto/cli 2.6.0 → 2.8.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.
@@ -1381,6 +1381,12 @@ export const CLAUDE_GATE_WRAPPER = `// [totem] auto-generated — Claude Code ac
1381
1381
  // or unknown disposition)
1382
1382
  // | an --event this wrapper has no payload projection for (a baked event
1383
1383
  // it cannot project is an applicable gate it cannot evaluate)
1384
+ // | the BUDGET spent before a gate could be evaluated — the projection's
1385
+ // git reads did not answer inside it (mmnto-ai/totem#2856 § D)
1386
+ // | the BUDGET spent before the envelope arrived on stdin — the host
1387
+ // opened this hook and never closed its input (same § D, fold F6)
1388
+ // Both budget arms are fail-closed at EVERY tier, --pilot included: an
1389
+ // applicable gate that could not be evaluated is not a softened deny.
1384
1390
  'use strict';
1385
1391
 
1386
1392
  const { spawnSync } = require('child_process');
@@ -1449,93 +1455,128 @@ function resolveCliFromPath() {
1449
1455
  return null;
1450
1456
  }
1451
1457
 
1452
- // ─── Parse baked args (--event <name>, optional --pilot / --strict) ─────
1453
- // The tier is read ONLY from argv (baked into the installed command at
1454
- // install time). There is NO env-var override: env sourcing would be a
1455
- // fail-open (any shell with TOTEM_GATE_TIER=pilot could silently downgrade
1456
- // enforcement). Default (no flag) = strict, so a default install is
1457
- // environment-immune; --pilot is an explicit install-time opt-in.
1458
- const argv = process.argv.slice(2);
1459
- let event = '';
1460
- let tier = 'strict';
1461
- for (let i = 0; i < argv.length; i++) {
1462
- if (argv[i] === '--event') {
1463
- event = argv[i + 1] || '';
1464
- i++;
1465
- } else if (argv[i] === '--pilot') {
1466
- tier = 'pilot';
1467
- } else if (argv[i] === '--strict') {
1468
- tier = 'strict';
1469
- }
1470
- }
1471
-
1472
1458
  // ─── merge-ready: \`gh pr merge\` at COMMAND POSITION + its payload ──────
1473
1459
  //
1474
1460
  // One walk over the command text does BOTH jobs, so recognition and argv
1475
1461
  // extraction can never disagree: it tracks quoting, splits on the unquoted
1476
- // command separators (\`;\`, \`&\`, \`|\`, a newline, \`(\`/\`)\`, \`{\`/\`}\`) and
1477
- // tokenizes each segment. A segment whose FIRST token is \`gh\`, followed by
1478
- // \`pr\` and \`merge\`, is a merge at command position; a quoted
1479
- // "gh pr merge" is a single token and never matches, so
1480
- // \`echo "gh pr merge"\` does not fire. The shell's own COMMAND-POSITION words
1481
- // are skipped before the anchor is read — the reserved words \`do\`, \`then\`,
1482
- // \`else\`, \`if\`, \`elif\`, \`while\`, \`until\` and \`!\`, the builtins \`exec\` and
1483
- // \`command\` (which run their operand as the command), and any run of
1484
- // \`NAME=value\` assignment prefixes — so \`for … ; do gh pr merge; done\`,
1485
- // \`if gh pr merge 5; then …\` and \`GH_TOKEN=x gh pr merge 5\` all fire. Before
1486
- // the PR's review round only \`do\`/\`then\`/\`else\`/\`!\` were skipped, so a merge
1487
- // used AS an \`if\` condition, or behind an assignment prefix, went unjudged
1488
- // (mmnto-ai/totem#2844 round 1, greptile). EVERY matching segment is
1462
+ // command separators (\`;\`, \`&\`, \`|\`, a newline, \`(\`/\`)\`, \`{\`/\`}\`, and a
1463
+ // backtick) and tokenizes each segment. A segment whose FIRST token is the
1464
+ // \`gh\` executable, followed by \`pr\` and \`merge\`, is a merge at command
1465
+ // position; a quoted "gh pr merge" is a single token and never matches, so
1466
+ // \`echo "gh pr merge"\` does not fire. What is NOT the command is stripped from
1467
+ // the segment's front before the anchor is read — a leading redirection, the
1468
+ // transparent wrapper programs with their options, the shell's reserved words
1469
+ // (\`do\`, \`then\`, \`else\`, \`if\`, \`elif\`, \`while\`, \`until\`, \`!\`, \`coproc\`) and
1470
+ // any run of \`NAME=value\` assignment prefixes — so \`for … ; do gh pr merge;
1471
+ // done\`, \`if gh pr merge 5; then …\` and \`GH_TOKEN=x gh pr merge 5\` all fire.
1472
+ // Before the PR's review round only \`do\`/\`then\`/\`else\`/\`!\` were skipped, so a
1473
+ // merge used AS an \`if\` condition, or behind an assignment prefix, went
1474
+ // unjudged (mmnto-ai/totem#2844 round 1, greptile). EVERY matching segment is
1489
1475
  // collected, not the first: \`gh pr merge 7; gh pr merge 8\` yields two argv
1490
1476
  // lists and the wrapper judges each PR on its own facts (same round).
1491
1477
  //
1492
- // HEREDOC BODIES ARE BLANKED FIRST (mmnto-ai/totem#2800 fold F4). A heredoc
1493
- // body is DATA, not commands: \`cat <<EOF\` … \`gh pr merge 5\` … \`EOF\` writes a
1494
- // line of text and merges nothing, and firing there was a false deny — the one
1495
- // direction this projection must not have. The blanker is the shape core's
1496
- // transport-shield scanner uses, in a self-contained form because a distributed
1497
- // hook cannot import core: quoted (\`<<'EOF'\`, \`<<"EOF"\`, \`<<\\EOF\`) and bare
1498
- // delimiters, \`<<\` and \`<<-\` (whose terminator may be tab-indented), an
1499
- // unterminated body read to the end of the command, and \`<<<\` left alone (a
1500
- // here-string is not a heredoc). Round 2 added the two guards that keep the
1501
- // blanker from EATING commands: \`$(( … ))\` / \`(( … ))\` is skipped whole, so a
1502
- // shift (\`$((1<<2))\`) opens nothing, and a \`#\` that begins a word is a comment
1503
- // discarded to end-of-line, so neither its text nor a \`<<note\` inside it is
1504
- // read before them, either one swallowed the rest of the command and a real
1505
- // merge after it went unjudged. Every \`<<\` on the operator line is queued and
1506
- // its body consumed in order, as bash does for \`cat <<A <<B\`.
1478
+ // HEREDOC BODIES AND COMMENTS ARE BLANKED FIRST (mmnto-ai/totem#2800 fold F4).
1479
+ // A heredoc body is DATA, not commands: \`cat <<EOF\` … \`gh pr merge 5\` … \`EOF\`
1480
+ // writes a line of text and merges nothing, and firing there was a false deny —
1481
+ // the one direction this projection must not have. Since mmnto-ai/totem#2857
1482
+ // the scanner that finds those bodies is a VERBATIM port of core's
1483
+ // \`findHeredocs\` (see the sync anchor below), not a second reading of the same
1484
+ // grammar: quoted (\`<<'EOF'\`, \`<<"EOF"\`, \`<<\\EOF\`) and bare delimiters,
1485
+ // \`<<\` and \`<<-\` (whose terminator may be tab-indented), an unterminated body
1486
+ // read to the end of the command, \`<<<\` left alone (a here-string is not a
1487
+ // heredoc), \`$(( … ))\` / \`(( … ))\` skipped whole so a shift opens nothing, a
1488
+ // \`#\` that begins a word discarded to end-of-line, and the paren bookkeeping
1489
+ // that says whether a \`)\` ends a word. Every \`<<\` on the operator line is
1490
+ // queued and its body consumed in order, as bash does for \`cat <<A <<B\`.
1491
+ //
1492
+ // WHAT THE ANCHOR NOW READS (mmnto-ai/totem#2856, the strict tier's
1493
+ // precondition — under PILOT each of these was one lost advisory read, under
1494
+ // STRICT a bypass): the executable may be spelled \`gh\`, \`gh.exe\` or either
1495
+ // behind a path; a CLOSED table of transparent wrapper programs (\`sudo\`,
1496
+ // \`env\`, \`timeout\`, \`nice\`, \`nohup\`, \`command\`, \`exec\`, \`time\`) is stripped
1497
+ // with its option grammar; \`eval\` re-tokenizes its operand once and
1498
+ // \`env -S\` / \`--split-string\` splits its own operand into the words that
1499
+ // take the option's place; a leading redirection is skipped with its file;
1500
+ // and a backtick substitution is a segment of its own. In POWERSHELL mode a
1501
+ // trailing backtick is that shell's LINE CONTINUATION instead — at a WORD
1502
+ // BOUNDARY the backtick and the newline are consumed and the next line
1503
+ // continues the command, the twin of bash's trailing backslash there
1504
+ // (round-6 leg, G3, bounded by round-7's H4); it was a separator in both
1505
+ // modes before, which made the backtick itself the merge's target and left
1506
+ // the real one in the next segment. INSIDE a word the two shells differ:
1507
+ // PowerShell's backtick escapes the newline INTO the argument, so
1508
+ // \`gh pr merg<backtick><LF>e 5\` is the word \`merg<LF>e\` and merges
1509
+ // nothing — and neither does this.
1507
1510
  //
1508
1511
  // Disclosed misses, same posture as transport-shield's scanner — the gate does
1509
- // NOT fire, which is the safe direction, never a false deny:
1510
- // - a wrapper PROGRAM that takes operands before \`gh\` (\`sudo\`, \`timeout 30\`,
1511
- // \`npx\`, \`env\`, \`nohup\`): the program is the segment's first token, so the
1512
- // position anchor does not see \`gh\` (the shell's reserved words and the
1513
- // \`exec\`/\`command\`/\`eval\` builtins are skipped; an arbitrary program is
1514
- // not, since the walk cannot know which of its operands is the command);
1515
- // - the executable spelled with an extension or a path (\`gh.exe pr merge 5\`,
1516
- // \`./gh pr merge 5\`): the anchor reads the bare token \`gh\` only (greptile
1517
- // on mmnto-ai/totem#2855; widening it is mmnto-ai/totem#2856, the strict
1518
- // tier's precondition);
1519
- // - two places this blanker still diverges from core's scanner, each opening
1520
- // a heredoc core does not so that a merge on a later line is blanked: a
1521
- // \`#\` right after \`(\` or after an operator \`)\` (no paren-boundary arms
1522
- // here \`(true)#<<note\` then a merge line), and a bare delimiter carrying
1523
- // a character outside \`[A-Za-z0-9_.-/]\` (\`<<E:F\`, read as the prefix \`E\`
1524
- // so the real terminator never matches). Both found by the pilot-install
1525
- // legs; the cure is one shared scanner, mmnto-ai/totem#2857;
1526
- // - a skipped word carrying a FLAG (\`command -p gh pr merge 5\`,
1527
- // \`exec -a x gh pr merge 5\`, and the reserved word's own \`time -p\` /
1528
- // \`time --\`): the flag is a token before \`gh\`, and bash runs the merge
1529
- // all the same (round 3, F1);
1530
- // - a merge handed over as ONE quoted word (\`eval "gh pr merge 5"\`,
1531
- // \`bash -c "gh pr merge 5"\`): a quoted string is data to this walk;
1532
- // - a backtick command substitution (\`echo \\\`gh pr merge 5\\\`\`): the walk
1533
- // splits on \`$( )\` parens but treats a backtick as an ordinary character,
1534
- // so the merge inside it stays part of \`echo\`'s segment;
1535
- // - a leading redirection (\`> out.txt gh pr merge 5\`): the redirection word
1536
- // is the segment's first token;
1537
- // - PowerShell's own quoting (backtick escapes, here-strings) is not
1538
- // modelled the walk reads POSIX quoting for both tools.
1512
+ // NOT fire, which is the safe direction, never a false deny. Every one of them
1513
+ // is a LOCKED row in gate-install.test.ts, so this list is read from the suite,
1514
+ // not from memory:
1515
+ // - a VARIABLE executable (\`$GH pr merge 5\`, \`\${GH} pr merge 5\`): the walk
1516
+ // cannot expand it, and the \`unresolvedTarget\` arm covers only the PR
1517
+ // argument, not the program;
1518
+ // - an UNQUOTED win32 path (\`C:\\tools\\gh.exe pr merge 5\`): this walk reads
1519
+ // POSIX quoting for BOTH tools, so the separators are consumed as escapes
1520
+ // and the token arrives as \`C:toolsgh.exe\`. Quoted, it projects;
1521
+ // - \`timeout\` with NO duration (\`timeout gh pr merge 5\`): the grammar
1522
+ // consumes exactly one positional before the command, so \`gh\` reads as the
1523
+ // duration. The form is invalid to \`timeout\` itself;
1524
+ // - a wrapper program not on the table (\`npx\`, \`xargs\`, \`bash -c "…"\`) and a
1525
+ // builtin flag not in it: the table is closed on purpose — the walk cannot
1526
+ // know which operand of an arbitrary program is the command;
1527
+ // - a table word spelled by PATH (\`/usr/bin/time -f x gh pr merge 5\`): the
1528
+ // table is keyed on the bare word the shell reads at command position, and
1529
+ // \`/usr/bin/time\` is a PROGRAM with its own option grammar, not the
1530
+ // reserved word this table models;
1531
+ // - env's OWN splitting rules inside a \`-S\` / \`--split-string\` operand.
1532
+ // The operand itself is no longer a miss: it is SPLIT and read, because
1533
+ // every spelling of it RUNS the merge (fold 3, measured on coreutils
1534
+ // 8.32 with a stub \`gh\`) and consuming it with the option made all of
1535
+ // them a bypass under STRICT. But it is split on WHITESPACE and nothing
1536
+ // more: env's own escapes (\`\\_\` is a SPACE, \`\\n\`, \`\\t\`, \`\\#\`,
1537
+ // \`\\$\`), its \`$VAR\` expansion inside the string and its \`#\` comment
1538
+ // are not modelled, and quotes INSIDE the string are not stripped.
1539
+ // Measured: \`env -S 'gh\\_pr\\_merge\\_5'\` runs \`gh pr merge 5\`, while
1540
+ // the split reads ONE word here and nothing projects; and
1541
+ // \`env -S 'env -S "gh pr merge 5"'\` runs it too, because env strips the
1542
+ // quotes inside its own operand while this walk keeps them and reads
1543
+ // \`"gh\` as the executable (both locked rows, round-7 leg H5). The
1544
+ // ATTACHED SHORT spelling is no longer among them: \`env -Sgh pr merge 5\`
1545
+ // and \`env -S'gh pr merge 5'\` both arrive as the token
1546
+ // \`-Sgh pr merge 5\`, and the rest of that token is now read as the
1547
+ // operand, so both are judged;
1548
+ // - CLUSTERED short options on a table word (\`env -vu X gh pr merge 5\`,
1549
+ // \`env -iS '<cmd>'\`): the option test reads the WHOLE \`-\` token, so a
1550
+ // cluster matches no entry of that word's operand list, is dropped as one
1551
+ // flag, and the operand belonging to the cluster's LAST letter (\`X\` for
1552
+ // \`-vu\`, the command string for \`-iS\`) is left standing at the front of
1553
+ // the strip, where it blocks the anchor. coreutils RUNS the merge in both
1554
+ // (measured, 8.32 — the \`-i\` spelling with absolute paths inside the
1555
+ // operand, since \`-i\` clears the environment). ATTACHMENT is not the gap:
1556
+ // \`env -uX\`, \`nice -n10\`, \`timeout -k5 30\` and \`timeout -sTERM 30\` all
1557
+ // project and all run. CLUSTERING is (round-8 leg, J4);
1558
+ // - \`eval\` nested deeper than ONE level
1559
+ // (\`eval "eval \\"gh pr merge 5\\""\`);
1560
+ // - a substitution inside DOUBLE quotes (\`echo "\`gh pr merge 5\`"\`,
1561
+ // \`echo "$(gh pr merge 5)"\`): bash EXECUTES both of those, but the
1562
+ // tokenizer's quote arm swallows the whole string as ONE token, so the
1563
+ // merge inside runs unjudged. Filed as mmnto-ai/totem#2893 (round-5 leg,
1564
+ // F4); the single-quoted spelling really is data and stays a control row;
1565
+ // - a redirection operator carrying a tokenizer separator (\`>|\`, \`2>&1\`,
1566
+ // \`>& file\`, \`<& 3\`, \`exec 3>&1 …\`): \`|\` and \`&\` end the segment before
1567
+ // the operator is read as one word, and ALL FIVE of those are merges the
1568
+ // shell runs — measured with a stub on bash 5.3, each applies its
1569
+ // redirection and then runs \`gh pr merge 5\` (the \`<& 3\` form once that
1570
+ // descriptor is open). The segment they leave starts at the FILE
1571
+ // (\`out.txt\`, \`1\`, \`file\`, \`3\`), so nothing of the merge is read: a
1572
+ // fail-open, not text the shell ignores (round-6 leg, G2). A \`&>\` splits
1573
+ // the same way but leaves a readable \`>\` at the front of the next
1574
+ // segment, so THAT one projects;
1575
+ // - PowerShell's own quoting (backtick escapes outside double quotes,
1576
+ // here-strings) is not modelled — the walk reads POSIX quoting for both
1577
+ // tools. PowerShell's call operator is NOT a miss: \`& gh pr merge 5\`
1578
+ // projects, because \`&\` is one of the separators and the segment after it
1579
+ // starts at \`gh\` (row, not memory).
1539
1580
  // Disclosed FALSE FIRES, the deny direction, all contrived — text the shell
1540
1581
  // does not execute as a merge but that sits at a segment's front here: a bash
1541
1582
  // array assignment whose elements spell a merge (\`A=(gh pr merge 8)\`) is judged
@@ -1545,21 +1586,86 @@ for (let i = 0; i < argv.length; i++) {
1545
1586
  // to be collected, round 2 F6); and a function DEFINITION whose body is a
1546
1587
  // merge (\`f() { gh pr merge 5; }\`) fires at definition time, because \`{\` is a
1547
1588
  // separator and the body is its own segment (round 3, F4; it fired before this
1548
- // PR's rounds too). \`TOTEM_MERGE_GATE_OVERRIDE=1\` is the audited way past any
1549
- // of them.
1550
- // Which characters END a word, so the scanner can say whether the next one
1551
- // BEGINS one. Same set core's scanner uses (mmnto-ai/totem#2800 round 2, F1).
1552
- function isWordBoundary(ch) {
1553
- return (
1554
- ch === ' ' ||
1555
- ch === '\\t' ||
1556
- ch === '\\r' ||
1557
- ch === '\\n' ||
1558
- ch === ';' ||
1559
- ch === '|' ||
1560
- ch === '&'
1561
- );
1562
- }
1589
+ // PR's rounds too). A fourth, PowerShell's own: a double-quoted string whose
1590
+ // backtick escapes a quote (\`Write-Output "a \`"; gh pr merge 5\`"b"\`) is ONE
1591
+ // string to PowerShell and merges nothing, but this walk reads POSIX quoting
1592
+ // for both tools, so the \`"\` after the escaping backtick closes the string and
1593
+ // the merge reaches a segment's front (round-5 leg, F13; a row asserts it).
1594
+ // A fifth, and the only one that is not contrived: an INVALID OPTION to a
1595
+ // word on the table above (\`command -x\`, \`exec -x\`, \`timeout -Z 30\`,
1596
+ // \`nice -Z\`, \`env -Z\`, \`nohup -x\`, \`sudo -Z\`). Each makes the program answer
1597
+ // "invalid option" and run NOTHING, while the strip below reads any unknown
1598
+ // \`-\` token as one of that word's own options and projects the merge behind
1599
+ // it. Ruled disclose-not-cure (round-6 leg, G4): the cure is a closed \`flags\`
1600
+ // list per program — the shape \`time\` carries, whose reserved-word grammar
1601
+ // really is two flags — and on a mutant with that list everywhere it turns
1602
+ // every real flag the list omits (\`sudo -n\`, \`sudo -E\`,
1603
+ // \`timeout --foreground\`) into a MISS, which is a bypass under STRICT. A
1604
+ // false fire on a command that runs nothing costs one bogus deny; rows assert
1605
+ // each of them, so this paragraph is read from the suite.
1606
+ // A sixth, PowerShell's again (round-7 leg, H4): a backtick that is the LAST
1607
+ // CHARACTER OF THE INPUT (\`gh pr merge 5 <backtick>\`, with nothing after it,
1608
+ // not even a newline) is a continuation with nothing to continue — pwsh
1609
+ // answers with a parse error and runs NOTHING — while here the backtick is
1610
+ // not followed by a newline, so it falls through to the separator arm and the
1611
+ // merge in front of it is judged. Narrowed to that one spelling on a
1612
+ // measurement (round-8 leg, J3): give the SAME input a trailing newline
1613
+ // (\`gh pr merge 5 <backtick><LF>\`) and pwsh runs the merge, while the
1614
+ // continuation arm here consumes the pair and projects it — no divergence.
1615
+ // A seventh, env's (round-7 leg, H5): a \`$VAR\` inside a \`-S\` operand
1616
+ // (\`env -S 'gh pr merge $PR'\`) makes env REFUSE the whole command — it
1617
+ // supports only \`\${VARNAME}\` and answers "only \${VARNAME} expansion is
1618
+ // supported" — so NOTHING runs, while the split reaches the anchor and
1619
+ // \`$PR\` rides as an \`unresolvedTarget\` the strict tier denies.
1620
+ // An eighth, PowerShell's third (round-8 leg, J3): a backtick followed by
1621
+ // WHITESPACE and then a newline (\`gh pr merge <backtick><space><LF>5\`) is not
1622
+ // a continuation either, because the backtick escapes that SPACE. pwsh runs
1623
+ // \`gh pr merge\` with NO TARGET — the current branch's PR merges — and reads
1624
+ // the next line as its own statement, while here the backtick is not
1625
+ // IMMEDIATELY followed by a newline, so the separator arm takes it and the
1626
+ // walk projects \`unresolvedTarget\` naming the backtick: a target nobody
1627
+ // wrote, which the strict tier denies (measured on pwsh 7 with a stub \`gh\`;
1628
+ // a row asserts the projection).
1629
+ // \`TOTEM_MERGE_GATE_OVERRIDE=1\` is the audited way past any of them.
1630
+ // ─── The heredoc scanner (mmnto-ai/totem#2857) ─────────────────────────
1631
+ // sync-anchor: findHeredocs-scanner-downstream (packages/core/src/transport-shield.ts findHeredocs; the parity test in gate-install.test.ts is the lock)
1632
+ //
1633
+ // A VERBATIM port of core's \`findHeredocs\` and its three tables, not a second
1634
+ // reading of the same grammar. The hand copy this replaces had diverged in the
1635
+ // FAIL-OPEN direction — it opened heredocs core does not, and each one blanked
1636
+ // the \`gh pr merge\` on a following line (a lost advisory read under PILOT, a
1637
+ // bypass under STRICT): no paren-boundary arms, so a \`#\` after \`(\` or after an
1638
+ // operator \`)\` was text and a \`<<word\` inside it opened a body; a narrower
1639
+ // bare-delimiter class, so \`<<E:F\` read as the prefix \`E\` and the terminator
1640
+ // line never matched; and a double-quote backslash that escaped ANY next
1641
+ // character where core escapes only DQ_ESCAPABLE.
1642
+ //
1643
+ // A distributed hook cannot import core (its exports map carries \`import\`
1644
+ // conditions only and no scanner subpath — mmnto-ai/totem#2851), so the cohort
1645
+ // lesson for an inlined standalone utility rules: port verbatim, anchor both
1646
+ // sites, lock it with an executable parity test. Change nothing here without
1647
+ // changing core's \`findHeredocs\` and re-running that test.
1648
+
1649
+ /** Inside double quotes a backslash escapes only these (POSIX); elsewhere it is kept. */
1650
+ const DQ_ESCAPABLE = ['$', '\`', '"', '\\\\', '\\n'];
1651
+
1652
+ /**
1653
+ * \`<<\` or \`<<-\`, optional blanks, then the delimiter WORD as bash delimits it:
1654
+ * single-quoted, double-quoted, backslash-quoted (\`\\EOF\`) or bare — a bare
1655
+ * word running to the next blank, quote, backslash or operator character, so
1656
+ * \`EOF.TXT\`, \`E:F\` and \`1EOF\` are whole delimiter words. Groups: 1 the dash,
1657
+ * 2 single-quoted, 3 double-quoted, 4 backslash-quoted, 5 bare.
1658
+ */
1659
+ const HEREDOC_AT =
1660
+ /^<<(-?)[ \\t]*(?:'([^'\\n]+)'|"([^"\\n]+)"|\\\\([^\\s'"\\\\<>()|&;]+)|([^\\s'"\\\\<>()|&;]+))/;
1661
+
1662
+ /**
1663
+ * Characters after which the next character BEGINS a word — where a \`#\` starts
1664
+ * a comment (POSIX 2.3 rule 9). Parentheses are not here: an opening \`(\` and an
1665
+ * OPERATOR \`)\` begin a word, but the \`)\` that closes a \`$( … )\` continues one,
1666
+ * so the walk tracks which \`(\` each \`)\` closes and sets the boundary from that.
1667
+ */
1668
+ const WORD_BOUNDARY = [' ', '\\t', '\\r', '\\n', ';', '|', '&'];
1563
1669
 
1564
1670
  // The index just past the \`))\` that closes an arithmetic expansion whose
1565
1671
  // opening \`$((\` / \`((\` ends at \`from\`; the end of the command when it is
@@ -1581,192 +1687,238 @@ function skipArithmetic(command, from) {
1581
1687
  return command.length;
1582
1688
  }
1583
1689
 
1584
- function blankHeredocBodies(command, powershell) {
1585
- let out = '';
1586
- let i = 0;
1587
- let quote = '';
1690
+ /**
1691
+ * ONE pass over the command that tracks shell quoting and SKIPS heredoc
1692
+ * bodies, returning every heredoc's span. Core's \`findHeredocs\`, arm for arm:
1693
+ * an operator inside a quoted argument is text, \`<<<\` is a here-string, a
1694
+ * \`#\` that begins a word discards the rest of its line without quote
1695
+ * processing, \`$(( … ))\` / \`(( … ))\` is skipped whole, and \`parens\` records
1696
+ * what each open \`(\` is — a substitution (\`$(\`, \`<(\`, \`>(\`), which is part of
1697
+ * a word, or a grouping operator — so the \`)\` that closes it can say whether
1698
+ * the next character begins a word. A body starts after the newline that ends
1699
+ * the operator's line and runs to the first line that IS the delimiter (an
1700
+ * exact line match, as bash reads it), or to the end of the command.
1701
+ *
1702
+ * The one thing core's scanner does not need and this one does: the COMMENT
1703
+ * regions. Core's tokenizer reads comments itself; this wrapper's does not, so
1704
+ * the blanker below has to blank them exactly as the hand copy dropped them,
1705
+ * or a \`<# … #>\` block or a \`#\` comment whose text begins with a merge would
1706
+ * reach the anchor. They are collected in the SAME two arms that discard them,
1707
+ * so the two readings cannot disagree, and they are NOT part of the span list
1708
+ * the parity lock compares.
1709
+ */
1710
+ function scanShell(command, ps) {
1711
+ const spans = [];
1712
+ const comments = [];
1713
+ const pending = [];
1714
+ const parens = [];
1715
+ let inSingle = false;
1716
+ let inDouble = false;
1588
1717
  let boundary = true;
1589
- let pending = [];
1590
-
1718
+ let i = 0;
1591
1719
  // Consume EVERY body queued on the operator line, in order, starting just
1592
1720
  // past that line's newline — bash reads \`cat <<A <<B\` as two bodies, so a
1593
- // command sitting in B's body is data too (F2). Terminator lines are kept;
1594
- // body lines are dropped with their newlines, so the segments around them
1595
- // stay separated exactly as the shell separates them. An unterminated body
1596
- // runs to the end and is dropped whole.
1721
+ // command sitting in B's body is data too (F2). An unterminated body runs to
1722
+ // the end of the command and no later heredoc on that line can start.
1597
1723
  const consumeBodies = (from) => {
1598
1724
  let cursor = from;
1599
1725
  for (let p = 0; p < pending.length; p++) {
1600
1726
  const h = pending[p];
1601
- let at = cursor;
1602
- cursor = command.length;
1727
+ const bodyStart = cursor;
1728
+ let bodyEnd = command.length;
1729
+ let unterminated = true;
1730
+ let resume = command.length;
1731
+ let at = bodyStart;
1603
1732
  while (at <= command.length) {
1604
1733
  const nl = command.indexOf('\\n', at);
1605
1734
  const stop = nl === -1 ? command.length : nl;
1606
1735
  let line = command.slice(at, stop);
1607
1736
  if (h.stripTabs) line = line.replace(/^\\t+/, '');
1608
- line = line.replace(/\\r$/, '');
1609
- const next = nl === -1 ? command.length : nl + 1;
1610
1737
  if (line === h.delimiter) {
1611
- out += command.slice(at, next);
1612
- cursor = next;
1738
+ bodyEnd = at;
1739
+ unterminated = false;
1740
+ resume = nl === -1 ? command.length : nl + 1;
1613
1741
  break;
1614
1742
  }
1615
1743
  if (nl === -1) break;
1616
- out += '\\n';
1617
- at = next;
1744
+ at = nl + 1;
1618
1745
  }
1619
- }
1620
- pending = [];
1746
+ spans.push({
1747
+ delimiter: h.delimiter,
1748
+ quoted: h.quoted,
1749
+ stripTabs: h.stripTabs,
1750
+ unterminated: unterminated,
1751
+ bodyStart: bodyStart,
1752
+ bodyEnd: bodyEnd,
1753
+ });
1754
+ cursor = resume;
1755
+ if (unterminated) break;
1756
+ }
1757
+ pending.length = 0;
1621
1758
  return cursor;
1622
1759
  };
1623
-
1624
1760
  while (i < command.length) {
1625
1761
  const ch = command[i];
1626
- if (quote !== '') {
1627
- out += ch;
1628
- if (ch === '\\\\' && quote === '"' && i + 1 < command.length) {
1629
- out += command[i + 1];
1762
+ if (inSingle) {
1763
+ if (ch === "'") inSingle = false;
1764
+ i += 1;
1765
+ boundary = false;
1766
+ continue;
1767
+ }
1768
+ if (inDouble) {
1769
+ if (ps && ch === '\`' && i + 1 < command.length) {
1770
+ // PowerShell's escape inside a double-quoted string is the backtick.
1630
1771
  i += 2;
1772
+ boundary = false;
1631
1773
  continue;
1632
1774
  }
1633
- if (ch === quote) quote = '';
1634
- i++;
1775
+ if (ch === '\\\\' && i + 1 < command.length && DQ_ESCAPABLE.indexOf(command[i + 1]) !== -1) {
1776
+ i += 2;
1777
+ boundary = false;
1778
+ continue;
1779
+ }
1780
+ if (ch === '"') inDouble = false;
1781
+ i += 1;
1635
1782
  boundary = false;
1636
1783
  continue;
1637
1784
  }
1638
- if (ch === "'" || ch === '"') {
1639
- quote = ch;
1640
- out += ch;
1641
- i++;
1642
- boundary = false;
1785
+ if (ps && command.slice(i, i + 2) === '<#') {
1786
+ // PowerShell's block comment, discarded without quote processing; an
1787
+ // unterminated one runs to the end. Applied ONLY for the PowerShell tool
1788
+ // (round 4, F8): in bash \`sort <#tmp\` is a redirect from a file named
1789
+ // \`#tmp\`, and discarding from it to a later \`#>\` would swallow real
1790
+ // commands.
1791
+ const close = command.indexOf('#>', i + 2);
1792
+ const end = close === -1 ? command.length : close + 2;
1793
+ comments.push({ start: i, end: end });
1794
+ i = end;
1795
+ boundary = true;
1643
1796
  continue;
1644
1797
  }
1645
- // A backslash before a NEWLINE is a line continuation: the shell removes
1646
- // both characters and the command carries on, so the scanner must too
1647
- // (mmnto-ai/totem#2800 round 3, F6). Absorbing the newline into a token is
1648
- // what hid \`gh \\<LF>pr merge 5\` from the position anchor.
1649
- if (ch === '\\\\' && (command[i + 1] === '\\n' || (command[i + 1] === '\\r' && command[i + 2] === '\\n'))) {
1650
- i += command[i + 1] === '\\r' ? 3 : 2;
1798
+ if (ch === '#' && boundary) {
1799
+ // A comment: discarded to the end of the line without quote processing;
1800
+ // the newline itself stays (it may end an operator line).
1801
+ const nl = command.indexOf('\\n', i);
1802
+ const end = nl === -1 ? command.length : nl;
1803
+ comments.push({ start: i, end: end });
1804
+ i = end;
1651
1805
  continue;
1652
1806
  }
1653
- if (ch === '\\\\' && i + 1 < command.length) {
1654
- out += ch + command[i + 1];
1655
- i += 2;
1807
+ if (ch === '$' && command.slice(i, i + 3) === '$((') {
1808
+ i = skipArithmetic(command, i + 3);
1656
1809
  boundary = false;
1657
1810
  continue;
1658
1811
  }
1659
- // A \`#\` that BEGINS a word is a comment: discarded to the end of the line
1660
- // WITHOUT quote processing, so neither its text nor a \`<<note\` inside it
1661
- // reaches the tokenizer (F1). The newline stays — it may end an operator
1662
- // line whose bodies are still queued.
1663
- if (ch === '#' && boundary) {
1664
- const nl = command.indexOf('\\n', i);
1665
- i = nl === -1 ? command.length : nl;
1812
+ if (ch === '(' && command[i + 1] === '(' && boundary) {
1813
+ i = skipArithmetic(command, i + 2);
1814
+ boundary = false;
1666
1815
  continue;
1667
1816
  }
1668
- // PowerShell's \`<# #>\` block comment is data, not commands: blank it
1669
- // whole, the way a heredoc body is blanked (round 3, F8). Applied ONLY when
1670
- // the TOOL is PowerShell (round 4, F8): bash has no such comment, and there
1671
- // \`sort <#tmp\` is a redirect from a file named \`#tmp\` — blanking from it
1672
- // to a later \`#>\` would swallow real commands. A \`<#\` inside a quoted
1673
- // string never reaches here, because the quote arms run first.
1674
- if (powershell && ch === '<' && command[i + 1] === '#') {
1675
- const close = command.indexOf('#>', i + 2);
1676
- i = close === -1 ? command.length : close + 2;
1817
+ if ((ch === '$' || ch === '<' || ch === '>') && command[i + 1] === '(') {
1818
+ // A command or process substitution: part of the word that carries it.
1819
+ // Its first character begins a word (a \`#\` right after \`$(\` is a
1820
+ // comment).
1821
+ parens.push('subst');
1822
+ i += 2;
1677
1823
  boundary = true;
1678
1824
  continue;
1679
1825
  }
1680
- if (ch === '$' && command.slice(i, i + 3) === '$((') {
1681
- const end = skipArithmetic(command, i + 3);
1682
- out += command.slice(i, end);
1683
- i = end;
1684
- boundary = false;
1826
+ if (ch === '(') {
1827
+ parens.push('group');
1828
+ i += 1;
1829
+ boundary = true;
1685
1830
  continue;
1686
1831
  }
1687
- // A command or process substitution opens a word: a \`#\` glued to \`$(\` is
1688
- // a comment, as core's scanner reads it. Without this arm the boundary
1689
- // stayed false, the \`#\` was text, and a \`<<word\` inside it opened a
1690
- // heredoc whose body swallowed every later line — the same fail-open class
1691
- // as the here-string (the pilot-install re-arm, R2, mmnto-ai/totem#2855).
1692
- if ((ch === '$' || ch === '<' || ch === '>') && command[i + 1] === '(') {
1693
- out += ch + '(';
1832
+ if (ch === ')') {
1833
+ // The \`)\` of a substitution continues the word; an operator \`)\` ends one.
1834
+ boundary = parens.pop() !== 'subst';
1835
+ i += 1;
1836
+ continue;
1837
+ }
1838
+ if (ch === '\\\\') {
1694
1839
  i += 2;
1695
- boundary = true;
1840
+ boundary = false;
1696
1841
  continue;
1697
1842
  }
1698
- if (ch === '(' && command[i + 1] === '(' && boundary) {
1699
- const end = skipArithmetic(command, i + 2);
1700
- out += command.slice(i, end);
1701
- i = end;
1843
+ if (ch === "'") {
1844
+ inSingle = true;
1845
+ i += 1;
1702
1846
  boundary = false;
1703
1847
  continue;
1704
1848
  }
1705
- // \`<<\` opens a heredoc; \`<<<\` is a here-string and is left alone — at
1706
- // BOTH of its first two characters (the preceding-character guard core's
1707
- // scanner carries; without it the second \`<\` of \`<<<\` opened a heredoc
1708
- // whose body swallowed every later line, a fail-open path — CodeRabbit on
1709
- // mmnto-ai/totem#2855).
1710
- if (ch === '<' && command[i + 1] === '<' && command[i - 1] !== '<' && command[i + 2] !== '<') {
1711
- let j = i + 2;
1712
- let head = '<<';
1713
- let dash = false;
1714
- if (command[j] === '-') {
1715
- dash = true;
1716
- head += '-';
1717
- j++;
1718
- }
1719
- while (j < command.length && (command[j] === ' ' || command[j] === '\\t')) {
1720
- head += command[j];
1721
- j++;
1722
- }
1723
- // The delimiter word, quoted (\`<<'EOF'\`, \`<<"EOF"\`) or bare, with a
1724
- // backslash-quoted form (\`<<\\EOF\`) read as bash reads it.
1725
- let delim = '';
1726
- const q = command[j] === "'" || command[j] === '"' ? command[j] : '';
1727
- if (q !== '') {
1728
- head += q;
1729
- j++;
1730
- }
1731
- while (j < command.length) {
1732
- const c = command[j];
1733
- if (q !== '') {
1734
- head += c;
1735
- j++;
1736
- if (c === q) break;
1737
- delim += c;
1738
- continue;
1739
- }
1740
- if (c === '\\\\' && j + 1 < command.length) {
1741
- head += c + command[j + 1];
1742
- delim += command[j + 1];
1743
- j += 2;
1744
- continue;
1745
- }
1746
- if (/[A-Za-z0-9_.\\-\\/]/.test(c)) {
1747
- head += c;
1748
- delim += c;
1749
- j++;
1750
- continue;
1751
- }
1752
- break;
1753
- }
1754
- out += head;
1755
- i = j;
1849
+ if (ch === '"') {
1850
+ inDouble = true;
1851
+ i += 1;
1756
1852
  boundary = false;
1757
- if (delim !== '') pending.push({ delimiter: delim, stripTabs: dash });
1758
1853
  continue;
1759
1854
  }
1760
1855
  if (ch === '\\n') {
1761
- out += '\\n';
1762
- i += 1;
1763
- if (pending.length > 0) i = consumeBodies(i);
1856
+ i = pending.length > 0 ? consumeBodies(i + 1) : i + 1;
1764
1857
  boundary = true;
1765
1858
  continue;
1766
1859
  }
1767
- out += ch;
1768
- boundary = isWordBoundary(ch);
1769
- i++;
1860
+ // \`<<\` opens a heredoc; \`<<<\` is a here-string and is left alone — at BOTH
1861
+ // of its first two characters (without the preceding-character guard the
1862
+ // second \`<\` of \`<<<\` opened a heredoc whose body swallowed every later
1863
+ // line, a fail-open path — CodeRabbit on mmnto-ai/totem#2855).
1864
+ if (ch === '<' && command[i + 1] === '<' && command[i - 1] !== '<' && command[i + 2] !== '<') {
1865
+ const m = HEREDOC_AT.exec(command.slice(i));
1866
+ if (m !== null) {
1867
+ pending.push({
1868
+ stripTabs: (m[1] || '') === '-',
1869
+ quoted: m[2] !== undefined || m[3] !== undefined || m[4] !== undefined,
1870
+ delimiter:
1871
+ m[2] !== undefined
1872
+ ? m[2]
1873
+ : m[3] !== undefined
1874
+ ? m[3]
1875
+ : m[4] !== undefined
1876
+ ? m[4]
1877
+ : m[5] !== undefined
1878
+ ? m[5]
1879
+ : '',
1880
+ });
1881
+ i += m[0].length;
1882
+ boundary = false;
1883
+ continue;
1884
+ }
1885
+ }
1886
+ boundary = WORD_BOUNDARY.indexOf(ch) !== -1;
1887
+ i += 1;
1888
+ }
1889
+ if (pending.length > 0) consumeBodies(command.length);
1890
+ return { heredocs: spans, comments: comments };
1891
+ }
1892
+
1893
+ /** Every heredoc in the command — core's span shape minus the unused \`body\`. */
1894
+ function findHeredocSpans(command, powershell) {
1895
+ return scanShell(command, powershell === true).heredocs;
1896
+ }
1897
+
1898
+ /**
1899
+ * The command with every heredoc body, and every comment, replaced by SPACES:
1900
+ * core's blanking shape, so LENGTH and every offset are preserved (the hand
1901
+ * copy dropped body lines instead, which moved every offset after them). The
1902
+ * tokenizer below treats any run of spaces as one boundary, so the change of
1903
+ * shape is invisible to it — the heredoc rows in the suite are that proof.
1904
+ */
1905
+ function blankHeredocBodies(command, powershell) {
1906
+ const scan = scanShell(command, powershell === true);
1907
+ const regions = [];
1908
+ for (let s = 0; s < scan.heredocs.length; s++) {
1909
+ regions.push({ start: scan.heredocs[s].bodyStart, end: scan.heredocs[s].bodyEnd });
1910
+ }
1911
+ for (let c = 0; c < scan.comments.length; c++) {
1912
+ regions.push(scan.comments[c]);
1913
+ }
1914
+ let out = command;
1915
+ for (let r = 0; r < regions.length; r++) {
1916
+ const region = regions[r];
1917
+ if (region.end <= region.start) continue;
1918
+ out =
1919
+ out.slice(0, region.start) +
1920
+ ' '.repeat(region.end - region.start) +
1921
+ out.slice(region.end);
1770
1922
  }
1771
1923
  return out;
1772
1924
  }
@@ -1775,10 +1927,14 @@ function blankHeredocBodies(command, powershell) {
1775
1927
  // reserved words that introduce a compound command (\`time\` and \`coproc\` are
1776
1928
  // reserved words too — the round-2 leg found them missing), the negation, and
1777
1929
  // the builtins that execute their operand as the command (\`exec\`, \`command\`,
1778
- // and \`eval\` on an UNQUOTED operand \`eval "gh pr merge 5"\` hands the shell a
1779
- // single quoted word, which this walk reads as data, a disclosed miss below).
1780
- // Stripped from a segment's front, in any run, before the \`gh pr merge\`
1781
- // anchor is read.
1930
+ // \`eval\`). Stripped from a segment's front, in any run, before the
1931
+ // \`gh pr merge\` anchor is read.
1932
+ //
1933
+ // Four of them — \`time\`, \`exec\`, \`command\`, \`eval\` — also carry an OPTION
1934
+ // grammar, so they appear again in TRANSPARENT_WRAPPERS below and the strip
1935
+ // reads them from THERE (the table is consulted first). They stay here so this
1936
+ // list still reads as what it is: every word the shell itself skips at command
1937
+ // position.
1782
1938
  const COMMAND_POSITION_WORDS = [
1783
1939
  'do',
1784
1940
  'then',
@@ -1801,35 +1957,307 @@ function isAssignmentPrefix(token) {
1801
1957
  return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token);
1802
1958
  }
1803
1959
 
1960
+ // The executable spellings the anchor accepts (mmnto-ai/totem#2856 § A):
1961
+ // \`gh\`, \`gh.exe\`, and either of those behind a path (\`./gh\`,
1962
+ // \`/usr/local/bin/gh\`, \`'C:\\tools\\gh.exe'\`). The BASENAME after the last
1963
+ // \`/\` or \`\\\` is what is read, and the \`.exe\` suffix is case-insensitive as
1964
+ // win32 resolves it. Reading only the bare token \`gh\` left every other
1965
+ // spelling of the SAME executable unjudged — one lost advisory read under
1966
+ // PILOT, a bypass under STRICT (greptile P1 on mmnto-ai/totem#2855).
1967
+ // A VARIABLE executable (\`$GH\`, \`\${GH}\`) is not a spelling this wrapper can
1968
+ // expand, and stays a disclosed miss below.
1969
+ function isGhExecutable(token) {
1970
+ if (typeof token !== 'string' || token === '') return false;
1971
+ const slash = token.lastIndexOf('/');
1972
+ const back = token.lastIndexOf('\\\\');
1973
+ const cut = slash > back ? slash : back;
1974
+ const base = cut === -1 ? token : token.slice(cut + 1);
1975
+ if (base === 'gh') return true;
1976
+ // The whole \`gh.exe\` basename compares case-insensitively: win32 resolves
1977
+ // file names without case, so \`GH.EXE pr merge 5\` and \`Gh.exe pr merge 5\`
1978
+ // run the same executable as \`gh.exe pr merge 5\` (CodeRabbit on
1979
+ // mmnto-ai/totem#2894 — the stem-only lower-casing left both unjudged). The
1980
+ // bare \`gh\` stays EXACT: \`GH\` is a different name on a POSIX filesystem,
1981
+ // and on win32 it is a disclosed miss locked in the suite.
1982
+ return base.length === 6 && base.toLowerCase() === 'gh.exe';
1983
+ }
1984
+
1985
+ // ─── Transparent wrapper programs (mmnto-ai/totem#2856 § B) ─────────────
1986
+ // A CLOSED table of words that RUN their operand as the command, with the
1987
+ // option grammar needed to find that operand:
1988
+ // operand — options that take a SEPARATE next token (skip the option AND
1989
+ // that token);
1990
+ // positional — how many non-option words the program itself consumes before
1991
+ // the command (only \`timeout\`'s duration);
1992
+ // terminator — whether a \`--\` ends its options;
1993
+ // describe — options that make the word DESCRIBE its operand instead of
1994
+ // executing it (\`command -v\`, \`sudo -l\`): the segment ends
1995
+ // with NO projection, because nothing is executed;
1996
+ // flags — when present, the ONLY options the word HAS: any other
1997
+ // \`-\` token is not an option of it, so the shell runs no
1998
+ // command and there is nothing to project (bash's \`time\`
1999
+ // reserved word answers \`-f: command not found\`);
2000
+ // evaluates — the builtin whose operand is a STRING to re-tokenize;
2001
+ // evaluatesOperand
2002
+ // — the OPTIONS whose own operand is a command STRING: the
2003
+ // operand is split into words and those words TAKE THE
2004
+ // OPTION'S PLACE, so the strip reads on from them
2005
+ // (\`env -S 'gh pr merge 5'\`).
2006
+ // Every other \`-\` token is skipped as a flag of the wrapper, so a long option
2007
+ // with an ATTACHED value (\`--user=x\`, \`--kill-after=5\`, \`--adjustment=10\`)
2008
+ // needs no entry and a bare \`-10\` reads as \`nice\`'s adjustment. A long option
2009
+ // that takes a SEPARATE operand does need one, beside its short spelling, or
2010
+ // the operand itself reads as the command (\`sudo --user root gh …\` read
2011
+ // \`root\` as the program — round-5 leg, F2). After a wrapper is consumed the
2012
+ // strip loops, so the assignment prefixes of \`env NAME=v gh …\` and a wrapper
2013
+ // wrapping a wrapper both resolve.
2014
+ //
2015
+ // The table is CLOSED on purpose (ADR-082 A1, Tenet 19): a program that is not
2016
+ // on it IS the command, because this walk cannot know which of an arbitrary
2017
+ // program's operands is a command — \`npx\` runs a package, \`xargs\` builds its
2018
+ // own argv. Widening it is a later PR with its own rows, never a guess here.
2019
+ const TRANSPARENT_WRAPPERS = {
2020
+ sudo: {
2021
+ // Every sudo option that takes an argument, per sudo(8): \`-a type\`,
2022
+ // \`-C num\`, \`-c class\`, \`-D directory\`, \`-g group\`, \`-h host\`,
2023
+ // \`-p prompt\`, \`-R directory\`, \`-r role\`, \`-t type\`, \`-T timeout\`,
2024
+ // \`-u user\`, \`-U user\`, each with its long spelling. \`-R\`, \`-a\` and
2025
+ // \`-c\` were missing (Greptile P1 on mmnto-ai/totem#2894 named \`-R\`): the
2026
+ // generic path dropped the option alone and left its operand standing at
2027
+ // command position, so \`sudo -R /chroot gh pr merge 5\` ran unjudged.
2028
+ // \`--preserve-env=list\` is long-only with an attached operand, so the
2029
+ // generic drop already reads it right.
2030
+ operand: [
2031
+ '-a',
2032
+ '-c',
2033
+ '-u',
2034
+ '-g',
2035
+ '-p',
2036
+ '-C',
2037
+ '-D',
2038
+ '-h',
2039
+ '-R',
2040
+ '-r',
2041
+ '-t',
2042
+ '-T',
2043
+ '-U',
2044
+ '--auth-type',
2045
+ '--login-class',
2046
+ '--user',
2047
+ '--group',
2048
+ '--prompt',
2049
+ '--chdir',
2050
+ '--chroot',
2051
+ '--host',
2052
+ '--role',
2053
+ '--type',
2054
+ '--other-user',
2055
+ '--command-timeout',
2056
+ ],
2057
+ positional: 0,
2058
+ terminator: true,
2059
+ // sudo's DESCRIBE-only options: \`-l\`/\`--list\` prints what the user may
2060
+ // run, \`-v\`/\`--validate\` refreshes the timestamp, \`-V\`/\`--version\`
2061
+ // prints the version, \`-K\`/\`--remove-timestamp\` clears the credentials
2062
+ // and may not carry a command. None of them executes the operand, so
2063
+ // \`sudo -l gh pr merge 5\` merges nothing — projecting there was a FALSE
2064
+ // DENY (round-5 leg, F1).
2065
+ describe: ['-l', '--list', '-v', '--validate', '-V', '--version', '-K', '--remove-timestamp'],
2066
+ },
2067
+ env: {
2068
+ operand: ['-u', '-C', '--unset', '--chdir'],
2069
+ positional: 0,
2070
+ terminator: false,
2071
+ // \`-S\` / \`--split-string\` does NOT consume its operand: env splits that
2072
+ // string into words and PREPENDS them to the arguments that follow, then
2073
+ // runs the first word as the command. Measured on coreutils 8.32 with a
2074
+ // stub \`gh\`, every one of \`env -S 'gh pr merge 5'\`,
2075
+ // \`env -S "gh pr merge" 5\`, \`env -S gh pr merge 5\`,
2076
+ // \`env --split-string gh pr merge 5\`, \`env --split-string='gh pr merge 5'\`,
2077
+ // \`env -u X -S 'gh pr merge 5'\` and \`env -S 'A=1 gh pr merge 5'\` RUNS
2078
+ // \`gh pr merge 5\`. Consuming the operand with the option made all seven a
2079
+ // MISS — consistency in the miss direction, which is a bypass under STRICT
2080
+ // (fold 3, on the round-6 fold's own measurement). So the words take the
2081
+ // option's place and the strip reads on from them, the way \`eval\`'s
2082
+ // operand is re-read. The ATTACHED SHORT spellings join them (round-7
2083
+ // leg, H5): \`env -Sgh pr merge 5\` and \`env -S'gh pr merge 5'\` both
2084
+ // arrive as the one token \`-Sgh pr merge 5\` and run the merge too. An
2085
+ // \`=\` is NOT a separator for a short option, so \`env -S=x\` reads its
2086
+ // operand as \`=x\` — which is what env does with it.
2087
+ evaluatesOperand: ['-S', '--split-string'],
2088
+ },
2089
+ timeout: { operand: ['-k', '-s', '--kill-after', '--signal'], positional: 1, terminator: true },
2090
+ nice: { operand: ['-n', '--adjustment'], positional: 0, terminator: false },
2091
+ nohup: { operand: [], positional: 0, terminator: false },
2092
+ command: { operand: [], positional: 0, terminator: false, describe: ['-v', '-V'] },
2093
+ exec: { operand: ['-a'], positional: 0, terminator: false },
2094
+ // \`time\` here is BASH'S RESERVED WORD, not \`/usr/bin/time\`: its grammar is
2095
+ // \`time [-p] [--] pipeline\` — no option of it takes an operand, and any
2096
+ // other \`-\` token is not an option at all (bash runs \`-f\` as a command and
2097
+ // answers "command not found", merging nothing). \`time -f x gh pr merge 5\`
2098
+ // was read with GNU time's option grammar and projected a merge the shell
2099
+ // never runs — a false deny (round-5 leg, F3). The PROGRAM \`/usr/bin/time\`
2100
+ // is a path-spelled wrapper, which this closed table does not carry.
2101
+ time: { operand: [], positional: 0, terminator: true, flags: ['-p'] },
2102
+ eval: { operand: [], positional: 0, terminator: false, evaluates: true },
2103
+ };
2104
+
2105
+ // A REDIRECTION is not the command — and it is not an ARGUMENT either
2106
+ // (mmnto-ai/totem#2856 § C, widened by the round-5 leg's F5 and F12). The
2107
+ // shell applies it wherever it stands and runs the rest, so
2108
+ // \`> out.txt gh pr merge 5\` merges, \`gh > out.txt pr merge 5\` merges, and
2109
+ // \`gh pr merge 5 > out.txt\` merges PR 5 — while reading it at the segment's
2110
+ // FRONT only left \`>\` riding into argv as the merge's target, where the
2111
+ // engine denied a pull request on branch "\`>\`" with a reason no one wrote.
2112
+ // So: ONE strip over the WHOLE segment, ahead of every other strip and of the
2113
+ // anchor test.
2114
+ //
2115
+ // An operator ALONE (\`>\`, \`>>\`, \`<\`, \`<>\`, \`2>\`, \`<<<\`) takes the next
2116
+ // token — the file — with it; a FUSED form (\`>out.txt\`, \`2>/dev/null\`,
2117
+ // \`<<<bar\`, \`2<>file\`) is one token and drops alone. A \`<<EOF\` head is a
2118
+ // fused form too and drops harmlessly: the scanner blanked its BODY long
2119
+ // before this, so nothing of the heredoc is left to decide here.
2120
+ //
2121
+ // Residue, disclosed and unreachable rather than claimed: an operator carrying
2122
+ // \`|\` or \`&\` (\`>|\`, \`2>&1\`, \`>& file\`, \`<& 3\`, \`exec 3>&1 …\`) never
2123
+ // arrives as ONE token, because those two characters are the tokenizer's own
2124
+ // segment separators and end the token first. ALL FIVE of those run the merge
2125
+ // — measured with a stub on bash 5.3, each applies its redirection and then
2126
+ // runs \`gh pr merge 5\` (the \`<& 3\` form once that descriptor is open) — so
2127
+ // every one of them is a fail-open miss, not text the shell ignores (round-6
2128
+ // leg, G2). \`&>\` splits the same way but leaves a readable \`>\` at the front
2129
+ // of the next segment, so that one IS read.
2130
+ //
2131
+ // WHAT MAKES A REDIRECTION REAL IS THE QUOTING OF THE OPERATOR, not of the
2132
+ // word it sits in (round-7 leg, H1/H2; the round-6 rule this replaces read
2133
+ // "any part of which came from inside quotes or from an escape", which is not
2134
+ // the shell's). Bash decides on the operator characters alone: quote the
2135
+ // FILENAME and the redirection still happens — \`>"out.txt" gh pr merge 5\`
2136
+ // truncates out.txt and merges PR 5 — while quoting the OPERATOR makes the
2137
+ // whole word an argument: \`gh pr merge --squash ">"out.txt\` passes the string
2138
+ // \`>out.txt\` to gh and redirects nothing. So the walk records, per token, the
2139
+ // INDEX of its first character that came from inside quotes or from a
2140
+ // backslash escape (\`-1\` when none), and a token is stripped only when an
2141
+ // operator prefix lies ENTIRELY BEFORE that index — the LONGEST prefix that
2142
+ // does, which is not always the longest the patterns match (the bounded rule
2143
+ // below). Under the round-6 rule every one of \`>"out.txt"\`, \`2>"err.log"\`,
2144
+ // \`<<<'bar'\` and \`>"$FILE"\` rode into argv as data — a merge judged on a
2145
+ // target nobody wrote, or (trailing) a branch named \`>merge.log\`. The rows
2146
+ // that made the round-6 rule necessary are unchanged by this one, because
2147
+ // their operator character is itself quoted or escaped: \`-b "<br>" 5\` and
2148
+ // \`-b \\<br\\> 5\` both have their first literal character at index 0.
2149
+ //
2150
+ // The FILENAME may hold ANYTHING, whitespace included (round-8 leg, J1):
2151
+ // \`>"out file.txt" gh pr merge 5\` is one token here, and while the fused
2152
+ // pattern's filename class excluded whitespace that token matched neither
2153
+ // pattern — so it stood in front of \`gh\`, broke the anchor, and a real
2154
+ // redirection with a real merge behind it went unjudged; trailing, the same
2155
+ // word rode into argv and the engine read a branch named \`>merge log.txt\`.
2156
+ // The operator prefix is what decides, so the class is \`[\\s\\S]+\` and the
2157
+ // first-literal index above is what still keeps a quoted OPERATOR out.
2158
+ //
2159
+ // The operator prefix is BOUNDED by that first literal index, and the bound
2160
+ // is what picks the operator (fold 6, the round-8 corpus partitioned by
2161
+ // provenance): bash extends an operator token over UNQUOTED characters only,
2162
+ // so \`>">"out.txt gh pr merge 5\` is the operator \`>\` with the filename
2163
+ // \`>out.txt\` — a real redirection with a real merge behind it. The greedy
2164
+ // \`[<>]{1,2}\` reads \`>>\` there, a prefix that ends PAST the index, and under
2165
+ // the index rule alone the word stayed, stood in front of \`gh\`, broke the
2166
+ // anchor and nothing was judged: a bypass under STRICT, and the same shape in
2167
+ // \`<<"<"bar\` and \`2<">"out.txt\`. So the prefix taken is the LONGEST one that
2168
+ // is itself an operator and ends at or before the index (\`>>\` → \`>\`,
2169
+ // \`<<<\` → \`<<\` → \`<\`, \`2<>\` → \`2<\`), which is the shell's own rule; a word
2170
+ // whose first literal character is at index 0 has no such prefix and stays
2171
+ // data (\`">"out.txt\` is the ARGUMENT \`>out.txt\`).
2172
+ //
2173
+ // Residue of both rules, disclosed and locked as rows (round-8 leg, J5,
2174
+ // re-measured at fold 6 over the 3 768-case quoting corpus, where the two
2175
+ // fail-open families read 0 and every divergence left is one of three kinds).
2176
+ // FIRST, a quote pair that opens at index 0 of the word — \`"">out.txt\`,
2177
+ // \`">">out.txt\`, \`"2">file\` — leaves no operator prefix before it, so the
2178
+ // word is data here (\`>out.txt\`, \`>>out.txt\`, \`2>file\`) while bash passes
2179
+ // the empty string, \`>\` or \`2\` as an ARGUMENT and applies the redirection
2180
+ // that follows it. SECOND, a \`$VAR\` in a kept filename is not expanded here,
2181
+ // so the word this walk names is \`>$FILE\` where bash wrote \`>varfile.txt\`.
2182
+ // Both of those are confined to the argv's TEXT, and each word they keep
2183
+ // names a target the engine denies. THIRD, where the quote sits INSIDE the
2184
+ // operator prefix the bounded rule strips the word, and bash sometimes runs
2185
+ // nothing at all behind it: an empty filename (\`>"">out.txt\`) or a file that
2186
+ // does not exist (\`<"<"<out.txt\`, \`2<">"out.txt\`) fails the redirection, so
2187
+ // the walk judges a merge the shell never ran — a bogus deny, not a bypass.
2188
+ // All three are the deny direction, and the rows assert the projection.
2189
+ const REDIRECTION_ALONE = /^[0-9]*(?:<<<|[<>]{1,2})$/;
2190
+ const REDIRECTION_FUSED = /^([0-9]*(?:<<<|[<>]{1,2}))[\\s\\S]+$/;
2191
+
2192
+ // The length of the operator prefix BOUNDED by \`at\`, the token's first
2193
+ // literal index: the longest prefix of the greedy match that is itself a
2194
+ // redirection operator and ends at or before \`at\`, or \`-1\` when none is
2195
+ // (\`">"out.txt\`, first literal at 0). A token with no literal character at
2196
+ // all (\`at === -1\`) keeps the greedy match, as it always has.
2197
+ function boundedOperatorLength(prefix, at) {
2198
+ if (at === -1) return prefix.length;
2199
+ for (let n = prefix.length < at ? prefix.length : at; n > 0; n--) {
2200
+ if (REDIRECTION_ALONE.test(prefix.slice(0, n))) return n;
2201
+ }
2202
+ return -1;
2203
+ }
2204
+
1804
2205
  /**
1805
2206
  * The argv after EVERY \`gh pr merge\` at command position in the command —
1806
2207
  * one array per merge, in command order — or an empty array when there is
1807
2208
  * none. A compound command that merges twice yields two, and the wrapper
1808
2209
  * judges each (mmnto-ai/totem#2844 round 1).
1809
2210
  */
1810
- function ghPrMergeArgvs(rawCommand, powershell) {
2211
+ function ghPrMergeArgvs(rawCommand, powershell, depth) {
2212
+ // \`eval\` re-enters this function ONCE (§ B); every other caller is depth 0.
2213
+ const level = typeof depth === 'number' ? depth : 0;
1811
2214
  const command = blankHeredocBodies(rawCommand, powershell === true);
2215
+ // Each segment's tokens, and beside them ONE NUMBER PER TOKEN: the INDEX,
2216
+ // within the token, of the first character that came from inside quotes or
2217
+ // from a backslash escape — \`-1\` when the whole word is bare. The
2218
+ // redirection strip below is its only reader, and it needs the index rather
2219
+ // than a yes/no because the shell decides a redirection on the QUOTING OF
2220
+ // THE OPERATOR: \`>"out.txt"\` redirects (first literal character at 1, past
2221
+ // the \`>\`) while \`">"out.txt\` is the argument \`>out.txt\` (first literal
2222
+ // character at 0, on the operator itself). A yes/no answered both with
2223
+ // "data" and let a real redirection ride into argv (round-7 leg, H1/H2); it
2224
+ // answered \`-b "<br>" 5\` correctly, and so does the index (round-6 leg,
2225
+ // G1). The numbers ride in a PARALLEL array so every reader of a token stays
2226
+ // a reader of a plain string. It annotates the walk's output; it changes no
2227
+ // grammar.
1812
2228
  const segments = [];
2229
+ const literalAts = [];
1813
2230
  let current = [];
2231
+ let currentLiteralAt = [];
1814
2232
  let token = '';
1815
2233
  let hasToken = false;
2234
+ let tokenLiteralAt = -1;
1816
2235
  let i = 0;
2236
+ /** The next character appended to this token is literal: mark the first. */
2237
+ const markLiteral = () => {
2238
+ if (tokenLiteralAt === -1) tokenLiteralAt = token.length;
2239
+ };
1817
2240
  const endToken = () => {
1818
2241
  if (hasToken) {
1819
2242
  current.push(token);
2243
+ currentLiteralAt.push(tokenLiteralAt);
1820
2244
  token = '';
1821
2245
  hasToken = false;
2246
+ tokenLiteralAt = -1;
1822
2247
  }
1823
2248
  };
1824
2249
  const endSegment = () => {
1825
2250
  endToken();
1826
2251
  segments.push(current);
2252
+ literalAts.push(currentLiteralAt);
1827
2253
  current = [];
2254
+ currentLiteralAt = [];
1828
2255
  };
1829
2256
  while (i < command.length) {
1830
2257
  const ch = command[i];
1831
2258
  if (ch === "'") {
1832
2259
  hasToken = true;
2260
+ markLiteral();
1833
2261
  i++;
1834
2262
  while (i < command.length && command[i] !== "'") {
1835
2263
  token += command[i];
@@ -1840,6 +2268,7 @@ function ghPrMergeArgvs(rawCommand, powershell) {
1840
2268
  }
1841
2269
  if (ch === '"') {
1842
2270
  hasToken = true;
2271
+ markLiteral();
1843
2272
  i++;
1844
2273
  while (i < command.length && command[i] !== '"') {
1845
2274
  if (command[i] === '\\\\' && i + 1 < command.length) {
@@ -1871,6 +2300,58 @@ function ghPrMergeArgvs(rawCommand, powershell) {
1871
2300
  i++;
1872
2301
  continue;
1873
2302
  }
2303
+ // A BACKTICK command substitution opens a segment of its own
2304
+ // (mmnto-ai/totem#2856 § C): its operand is a command the shell runs, so a
2305
+ // merge inside one has to be judged, exactly as a merge inside \`$( … )\`
2306
+ // is. The backtick is kept as a TOKEN, the way \`$(\` leaves its \`$\` behind:
2307
+ // without it a merge whose TARGET is a backtick substitution would lose
2308
+ // that target and fall back to the current branch — judging a pull request
2309
+ // the command never named. One inside a heredoc body is already blanked,
2310
+ // and that is correct — a body is data. One inside DOUBLE quotes never
2311
+ // reaches here either, because the quote arm above swallows the whole
2312
+ // string as one token — and that one is NOT data: bash executes a backtick
2313
+ // pair and a \`$( … )\` inside double quotes, so \`echo "\`gh pr merge 5\`"\`
2314
+ // merges PR 5 unjudged. A disclosed fail-open, filed as
2315
+ // mmnto-ai/totem#2893 (round-5 leg, F4).
2316
+ // POWERSHELL'S LINE CONTINUATION is a trailing BACKTICK — the twin of the
2317
+ // backslash-newline arm below AT A WORD BOUNDARY, and the reason this one
2318
+ // has to be read first: in ps mode the backtick and the newline after it
2319
+ // are consumed and the next line's words continue the command, so
2320
+ // \`gh pr merge <backtick><LF>5\` is \`gh pr merge 5\`. Read as the segment
2321
+ // separator it is in BASH, that command projected the backtick itself as
2322
+ // the merge's target (\`unresolvedTarget\`, a deny on a target nobody wrote
2323
+ // under strict) while the real target sat in the next segment and merged
2324
+ // unjudged (round-6 leg, G3). Bash keeps the separator: there a backtick
2325
+ // opens a command substitution, whatever follows it.
2326
+ //
2327
+ // INSIDE A WORD the two shells part company (round-7 leg, H4). Bash's
2328
+ // backslash-newline really joins the halves — \`me\\<LF>rge\` is \`merge\`
2329
+ // — while PowerShell's backtick is its ESCAPE character and
2330
+ // \`merg<backtick><LF>e\` is the single argument \`merg<LF>e\`, which is not
2331
+ // \`merge\` and runs nothing. So the join applies only where no token is
2332
+ // open; inside one, the escaped newline lands IN the token, the anchor
2333
+ // fails to match, and nothing is projected — which is what PowerShell
2334
+ // does. Joining there projected a merge the shell never runs.
2335
+ if (
2336
+ powershell === true &&
2337
+ ch === '\`' &&
2338
+ (command[i + 1] === '\\n' || (command[i + 1] === '\\r' && command[i + 2] === '\\n'))
2339
+ ) {
2340
+ if (hasToken) {
2341
+ markLiteral();
2342
+ token += '\\n';
2343
+ }
2344
+ i += command[i + 1] === '\\r' ? 3 : 2;
2345
+ continue;
2346
+ }
2347
+ if (ch === '\`') {
2348
+ endToken();
2349
+ current.push('\`');
2350
+ currentLiteralAt.push(-1);
2351
+ endSegment();
2352
+ i++;
2353
+ continue;
2354
+ }
1874
2355
  if (
1875
2356
  ch === ';' ||
1876
2357
  ch === '&' ||
@@ -1893,6 +2374,7 @@ function ghPrMergeArgvs(rawCommand, powershell) {
1893
2374
  continue;
1894
2375
  }
1895
2376
  if (ch === '\\\\' && i + 1 < command.length) {
2377
+ markLiteral();
1896
2378
  token += command[i + 1];
1897
2379
  hasToken = true;
1898
2380
  i += 2;
@@ -1905,24 +2387,201 @@ function ghPrMergeArgvs(rawCommand, powershell) {
1905
2387
  endSegment();
1906
2388
 
1907
2389
  const found = [];
1908
- for (const segment of segments) {
1909
- let tokens = segment;
1910
- while (
1911
- tokens.length > 0 &&
1912
- (COMMAND_POSITION_WORDS.indexOf(tokens[0]) !== -1 || isAssignmentPrefix(tokens[0]))
1913
- ) {
2390
+ for (let s = 0; s < segments.length; s++) {
2391
+ const segment = segments[s];
2392
+ const literalAt = literalAts[s];
2393
+ // FIRST, over the WHOLE segment: drop every redirection (the two patterns
2394
+ // above). It runs before the strip below and before the anchor test, so a
2395
+ // redirection in front of the command does not hide it, one in the middle
2396
+ // does not break the anchor, and a trailing one never rides into argv.
2397
+ // The OPERATOR's own quoting decides, as it does in the shell: strip only
2398
+ // when an operator prefix lies entirely before the token's first literal
2399
+ // character (round-7 leg, H1/H2), and the prefix taken is the longest one
2400
+ // that does — \`boundedOperatorLength\` above, fold 6.
2401
+ let tokens = [];
2402
+ for (let r = 0; r < segment.length; r++) {
2403
+ const word = segment[r];
2404
+ const at = literalAt[r];
2405
+ if (REDIRECTION_ALONE.test(word) && (at === -1 || word.length <= at)) {
2406
+ // The operator and the file it names, both gone — however that file
2407
+ // is spelled: \`> "out.txt"\` is as real a redirection as \`> out.txt\`.
2408
+ r += 1;
2409
+ continue;
2410
+ }
2411
+ const fused = REDIRECTION_FUSED.exec(word);
2412
+ if (fused !== null && boundedOperatorLength(fused[1], at) !== -1) continue;
2413
+ tokens.push(word);
2414
+ }
2415
+ // Then strip everything at the segment's front that is NOT the command, in
2416
+ // any run: a transparent wrapper with its options (the table above), a
2417
+ // command-position word, an assignment prefix. The loop re-runs after each
2418
+ // one, so \`env -u X A=1 gh …\` and \`sudo -u root timeout 30 gh …\` both
2419
+ // resolve to the same anchor test.
2420
+ let stripping = true;
2421
+ while (stripping && tokens.length > 0) {
2422
+ const head = tokens[0];
2423
+ const wrapper = Object.prototype.hasOwnProperty.call(TRANSPARENT_WRAPPERS, head)
2424
+ ? TRANSPARENT_WRAPPERS[head]
2425
+ : null;
2426
+ if (wrapper === null) {
2427
+ if (COMMAND_POSITION_WORDS.indexOf(head) !== -1 || isAssignmentPrefix(head)) {
2428
+ tokens = tokens.slice(1);
2429
+ continue;
2430
+ }
2431
+ break;
2432
+ }
1914
2433
  tokens = tokens.slice(1);
2434
+ // \`eval\` hands the shell a STRING: join what is left with one space and
2435
+ // re-tokenize it ONCE. That inner projection IS this segment's, and the
2436
+ // depth bound keeps \`eval "eval \\"gh pr merge 5\\""\` a disclosed miss.
2437
+ if (wrapper.evaluates === true) {
2438
+ if (level < 1 && tokens.length > 0) {
2439
+ const inner = ghPrMergeArgvs(tokens.join(' '), powershell, level + 1);
2440
+ for (let k = 0; k < inner.length; k++) {
2441
+ found.push(inner[k]);
2442
+ }
2443
+ }
2444
+ tokens = [];
2445
+ break;
2446
+ }
2447
+ while (tokens.length > 0 && tokens[0].charAt(0) === '-' && tokens[0].length > 1) {
2448
+ const opt = tokens[0];
2449
+ if (opt === '--') {
2450
+ tokens = tokens.slice(1);
2451
+ if (wrapper.terminator === true) break;
2452
+ continue;
2453
+ }
2454
+ if (wrapper.describe !== undefined && wrapper.describe.indexOf(opt) !== -1) {
2455
+ // \`command -v gh …\` prints a path and \`sudo -l gh …\` prints a
2456
+ // policy line; each runs nothing, so there is nothing to judge and
2457
+ // nothing to project.
2458
+ tokens = [];
2459
+ stripping = false;
2460
+ break;
2461
+ }
2462
+ if (wrapper.flags !== undefined && wrapper.flags.indexOf(opt) === -1) {
2463
+ // A \`-\` token that is not one of this word's OWN options: the shell
2464
+ // has no command to run here (\`time -f x gh pr merge 5\` makes bash
2465
+ // try to run \`-f\`), so the segment ends with no projection.
2466
+ tokens = [];
2467
+ stripping = false;
2468
+ break;
2469
+ }
2470
+ if (wrapper.evaluatesOperand !== undefined) {
2471
+ // Where the operand is: a LONG option carries an attached one after
2472
+ // an \`=\` (\`--split-string='gh pr merge 5'\`), a SHORT one carries it
2473
+ // with no separator at all (\`-Sgh pr merge 5\`, and
2474
+ // \`-S'gh pr merge 5'\`, which the quote arm joins into that same
2475
+ // token), and otherwise it is the NEXT token. An \`=\` is not a
2476
+ // separator for a short option — \`env -S=x\` hands env the operand
2477
+ // \`=x\` — so the split is long-only (round-7 leg, H5).
2478
+ //
2479
+ // …and when an \`=\` FOLLOWS the short option's letter this arm does
2480
+ // not apply at all: the token falls through to be dropped as a flag
2481
+ // of the word, which is the route that reads both spellings right
2482
+ // (round-8 leg, J2). \`env -S=X gh pr merge 5\` is env splitting the
2483
+ // operand \`=X\` into one word, an assignment with an EMPTY NAME, so
2484
+ // the command is \`gh pr merge 5\` and the merge RUNS (measured,
2485
+ // coreutils 8.32); reading \`=X\` as the operand here put it at the
2486
+ // front of the strip, where it is neither an assignment this walk
2487
+ // accepts nor a command, and the merge behind it went unjudged.
2488
+ // \`env -S='gh pr merge 5'\` is the same rule the other way: env's
2489
+ // words are \`=gh\`, \`pr\`, \`merge\`, \`5\`, the command is coreutils
2490
+ // \`pr\` and no merge runs — and none is projected.
2491
+ let name = opt;
2492
+ let attached = null;
2493
+ if (opt.charAt(1) === '-') {
2494
+ const eq = opt.indexOf('=');
2495
+ if (eq !== -1) {
2496
+ name = opt.slice(0, eq);
2497
+ attached = opt.slice(eq + 1);
2498
+ }
2499
+ } else if (opt.charAt(2) !== '=') {
2500
+ name = opt.slice(0, 2);
2501
+ if (opt.length > 2) attached = opt.slice(2);
2502
+ }
2503
+ if (wrapper.evaluatesOperand.indexOf(name) !== -1) {
2504
+ // The operand is a COMMAND STRING, not a value to skip past: env
2505
+ // splits it into words and prepends them to what follows. Split
2506
+ // on whitespace — env's own rule — put the words where the option
2507
+ // stood, and let the strip read on, so the assignment strip runs
2508
+ // for \`env -S 'A=1 gh pr merge 5'\` and the anchor sees \`gh\`.
2509
+ const operandText = attached === null ? (tokens.length > 1 ? tokens[1] : '') : attached;
2510
+ const rest = tokens.slice(attached === null ? 2 : 1);
2511
+ const words = operandText.split(/\\s+/);
2512
+ tokens = [];
2513
+ for (let w = 0; w < words.length; w++) {
2514
+ if (words[w] !== '') tokens.push(words[w]);
2515
+ }
2516
+ tokens = tokens.concat(rest);
2517
+ continue;
2518
+ }
2519
+ }
2520
+ if (wrapper.operand.indexOf(opt) !== -1) {
2521
+ tokens = tokens.slice(2);
2522
+ continue;
2523
+ }
2524
+ tokens = tokens.slice(1);
2525
+ }
2526
+ for (let p = 0; stripping && p < wrapper.positional && tokens.length > 0; p++) {
2527
+ tokens = tokens.slice(1);
2528
+ }
1915
2529
  }
1916
- if (tokens.length >= 3 && tokens[0] === 'gh' && tokens[1] === 'pr' && tokens[2] === 'merge') {
2530
+ if (
2531
+ tokens.length >= 3 &&
2532
+ isGhExecutable(tokens[0]) &&
2533
+ tokens[1] === 'pr' &&
2534
+ tokens[2] === 'merge'
2535
+ ) {
1917
2536
  found.push(tokens.slice(3));
1918
2537
  }
1919
2538
  }
1920
2539
  return found;
1921
2540
  }
1922
2541
 
1923
- /** Run git read-only and return trimmed stdout, or '' when it did not answer. */
2542
+ // ─── The one budget for the whole run (mmnto-ai/totem#2856 § D) ─────────
2543
+ // The instant this hook must be finished by. It is set as the FIRST thing the
2544
+ // entry does — before stdin is read and before any projection — so that EVERY
2545
+ // spawn this process makes, the projection's git reads included, is bounded by
2546
+ // it. Before this PR the deadline came into being only at the evaluation loop,
2547
+ // and the projection ran up to three 10-second git reads per merge ahead of
2548
+ // it: a hung git on a multi-merge envelope reached the HOST's hook timeout,
2549
+ // where a killed hook's exit code is never applied — a fail-OPEN on a gate
2550
+ // whose posture is fail-closed (round 3, F3).
2551
+ //
2552
+ // Zero until the entry sets it, which reads as "already spent": an exported
2553
+ // \`projectMergeReady\` (the seam below) therefore does no git reads at all.
2554
+ let deadline = 0;
2555
+
2556
+ /** The default budget, and the ceiling \`--budget-ms\` is clamped to. */
2557
+ const DEFAULT_BUDGET_MS = 30000;
2558
+
2559
+ /**
2560
+ * The budget a \`--budget-ms <n>\` argument asks for, clamped to
2561
+ * [1000, 30000]. The clamp is SILENT and one-directional by design: the
2562
+ * argument exists so a test can shorten the window, and a malformed or
2563
+ * oversized value must never WIDEN it (Tenet 4 keeps the safe direction). The
2564
+ * value it settles on is echoed in the budget line when the arm fires.
2565
+ */
2566
+ function clampBudgetMs(raw) {
2567
+ const n = parseInt(String(raw), 10);
2568
+ if (!isFinite(n) || n > DEFAULT_BUDGET_MS) return DEFAULT_BUDGET_MS;
2569
+ if (n < 1000) return 1000;
2570
+ return n;
2571
+ }
2572
+
2573
+ /**
2574
+ * Run git read-only and return trimmed stdout, or '' when it did not answer.
2575
+ * Bounded by what is LEFT of the budget (never more than 10 s, never less than
2576
+ * a 250 ms floor), and it does not spawn at all once the budget is spent.
2577
+ */
1924
2578
  function gitRead(args) {
1925
- const res = spawnSync('git', args, { encoding: 'utf-8', timeout: 10000 });
2579
+ const remaining = deadline - Date.now();
2580
+ if (remaining <= 0) return '';
2581
+ const res = spawnSync('git', args, {
2582
+ encoding: 'utf-8',
2583
+ timeout: Math.max(250, Math.min(10000, remaining)),
2584
+ });
1926
2585
  if (res.error || typeof res.status !== 'number' || res.status !== 0) return '';
1927
2586
  return (res.stdout || '').trim();
1928
2587
  }
@@ -2017,6 +2676,99 @@ function projectMergeReady(argv) {
2017
2676
  return out;
2018
2677
  }
2019
2678
 
2679
+ // ─── The export seam (mmnto-ai/totem#2856 § E) ─────────────────────────
2680
+ // Everything above is pure and side-effect free; everything below is the
2681
+ // hook's ENTRY — it reads argv and stdin and exits the process. A \`require\`
2682
+ // of this file (the suite's in-process driver for the strip table, the
2683
+ // executable test, the budget clamp and the scanner-parity lock) must run
2684
+ // NEITHER, so the entry runs only when this file is the main module. The
2685
+ // module-scope \`return\` is CommonJS's own early exit, and it sits AFTER every
2686
+ // module-level binding above so the exported functions are all initialized.
2687
+ //
2688
+ // Nothing else changes when the file runs as a hook: \`require.main\` is this
2689
+ // module, the \`return\` is not taken, and the entry below is the same code it
2690
+ // has always been. One consequence worth naming: an exported
2691
+ // \`projectMergeReady\` runs with no budget set (see \`deadline\`), so it does no
2692
+ // git reads — the projection's shape is what the seam is for, the git facts
2693
+ // are the entry's.
2694
+ if (require.main !== module) {
2695
+ module.exports = {
2696
+ blankHeredocBodies: blankHeredocBodies,
2697
+ clampBudgetMs: clampBudgetMs,
2698
+ findHeredocSpans: findHeredocSpans,
2699
+ ghPrMergeArgvs: ghPrMergeArgvs,
2700
+ isGhExecutable: isGhExecutable,
2701
+ projectMergeReady: projectMergeReady,
2702
+ };
2703
+ return;
2704
+ }
2705
+
2706
+ // ─── Parse baked args (--event <name>, optional --pilot / --strict) ─────
2707
+ // The tier is read ONLY from argv (baked into the installed command at
2708
+ // install time). There is NO env-var override: env sourcing would be a
2709
+ // fail-open (any shell with TOTEM_GATE_TIER=pilot could silently downgrade
2710
+ // enforcement). Default (no flag) = strict, so a default install is
2711
+ // environment-immune; --pilot is an explicit install-time opt-in.
2712
+ //
2713
+ // \`--budget-ms <n>\` is the one argument the install line never writes: it
2714
+ // exists so a test can SHORTEN the run's budget, and it is clamped so it can
2715
+ // only ever shorten it (§ D). An env var was the alternative and was ruled
2716
+ // out for the same reason the tier is argv-only — any shell could set it.
2717
+ // BOTH spellings parse: \`--budget-ms 1500\` and \`--budget-ms=1500\`. The
2718
+ // attached form used to fall through as an unknown argument and silently left
2719
+ // the 30 000 ms default standing — a WIDENING on a caller that wrote the
2720
+ // argument to shorten the window (round-5 leg, F7). A repeated flag is
2721
+ // last-wins, and no spelling of it can ever exceed the default, because every
2722
+ // value goes through the same clamp.
2723
+ const argv = process.argv.slice(2);
2724
+ let event = '';
2725
+ let tier = 'strict';
2726
+ let budgetMs = DEFAULT_BUDGET_MS;
2727
+ for (let i = 0; i < argv.length; i++) {
2728
+ if (argv[i] === '--event') {
2729
+ event = argv[i + 1] || '';
2730
+ i++;
2731
+ } else if (argv[i] === '--pilot') {
2732
+ tier = 'pilot';
2733
+ } else if (argv[i] === '--strict') {
2734
+ tier = 'strict';
2735
+ } else if (argv[i] === '--budget-ms') {
2736
+ budgetMs = clampBudgetMs(argv[i + 1]);
2737
+ i++;
2738
+ } else if (argv[i].slice(0, 12) === '--budget-ms=') {
2739
+ budgetMs = clampBudgetMs(argv[i].slice(12));
2740
+ }
2741
+ }
2742
+
2743
+ // The FIRST thing the entry does after reading its own arguments: from here on
2744
+ // every spawn — the projection's git reads and the evaluation loop's gate
2745
+ // checks alike — is bounded by one deadline, so the wrapper always terminates
2746
+ // within the budget plus one 1 000 ms floor with its OWN exit code.
2747
+ deadline = Date.now() + budgetMs;
2748
+
2749
+ // …and the READ of the envelope is inside it too (round-5 leg, F6). The budget
2750
+ // used to start counting for everything the hook did AFTER the envelope had
2751
+ // arrived; arriving itself was unbounded. A host that writes the envelope and
2752
+ // holds the pipe open, or hands this hook a stdin that never ends, left it
2753
+ // waiting with no deadline of its own until the HOST's own timeout killed it —
2754
+ // and a killed hook's exit code is never applied, which is a fail-OPEN on a
2755
+ // gate whose posture is fail-closed. Exactly the class § D cured for the
2756
+ // projection's git reads, one step earlier in the run.
2757
+ //
2758
+ // The timer is cleared by the \`end\` handler below BEFORE anything is
2759
+ // evaluated, so a normal run — every run where stdin closes — never sees it.
2760
+ const stdinBudgetTimer = setTimeout(
2761
+ () => {
2762
+ process.stderr.write(
2763
+ '[totem gate-wrapper] the ' +
2764
+ budgetMs +
2765
+ ' ms budget was spent before the envelope arrived on stdin — blocking (fail-closed).\\n',
2766
+ );
2767
+ process.exit(2);
2768
+ },
2769
+ Math.max(0, deadline - Date.now()),
2770
+ );
2771
+
2020
2772
  // Read the PreToolUse stdin envelope.
2021
2773
  let stdin = '';
2022
2774
  process.stdin.setEncoding('utf-8');
@@ -2024,6 +2776,7 @@ process.stdin.on('data', (chunk) => {
2024
2776
  stdin += chunk;
2025
2777
  });
2026
2778
  process.stdin.on('end', () => {
2779
+ clearTimeout(stdinBudgetTimer);
2027
2780
  let parsed;
2028
2781
  try {
2029
2782
  parsed = stdin ? JSON.parse(stdin) : {};
@@ -2254,21 +3007,32 @@ process.stdin.on('end', () => {
2254
3007
  // merge in the envelope gets its stderr line; exit 0 only once every payload
2255
3008
  // has allowed or warned.
2256
3009
  //
2257
- // ONE 30-second budget across every payload, not 30 seconds each (round 2,
2258
- // F5): the hook host kills a PreToolUse hook at its own default budget (60 s
2259
- // on both Claude Code and Gemini, the same figure the session-hook templates
2260
- // above cut their legs against) and a killed hook's exit code is never
2261
- // applied — a fail-OPEN on a gate whose posture is fail-closed. With the
2262
- // budget shared, the loop's wall time is bounded at 30 s plus the one-second
2263
- // floor each payload past the budget still gets (round 3, F2), and a merge
2264
- // that cannot be judged inside it lands in the fail-closed arm below (the
2265
- // spawn times out \`result.error\`) rather than in the host's kill. What the
2266
- // budget does NOT cover, disclosed (round 3, F3): the projection above runs
2267
- // up to three \`gitRead\`s per merge, each on its own 10 s timeout, before this
2268
- // deadline exists a hung git on a multi-merge envelope can still reach the
2269
- // host's budget through them.
2270
- const deadline = Date.now() + 30000;
3010
+ // ONE budget across every payload, not one per payload (round 2, F5): the
3011
+ // hook host kills a PreToolUse hook at its own default budget (60 s on both
3012
+ // Claude Code and Gemini, the same figure the session-hook templates above
3013
+ // cut their legs against) and a killed hook's exit code is never applied — a
3014
+ // fail-OPEN on a gate whose posture is fail-closed. The budget is set at the
3015
+ // ENTRY (see \`deadline\` above), so it now covers the projection's git reads
3016
+ // too (mmnto-ai/totem#2856 § D); before that it began here, and a hung git
3017
+ // ahead of it could run the hook into the host's kill through up to three
3018
+ // 10-second reads per merge (round 3, F3).
3019
+ //
3020
+ // Two arms keep the whole run inside it: a payload whose spawn would start
3021
+ // past the deadline gets the fail-closed line below INSTEAD of a spawn, and
3022
+ // a spawn that starts inside it still gets the one-second floor (round 3,
3023
+ // F2) and times out into the evaluation-failed arm. Either way the wrapper
3024
+ // exits with its OWN code, inside the budget plus one floor.
2271
3025
  for (let p = 0; p < payloads.length; p++) {
3026
+ if (Date.now() >= deadline) {
3027
+ process.stderr.write(
3028
+ '[totem gate-wrapper] the ' +
3029
+ budgetMs +
3030
+ ' ms budget was spent before gate "' +
3031
+ event +
3032
+ "\\" could be evaluated (the projection's git reads did not answer in time) — blocking (fail-closed).\\n",
3033
+ );
3034
+ process.exit(2);
3035
+ }
2272
3036
  const result = spawnSync(process.execPath, checkArgs, {
2273
3037
  encoding: 'utf-8',
2274
3038
  timeout: Math.max(1000, deadline - Date.now()),
@@ -2642,9 +3406,9 @@ End-of-session wrap-up. Post-Proposal-282 (ADR-106), journals + handoffs live in
2642
3406
  | \`totem-status\` | \`status-claude\` | \`status-gemini\` | \`status-kimi\` |
2643
3407
  | \`totem-playground\` | _(orphan stream — no native agent)_ | _(orphan stream)_ | _(orphan stream)_ |
2644
3408
 
2645
- This table is the BOOTSTRAP FALLBACK, not the roster: three of the roster's five vendor columns plus two archived repositories, and it rots when a seat is added — three of its four Kimi cells read "not seated" for weeks after those seats were seated (filled here; mmnto-ai/totem#2865). The core map it twins does not carry the Kimi seats yet (mmnto-ai/totem#2875), so with no \`TOTEM_SELF_AGENT\` declared and no \`config.json\` \`host_agents\` naming it, a Kimi seat resolves only where its seat dir exists. \`totem mail --derive-seat\` is the derivation; no cell here overrides it. Seat discovery is dir-derived (mmnto-ai/totem#2141): any \`.totem/orchestration/<agent-id>/\` directory registers that seat for this repo, UNIONED with the basename map above so roster siblings stay visible on fresh clones where the gitignored tree is partial (precedence: \`TOTEM_SELF_AGENT\` env > \`config.json\` \`host_agents\` > seat dirs ∪ basename map). Override hook: a \`host_agents: string[]\` field in \`.totem/orchestration/config.json\` still **replaces** the derived answer — but omitting a PRESENT seat dir attaches a loud warning naming the omitted seat (the dir is the registration; config-exclusion is not a decommission mechanism). The returned list of agent-ids is used by consumers (e.g., \`totem mail\`) to filter cross-repo handoffs — messages addressed to any agent-id in the list belong to this repo's session.
3409
+ This table is the BOOTSTRAP FALLBACK, not the roster: three of the roster's five vendor columns plus two archived repositories, and it rots when a seat is added — three of its four Kimi cells read "not seated" for weeks after those seats were seated (filled here; mmnto-ai/totem#2865). The core map it twins carries the four Kimi seats (mmnto-ai/totem#2875), and the table-to-map sync test holds the two as an equality, so a Kimi seat resolves on a fresh clone with no seat dir, no \`TOTEM_SELF_AGENT\` and no \`config.json\` \`host_agents\`. \`totem mail --derive-seat\` is the derivation; no cell here overrides it. Seat discovery is dir-derived (mmnto-ai/totem#2141): any \`.totem/orchestration/<agent-id>/\` directory registers that seat for this repo, UNIONED with the basename map above so roster siblings stay visible on fresh clones where the gitignored tree is partial (precedence: \`TOTEM_SELF_AGENT\` env > \`config.json\` \`host_agents\` > seat dirs ∪ basename map). Override hook: a \`host_agents: string[]\` field in \`.totem/orchestration/config.json\` still **replaces** the derived answer — but omitting a PRESENT seat dir attaches a loud warning naming the omitted seat (the dir is the registration; config-exclusion is not a decommission mechanism). The returned list of agent-ids is used by consumers (e.g., \`totem mail\`) to filter cross-repo handoffs — messages addressed to any agent-id in the list belong to this repo's session.
2646
3410
 
2647
- **Visiting case.** If your row's Claude-agent-id column is \`_(no Claude variant)_\` or \`_(orphan stream no native agent)_\`, you are visiting a repo that doesn't natively host your agent. Resolve the journal path to \`<repoRoot>/.totem/orchestration/<your-home-agent-id>/journal/\`, where \`<your-home-agent-id>\` is your own agent-id (e.g., a \`strategy-claude\` session always writes as \`strategy-claude\` regardless of which repo it's visiting; concretely, \`strategy-claude\` visiting \`totem-status\` writes to \`totem-status/.totem/orchestration/strategy-claude/journal/\`). The journal records the visiting agent's session state — the host repo doesn't need a native Claude agent to be a valid write target.
3411
+ **Visiting case.** If your own agent-id does not appear in your row — the cell for your vendor reads \`_(not seated)_\` or an orphan-stream value, or the table has no column for your vendor at all — you are visiting a repo that doesn't natively host your agent. Resolve the journal path to \`<repoRoot>/.totem/orchestration/<your-home-agent-id>/journal/\`, where \`<your-home-agent-id>\` is your own agent-id (e.g., a \`strategy-claude\` session always writes as \`strategy-claude\` regardless of which repo it's visiting; concretely, \`strategy-claude\` visiting \`totem-status\` — whose row seats \`status-claude\`, not \`strategy-claude\` — writes to \`totem-status/.totem/orchestration/strategy-claude/journal/\`). The journal records the visiting agent's session state — the host repo doesn't need a native agent of your vendor to be a valid write target.
2648
3412
 
2649
3413
  b. **Resolve the journal directory** via \`resolveOrchestrationPaths(repoRoot, agentId).journal\` from \`@mmnto/totem\`. Returns the absolute path to \`<repoRoot>/.totem/orchestration/<agent-id>/journal/\` when the tree exists. If \`source === 'none'\` (the tree does not exist yet in this repo) the resolver returns \`null\` for every path field — in that case, construct the path manually as \`<repoRoot>/.totem/orchestration/<agent-id>/journal/\` and create the directory first via \`mkdir -p\`; the path is gitignored and safe to create.
2650
3414
 
@@ -2706,6 +3470,19 @@ ${SKILL_MARKER_START}
2706
3470
 
2707
3471
  Triage PR review comments from all bots for PR $ARGUMENTS.
2708
3472
 
3473
+ ## Before Phase 1: confirm each invoked bot's review is on the head
3474
+
3475
+ A review trigger is the operator's to post, and what a bot does with it is not ours to control — so before triaging, confirm every invoked bot's review against the review object for THIS head sha, or the bot's summary comment for that sha, never a green commit status on the head: CodeRabbit's status settles green on every push head whether or not it reviewed that head (\`Review completed\` on the sha it reviewed, \`Review skipped\` on every push head it did not), and a PENDING status means still reviewing; Greptile's green \`Greptile Review\` check run does mark the sha it reviewed; GCA posted neither a status nor a check run on any GCA-reviewed sha measured so far. The reads, in order — the head sha; every review with the sha it was submitted against; the sha each Greptile summary comment names as its \`Last reviewed commit\`; the sha each CodeRabbit summary comment names as covered in its \`final_review_risk_coverage\` marker — so both halves compare a printed sha against a printed head, never an improvised one:
3476
+
3477
+ \`\`\`bash
3478
+ gh pr view $ARGUMENTS --json headRefOid --jq .headRefOid
3479
+ gh api --paginate "repos/{owner}/{repo}/pulls/$ARGUMENTS/reviews" --jq '.[] | [.user.login, .commit_id, .state, .submitted_at] | join(" ")'
3480
+ gh api --paginate "repos/{owner}/{repo}/issues/$ARGUMENTS/comments" --jq '.[] | select(.user.login == "greptile-apps[bot]") | .body | capture("Last reviewed commit:.*?/commit/(?<sha>[0-9a-f]{40})") | .sha'
3481
+ gh api --paginate "repos/{owner}/{repo}/issues/$ARGUMENTS/comments" --jq '.[] | select(.user.login == "coderabbitai[bot]") | .body | capture("coveredCommitId.:.(?<sha>[0-9a-f]{40})") | .sha'
3482
+ \`\`\`
3483
+
3484
+ A summary-comment verdict is read from the PR's issue comments and matched to the head by the \`Last reviewed commit\` sha its own body names — never by the comment's timestamp, which an in-place re-review does not advance. A chat reply or silence with no review to confirm is not a pass under the cadence of one external pass per chosen bot, so the pass is a standalone re-trigger, posted by the operator on the same terms — that bot's first pass, not a re-invoke, which the cadence reserves for risky rework with the reason recorded in the round comment. Before merging on the other reviewers, either wait one acknowledgement window (about 12 minutes from the trigger) or merge and record the late acknowledgement as one line on the PR thread naming the bot and the time it acknowledged.
3485
+
2709
3486
  ## Phase 1: Fetch & Categorize (Deterministic)
2710
3487
 
2711
3488
  Run the triage command to fetch, normalize, deduplicate, and categorize all bot comments: