@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.
- package/dist/commands/config-drift.test.js +4 -1
- package/dist/commands/config-drift.test.js.map +1 -1
- package/dist/commands/gate-install.d.ts +17 -0
- package/dist/commands/gate-install.d.ts.map +1 -1
- package/dist/commands/gate-install.js +23 -0
- package/dist/commands/gate-install.js.map +1 -1
- package/dist/commands/gate-install.test.js +467 -0
- package/dist/commands/gate-install.test.js.map +1 -1
- package/dist/commands/gate.d.ts +9 -0
- package/dist/commands/gate.d.ts.map +1 -1
- package/dist/commands/gate.js +24 -2
- package/dist/commands/gate.js.map +1 -1
- package/dist/commands/gate.test.js +42 -1
- package/dist/commands/gate.test.js.map +1 -1
- package/dist/commands/init-templates.d.ts +5 -5
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +758 -79
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +8 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +122 -0
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/mail-cli-wiring.test.js +58 -2
- package/dist/commands/mail-cli-wiring.test.js.map +1 -1
- package/dist/commands/mail-derive-seat.test.d.ts +28 -0
- package/dist/commands/mail-derive-seat.test.d.ts.map +1 -0
- package/dist/commands/mail-derive-seat.test.js +557 -0
- package/dist/commands/mail-derive-seat.test.js.map +1 -0
- package/dist/commands/mail.d.ts +112 -0
- package/dist/commands/mail.d.ts.map +1 -1
- package/dist/commands/mail.js +189 -0
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/resolve-threads-cli-wiring.test.d.ts +19 -0
- package/dist/commands/resolve-threads-cli-wiring.test.d.ts.map +1 -0
- package/dist/commands/resolve-threads-cli-wiring.test.js +103 -0
- package/dist/commands/resolve-threads-cli-wiring.test.js.map +1 -0
- package/dist/commands/resolve-threads.d.ts +472 -0
- package/dist/commands/resolve-threads.d.ts.map +1 -0
- package/dist/commands/resolve-threads.js +793 -0
- package/dist/commands/resolve-threads.js.map +1 -0
- package/dist/commands/resolve-threads.test.d.ts +2 -0
- package/dist/commands/resolve-threads.test.d.ts.map +1 -0
- package/dist/commands/resolve-threads.test.js +1123 -0
- package/dist/commands/resolve-threads.test.js.map +1 -0
- package/dist/commands/sync-labels-forms.test.d.ts +7 -4
- package/dist/commands/sync-labels-forms.test.d.ts.map +1 -1
- package/dist/commands/sync-labels-forms.test.js +266 -18
- package/dist/commands/sync-labels-forms.test.js.map +1 -1
- package/dist/index.js +74 -5
- package/dist/index.js.map +1 -1
- package/dist/parsers/bot-identity-parity.test.d.ts +2 -0
- package/dist/parsers/bot-identity-parity.test.d.ts.map +1 -0
- package/dist/parsers/bot-identity-parity.test.js +229 -0
- package/dist/parsers/bot-identity-parity.test.js.map +1 -0
- package/dist/parsers/bot-review-parser.d.ts +8 -5
- package/dist/parsers/bot-review-parser.d.ts.map +1 -1
- package/dist/parsers/bot-review-parser.js +12 -34
- package/dist/parsers/bot-review-parser.js.map +1 -1
- package/package.json +2 -2
|
@@ -1347,9 +1347,12 @@ export const CLAUDE_SESSION_START_ENTRY = {
|
|
|
1347
1347
|
// subsystem IS present does the wrapper shell out; only then can a broken
|
|
1348
1348
|
// deterministic source (corrupt freeze.json) fail-close (exit 2).
|
|
1349
1349
|
//
|
|
1350
|
-
// Tier (--strict default / --pilot) is read by the WRAPPER,
|
|
1351
|
-
// (
|
|
1352
|
-
//
|
|
1350
|
+
// Tier (--strict default / --pilot) is read by the WRAPPER and, since
|
|
1351
|
+
// mmnto-ai/totem#2800 (R1), also FORWARDED to `gate check --tier` so a gate can
|
|
1352
|
+
// apply it to its OWN unevaluable class (merge-ready warns under pilot where a
|
|
1353
|
+
// read failed to derive; freeze-check ignores it and fails closed at every
|
|
1354
|
+
// tier). The engine stays pure — the tier is an input, not state. The tier is
|
|
1355
|
+
// BAKED into the installed command string at install
|
|
1353
1356
|
// time and read ONLY from argv — there is NO env-var override, so a default
|
|
1354
1357
|
// (`--strict`) install is enforcement-immune to a consumer's environment
|
|
1355
1358
|
// (env-var sourcing would be a fail-open: a `TOTEM_GATE_TIER=pilot` in any
|
|
@@ -1370,7 +1373,8 @@ export const CLAUDE_GATE_WRAPPER = `// [totem] auto-generated — Claude Code ac
|
|
|
1370
1373
|
// 0 = allow | warn | --pilot deny | NOT-APPLICABLE fail-soft
|
|
1371
1374
|
// (unparseable/non-object envelope; freeze-check with no declared
|
|
1372
1375
|
// subsystem; transport-shield on a tool other than Bash/PowerShell or
|
|
1373
|
-
// with no non-empty string command
|
|
1376
|
+
// with no non-empty string command; merge-ready on any command that is
|
|
1377
|
+
// not \`gh pr merge\` at command position)
|
|
1374
1378
|
// 2 = deny (--strict, Claude block convention)
|
|
1375
1379
|
// | APPLICABLE-gate-not-evaluable fail-closed (no CLI resolvable
|
|
1376
1380
|
// (repo-local, then PATH), non-zero \`gate check\`, unparseable verdict,
|
|
@@ -1465,6 +1469,554 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
1465
1469
|
}
|
|
1466
1470
|
}
|
|
1467
1471
|
|
|
1472
|
+
// ─── merge-ready: \`gh pr merge\` at COMMAND POSITION + its payload ──────
|
|
1473
|
+
//
|
|
1474
|
+
// One walk over the command text does BOTH jobs, so recognition and argv
|
|
1475
|
+
// 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
|
|
1489
|
+
// collected, not the first: \`gh pr merge 7; gh pr merge 8\` yields two argv
|
|
1490
|
+
// lists and the wrapper judges each PR on its own facts (same round).
|
|
1491
|
+
//
|
|
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\`.
|
|
1507
|
+
//
|
|
1508
|
+
// 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.
|
|
1539
|
+
// Disclosed FALSE FIRES, the deny direction, all contrived — text the shell
|
|
1540
|
+
// does not execute as a merge but that sits at a segment's front here: a bash
|
|
1541
|
+
// array assignment whose elements spell a merge (\`A=(gh pr merge 8)\`) is judged
|
|
1542
|
+
// as a merge of 8, because \`(\` is a separator and the segment inside it starts
|
|
1543
|
+
// with \`gh\`; a \`case\` pattern \`gh pr merge)\` yields an EMPTY argv, which
|
|
1544
|
+
// projects to the current branch's PR (both surfaced when every segment began
|
|
1545
|
+
// to be collected, round 2 F6); and a function DEFINITION whose body is a
|
|
1546
|
+
// merge (\`f() { gh pr merge 5; }\`) fires at definition time, because \`{\` is a
|
|
1547
|
+
// 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
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// The index just past the \`))\` that closes an arithmetic expansion whose
|
|
1565
|
+
// opening \`$((\` / \`((\` ends at \`from\`; the end of the command when it is
|
|
1566
|
+
// unterminated. A \`<<\` inside is a SHIFT, never a heredoc operator — without
|
|
1567
|
+
// this guard \`echo $((1<<2))\` opened a heredoc and swallowed every command
|
|
1568
|
+
// after it, so a real \`gh pr merge\` went unjudged (F1).
|
|
1569
|
+
function skipArithmetic(command, from) {
|
|
1570
|
+
let depth = 2;
|
|
1571
|
+
let i = from;
|
|
1572
|
+
while (i < command.length) {
|
|
1573
|
+
const ch = command[i];
|
|
1574
|
+
if (ch === '(') depth += 1;
|
|
1575
|
+
else if (ch === ')') {
|
|
1576
|
+
depth -= 1;
|
|
1577
|
+
if (depth === 0) return i + 1;
|
|
1578
|
+
}
|
|
1579
|
+
i += 1;
|
|
1580
|
+
}
|
|
1581
|
+
return command.length;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
function blankHeredocBodies(command, powershell) {
|
|
1585
|
+
let out = '';
|
|
1586
|
+
let i = 0;
|
|
1587
|
+
let quote = '';
|
|
1588
|
+
let boundary = true;
|
|
1589
|
+
let pending = [];
|
|
1590
|
+
|
|
1591
|
+
// Consume EVERY body queued on the operator line, in order, starting just
|
|
1592
|
+
// 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.
|
|
1597
|
+
const consumeBodies = (from) => {
|
|
1598
|
+
let cursor = from;
|
|
1599
|
+
for (let p = 0; p < pending.length; p++) {
|
|
1600
|
+
const h = pending[p];
|
|
1601
|
+
let at = cursor;
|
|
1602
|
+
cursor = command.length;
|
|
1603
|
+
while (at <= command.length) {
|
|
1604
|
+
const nl = command.indexOf('\\n', at);
|
|
1605
|
+
const stop = nl === -1 ? command.length : nl;
|
|
1606
|
+
let line = command.slice(at, stop);
|
|
1607
|
+
if (h.stripTabs) line = line.replace(/^\\t+/, '');
|
|
1608
|
+
line = line.replace(/\\r$/, '');
|
|
1609
|
+
const next = nl === -1 ? command.length : nl + 1;
|
|
1610
|
+
if (line === h.delimiter) {
|
|
1611
|
+
out += command.slice(at, next);
|
|
1612
|
+
cursor = next;
|
|
1613
|
+
break;
|
|
1614
|
+
}
|
|
1615
|
+
if (nl === -1) break;
|
|
1616
|
+
out += '\\n';
|
|
1617
|
+
at = next;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
pending = [];
|
|
1621
|
+
return cursor;
|
|
1622
|
+
};
|
|
1623
|
+
|
|
1624
|
+
while (i < command.length) {
|
|
1625
|
+
const ch = command[i];
|
|
1626
|
+
if (quote !== '') {
|
|
1627
|
+
out += ch;
|
|
1628
|
+
if (ch === '\\\\' && quote === '"' && i + 1 < command.length) {
|
|
1629
|
+
out += command[i + 1];
|
|
1630
|
+
i += 2;
|
|
1631
|
+
continue;
|
|
1632
|
+
}
|
|
1633
|
+
if (ch === quote) quote = '';
|
|
1634
|
+
i++;
|
|
1635
|
+
boundary = false;
|
|
1636
|
+
continue;
|
|
1637
|
+
}
|
|
1638
|
+
if (ch === "'" || ch === '"') {
|
|
1639
|
+
quote = ch;
|
|
1640
|
+
out += ch;
|
|
1641
|
+
i++;
|
|
1642
|
+
boundary = false;
|
|
1643
|
+
continue;
|
|
1644
|
+
}
|
|
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;
|
|
1651
|
+
continue;
|
|
1652
|
+
}
|
|
1653
|
+
if (ch === '\\\\' && i + 1 < command.length) {
|
|
1654
|
+
out += ch + command[i + 1];
|
|
1655
|
+
i += 2;
|
|
1656
|
+
boundary = false;
|
|
1657
|
+
continue;
|
|
1658
|
+
}
|
|
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;
|
|
1666
|
+
continue;
|
|
1667
|
+
}
|
|
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;
|
|
1677
|
+
boundary = true;
|
|
1678
|
+
continue;
|
|
1679
|
+
}
|
|
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;
|
|
1685
|
+
continue;
|
|
1686
|
+
}
|
|
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 + '(';
|
|
1694
|
+
i += 2;
|
|
1695
|
+
boundary = true;
|
|
1696
|
+
continue;
|
|
1697
|
+
}
|
|
1698
|
+
if (ch === '(' && command[i + 1] === '(' && boundary) {
|
|
1699
|
+
const end = skipArithmetic(command, i + 2);
|
|
1700
|
+
out += command.slice(i, end);
|
|
1701
|
+
i = end;
|
|
1702
|
+
boundary = false;
|
|
1703
|
+
continue;
|
|
1704
|
+
}
|
|
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;
|
|
1756
|
+
boundary = false;
|
|
1757
|
+
if (delim !== '') pending.push({ delimiter: delim, stripTabs: dash });
|
|
1758
|
+
continue;
|
|
1759
|
+
}
|
|
1760
|
+
if (ch === '\\n') {
|
|
1761
|
+
out += '\\n';
|
|
1762
|
+
i += 1;
|
|
1763
|
+
if (pending.length > 0) i = consumeBodies(i);
|
|
1764
|
+
boundary = true;
|
|
1765
|
+
continue;
|
|
1766
|
+
}
|
|
1767
|
+
out += ch;
|
|
1768
|
+
boundary = isWordBoundary(ch);
|
|
1769
|
+
i++;
|
|
1770
|
+
}
|
|
1771
|
+
return out;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// The words the shell reads at command position that are NOT the command:
|
|
1775
|
+
// reserved words that introduce a compound command (\`time\` and \`coproc\` are
|
|
1776
|
+
// reserved words too — the round-2 leg found them missing), the negation, and
|
|
1777
|
+
// 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.
|
|
1782
|
+
const COMMAND_POSITION_WORDS = [
|
|
1783
|
+
'do',
|
|
1784
|
+
'then',
|
|
1785
|
+
'else',
|
|
1786
|
+
'if',
|
|
1787
|
+
'elif',
|
|
1788
|
+
'while',
|
|
1789
|
+
'until',
|
|
1790
|
+
'time',
|
|
1791
|
+
'coproc',
|
|
1792
|
+
'!',
|
|
1793
|
+
'exec',
|
|
1794
|
+
'command',
|
|
1795
|
+
'eval',
|
|
1796
|
+
];
|
|
1797
|
+
|
|
1798
|
+
// A \`NAME=value\` word at a segment's front is an assignment PREFIX to the
|
|
1799
|
+
// command that follows it (\`GH_TOKEN=x gh pr merge 5\`), never the command.
|
|
1800
|
+
function isAssignmentPrefix(token) {
|
|
1801
|
+
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token);
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
/**
|
|
1805
|
+
* The argv after EVERY \`gh pr merge\` at command position in the command —
|
|
1806
|
+
* one array per merge, in command order — or an empty array when there is
|
|
1807
|
+
* none. A compound command that merges twice yields two, and the wrapper
|
|
1808
|
+
* judges each (mmnto-ai/totem#2844 round 1).
|
|
1809
|
+
*/
|
|
1810
|
+
function ghPrMergeArgvs(rawCommand, powershell) {
|
|
1811
|
+
const command = blankHeredocBodies(rawCommand, powershell === true);
|
|
1812
|
+
const segments = [];
|
|
1813
|
+
let current = [];
|
|
1814
|
+
let token = '';
|
|
1815
|
+
let hasToken = false;
|
|
1816
|
+
let i = 0;
|
|
1817
|
+
const endToken = () => {
|
|
1818
|
+
if (hasToken) {
|
|
1819
|
+
current.push(token);
|
|
1820
|
+
token = '';
|
|
1821
|
+
hasToken = false;
|
|
1822
|
+
}
|
|
1823
|
+
};
|
|
1824
|
+
const endSegment = () => {
|
|
1825
|
+
endToken();
|
|
1826
|
+
segments.push(current);
|
|
1827
|
+
current = [];
|
|
1828
|
+
};
|
|
1829
|
+
while (i < command.length) {
|
|
1830
|
+
const ch = command[i];
|
|
1831
|
+
if (ch === "'") {
|
|
1832
|
+
hasToken = true;
|
|
1833
|
+
i++;
|
|
1834
|
+
while (i < command.length && command[i] !== "'") {
|
|
1835
|
+
token += command[i];
|
|
1836
|
+
i++;
|
|
1837
|
+
}
|
|
1838
|
+
i++;
|
|
1839
|
+
continue;
|
|
1840
|
+
}
|
|
1841
|
+
if (ch === '"') {
|
|
1842
|
+
hasToken = true;
|
|
1843
|
+
i++;
|
|
1844
|
+
while (i < command.length && command[i] !== '"') {
|
|
1845
|
+
if (command[i] === '\\\\' && i + 1 < command.length) {
|
|
1846
|
+
token += command[i + 1];
|
|
1847
|
+
i += 2;
|
|
1848
|
+
continue;
|
|
1849
|
+
}
|
|
1850
|
+
token += command[i];
|
|
1851
|
+
i++;
|
|
1852
|
+
}
|
|
1853
|
+
i++;
|
|
1854
|
+
continue;
|
|
1855
|
+
}
|
|
1856
|
+
// A parameter expansion is ONE word: without this, \`\${PR}\` splits on its
|
|
1857
|
+
// braces and the unresolved-target evidence line reads just "$"
|
|
1858
|
+
// (mmnto-ai/totem#2800 round 2, F10). A \`$( … )\` is deliberately NOT
|
|
1859
|
+
// swallowed the same way — a real \`gh pr merge\` inside a command
|
|
1860
|
+
// substitution has to keep firing, so its parens stay separators.
|
|
1861
|
+
if (ch === '$' && command[i + 1] === '{') {
|
|
1862
|
+
const close = command.indexOf('}', i + 2);
|
|
1863
|
+
const end = close === -1 ? command.length : close + 1;
|
|
1864
|
+
token += command.slice(i, end);
|
|
1865
|
+
hasToken = true;
|
|
1866
|
+
i = end;
|
|
1867
|
+
continue;
|
|
1868
|
+
}
|
|
1869
|
+
if (ch === ' ' || ch === '\\t' || ch === '\\r') {
|
|
1870
|
+
endToken();
|
|
1871
|
+
i++;
|
|
1872
|
+
continue;
|
|
1873
|
+
}
|
|
1874
|
+
if (
|
|
1875
|
+
ch === ';' ||
|
|
1876
|
+
ch === '&' ||
|
|
1877
|
+
ch === '|' ||
|
|
1878
|
+
ch === '\\n' ||
|
|
1879
|
+
ch === '(' ||
|
|
1880
|
+
ch === ')' ||
|
|
1881
|
+
ch === '{' ||
|
|
1882
|
+
ch === '}'
|
|
1883
|
+
) {
|
|
1884
|
+
endSegment();
|
|
1885
|
+
i++;
|
|
1886
|
+
continue;
|
|
1887
|
+
}
|
|
1888
|
+
// A line continuation joins the two halves of ONE word (\`gh \\<LF>pr\` is
|
|
1889
|
+
// \`ghpr\` to the shell, and \`gh \\<LF>pr merge\` keeps \`gh\` at the front of
|
|
1890
|
+
// the segment): drop both characters and keep tokenizing (round 3, F6).
|
|
1891
|
+
if (ch === '\\\\' && (command[i + 1] === '\\n' || (command[i + 1] === '\\r' && command[i + 2] === '\\n'))) {
|
|
1892
|
+
i += command[i + 1] === '\\r' ? 3 : 2;
|
|
1893
|
+
continue;
|
|
1894
|
+
}
|
|
1895
|
+
if (ch === '\\\\' && i + 1 < command.length) {
|
|
1896
|
+
token += command[i + 1];
|
|
1897
|
+
hasToken = true;
|
|
1898
|
+
i += 2;
|
|
1899
|
+
continue;
|
|
1900
|
+
}
|
|
1901
|
+
token += ch;
|
|
1902
|
+
hasToken = true;
|
|
1903
|
+
i++;
|
|
1904
|
+
}
|
|
1905
|
+
endSegment();
|
|
1906
|
+
|
|
1907
|
+
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
|
+
) {
|
|
1914
|
+
tokens = tokens.slice(1);
|
|
1915
|
+
}
|
|
1916
|
+
if (tokens.length >= 3 && tokens[0] === 'gh' && tokens[1] === 'pr' && tokens[2] === 'merge') {
|
|
1917
|
+
found.push(tokens.slice(3));
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
return found;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
/** Run git read-only and return trimmed stdout, or '' when it did not answer. */
|
|
1924
|
+
function gitRead(args) {
|
|
1925
|
+
const res = spawnSync('git', args, { encoding: 'utf-8', timeout: 10000 });
|
|
1926
|
+
if (res.error || typeof res.status !== 'number' || res.status !== 0) return '';
|
|
1927
|
+
return (res.stdout || '').trim();
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/** \`owner/name\` out of any git remote URL shape (ssh, https, with or without .git). */
|
|
1931
|
+
function repoFromRemote(url) {
|
|
1932
|
+
const m = /[:/]([^/:]+)\\/([^/]+?)(?:\\.git)?$/.exec(url.trim());
|
|
1933
|
+
return m ? m[1] + '/' + m[2] : '';
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
// The flags of \`gh pr merge\` that CONSUME the next argv element — without this
|
|
1937
|
+
// list, \`gh pr merge -b "some branch"\` would read the body as the PR target.
|
|
1938
|
+
const GH_MERGE_VALUE_FLAGS = [
|
|
1939
|
+
'-R',
|
|
1940
|
+
'--repo',
|
|
1941
|
+
'-b',
|
|
1942
|
+
'--body',
|
|
1943
|
+
'-F',
|
|
1944
|
+
'--body-file',
|
|
1945
|
+
'-t',
|
|
1946
|
+
'--subject',
|
|
1947
|
+
'--match-head-commit',
|
|
1948
|
+
'--author-email',
|
|
1949
|
+
];
|
|
1950
|
+
|
|
1951
|
+
/**
|
|
1952
|
+
* Project { repo, pr, branch?, headSha? } from the argv after \`gh pr merge\`:
|
|
1953
|
+
* a number, a PR URL, a branch, \`-R/--repo\`. With no argument at all, gh
|
|
1954
|
+
* merges the PR for the CURRENT branch — so the payload carries pr: null plus
|
|
1955
|
+
* that branch, exactly as the gate's payload contract allows.
|
|
1956
|
+
*/
|
|
1957
|
+
function projectMergeReady(argv) {
|
|
1958
|
+
let repo = '';
|
|
1959
|
+
let pr = null;
|
|
1960
|
+
let branch = '';
|
|
1961
|
+
let positional = null;
|
|
1962
|
+
|
|
1963
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1964
|
+
const arg = argv[i];
|
|
1965
|
+
const eq = arg.indexOf('=');
|
|
1966
|
+
if (arg.slice(0, 2) === '--' && eq > 2) {
|
|
1967
|
+
if (arg.slice(0, eq) === '--repo') repo = arg.slice(eq + 1);
|
|
1968
|
+
continue;
|
|
1969
|
+
}
|
|
1970
|
+
if (GH_MERGE_VALUE_FLAGS.indexOf(arg) !== -1) {
|
|
1971
|
+
if (arg === '-R' || arg === '--repo') repo = argv[i + 1] || '';
|
|
1972
|
+
i++;
|
|
1973
|
+
continue;
|
|
1974
|
+
}
|
|
1975
|
+
if (arg.charAt(0) === '-') continue;
|
|
1976
|
+
if (positional === null) positional = arg;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
// An UNEXPANDED shell variable (\`gh pr merge $PR\`) is not a target this
|
|
1980
|
+
// projection can know (mmnto-ai/totem#2800 fold F13): the shell expands it
|
|
1981
|
+
// after the hook has already decided. Reading it as a branch name would judge
|
|
1982
|
+
// the wrong PR — or none — so it rides as \`unresolvedTarget\`, which the
|
|
1983
|
+
// engine treats as unevaluable (strict denies, pilot warns).
|
|
1984
|
+
let unresolvedTarget = '';
|
|
1985
|
+
if (positional !== null && /[$\`]/.test(positional)) {
|
|
1986
|
+
unresolvedTarget = positional;
|
|
1987
|
+
positional = null;
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
if (positional !== null) {
|
|
1991
|
+
const url = /^https?:\\/\\/[^/]+\\/([^/]+)\\/([^/]+)\\/pull\\/(\\d+)/.exec(positional);
|
|
1992
|
+
if (url) {
|
|
1993
|
+
if (repo === '') repo = url[1] + '/' + url[2];
|
|
1994
|
+
pr = parseInt(url[3], 10);
|
|
1995
|
+
} else if (/^\\d+$/.test(positional)) {
|
|
1996
|
+
pr = parseInt(positional, 10);
|
|
1997
|
+
} else {
|
|
1998
|
+
branch = positional;
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
// gh itself honours GH_REPO before the git remote; mirror that order so the
|
|
2003
|
+
// gate reads the SAME pull request the command would merge.
|
|
2004
|
+
if (repo === '') repo = (process.env.GH_REPO || '').trim();
|
|
2005
|
+
if (repo === '') repo = repoFromRemote(gitRead(['config', '--get', 'remote.origin.url']));
|
|
2006
|
+
// The current-branch fallback is for a command that named NO target. An
|
|
2007
|
+
// unresolved one named a target we could not read, so it must not fall back.
|
|
2008
|
+
if (pr === null && branch === '' && unresolvedTarget === '') {
|
|
2009
|
+
branch = gitRead(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
const headSha = gitRead(['rev-parse', 'HEAD']);
|
|
2013
|
+
const out = { repo: repo, pr: pr };
|
|
2014
|
+
if (branch !== '') out.branch = branch;
|
|
2015
|
+
if (unresolvedTarget !== '') out.unresolvedTarget = unresolvedTarget;
|
|
2016
|
+
if (/^[0-9a-f]{40}$/i.test(headSha)) out.headSha = headSha;
|
|
2017
|
+
return out;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
1468
2020
|
// Read the PreToolUse stdin envelope.
|
|
1469
2021
|
let stdin = '';
|
|
1470
2022
|
process.stdin.setEncoding('utf-8');
|
|
@@ -1507,8 +2059,15 @@ process.stdin.on('end', () => {
|
|
|
1507
2059
|
// transport-shield | tool_name is Bash or PowerShell | { tool, command, platform }
|
|
1508
2060
|
// | AND tool_input.command is a |
|
|
1509
2061
|
// | non-empty string |
|
|
2062
|
+
// merge-ready | tool_name is Bash or PowerShell | { repo, pr, branch?, headSha? }
|
|
2063
|
+
// | AND the command runs \`gh pr merge\`|
|
|
2064
|
+
// | at COMMAND POSITION |
|
|
1510
2065
|
// (anything else) | — no projection → fail closed | —
|
|
1511
|
-
|
|
2066
|
+
//
|
|
2067
|
+
// A projection yields ONE payload per gate evaluation — and merge-ready can
|
|
2068
|
+
// yield several for one envelope (one per \`gh pr merge\` at command
|
|
2069
|
+
// position), each judged on its own below.
|
|
2070
|
+
let payloads = [];
|
|
1512
2071
|
|
|
1513
2072
|
if (event === 'freeze-check') {
|
|
1514
2073
|
// THE EMPTY-SUBSYSTEM GUARDRAIL: freeze-check's predicate is on a DECLARED
|
|
@@ -1523,7 +2082,7 @@ process.stdin.on('end', () => {
|
|
|
1523
2082
|
if (declaredSubsystem === '') {
|
|
1524
2083
|
process.exit(0);
|
|
1525
2084
|
}
|
|
1526
|
-
|
|
2085
|
+
payloads = [JSON.stringify({ subsystem: declaredSubsystem })];
|
|
1527
2086
|
} else if (event === 'transport-shield') {
|
|
1528
2087
|
// transport-shield's predicate is on a SHELL COMMAND. Anything that is not
|
|
1529
2088
|
// a Bash/PowerShell invocation carrying a command string is NOT an
|
|
@@ -1537,11 +2096,34 @@ process.stdin.on('end', () => {
|
|
|
1537
2096
|
if (typeof input.command !== 'string' || input.command.trim() === '') {
|
|
1538
2097
|
process.exit(0);
|
|
1539
2098
|
}
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
2099
|
+
payloads = [
|
|
2100
|
+
JSON.stringify({
|
|
2101
|
+
tool: tool,
|
|
2102
|
+
command: input.command,
|
|
2103
|
+
platform: process.platform,
|
|
2104
|
+
}),
|
|
2105
|
+
];
|
|
2106
|
+
} else if (event === 'merge-ready') {
|
|
2107
|
+
// merge-ready's predicate is on a PULL REQUEST about to be merged. The gate
|
|
2108
|
+
// installs under Bash|PowerShell, so this branch sees every shell command:
|
|
2109
|
+
// anything that is not \`gh pr merge\` at COMMAND POSITION is NOT an
|
|
2110
|
+
// applicable gate → pass through (exit 0) WITHOUT spawning.
|
|
2111
|
+
const tool = parsed.tool_name;
|
|
2112
|
+
if (tool !== 'Bash' && tool !== 'PowerShell') {
|
|
2113
|
+
process.exit(0);
|
|
2114
|
+
}
|
|
2115
|
+
if (typeof input.command !== 'string' || input.command.trim() === '') {
|
|
2116
|
+
process.exit(0);
|
|
2117
|
+
}
|
|
2118
|
+
const merges = ghPrMergeArgvs(input.command, tool === 'PowerShell');
|
|
2119
|
+
if (merges.length === 0) {
|
|
2120
|
+
process.exit(0);
|
|
2121
|
+
}
|
|
2122
|
+
// One payload per merge: \`gh pr merge 7; gh pr merge 8\` is judged twice,
|
|
2123
|
+
// each PR against its own facts (mmnto-ai/totem#2844 round 1).
|
|
2124
|
+
for (let m = 0; m < merges.length; m++) {
|
|
2125
|
+
payloads.push(JSON.stringify(projectMergeReady(merges[m])));
|
|
2126
|
+
}
|
|
1545
2127
|
} else {
|
|
1546
2128
|
// A baked --event this wrapper cannot project is an APPLICABLE gate it
|
|
1547
2129
|
// cannot evaluate → fail closed (ADR-109). Reinstalling refreshes the
|
|
@@ -1552,6 +2134,17 @@ process.stdin.on('end', () => {
|
|
|
1552
2134
|
process.exit(2);
|
|
1553
2135
|
}
|
|
1554
2136
|
|
|
2137
|
+
// No payload past the projection is not an applicable gate that passed — it
|
|
2138
|
+
// is a branch above that forgot to project, and before the loop that shape
|
|
2139
|
+
// fail-closed through the child's non-zero exit. Keep the default closed
|
|
2140
|
+
// (round 2, F7).
|
|
2141
|
+
if (payloads.length === 0) {
|
|
2142
|
+
process.stderr.write(
|
|
2143
|
+
'[totem gate-wrapper] event "' + event + '" projected no payload; failing closed.\\n',
|
|
2144
|
+
);
|
|
2145
|
+
process.exit(2);
|
|
2146
|
+
}
|
|
2147
|
+
|
|
1555
2148
|
// Resolve the Totem CLI: the repo-local pinned dist FIRST (a global \`totem\`
|
|
1556
2149
|
// may be stale and missing deps — the known repo gotcha; the
|
|
1557
2150
|
// pinned-beats-ambient ordering of ADR-072 § 2, Tenet 14), then a \`totem\` on
|
|
@@ -1612,11 +2205,24 @@ process.stdin.on('end', () => {
|
|
|
1612
2205
|
// 32,767 characters — an argv payload past it fails the spawn with
|
|
1613
2206
|
// ENAMETOOLONG and would land in the fail-closed arm below with nothing
|
|
1614
2207
|
// broken (mmnto-ai/totem#2799, pass 3).
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
2208
|
+
// The baked tier rides along (mmnto-ai/totem#2800 R1): the ENGINE owns the
|
|
2209
|
+
// strict/pilot split for a gate's UNEVALUABLE class (a read that could not
|
|
2210
|
+
// derive), while the disposition → exit map below stays the wrapper's. A gate
|
|
2211
|
+
// that ignores the tier — freeze-check — still fails closed at both.
|
|
2212
|
+
//
|
|
2213
|
+
// \`--tier\` is forwarded ONLY when it is NOT the default (fold F3): a CLI at
|
|
2214
|
+
// or below 2.2.1 has no such option and would exit non-zero with
|
|
2215
|
+
// "unknown option", which is the fail-closed arm — and on a
|
|
2216
|
+
// \`Bash|PowerShell\` gate that re-creates the mmnto-ai/totem#2822 bootstrap
|
|
2217
|
+
// self-block through the PATH arm. \`strict\` IS the engine's default, so a
|
|
2218
|
+
// strict wrapper stays runnable against a 2.2.x CLI; a \`--pilot\` install
|
|
2219
|
+
// passes the flag and needs a CLI at 2.3.0 or newer (the install-time
|
|
2220
|
+
// disclosure says so).
|
|
2221
|
+
const checkArgs = [cliPath, 'gate', 'check', '--event', event];
|
|
2222
|
+
if (tier !== 'strict') {
|
|
2223
|
+
checkArgs.push('--tier', tier);
|
|
2224
|
+
}
|
|
2225
|
+
checkArgs.push('--payload', '-');
|
|
1620
2226
|
|
|
1621
2227
|
// Provenance for the PATH fallback arm (mmnto-ai/totem#2822): a CLI older
|
|
1622
2228
|
// than 2.2.0 has no \`gate check --payload -\` and lands in the fail-closed
|
|
@@ -1624,75 +2230,128 @@ process.stdin.on('end', () => {
|
|
|
1624
2230
|
// BEHAVIOUR is unchanged — exit 2 either way — the line only names WHICH CLI
|
|
1625
2231
|
// evaluated and how to update it. Empty on the repo-local arm. It prints the
|
|
1626
2232
|
// basename rendering, never the absolute entry: this is transcript-bound text.
|
|
2233
|
+
// The floor NAMED here is the floor this wrapper actually needs: a strict
|
|
2234
|
+
// wrapper sends no \`--tier\`, so 2.2.0 (the \`--payload -\` cut) still answers
|
|
2235
|
+
// it; a pilot wrapper sends \`--tier pilot\`, which only 2.3.0 and newer parse
|
|
2236
|
+
// (mmnto-ai/totem#2800 fold F3).
|
|
1627
2237
|
const armNote =
|
|
1628
2238
|
arm === 'PATH'
|
|
1629
2239
|
? 'evaluated by the PATH CLI at ' +
|
|
1630
2240
|
cliDisplay +
|
|
1631
|
-
|
|
2241
|
+
(tier === 'strict'
|
|
2242
|
+
? "; a CLI older than 2.2.0 lacks 'gate check --payload -' — "
|
|
2243
|
+
: "; a CLI older than 2.3.0 lacks 'gate check --tier' (this entry is baked --pilot) — ") +
|
|
1632
2244
|
'update it: npm i -g @mmnto/cli@latest\\n'
|
|
1633
2245
|
: '';
|
|
1634
2246
|
|
|
1635
|
-
// ───
|
|
1636
|
-
//
|
|
1637
|
-
//
|
|
1638
|
-
//
|
|
1639
|
-
//
|
|
1640
|
-
//
|
|
1641
|
-
//
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
2247
|
+
// ─── One evaluation per projected payload ─────────────────────────────
|
|
2248
|
+
// freeze-check and transport-shield project exactly one. merge-ready projects
|
|
2249
|
+
// one per \`gh pr merge\` at command position, so \`gh pr merge 7; gh pr merge 8\`
|
|
2250
|
+
// is judged TWICE, each PR on its own facts — judging only the first let the
|
|
2251
|
+
// shell run the second unjudged (mmnto-ai/totem#2844 round 1). The first
|
|
2252
|
+
// strict deny, and every fail-closed arm, EXITS at once; a warn, and a deny
|
|
2253
|
+
// under --pilot, print their line and let the NEXT payload be judged, so every
|
|
2254
|
+
// merge in the envelope gets its stderr line; exit 0 only once every payload
|
|
2255
|
+
// has allowed or warned.
|
|
2256
|
+
//
|
|
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;
|
|
2271
|
+
for (let p = 0; p < payloads.length; p++) {
|
|
2272
|
+
const result = spawnSync(process.execPath, checkArgs, {
|
|
2273
|
+
encoding: 'utf-8',
|
|
2274
|
+
timeout: Math.max(1000, deadline - Date.now()),
|
|
2275
|
+
input: payloads[p],
|
|
2276
|
+
});
|
|
1653
2277
|
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
//
|
|
1659
|
-
//
|
|
2278
|
+
// ─── The child's stderr IS a gate surface (fold F1) ──────────────────
|
|
2279
|
+
// merge-ready's audited-override line, its zero-checks fact and every
|
|
2280
|
+
// "could not derive" line are written by the ENGINE to stderr. Passing them
|
|
2281
|
+
// through verbatim in EVERY arm — allow included — is what puts them in the
|
|
2282
|
+
// transcript; printing them only on failure hid the override's audit trail,
|
|
2283
|
+
// the one line that must never be silent.
|
|
2284
|
+
if (typeof result.stderr === 'string' && result.stderr !== '') {
|
|
2285
|
+
process.stderr.write(result.stderr);
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
// ─── FAIL-CLOSED ────────────────────────────────────────────────────
|
|
2289
|
+
// A gate genuinely applies (the per-event projection above found its input:
|
|
2290
|
+
// a declared subsystem, or a Bash/PowerShell command) and the evaluation
|
|
2291
|
+
// itself failed (non-zero exit: corrupt freeze.json, an invalid payload,
|
|
2292
|
+
// spawn error, etc.). Never silently allow when an applicable gate's source
|
|
2293
|
+
// is broken → exit 2. (Not-applicable envelopes already returned exit 0
|
|
2294
|
+
// above, so this only blocks when the gate's input was actually present.)
|
|
2295
|
+
if (result.error || typeof result.status !== 'number' || result.status !== 0) {
|
|
2296
|
+
process.stderr.write(
|
|
2297
|
+
'[totem gate-wrapper] gate "' +
|
|
2298
|
+
event +
|
|
2299
|
+
'" evaluation failed (source broken or unavailable) — blocking (fail-closed).\\n' +
|
|
2300
|
+
// The child's stderr already went through verbatim above (fold F1);
|
|
2301
|
+
// only a spawn-level error (no child, so no stderr) is added here.
|
|
2302
|
+
(result.error ? String(result.error.message || result.error) + '\\n' : '') +
|
|
2303
|
+
armNote,
|
|
2304
|
+
);
|
|
2305
|
+
process.exit(2);
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
let verdict;
|
|
2309
|
+
try {
|
|
2310
|
+
verdict = JSON.parse(result.stdout || '');
|
|
2311
|
+
} catch (err) {
|
|
2312
|
+
// The command emitted unparseable stdout despite a 0 exit — an applicable
|
|
2313
|
+
// gate whose verdict we cannot read is a broken source → fail-closed.
|
|
2314
|
+
process.stderr.write(
|
|
2315
|
+
'[totem gate-wrapper] gate "' + event + '" emitted unparseable verdict — blocking (fail-closed).\\n' + armNote,
|
|
2316
|
+
);
|
|
2317
|
+
process.exit(2);
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
// ─── Disposition → host exit code (branch ONLY on disposition) ───────
|
|
2321
|
+
const disposition = verdict && typeof verdict.disposition === 'string' ? verdict.disposition : '';
|
|
2322
|
+
// reason/provenance are OPAQUE stderr passthrough — never parsed for control flow.
|
|
2323
|
+
const detail =
|
|
2324
|
+
(verdict && verdict.reason ? verdict.reason : '') +
|
|
2325
|
+
(verdict && verdict.provenance ? ' [' + JSON.stringify(verdict.provenance) + ']' : '');
|
|
2326
|
+
|
|
2327
|
+
if (disposition === 'allow') {
|
|
2328
|
+
// Deliberately SILENT on the PATH arm too: a provenance line on every
|
|
2329
|
+
// allowed Bash command would be transcript noise on the common path, and
|
|
2330
|
+
// the operator already learned the property at install time (the
|
|
2331
|
+
// \`gate install\` disclosure) — stderr here is reserved for what blocks.
|
|
2332
|
+
continue;
|
|
2333
|
+
}
|
|
2334
|
+
if (disposition === 'warn') {
|
|
2335
|
+
process.stderr.write('[totem gate-wrapper] ' + event + ' (warn): ' + detail + '\\n');
|
|
2336
|
+
continue;
|
|
2337
|
+
}
|
|
2338
|
+
if (disposition === 'deny') {
|
|
2339
|
+
process.stderr.write('[totem gate-wrapper] ' + event + ' (deny): ' + detail + '\\n');
|
|
2340
|
+
if (tier !== 'pilot') {
|
|
2341
|
+
process.exit(2);
|
|
2342
|
+
}
|
|
2343
|
+
continue;
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
// Unknown disposition from an applicable gate — fail-closed. The provenance
|
|
2347
|
+
// note rides here too, so all four not-evaluable causes in the exit-code
|
|
2348
|
+
// contract above disclose which arm evaluated.
|
|
1660
2349
|
process.stderr.write(
|
|
1661
|
-
'[totem gate-wrapper] gate "' + event + '"
|
|
2350
|
+
'[totem gate-wrapper] gate "' + event + '" returned unknown disposition "' + disposition + '" — blocking (fail-closed).\\n' + armNote,
|
|
1662
2351
|
);
|
|
1663
2352
|
process.exit(2);
|
|
1664
2353
|
}
|
|
1665
|
-
|
|
1666
|
-
// ─── Disposition → host exit code (branch ONLY on disposition) ─────────
|
|
1667
|
-
const disposition = verdict && typeof verdict.disposition === 'string' ? verdict.disposition : '';
|
|
1668
|
-
// reason/provenance are OPAQUE stderr passthrough — never parsed for control flow.
|
|
1669
|
-
const detail =
|
|
1670
|
-
(verdict && verdict.reason ? verdict.reason : '') +
|
|
1671
|
-
(verdict && verdict.provenance ? ' [' + JSON.stringify(verdict.provenance) + ']' : '');
|
|
1672
|
-
|
|
1673
|
-
if (disposition === 'allow') {
|
|
1674
|
-
// Deliberately SILENT on the PATH arm too: a provenance line on every
|
|
1675
|
-
// allowed Bash command would be transcript noise on the common path, and
|
|
1676
|
-
// the operator already learned the property at install time (the
|
|
1677
|
-
// \`gate install\` disclosure) — stderr here is reserved for what blocks.
|
|
1678
|
-
process.exit(0);
|
|
1679
|
-
}
|
|
1680
|
-
if (disposition === 'warn') {
|
|
1681
|
-
process.stderr.write('[totem gate-wrapper] ' + event + ' (warn): ' + detail + '\\n');
|
|
1682
|
-
process.exit(0);
|
|
1683
|
-
}
|
|
1684
|
-
if (disposition === 'deny') {
|
|
1685
|
-
process.stderr.write('[totem gate-wrapper] ' + event + ' (deny): ' + detail + '\\n');
|
|
1686
|
-
process.exit(tier === 'pilot' ? 0 : 2);
|
|
1687
|
-
}
|
|
1688
|
-
|
|
1689
|
-
// Unknown disposition from an applicable gate — fail-closed. The provenance
|
|
1690
|
-
// note rides here too, so all four not-evaluable causes in the exit-code
|
|
1691
|
-
// contract above disclose which arm evaluated.
|
|
1692
|
-
process.stderr.write(
|
|
1693
|
-
'[totem gate-wrapper] gate "' + event + '" returned unknown disposition "' + disposition + '" — blocking (fail-closed).\\n' + armNote,
|
|
1694
|
-
);
|
|
1695
|
-
process.exit(2);
|
|
2354
|
+
process.exit(0);
|
|
1696
2355
|
});
|
|
1697
2356
|
${TOTEM_FILE_END}
|
|
1698
2357
|
`;
|
|
@@ -2022,15 +2681,19 @@ ${SKILL_MARKER_START}
|
|
|
2022
2681
|
|
|
2023
2682
|
Session-start bring-up. **Read-only** — no mutations, no dispatches, no board edits until the operator rules on next steps (Proposal 295 d2: read-only orient + grounded next-work). Solo — no agent fleet (\`feedback_session_start_derive_cheaply\`: cheap derivation IS the validation dogfood).
|
|
2024
2683
|
|
|
2025
|
-
|
|
2684
|
+
0. **Derive the seat FIRST — before orient, before any journal read, before any knowledge search** (mmnto-ai/totem#2801). Read the INHERITED \`TOTEM_SELF_AGENT\` this session was launched with; \`totem mail --derive-seat\` is the probe — one stdout line \`seat=<id> source=env\` and exit 0 when the inherited env names exactly one seat this repo hosts, otherwise a stderr refusal (exit 2) naming the supplied value (or \`unset\`) and every seat this repo hosts. **Hosts** means one thing everywhere: config.json \`host_agents\`, else the seat dirs, else the cohort map keyed on the origin repository — never the env you supplied, which is the thing being checked. **Set → that is your seat: use it, and never write a different one.** An inline \`TOTEM_SELF_AGENT=<seat>\` on any later command may only RESTATE the inherited value; writing a different seat over it silently re-points every step below at a seat that is not yours, and once overridden the tool can no longer see what you inherited — the operator's account is the only witness left. **Set but REFUSED** (the probe exits 2 while the env names a seat): your declaration and this repo disagree, so act as if you have no seat — put the refusal line itself in front of the operator, verbatim, and change nothing to make it pass; signon is read-only, so \`totem seat add\` is never run here. On a repo with no seat registration at all the refusal says the repo cannot CORROBORATE you, not that your seat is wrong: \`totem mail --as <your-seat>\` and a per-shell poll still serve your declaration, because a declaration on the command line is yours to make and this probe only checks one. **Empty → STOP and ask the operator.** Do not guess, do not fall back to the crown, do not derive a seat from the repo basename, the branch, or the only seat in sight; a bring-up on an unknown identity is worse than no bring-up. Report exactly one line and wait: \`Failure Mode: Hook-less session launched without TOTEM_SELF_AGENT set.\`
|
|
2685
|
+
|
|
2686
|
+
1. **Assignment mail before orient — poll since last signoff, seat-anchored.** On a hook-less seat the order is fixed: assignment mail before orient, before any journal read, and before any knowledge search — an assignment can turn every one of those into a disclosure (step 2), and the managed hook derives neither the poll nor the journal for you (step 3). Poll AS YOUR SEAT: per-shell \`TOTEM_SELF_AGENT=<your-seat>\` (the mmnto-ai/totem#2629 scope ruling — never user/machine scope; prefer the inline form \`TOTEM_SELF_AGENT=<seat> totem mail\` where shell state does not persist between tool calls, and on a session whose step-0 env is already set that inline form RESTATES that seat and never another) or \`totem mail --as <your-seat>\`. **S0 identity check:** the banner's \`Self agents:\` line must name exactly your seat. Since @mmnto/cli 1.117.0 an identity-less multi-seat poll gates itself (broadcast-only serve, directed mail withheld as a count, exit 2 — the mmnto-ai/totem#2204 deterministic floor), so the residual S0 catches is the WRONG-single-identity class: a mis-scoped or inherited env naming a foreign seat resolves single-seat, ungated, and serves that seat's directed mail. A banner naming a FOREIGN seat = STOP: act on nothing served, propagate nothing from it, fix the identity, re-poll. A GATED poll's LISTING is broadcast-only by construction — but warning lines can still name an unclassifiable or unresolvable file (an ECL basename is recipient + compressed subject — the CLI's named limit), so propagate nothing from a gated poll's warnings either: fix the identity and re-poll for your directed mail. An \`Error:\` line is the same surface: since the mmnto-ai/totem#2685 fix a poll whose OWN outbox (one this repo hosts for a resolved seat) carries a dispatch with an unresolvable \`to:\` exits 4 (SENDER FAULT) — the verdict IS derived, read it, but the \`to:\` is yours to fix first (one recipient per dispatch, or broadcast; a comma list is never a recipient), and propagate nothing from the fault line; exit 2 stays NOT-DERIVED and wins when both hold. Unread = inbound − handled: consumption is tracked by \`processed/\` marks (\`feedback_check_outbox_before_replying\`), so the CLI path needs no cutoff stamp. Read every hit before proceeding — new mail can reprioritize everything below. (Fallback — a seat that must stamp-poll instead derives the cutoff from the newest journal's CONTENT date, the filename stamp or frontmatter, **never file mtime**, which git resets on clone/worktree and silently reports "inbox clean" over waiting mail; mmnto-ai/totem-strategy#813.)
|
|
2026
2687
|
|
|
2027
|
-
2. **
|
|
2688
|
+
2. **Kit-first — while a BLIND round is in flight, read the kit and nothing else.** The BLIND-kit marker is the dispatch SUBJECT's literal prefix \`BLIND round: \`, followed by the round's name and, in the same subject, the deposit clause \`— deposit to <orchestrator-seat> by <ISO-8601 deadline>\`; no frontmatter key carries it, so the subject line is what you read. **A round is in flight while such a dispatch has an unpassed deadline and no deposit received.** In flight, signon ENDS here: read the kit and the artifact the kit names, and nothing else — no \`totem orient\`, no journal, no knowledge search, no board or PR read — because live repository detail is precisely what a blind classification must not carry. The kit-first rule has no partial arm: if any step ran before you reached the kit, report the exposure to the round's orchestrator seat by directed mail and do not classify; a disclosed round is re-armed in a fresh session, never repaired in place.
|
|
2028
2689
|
|
|
2029
|
-
3. **
|
|
2690
|
+
3. **Consume the injected orientation — the managed hook injects exactly two blocks.** The managed SessionStart hook injects exactly two briefing blocks — \`totem describe\` and \`totem orient --session\` — and injects nothing else: no journal and no mail (it does other work at boot, none of which reaches your context), so your latest journal (step 4) and your seat-anchored mail (step 1) are derived BY HAND on every seat, hook or not — never assumed present. Do not re-run the two blocks it did inject. A repo may also run its own session hook; whatever such a hook injects serves the seat it is CONFIGURED for, not a derived identity, so confirm the injected material is YOURS: a visiting session receives the HOST seat's, and your carryforward derives from \`.totem/orchestration/<your-seat>/journal/\` (step 4), never from a foreign journal. On a hook-seat MISMATCH (an injected journal or mail banner naming another seat), consume NONE of the injected material — treat the session as hook-less and derive it all: \`totem orient\` plus your own seat-anchored poll (step 1). Everything else the bring-up needs (the full board in-flight set, corpus freshness, doctrine currency) is derived on demand via \`totem orient\`. On a hook-less seat (other vendors, cold starts), derive it all: \`totem orient\`.
|
|
2030
2691
|
|
|
2031
|
-
4. **
|
|
2692
|
+
4. **Re-derive the carryforward gates — don't trust the journal's framing** (Tenet 20 read-side twin). For each carryforward item in YOUR SEAT's latest journal (\`.totem/orchestration/<your-seat>/journal/\` — on a multi-seat repo another seat's newer journal is not your carryforward), freshly derive its gate state (the PR it waits on, the issue, the date, the release train) via \`gh\` / \`git\` reads. Cross-repo gates resolve through the frozen cohort roster — \`totem\` / \`strategy\` / \`status\` / \`lc\` → \`mmnto-ai/{totem, totem-strategy, totem-status, liquid-city}\` (mmnto-ai/totem-strategy#611 gates any change). An item whose gate fired leads the next-steps list; an item still gated is reported as waiting, not worked.
|
|
2032
2693
|
|
|
2033
|
-
5. **
|
|
2694
|
+
5. **Surface owed-now sensors.** Anything the injected/derived orientation flags as owed (corpus \`⚠ stale\`, strategy-doctrine \`⚠ publish owed\`, board drift) goes on the list as a candidate — sensors report, they don't gate (Tenet 13).
|
|
2695
|
+
|
|
2696
|
+
6. **Present and stop.** One message: state summary (seat, inbox, gate states, owed-now items) + ranked next-steps with a recommendation. Then wait for the operator's ruling — signon ends at the judgment handoff; mutations belong to the ruled work, not the bring-up.
|
|
2034
2697
|
|
|
2035
2698
|
${SKILL_MARKER_END}
|
|
2036
2699
|
`;
|
|
@@ -2090,7 +2753,7 @@ For each selected finding, generate a lesson and call \`mcp__totem-dev__add_less
|
|
|
2090
2753
|
|
|
2091
2754
|
### \`done\`
|
|
2092
2755
|
|
|
2093
|
-
Print a summary of actions taken, then — when the round is being dispositioned — assemble and post the single consolidated round-disposition comment (see the section below), which EXECUTES \`totem review --covariate\` to carry the \`local-lane:\` line, on the operator's explicit go. Then exit.
|
|
2756
|
+
Print a summary of actions taken, then — when the round is being dispositioned — assemble and post the single consolidated round-disposition comment (see the section below), which EXECUTES \`totem review --covariate\` to carry the \`local-lane:\` line, on the operator's explicit go. Then, as the LAST action of the round, run \`totem resolve-threads\` (step 4 of that section) — dry first, \`--apply\` only on the operator's explicit go. Then exit.
|
|
2094
2757
|
|
|
2095
2758
|
## CRITICAL: GCA Reply Protocol
|
|
2096
2759
|
|
|
@@ -2112,6 +2775,22 @@ It resolves the current branch lineage exactly as the review fan does and prints
|
|
|
2112
2775
|
|
|
2113
2776
|
3. **Post on an explicit go.** Show the assembled body and wait for the operator; on their go, post the ONE comment with \`gh pr comment $ARGUMENTS --body-file -\` (pipe the body via stdin). Never mutate the PR autonomously.
|
|
2114
2777
|
|
|
2778
|
+
4. **Resolve the threads this round dispositioned — dry first, \`--apply\` on the operator's explicit go.** The comment you just posted IS the evidence the verb reads, so this step runs AFTER it, and it is the LAST action of the round. Print the plan (this never mutates):
|
|
2779
|
+
|
|
2780
|
+
\`\`\`bash
|
|
2781
|
+
totem resolve-threads $ARGUMENTS
|
|
2782
|
+
\`\`\`
|
|
2783
|
+
|
|
2784
|
+
Every bot-rooted thread prints one row carrying its REST root comment id and a verdict: \`resolve\`, \`skip:already-resolved\`, \`skip:outdated\`, \`skip:no-evidence\`, \`skip:not-selected\`. A \`skip:no-evidence\` row is a thread this round has not answered — neither an in-thread reply from a human nor a PR-level comment created after that thread's root — and the verb will NEVER resolve it under any flag; give it evidence and re-run rather than working around it. Show the plan and wait. Only on the operator's explicit go, run the mutating half (add \`--ids <comma-separated REST root comment ids>\` to narrow it to named rows; an unmatched id aborts before anything is resolved):
|
|
2785
|
+
|
|
2786
|
+
\`\`\`bash
|
|
2787
|
+
totem resolve-threads $ARGUMENTS --apply
|
|
2788
|
+
\`\`\`
|
|
2789
|
+
|
|
2790
|
+
The verb never posts a comment, a reply or a review — the only mutation it can issue is \`resolveReviewThread\`, which is what the merge-ready gate's unresolved-bot-threads predicate reads. Exit \`2\` means it did not do everything asked (an unmatched id, a failed mutation, or a selected thread with no evidence); exit \`1\` means the read did not complete and NOTHING was resolved. Report what it printed, verbatim.
|
|
2791
|
+
|
|
2792
|
+
A clean \`--apply\` run is NOT an allow verdict, and it clears the unresolved-bot-threads predicate only when no unresolved, non-outdated thread rooted by a known review bot remains: a \`skip:no-evidence\` row stays unresolved and, under \`--apply\`, makes the run exit 2; a run narrowed with \`--ids\` leaves its unnamed rows as \`skip:not-selected\` and exits 0. The gate re-reads the PR when \`gh pr merge\` runs, and a bot HIGH inline whose commit cannot be read makes the evaluation UNEVALUABLE once every earlier predicate passes — a deny the resolve run does not predict (under the pilot tier it warns; strict denies). After the apply, read the floor itself — \`totem gate check --event merge-ready --payload '{"repo":"<owner/repo>","pr":$ARGUMENTS}'\`, with \`--tier pilot\` where the installed gate is the pilot — and report that verdict beside the resolve rows, before the merge word is asked for.
|
|
2793
|
+
|
|
2115
2794
|
${SKILL_MARKER_END}
|
|
2116
2795
|
`;
|
|
2117
2796
|
export const REVIEW_LOOP_SKILL_CONTENT = `---
|