@muggleai/works 5.27.0-staging.241 → 5.27.0-staging.243
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/.claude-plugin/marketplace.json +1 -1
- package/dist/{chunk-EK5DJBSY.js → chunk-EPIM27JX.js} +12 -4
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/.codex-mcp.json +1 -1
- package/dist/plugin/.codex-plugin/plugin.json +1 -1
- package/dist/plugin/.cursor-plugin/plugin.json +1 -1
- package/dist/plugin/.mcp.json +1 -1
- package/dist/plugin/hooks/README.md +1 -1
- package/dist/plugin/scripts/guardrails.mjs +172 -157
- package/dist/release-manifest.json +3 -3
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-mcp.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.cursor-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -1
- package/plugin/hooks/README.md +1 -1
- package/plugin/scripts/guardrails.mjs +172 -157
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"name": "muggleai",
|
|
10
10
|
"source": "./plugin",
|
|
11
11
|
"description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
|
|
12
|
-
"version": "5.27.0-staging.
|
|
12
|
+
"version": "5.27.0-staging.243"
|
|
13
13
|
}
|
|
14
14
|
]
|
|
15
15
|
}
|
|
@@ -4719,6 +4719,7 @@ mcpToolNamePattern(
|
|
|
4719
4719
|
mcpToolNamePattern("muggle-local-telemetry-event-emit");
|
|
4720
4720
|
mcpToolNamePattern("muggle-remote-user-feedback-create");
|
|
4721
4721
|
var LOCK_POLL_INTERVAL_MS = 10;
|
|
4722
|
+
var UNRECORDED_LOCK_HOLDER_GRACE_MS = 1e4;
|
|
4722
4723
|
|
|
4723
4724
|
// src/guardrails/types.ts
|
|
4724
4725
|
var ProviderPrState = /* @__PURE__ */ ((ProviderPrState2) => {
|
|
@@ -5104,10 +5105,17 @@ function recordedHolder(lockPath) {
|
|
|
5104
5105
|
return void 0;
|
|
5105
5106
|
}
|
|
5106
5107
|
}
|
|
5107
|
-
function
|
|
5108
|
+
function isUnrecordedLockPastGrace(lockPath) {
|
|
5109
|
+
try {
|
|
5110
|
+
return Date.now() - statSync(lockPath).mtimeMs > UNRECORDED_LOCK_HOLDER_GRACE_MS;
|
|
5111
|
+
} catch {
|
|
5112
|
+
return false;
|
|
5113
|
+
}
|
|
5114
|
+
}
|
|
5115
|
+
function breakAbandonedLock(lockPath) {
|
|
5108
5116
|
const holder = recordedHolder(lockPath);
|
|
5109
|
-
|
|
5110
|
-
if (
|
|
5117
|
+
const isAbandoned = holder ? holder.host === hostname() && !isProcessAlive(holder.pid) : isUnrecordedLockPastGrace(lockPath);
|
|
5118
|
+
if (!isAbandoned) return;
|
|
5111
5119
|
try {
|
|
5112
5120
|
unlinkSync(lockPath);
|
|
5113
5121
|
} catch {
|
|
@@ -5132,7 +5140,7 @@ function withFileLock(lockPath, waitMs, run) {
|
|
|
5132
5140
|
})
|
|
5133
5141
|
);
|
|
5134
5142
|
} catch {
|
|
5135
|
-
|
|
5143
|
+
breakAbandonedLock(lockPath);
|
|
5136
5144
|
if (Date.now() >= deadline) return void 0;
|
|
5137
5145
|
pauseBetweenAttempts();
|
|
5138
5146
|
continue;
|
package/dist/cli.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-
|
|
1
|
+
export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-EPIM27JX.js';
|
|
2
2
|
export { createChildLogger, e2e_exports as e2e, getConfig, getLocalQaTools, getLogger, getQaTools, local_exports as localQa, mcp_exports as mcp, e2e_exports as qa, src_exports as shared } from './chunk-FI6VHHZN.js';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muggle",
|
|
3
3
|
"description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
|
|
4
|
-
"version": "5.27.0-staging.
|
|
4
|
+
"version": "5.27.0-staging.243",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Muggle AI",
|
|
7
7
|
"email": "support@muggle-ai.com"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muggle",
|
|
3
|
-
"version": "5.27.0-staging.
|
|
3
|
+
"version": "5.27.0-staging.243",
|
|
4
4
|
"description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Muggle AI",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "muggle",
|
|
3
3
|
"displayName": "Muggle AI",
|
|
4
4
|
"description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
|
|
5
|
-
"version": "5.27.0-staging.
|
|
5
|
+
"version": "5.27.0-staging.243",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Muggle AI",
|
|
8
8
|
"email": "support@muggle-ai.com"
|
package/dist/plugin/.mcp.json
CHANGED
|
@@ -26,7 +26,7 @@ Enforcement is reserved for the handoffs that were being skipped: the E2E accept
|
|
|
26
26
|
|
|
27
27
|
A recorder that *clears* an obligation — a walkthrough posted, a failure diagnosed, a test case classified — records only when the call did not visibly fail (`callOutcome.ts`). Recording from the request alone let a rejected `gh pr comment` mark the walkthrough posted, so the gate went quiet on a PR that never received it.
|
|
28
28
|
|
|
29
|
-
Each guardrail is a bash gate script in `../scripts/` holding a `guardrail_wants` pre-filter and the `guardrails.mjs` subcommand it feeds; the bundled `../scripts/guardrails.mjs` holds the decision logic (built from `src/guardrails/`, vitest-covered). The busy events — a shell command before and after it runs, and every turn end — register one `guardrail-dispatch.sh` command listing their gates, which reads the payload once, asks every gate's pre-filter in that one process, and starts Node once (`guardrails.mjs run <gates...>`) for just the gates that could fire, merging their responses. Rarer events call a gate script directly. Per-session state in `~/.muggle-ai/guardrails/<session_id>.json` tracks what fired. Any *failure* degrades to `{}` (allow) — a gate blocks only by an explicit, tested decision, never by accident; `guardrail-lib.sh` is the one place that falls back, and `run` isolates gates from each other.
|
|
29
|
+
Each guardrail is a bash gate script in `../scripts/` holding a `guardrail_wants` pre-filter and the `guardrails.mjs` subcommand it feeds; the bundled `../scripts/guardrails.mjs` holds the decision logic (built from `src/guardrails/`, vitest-covered). The busy events — a shell command before and after it runs, and every turn end — register one `guardrail-dispatch.sh` command listing their gates, which reads the payload once, asks every gate's pre-filter in that one process, and starts Node once (`guardrails.mjs run <gates...>`) for just the gates that could fire, merging their responses. Rarer events call a gate script directly. Per-session state in `~/.muggle-ai/guardrails/<session_id>.json` tracks what fired. Every hook commits to it under the session's lock, re-reading inside it, because of two hooks that read, change and write it unlocked the later erases the earlier; a hook that cannot take the lock within its wait saves its change beside the state file for a detached job to commit, and one with nothing new to record takes no lock. A lock is broken when its recorded holder is dead, or, when it names none (a hook killed between creating it and recording itself), once it is 10 seconds old. Any *failure* degrades to `{}` (allow) — a gate blocks only by an explicit, tested decision, never by accident; `guardrail-lib.sh` is the one place that falls back, and `run` isolates gates from each other.
|
|
30
30
|
|
|
31
31
|
Every `hooks.json` command enters through `../scripts/hook-entry.sh`, with the hook's script and arguments set as positional parameters first: `set -- guardrail-dispatch.sh guardrail-e2e-gate.sh …; . "${CLAUDE_PLUGIN_ROOT}/scripts/hook-entry.sh"`. Claude Code runs a hook command through Git Bash on Windows and through `/bin/sh` elsewhere. Inside Git Bash the entry sources the script into that same shell, so a hook costs the one Git Bash start Claude Code makes rather than a second `bash` with its own MSYS runtime init — measured at 0.3–1.2 s per firing on a loaded box. Anywhere else `/bin/sh` may be dash, so the entry is POSIX sh and hands the script to a bash of its own, as `bash "<script>"` did. A gate script tells it is the hook's own script — not one `guardrail-dispatch.sh` sourced for its pre-filter — through `guardrail_is_hook_script`, which reads the script path the entry records, or `$0` when bash ran the script.
|
|
32
32
|
|
|
@@ -84,6 +84,7 @@ var SESSION_STATE_LOCK_WAIT_MS = 250;
|
|
|
84
84
|
var DEFERRED_STATE_LOCK_WAIT_MS = 3e4;
|
|
85
85
|
var DEFERRED_STATE_MUTATION_INFIX = ".deferred-";
|
|
86
86
|
var LOCK_POLL_INTERVAL_MS = 10;
|
|
87
|
+
var UNRECORDED_LOCK_HOLDER_GRACE_MS = 1e4;
|
|
87
88
|
var CALL_FAILURE_SIGNALS = [
|
|
88
89
|
/\bgh:\s/,
|
|
89
90
|
/\bglab:\s/,
|
|
@@ -121,10 +122,17 @@ function recordedHolder(lockPath) {
|
|
|
121
122
|
return void 0;
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
|
-
function
|
|
125
|
+
function isUnrecordedLockPastGrace(lockPath) {
|
|
126
|
+
try {
|
|
127
|
+
return Date.now() - statSync(lockPath).mtimeMs > UNRECORDED_LOCK_HOLDER_GRACE_MS;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function breakAbandonedLock(lockPath) {
|
|
125
133
|
const holder = recordedHolder(lockPath);
|
|
126
|
-
|
|
127
|
-
if (
|
|
134
|
+
const isAbandoned = holder ? holder.host === hostname() && !isProcessAlive(holder.pid) : isUnrecordedLockPastGrace(lockPath);
|
|
135
|
+
if (!isAbandoned) return;
|
|
128
136
|
try {
|
|
129
137
|
unlinkSync(lockPath);
|
|
130
138
|
} catch {
|
|
@@ -149,7 +157,7 @@ function withFileLock(lockPath, waitMs, run) {
|
|
|
149
157
|
})
|
|
150
158
|
);
|
|
151
159
|
} catch {
|
|
152
|
-
|
|
160
|
+
breakAbandonedLock(lockPath);
|
|
153
161
|
if (Date.now() >= deadline) return void 0;
|
|
154
162
|
pauseBetweenAttempts();
|
|
155
163
|
continue;
|
|
@@ -265,11 +273,6 @@ function commitDeferredStateMutations(sessionId2, applyMutation, dirOverride) {
|
|
|
265
273
|
return committed;
|
|
266
274
|
});
|
|
267
275
|
}
|
|
268
|
-
function markPrHandled(sessionId2, prUrl, dirOverride) {
|
|
269
|
-
const state = readState(sessionId2, dirOverride);
|
|
270
|
-
if (!state.prsHandled.includes(prUrl)) state.prsHandled.push(prUrl);
|
|
271
|
-
writeState(state, dirOverride);
|
|
272
|
-
}
|
|
273
276
|
|
|
274
277
|
// src/guardrails/types.ts
|
|
275
278
|
var GuardrailBackgroundJob = /* @__PURE__ */ ((GuardrailBackgroundJob2) => {
|
|
@@ -1614,67 +1617,6 @@ function stageGateDecision(state, unreadStagePaths, maxBlocks = MAX_STAGE_BLOCKS
|
|
|
1614
1617
|
return { action: "block" /* Block */, blockCount: blockCount + 1, unread: unreadStagePaths };
|
|
1615
1618
|
}
|
|
1616
1619
|
|
|
1617
|
-
// src/guardrails/stateCommit.ts
|
|
1618
|
-
function applyToolOutcome(state, outcome) {
|
|
1619
|
-
const declared = applyE2eSkipDeclaration(applyRecordedRun(state, outcome.run), outcome.acceptedSkip);
|
|
1620
|
-
const recorded = applyE2eSkipScope(declared, outcome.skipVerifiedChange);
|
|
1621
|
-
const withWatchSkip = applyWatchSkip(recorded, outcome.isWatchSkip);
|
|
1622
|
-
const withBuildSkip = applyBuildSkip(withWatchSkip, outcome.isBuildSkip);
|
|
1623
|
-
const withWalkthroughPost = applyWalkthroughPosted(
|
|
1624
|
-
withBuildSkip,
|
|
1625
|
-
outcome.isWalkthroughPosted,
|
|
1626
|
-
outcome.walkthroughPostedAt,
|
|
1627
|
-
outcome.walkthroughPostTargets
|
|
1628
|
-
);
|
|
1629
|
-
const withWalkthroughSkip = applyWalkthroughSkip(withWalkthroughPost, outcome.isWalkthroughSkip);
|
|
1630
|
-
return outcome.failedRunId ? applyFailedRun(withWalkthroughSkip, outcome.failedRunId) : withWalkthroughSkip;
|
|
1631
|
-
}
|
|
1632
|
-
function applyFields(state, fields) {
|
|
1633
|
-
const isUnchanged = Object.entries(fields).every(
|
|
1634
|
-
([field, value]) => state[field] === value
|
|
1635
|
-
);
|
|
1636
|
-
return isUnchanged ? state : { ...state, ...fields };
|
|
1637
|
-
}
|
|
1638
|
-
function applyStateMutation(state, mutation) {
|
|
1639
|
-
switch (mutation.kind) {
|
|
1640
|
-
case "tool-outcome" /* ToolOutcome */:
|
|
1641
|
-
return applyToolOutcome(state, mutation);
|
|
1642
|
-
case "skill-invocation" /* SkillInvocation */:
|
|
1643
|
-
return applyRecordedRun(applySkillInvocation(state, mutation.skillName, mutation.stagePaths), mutation.emittedRun);
|
|
1644
|
-
case "pr-opened" /* PrOpened */:
|
|
1645
|
-
return applyPrOpenedToSkipScope(applyPrOpenedToWalkthroughs(state), mutation.prUrl, mutation.prChange);
|
|
1646
|
-
case "e2e-delegation" /* E2eDelegation */:
|
|
1647
|
-
return applyE2eDelegation(state, mutation.delegation);
|
|
1648
|
-
case "watch-follow-through" /* WatchFollowThrough */:
|
|
1649
|
-
return { ...applyWatchHolds(state, mutation.heldPrNumbers), watchFollowThroughBlockCount: mutation.blockCount };
|
|
1650
|
-
case "fields" /* Fields */:
|
|
1651
|
-
return applyFields(state, mutation.fields);
|
|
1652
|
-
}
|
|
1653
|
-
}
|
|
1654
|
-
function commitStateMutation(sessionId2, mutation) {
|
|
1655
|
-
const committed = commitState(sessionId2, (state) => applyStateMutation(state, mutation));
|
|
1656
|
-
if (committed) return committed;
|
|
1657
|
-
deferStateMutation(sessionId2, mutation);
|
|
1658
|
-
startBackgroundJob("commit-deferred-state" /* CommitDeferredState */, [sessionId2]);
|
|
1659
|
-
return applyStateMutation(readState(sessionId2), mutation);
|
|
1660
|
-
}
|
|
1661
|
-
function commitDeferredSessionState(sessionId2) {
|
|
1662
|
-
commitDeferredStateMutations(sessionId2, applyStateMutation);
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
// src/guardrails/prOpened.ts
|
|
1666
|
-
var CREATE_CMD = /\bgh\s+pr\s+(create|ready)\b/;
|
|
1667
|
-
var MR_CREATE_CMD = /\bglab\s+mr\s+create\b|\bglab\s+mr\s+update\b.*--ready\b/;
|
|
1668
|
-
function detectPrOpened(input2) {
|
|
1669
|
-
if (!isShellToolCall(input2)) return null;
|
|
1670
|
-
const cmd = input2.tool_input?.command ?? "";
|
|
1671
|
-
if (!CREATE_CMD.test(cmd) && !MR_CREATE_CMD.test(cmd)) return null;
|
|
1672
|
-
const out = `${input2.tool_response?.stdout ?? ""}
|
|
1673
|
-
${input2.tool_response?.output ?? ""}`;
|
|
1674
|
-
const m = out.match(GITHUB_PR_URL) ?? out.match(GITLAB_MR_URL);
|
|
1675
|
-
return m ? m[0] : null;
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
1620
|
// src/guardrails/prTerminal.ts
|
|
1679
1621
|
function terminalProvenance(input2) {
|
|
1680
1622
|
const command = input2.tool_input?.command;
|
|
@@ -1754,39 +1696,6 @@ function prTerminalGateDecision(state, maxBlocks = MAX_PR_TERMINAL_BLOCKS) {
|
|
|
1754
1696
|
return { action: "block" /* Block */, blockCount: blockCount + 1 };
|
|
1755
1697
|
}
|
|
1756
1698
|
|
|
1757
|
-
// src/guardrails/capabilityClaim.ts
|
|
1758
|
-
var SENTENCE = /[^.!?\n]+/g;
|
|
1759
|
-
var IMPOSSIBILITY = /\b(?:can(?:'|’)?t|cannot|can not|could ?n(?:'|’)?t|unable to|no way to|not able to|impossible|infeasible|untestable|unverifiable|not testable|blocked from|no (?:local )?(?:mail|email|smtp) path)\b/i;
|
|
1760
|
-
var MUGGLE_CLEARS = /\b(?:magic[- ]?link|sign[- ]?in link|login link|email link|one[- ]time (?:code|password)|otp|verification (?:code|link|email|mail)|email verification|confirmation (?:code|link|email|mail)|2fa|two[- ]factor|password reset|reset link|inbox|mailbox|mailhog|mailpit|mailtrap|mailcatcher|smtp|transactional email|captcha|recaptcha|hcaptcha|log ?in|logged[- ]?in|sign ?in|signed[- ]in|authenticate|credential|sms|text message|texted code|authenticator app|google authenticator|authy|totp)/i;
|
|
1761
|
-
var TESTING_CONTEXT = /\b(?:e2e|end[- ]to[- ]end|test(?:s|ed|ing|able)?|verif(?:y|ied|ication)|validat(?:e|ed|ion)|automat(?:e|ed|ion)|coverage|covered|exercis(?:e|ed)|reproduc(?:e|ed)|replay|scripts?|scripted|muggle|playwright|cypress|selenium)\b/i;
|
|
1762
|
-
var GENUINELY_UNSUPPORTED = /\b(?:phone call|voice call|oauth|social (?:login|sign[- ]?in)|sign in with (?:google|github|apple|facebook)|no (?:phone )?number (?:is )?provisioned|no provisioned (?:phone )?number)\b/i;
|
|
1763
|
-
function detectFalseCapabilityClaim(text) {
|
|
1764
|
-
const sentences = (text ?? "").match(SENTENCE) ?? [];
|
|
1765
|
-
return sentences.some(
|
|
1766
|
-
(sentence) => IMPOSSIBILITY.test(sentence) && MUGGLE_CLEARS.test(sentence) && TESTING_CONTEXT.test(sentence) && !GENUINELY_UNSUPPORTED.test(sentence)
|
|
1767
|
-
);
|
|
1768
|
-
}
|
|
1769
|
-
function lastAssistantText(transcriptJsonl) {
|
|
1770
|
-
const lines = (transcriptJsonl ?? "").split("\n");
|
|
1771
|
-
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
1772
|
-
const line = lines[index]?.trim();
|
|
1773
|
-
if (!line) continue;
|
|
1774
|
-
let entry;
|
|
1775
|
-
try {
|
|
1776
|
-
entry = JSON.parse(line);
|
|
1777
|
-
} catch {
|
|
1778
|
-
continue;
|
|
1779
|
-
}
|
|
1780
|
-
if (entry.type !== "assistant") continue;
|
|
1781
|
-
const content = entry.message?.content;
|
|
1782
|
-
if (typeof content === "string") return content;
|
|
1783
|
-
if (!Array.isArray(content)) continue;
|
|
1784
|
-
const prose = content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n");
|
|
1785
|
-
if (prose) return prose;
|
|
1786
|
-
}
|
|
1787
|
-
return "";
|
|
1788
|
-
}
|
|
1789
|
-
|
|
1790
1699
|
// src/guardrails/preExecutionClassification.ts
|
|
1791
1700
|
var CLASSIFICATION_SKIP_MARKER = /^\s*echo\s+["']?MUGGLE_CLASSIFY_SKIP\b/;
|
|
1792
1701
|
function detectClassifiedTestCaseId(input2) {
|
|
@@ -1820,6 +1729,125 @@ function classificationGateDecision(state, input2) {
|
|
|
1820
1729
|
return { deny: true, testCaseId };
|
|
1821
1730
|
}
|
|
1822
1731
|
|
|
1732
|
+
// src/guardrails/stateCommit.ts
|
|
1733
|
+
function applyToolOutcome(state, outcome) {
|
|
1734
|
+
const declared = applyE2eSkipDeclaration(applyRecordedRun(state, outcome.run), outcome.acceptedSkip);
|
|
1735
|
+
const recorded = applyE2eSkipScope(declared, outcome.skipVerifiedChange);
|
|
1736
|
+
const withWatchSkip = applyWatchSkip(recorded, outcome.isWatchSkip);
|
|
1737
|
+
const withBuildSkip = applyBuildSkip(withWatchSkip, outcome.isBuildSkip);
|
|
1738
|
+
const withWalkthroughPost = applyWalkthroughPosted(
|
|
1739
|
+
withBuildSkip,
|
|
1740
|
+
outcome.isWalkthroughPosted,
|
|
1741
|
+
outcome.walkthroughPostedAt,
|
|
1742
|
+
outcome.walkthroughPostTargets
|
|
1743
|
+
);
|
|
1744
|
+
const withWalkthroughSkip = applyWalkthroughSkip(withWalkthroughPost, outcome.isWalkthroughSkip);
|
|
1745
|
+
return outcome.failedRunId ? applyFailedRun(withWalkthroughSkip, outcome.failedRunId) : withWalkthroughSkip;
|
|
1746
|
+
}
|
|
1747
|
+
function applyStageSignals(state, signals) {
|
|
1748
|
+
const withStageSkip = applyStageSkip(state, signals.isStageSkip);
|
|
1749
|
+
const withClassificationSkip = applyClassificationSkip(withStageSkip, signals.isClassificationSkip);
|
|
1750
|
+
const withDebugSkip = applyDebugSkip(withClassificationSkip, signals.debugSkipCommand ?? "");
|
|
1751
|
+
const withClassification = signals.classifiedTestCaseId ? applyClassifiedTestCase(withDebugSkip, signals.classifiedTestCaseId) : withDebugSkip;
|
|
1752
|
+
return applyDebugEvidence(withClassification, signals.debugEvidenceRunIds);
|
|
1753
|
+
}
|
|
1754
|
+
function applyPrHandled(state, prUrl) {
|
|
1755
|
+
return state.prsHandled.includes(prUrl) ? state : { ...state, prsHandled: [...state.prsHandled, prUrl] };
|
|
1756
|
+
}
|
|
1757
|
+
function applyFields(state, fields) {
|
|
1758
|
+
const isUnchanged = Object.entries(fields).every(
|
|
1759
|
+
([field, value]) => state[field] === value
|
|
1760
|
+
);
|
|
1761
|
+
return isUnchanged ? state : { ...state, ...fields };
|
|
1762
|
+
}
|
|
1763
|
+
function applyStateMutation(state, mutation) {
|
|
1764
|
+
switch (mutation.kind) {
|
|
1765
|
+
case "tool-outcome" /* ToolOutcome */:
|
|
1766
|
+
return applyToolOutcome(state, mutation);
|
|
1767
|
+
case "skill-invocation" /* SkillInvocation */:
|
|
1768
|
+
return applyRecordedRun(applySkillInvocation(state, mutation.skillName, mutation.stagePaths), mutation.emittedRun);
|
|
1769
|
+
case "pr-opened" /* PrOpened */:
|
|
1770
|
+
return applyPrOpenedToSkipScope(
|
|
1771
|
+
applyPrOpenedToWalkthroughs(applyPrHandled(state, mutation.prUrl)),
|
|
1772
|
+
mutation.prUrl,
|
|
1773
|
+
mutation.prChange
|
|
1774
|
+
);
|
|
1775
|
+
case "pr-terminal" /* PrTerminal */:
|
|
1776
|
+
return applyPrTerminalDetected(state, mutation.prNumber);
|
|
1777
|
+
case "pr-reopened" /* PrReopened */:
|
|
1778
|
+
return applyPrReopened(state, mutation.prNumber);
|
|
1779
|
+
case "next-options-offered" /* NextOptionsOffered */:
|
|
1780
|
+
return applyNextOptionsOffered(state);
|
|
1781
|
+
case "e2e-delegation" /* E2eDelegation */:
|
|
1782
|
+
return applyE2eDelegation(state, mutation.delegation);
|
|
1783
|
+
case "watch-follow-through" /* WatchFollowThrough */:
|
|
1784
|
+
return { ...applyWatchHolds(state, mutation.heldPrNumbers), watchFollowThroughBlockCount: mutation.blockCount };
|
|
1785
|
+
case "stage-reads" /* StageReads */:
|
|
1786
|
+
return mutation.readPaths.reduce(applyStageRead, state);
|
|
1787
|
+
case "stage-signals" /* StageSignals */:
|
|
1788
|
+
return applyStageSignals(state, mutation);
|
|
1789
|
+
case "fields" /* Fields */:
|
|
1790
|
+
return applyFields(state, mutation.fields);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
function commitStateMutation(sessionId2, mutation, observedState) {
|
|
1794
|
+
if (observedState && applyStateMutation(observedState, mutation) === observedState) return observedState;
|
|
1795
|
+
const committed = commitState(sessionId2, (state) => applyStateMutation(state, mutation));
|
|
1796
|
+
if (committed) return committed;
|
|
1797
|
+
deferStateMutation(sessionId2, mutation);
|
|
1798
|
+
startBackgroundJob("commit-deferred-state" /* CommitDeferredState */, [sessionId2]);
|
|
1799
|
+
return applyStateMutation(readState(sessionId2), mutation);
|
|
1800
|
+
}
|
|
1801
|
+
function commitDeferredSessionState(sessionId2) {
|
|
1802
|
+
commitDeferredStateMutations(sessionId2, applyStateMutation);
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
// src/guardrails/prOpened.ts
|
|
1806
|
+
var CREATE_CMD = /\bgh\s+pr\s+(create|ready)\b/;
|
|
1807
|
+
var MR_CREATE_CMD = /\bglab\s+mr\s+create\b|\bglab\s+mr\s+update\b.*--ready\b/;
|
|
1808
|
+
function detectPrOpened(input2) {
|
|
1809
|
+
if (!isShellToolCall(input2)) return null;
|
|
1810
|
+
const cmd = input2.tool_input?.command ?? "";
|
|
1811
|
+
if (!CREATE_CMD.test(cmd) && !MR_CREATE_CMD.test(cmd)) return null;
|
|
1812
|
+
const out = `${input2.tool_response?.stdout ?? ""}
|
|
1813
|
+
${input2.tool_response?.output ?? ""}`;
|
|
1814
|
+
const m = out.match(GITHUB_PR_URL) ?? out.match(GITLAB_MR_URL);
|
|
1815
|
+
return m ? m[0] : null;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// src/guardrails/capabilityClaim.ts
|
|
1819
|
+
var SENTENCE = /[^.!?\n]+/g;
|
|
1820
|
+
var IMPOSSIBILITY = /\b(?:can(?:'|’)?t|cannot|can not|could ?n(?:'|’)?t|unable to|no way to|not able to|impossible|infeasible|untestable|unverifiable|not testable|blocked from|no (?:local )?(?:mail|email|smtp) path)\b/i;
|
|
1821
|
+
var MUGGLE_CLEARS = /\b(?:magic[- ]?link|sign[- ]?in link|login link|email link|one[- ]time (?:code|password)|otp|verification (?:code|link|email|mail)|email verification|confirmation (?:code|link|email|mail)|2fa|two[- ]factor|password reset|reset link|inbox|mailbox|mailhog|mailpit|mailtrap|mailcatcher|smtp|transactional email|captcha|recaptcha|hcaptcha|log ?in|logged[- ]?in|sign ?in|signed[- ]in|authenticate|credential|sms|text message|texted code|authenticator app|google authenticator|authy|totp)/i;
|
|
1822
|
+
var TESTING_CONTEXT = /\b(?:e2e|end[- ]to[- ]end|test(?:s|ed|ing|able)?|verif(?:y|ied|ication)|validat(?:e|ed|ion)|automat(?:e|ed|ion)|coverage|covered|exercis(?:e|ed)|reproduc(?:e|ed)|replay|scripts?|scripted|muggle|playwright|cypress|selenium)\b/i;
|
|
1823
|
+
var GENUINELY_UNSUPPORTED = /\b(?:phone call|voice call|oauth|social (?:login|sign[- ]?in)|sign in with (?:google|github|apple|facebook)|no (?:phone )?number (?:is )?provisioned|no provisioned (?:phone )?number)\b/i;
|
|
1824
|
+
function detectFalseCapabilityClaim(text) {
|
|
1825
|
+
const sentences = (text ?? "").match(SENTENCE) ?? [];
|
|
1826
|
+
return sentences.some(
|
|
1827
|
+
(sentence) => IMPOSSIBILITY.test(sentence) && MUGGLE_CLEARS.test(sentence) && TESTING_CONTEXT.test(sentence) && !GENUINELY_UNSUPPORTED.test(sentence)
|
|
1828
|
+
);
|
|
1829
|
+
}
|
|
1830
|
+
function lastAssistantText(transcriptJsonl) {
|
|
1831
|
+
const lines = (transcriptJsonl ?? "").split("\n");
|
|
1832
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
1833
|
+
const line = lines[index]?.trim();
|
|
1834
|
+
if (!line) continue;
|
|
1835
|
+
let entry;
|
|
1836
|
+
try {
|
|
1837
|
+
entry = JSON.parse(line);
|
|
1838
|
+
} catch {
|
|
1839
|
+
continue;
|
|
1840
|
+
}
|
|
1841
|
+
if (entry.type !== "assistant") continue;
|
|
1842
|
+
const content = entry.message?.content;
|
|
1843
|
+
if (typeof content === "string") return content;
|
|
1844
|
+
if (!Array.isArray(content)) continue;
|
|
1845
|
+
const prose = content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n");
|
|
1846
|
+
if (prose) return prose;
|
|
1847
|
+
}
|
|
1848
|
+
return "";
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1823
1851
|
// src/guardrails/testsGreen.ts
|
|
1824
1852
|
var FAIL = /\b\d+\s+failed\b|\bFAIL\b|✗/;
|
|
1825
1853
|
var TEST_SCRIPT = /^test(:|$)/;
|
|
@@ -2617,6 +2645,9 @@ function releaseGate(field) {
|
|
|
2617
2645
|
commitStateMutation(sessionId, { kind: "fields" /* Fields */, fields: { [field]: true } });
|
|
2618
2646
|
return "{}";
|
|
2619
2647
|
}
|
|
2648
|
+
function recordGateBlock(field, blockCount) {
|
|
2649
|
+
commitStateMutation(sessionId, { kind: "fields" /* Fields */, fields: { [field]: blockCount } });
|
|
2650
|
+
}
|
|
2620
2651
|
function muggleSkill(slashCommand) {
|
|
2621
2652
|
return skillInvocation(slashCommand, host);
|
|
2622
2653
|
}
|
|
@@ -2631,7 +2662,6 @@ function prOpened() {
|
|
|
2631
2662
|
const url = detectPrOpened(input);
|
|
2632
2663
|
if (!url) return "{}";
|
|
2633
2664
|
if (readState(sessionId).prsHandled.includes(url)) return "{}";
|
|
2634
|
-
markPrHandled(sessionId, url);
|
|
2635
2665
|
const prChange = resolveOpenedPrChange(input.tool_input?.command ?? "", url, input.cwd);
|
|
2636
2666
|
const stateAfterOpen = commitStateMutation(sessionId, {
|
|
2637
2667
|
kind: "pr-opened" /* PrOpened */,
|
|
@@ -2657,37 +2687,30 @@ function prOpened() {
|
|
|
2657
2687
|
function prTerminal() {
|
|
2658
2688
|
const reopenedPrNumber = detectPrReopened(input);
|
|
2659
2689
|
if (reopenedPrNumber !== null) {
|
|
2660
|
-
|
|
2661
|
-
const next2 = applyPrReopened(state2, reopenedPrNumber);
|
|
2662
|
-
if (next2 !== state2) writeState(next2);
|
|
2690
|
+
commitStateMutation(sessionId, { kind: "pr-reopened" /* PrReopened */, prNumber: reopenedPrNumber }, readState(sessionId));
|
|
2663
2691
|
return "{}";
|
|
2664
2692
|
}
|
|
2665
2693
|
const terminalEvent = detectPrTerminal(input);
|
|
2666
2694
|
if (!terminalEvent) return "{}";
|
|
2667
2695
|
const state = readState(sessionId);
|
|
2668
|
-
const
|
|
2669
|
-
if (
|
|
2670
|
-
writeState(next);
|
|
2696
|
+
const terminalMutation = { kind: "pr-terminal" /* PrTerminal */, prNumber: terminalEvent.prNumber };
|
|
2697
|
+
if (commitStateMutation(sessionId, terminalMutation, state) === state) return "{}";
|
|
2671
2698
|
const ctx = `PR #${terminalEvent.prNumber} went terminal (${terminalEvent.verdict}). Run the post-merge handoff now: finalize the watcher slot, tear down per autoCleanup, then OFFER NEXT OPTIONS to the user ${nextOptionsOfferMethod(isOfferAskedInClosingMessage())} \u2014 release, queued work, deferred items. The stop gate holds until the offer runs.`;
|
|
2672
2699
|
return envelope("PostToolUse", ctx, host);
|
|
2673
2700
|
}
|
|
2674
2701
|
function offerRan() {
|
|
2675
2702
|
if (!NEXT_OPTIONS_OFFER_TOOL_NAMES.includes(input.tool_name ?? "")) return "{}";
|
|
2676
|
-
|
|
2677
|
-
const next = applyNextOptionsOffered(state);
|
|
2678
|
-
if (next !== state) writeState(next);
|
|
2703
|
+
commitStateMutation(sessionId, { kind: "next-options-offered" /* NextOptionsOffered */ }, readState(sessionId));
|
|
2679
2704
|
return "{}";
|
|
2680
2705
|
}
|
|
2681
2706
|
function terminalGate() {
|
|
2682
2707
|
const pendingState = readState(sessionId);
|
|
2683
2708
|
const isAskedInClosingMessage = isOfferAskedInClosingMessage();
|
|
2684
|
-
const state = isAskedInClosingMessage && closingMessageAsksUser(closingAssistantText()) ?
|
|
2685
|
-
if (state !== pendingState) writeState(state);
|
|
2709
|
+
const state = isAskedInClosingMessage && closingMessageAsksUser(closingAssistantText()) ? commitStateMutation(sessionId, { kind: "next-options-offered" /* NextOptionsOffered */ }, pendingState) : pendingState;
|
|
2686
2710
|
const decision = prTerminalGateDecision(state);
|
|
2687
2711
|
if (decision.action === "release" /* Release */) return releaseGate("terminalReleased");
|
|
2688
2712
|
if (decision.action !== "block" /* Block */) return "{}";
|
|
2689
|
-
|
|
2690
|
-
writeState(state);
|
|
2713
|
+
recordGateBlock("terminalBlockCount", decision.blockCount);
|
|
2691
2714
|
const pendingPrList = (state.terminalPending ?? []).map((prNumber) => `#${prNumber}`).join(", ");
|
|
2692
2715
|
const reason = decision.blockCount === 1 ? `Do not end the turn yet. PR ${pendingPrList} went terminal (merged/closed) but the post-merge handoff has not run. Finalize the watcher slot, tear down per autoCleanup, then offer next options to the user ${nextOptionsOfferMethod(isAskedInClosingMessage)} \u2014 release, queued work, deferred items. Only that offer clears this gate.` : `Post-merge handoff still owed for PR ${pendingPrList} (reminder ${decision.blockCount}/${MAX_PR_TERMINAL_BLOCKS}): finalize + tear down, then offer the next options ${nextOptionsOfferMethod(isAskedInClosingMessage)}.`;
|
|
2693
2716
|
return blockStop(reason, host);
|
|
@@ -2771,27 +2794,25 @@ function recordStageRead() {
|
|
|
2771
2794
|
const readPaths = filePath ? [filePath].filter(isSkillTreeMarkdown) : isShellToolCall(input) ? detectShellStageReads(input.tool_input?.command ?? "", input.cwd) : [];
|
|
2772
2795
|
if (readPaths.length === 0) return "{}";
|
|
2773
2796
|
if (callFailed(input)) return "{}";
|
|
2774
|
-
|
|
2775
|
-
const next = readPaths.reduce(applyStageRead, state);
|
|
2776
|
-
if (next !== state) writeState(next);
|
|
2797
|
+
commitStateMutation(sessionId, { kind: "stage-reads" /* StageReads */, readPaths }, readState(sessionId));
|
|
2777
2798
|
return "{}";
|
|
2778
2799
|
}
|
|
2779
2800
|
function recordStageSignals() {
|
|
2780
2801
|
const cmd = input.tool_input?.command ?? "";
|
|
2781
2802
|
const state = readState(sessionId);
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2803
|
+
commitStateMutation(
|
|
2804
|
+
sessionId,
|
|
2805
|
+
{
|
|
2806
|
+
kind: "stage-signals" /* StageSignals */,
|
|
2807
|
+
isStageSkip: isStageSkipMarker(cmd),
|
|
2808
|
+
isClassificationSkip: isClassificationSkipMarker(cmd),
|
|
2809
|
+
debugSkipCommand: isDebugSkipMarker(cmd) ? cmd : void 0,
|
|
2810
|
+
classifiedTestCaseId: detectClassifiedTestCaseId(input),
|
|
2811
|
+
// Matched against the runs owed as this hook read them; evidence for a run already debugged commits nothing.
|
|
2812
|
+
debugEvidenceRunIds: detectDebugEvidenceRunIds(input, undebuggedFailedRuns(state))
|
|
2813
|
+
},
|
|
2814
|
+
state
|
|
2793
2815
|
);
|
|
2794
|
-
if (next !== state) writeState(next);
|
|
2795
2816
|
return "{}";
|
|
2796
2817
|
}
|
|
2797
2818
|
function classifyGate() {
|
|
@@ -2809,8 +2830,7 @@ function stageGate() {
|
|
|
2809
2830
|
const decision = stageGateDecision(state, unreadMandatoryStages(state));
|
|
2810
2831
|
if (decision.action === "release" /* Release */) return releaseGate("stageReleased");
|
|
2811
2832
|
if (decision.action !== "block" /* Block */) return "{}";
|
|
2812
|
-
|
|
2813
|
-
writeState(state);
|
|
2833
|
+
recordGateBlock("stageBlockCount", decision.blockCount);
|
|
2814
2834
|
const stageList = decision.unread.map(stageLabel).join(", ");
|
|
2815
2835
|
const reason = decision.blockCount === 1 ? `Do not end the turn yet. A skill invoked this session declares mandatory stages that were never opened: ${stageList}. Read them and carry out what they require \u2014 they are steps, not background reading, and SKILL.md only links to them. If they genuinely do not apply to this run, say why and run \`echo "MUGGLE_STAGE_SKIP: <reason>"\` \u2014 that records the skip and keeps this gate quiet for the rest of the session.` : `Mandatory stages still unread (reminder ${decision.blockCount}/${MAX_STAGE_BLOCKS}): ${stageList}. Read them, or record a legitimate skip via \`echo "MUGGLE_STAGE_SKIP: <reason>"\`.`;
|
|
2816
2836
|
return blockStop(reason, host);
|
|
@@ -2820,8 +2840,7 @@ function debugPathGate() {
|
|
|
2820
2840
|
const decision = debugGateDecision(state);
|
|
2821
2841
|
if (decision.action === "release" /* Release */) return releaseGate("debugReleased");
|
|
2822
2842
|
if (decision.action !== "block" /* Block */) return "{}";
|
|
2823
|
-
|
|
2824
|
-
writeState(state);
|
|
2843
|
+
recordGateBlock("debugBlockCount", decision.blockCount);
|
|
2825
2844
|
const runList = decision.undebugged.join(", ");
|
|
2826
2845
|
const reason = decision.blockCount === 1 ? `Do not end the turn yet. These runs failed and never went through the debug path: ${runList}. muggle-test Step 7C makes that mandatory \u2014 route each through _shared/debug-failed-run.md: gather the attempted steps and the failing screenshot, diagnose the bucket per _shared/failure-mode-handling.md \xA7B/\xA7C with its classified telemetry emit, and present the offer in which "give feedback & rerun" is always available. A summarized-and-dropped failure is the run a reviewer most needs to see. If a run genuinely cannot be debugged, run \`echo "MUGGLE_DEBUG_SKIP: <runId> <reason>"\` \u2014 that clears just that run.` : `Failed runs still owe the debug path (reminder ${decision.blockCount}/${MAX_DEBUG_BLOCKS}): ${runList}. Route each through _shared/debug-failed-run.md, or record a legitimate skip via \`echo "MUGGLE_DEBUG_SKIP: <runId> <reason>"\`.`;
|
|
2827
2846
|
return blockStop(reason, host);
|
|
@@ -2836,8 +2855,7 @@ function e2eGate() {
|
|
|
2836
2855
|
return releaseGate("e2eReleased");
|
|
2837
2856
|
}
|
|
2838
2857
|
if (decision.action === "none" /* None */) return "{}";
|
|
2839
|
-
|
|
2840
|
-
writeState(state);
|
|
2858
|
+
recordGateBlock("e2eBlockCount", decision.blockCount);
|
|
2841
2859
|
const reason = decision.blockCount === 1 ? `Do not end the turn yet. This session went unit-green or opened a PR, but no E2E acceptance run has happened. Per the autoE2ETest preference (default: always), run change-driven E2E now via ${muggleSkill("/muggle:muggle-test")}, then finish. To skip, run \`echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"\` citing one of ${E2E_SKIP_CODES.join(", ")}. Muggle verifies each code itself, so one that does not hold leaves this gate blocked. Only when no verifiable code fits and a browser run genuinely cannot exercise the change, cite ${"NOT_APPLICABLE" /* NotApplicable */} with the reason \u2014 the PR shows it as your unverified judgment.` : `E2E acceptance run still owed (reminder ${decision.blockCount}/${MAX_E2E_BLOCKS}): run ${muggleSkill("/muggle:muggle-test")}, or cite a verifiable code via \`echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"\`.`;
|
|
2842
2860
|
return blockStop(reason, host);
|
|
2843
2861
|
}
|
|
@@ -2851,8 +2869,7 @@ function watchGate() {
|
|
|
2851
2869
|
const decision = watchGateDecision(state, resolveOwedWatchPrs(unwatchedPrUrls, state, prStateScan));
|
|
2852
2870
|
if (decision.action === "release" /* Release */) return releaseGate("watchReleased");
|
|
2853
2871
|
if (decision.action === "none" /* None */) return "{}";
|
|
2854
|
-
|
|
2855
|
-
writeState(state);
|
|
2872
|
+
recordGateBlock("watchBlockCount", decision.blockCount);
|
|
2856
2873
|
const prList = decision.unwatched.join(", ");
|
|
2857
2874
|
const staleMinutes = WATCH_HEARTBEAT_STALE_MS / 6e4;
|
|
2858
2875
|
const armCommand = `${muggleSkill("/muggle:muggle-pr-followup")} ${decision.unwatched[0]}`;
|
|
@@ -2902,8 +2919,7 @@ function buildFollowthroughGate() {
|
|
|
2902
2919
|
const decision = buildFollowthroughDecision(state);
|
|
2903
2920
|
if (decision.action === "release" /* Release */) return releaseGate("buildSkipped");
|
|
2904
2921
|
if (decision.action === "none" /* None */) return "{}";
|
|
2905
|
-
|
|
2906
|
-
writeState(state);
|
|
2922
|
+
recordGateBlock("buildBlockCount", decision.blockCount);
|
|
2907
2923
|
const reason = decision.blockCount === 1 ? `Do not end the turn yet. This session took a build/implement/fix request but no PR was opened. A root cause written into the transcript ships nothing \u2014 once the session ends it is gone, and the watcher gate never fires because it only looks at PRs that exist. Carry the work to a PR via ${muggleSkill("/muggle-do")}, which runs requirements \u2192 build \u2192 impact \u2192 unit tests \u2192 E2E \u2192 PR \u2192 watcher. If no PR is owed here (the user changed their mind, the fix landed in another repo, the answer was advice rather than a change), tell the user why and run \`echo "MUGGLE_BUILD_SKIP: <reason>"\` \u2014 that records the skip and keeps this gate quiet for the rest of the session.` : `Build request still unanswered \u2014 no PR opened (reminder ${decision.blockCount}/${MAX_BUILD_BLOCKS}): carry it to a PR via ${muggleSkill("/muggle-do")}, or record a legitimate skip via \`echo "MUGGLE_BUILD_SKIP: <reason>"\`.`;
|
|
2908
2924
|
return blockStop(reason, host);
|
|
2909
2925
|
}
|
|
@@ -2916,15 +2932,21 @@ function walkthroughGate() {
|
|
|
2916
2932
|
const unposted = withoutPostedPrs(scan.owed, state);
|
|
2917
2933
|
if (unposted.length === 0) {
|
|
2918
2934
|
if (scan.verified.length + scan.owed.length > 0) {
|
|
2919
|
-
|
|
2935
|
+
commitStateMutation(
|
|
2936
|
+
sessionId,
|
|
2937
|
+
{
|
|
2938
|
+
kind: "fields" /* Fields */,
|
|
2939
|
+
fields: state.e2eRun === true ? { walkthroughsVerified: true } : { e2eSkipSettledOnPrs: true }
|
|
2940
|
+
},
|
|
2941
|
+
state
|
|
2942
|
+
);
|
|
2920
2943
|
}
|
|
2921
2944
|
return "{}";
|
|
2922
2945
|
}
|
|
2923
2946
|
const decision = walkthroughGateDecision(state, withoutDelegatedPrs(unposted, state, Date.now()));
|
|
2924
2947
|
if (decision.action === "release" /* Release */) return releaseGate("walkthroughReleased");
|
|
2925
2948
|
if (decision.action !== "block" /* Block */) return "{}";
|
|
2926
|
-
|
|
2927
|
-
writeState(state);
|
|
2949
|
+
recordGateBlock("walkthroughBlockCount", decision.blockCount);
|
|
2928
2950
|
const declaredSkip = state.e2eSkipDeclaration;
|
|
2929
2951
|
const skipOwedPrUrls = declaredSkip ? decision.owed.filter((prUrl) => isPrCoveredBySkip(state, prUrl)) : [];
|
|
2930
2952
|
if (declaredSkip && skipOwedPrUrls.length > 0) {
|
|
@@ -2994,10 +3016,7 @@ function commentReplyGate() {
|
|
|
2994
3016
|
const decision = commentReplyGateDecision(state, overdueThreads(slotPath, sessionId));
|
|
2995
3017
|
if (decision.action === "release" /* Release */) return releaseGate("commentReplyReleased");
|
|
2996
3018
|
if (decision.action !== "block" /* Block */) return "{}";
|
|
2997
|
-
|
|
2998
|
-
kind: "fields" /* Fields */,
|
|
2999
|
-
fields: { commentReplyBlockCount: decision.blockCount }
|
|
3000
|
-
});
|
|
3019
|
+
recordGateBlock("commentReplyBlockCount", decision.blockCount);
|
|
3001
3020
|
const commentList = decision.unanswered.join(", ");
|
|
3002
3021
|
const reason = decision.blockCount === 1 ? `Do not end the turn yet. This session took these review comments as work and never answered them: ${commentList}. Post one reply per comment in its own thread per muggle-do do/per-comment-replies.md \u2014 "Addressed in <short-sha>: <what changed for THIS comment>", signed via sign-body.sh --mode loop. A silent round leaves the reviewer with no answer in the thread, and the loop marker that reply carries is the only thing that stops the watcher re-dispatching the same thread next tick. If a comment was escalated to the user instead of answered, record it as deferred rather than replying.` : `Review comments still owe a threaded reply (reminder ${decision.blockCount}/${MAX_REPLY_BLOCKS}): ${commentList}. Reply per do/per-comment-replies.md, or record the deferral.`;
|
|
3003
3022
|
return blockStop(reason, host);
|
|
@@ -3021,10 +3040,8 @@ function buildRouter() {
|
|
|
3021
3040
|
const prompt = input.prompt ?? "";
|
|
3022
3041
|
const isMuggleDoInvoked = isMuggleDoForwardInvocation(prompt);
|
|
3023
3042
|
if (!isMuggleDoInvoked && !detectBuildIntent(prompt)) return "{}";
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
state.buildIntentRouted = true;
|
|
3027
|
-
writeState(state);
|
|
3043
|
+
if (readState(sessionId).buildIntentRouted) return "{}";
|
|
3044
|
+
commitStateMutation(sessionId, { kind: "fields" /* Fields */, fields: { buildIntentRouted: true } });
|
|
3028
3045
|
if (isMuggleDoInvoked) return "{}";
|
|
3029
3046
|
const ctx = `This looks like a build/implement/fix request. Per the autoRouteBuildToMuggleDo preference, route it through ${muggleSkill("/muggle-do")} \u2014 which runs requirements \u2192 build (delegated to superpowers' design\u2192plan\u2192review) \u2192 impact \u2192 unit tests \u2192 E2E \u2192 PR \u2192 watcher. If autoRouteBuildToMuggleDo=always, enter that flow; if =ask, offer it; if =never, proceed normally.`;
|
|
3030
3047
|
return envelope("UserPromptSubmit", ctx, host);
|
|
@@ -3042,11 +3059,9 @@ function closingAssistantText() {
|
|
|
3042
3059
|
}
|
|
3043
3060
|
}
|
|
3044
3061
|
function capabilityClaimGate() {
|
|
3045
|
-
|
|
3046
|
-
if (state.capabilityClaimNudged) return "{}";
|
|
3062
|
+
if (readState(sessionId).capabilityClaimNudged) return "{}";
|
|
3047
3063
|
if (!detectFalseCapabilityClaim(closingAssistantText())) return "{}";
|
|
3048
|
-
|
|
3049
|
-
writeState(state);
|
|
3064
|
+
commitStateMutation(sessionId, { kind: "fields" /* Fields */, fields: { capabilityClaimNudged: true } });
|
|
3050
3065
|
const reason = `Do not end the turn yet. This turn tells the user that a login- or code-gated flow cannot be tested, reached, or verified \u2014 the one class of blocker Muggle clears. Every managed login profile owns a live inbox, so magic-link and sign-in-link logins, emailed OTPs, email-based 2FA, verification mail, and password resets run end to end with no mail stub and no test-only backdoor. An SMS code is read from the profile's provisioned number, an authenticator code is derived offline from its stored TOTP secret, and stored credentials and CAPTCHA solving are covered too. Re-read plugin/skills/_shared/identity-and-inbox.md, then correct the claim or run the flow. Genuine limits stay sayable: a voice call, an OAuth-only provider, or an account with no phone number provisioned (the runner cannot mint one) \u2014 name the one that applies and move on.`;
|
|
3051
3066
|
return blockStop(reason, host);
|
|
3052
3067
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"release": "5.26.0",
|
|
3
|
-
"buildId": "run-
|
|
4
|
-
"commitSha": "
|
|
5
|
-
"buildTime": "2026-10-
|
|
3
|
+
"buildId": "run-243-1",
|
|
4
|
+
"commitSha": "794d5f8d369c432b2843204503c101ff1ee71483",
|
|
5
|
+
"buildTime": "2026-10-28T04:24:25Z",
|
|
6
6
|
"serviceName": "muggle-ai-works-mcp"
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muggleai/works",
|
|
3
3
|
"mcpName": "io.github.multiplex-ai/muggle",
|
|
4
|
-
"version": "5.27.0-staging.
|
|
4
|
+
"version": "5.27.0-staging.243",
|
|
5
5
|
"description": "13 years of Microsoft hyperscale engineering, packaged as a harness for your AI coding agent.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muggle",
|
|
3
3
|
"description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
|
|
4
|
-
"version": "5.27.0-staging.
|
|
4
|
+
"version": "5.27.0-staging.243",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Muggle AI",
|
|
7
7
|
"email": "support@muggle-ai.com"
|
package/plugin/.codex-mcp.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muggle",
|
|
3
|
-
"version": "5.27.0-staging.
|
|
3
|
+
"version": "5.27.0-staging.243",
|
|
4
4
|
"description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Muggle AI",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "muggle",
|
|
3
3
|
"displayName": "Muggle AI",
|
|
4
4
|
"description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
|
|
5
|
-
"version": "5.27.0-staging.
|
|
5
|
+
"version": "5.27.0-staging.243",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Muggle AI",
|
|
8
8
|
"email": "support@muggle-ai.com"
|
package/plugin/.mcp.json
CHANGED
package/plugin/hooks/README.md
CHANGED
|
@@ -26,7 +26,7 @@ Enforcement is reserved for the handoffs that were being skipped: the E2E accept
|
|
|
26
26
|
|
|
27
27
|
A recorder that *clears* an obligation — a walkthrough posted, a failure diagnosed, a test case classified — records only when the call did not visibly fail (`callOutcome.ts`). Recording from the request alone let a rejected `gh pr comment` mark the walkthrough posted, so the gate went quiet on a PR that never received it.
|
|
28
28
|
|
|
29
|
-
Each guardrail is a bash gate script in `../scripts/` holding a `guardrail_wants` pre-filter and the `guardrails.mjs` subcommand it feeds; the bundled `../scripts/guardrails.mjs` holds the decision logic (built from `src/guardrails/`, vitest-covered). The busy events — a shell command before and after it runs, and every turn end — register one `guardrail-dispatch.sh` command listing their gates, which reads the payload once, asks every gate's pre-filter in that one process, and starts Node once (`guardrails.mjs run <gates...>`) for just the gates that could fire, merging their responses. Rarer events call a gate script directly. Per-session state in `~/.muggle-ai/guardrails/<session_id>.json` tracks what fired. Any *failure* degrades to `{}` (allow) — a gate blocks only by an explicit, tested decision, never by accident; `guardrail-lib.sh` is the one place that falls back, and `run` isolates gates from each other.
|
|
29
|
+
Each guardrail is a bash gate script in `../scripts/` holding a `guardrail_wants` pre-filter and the `guardrails.mjs` subcommand it feeds; the bundled `../scripts/guardrails.mjs` holds the decision logic (built from `src/guardrails/`, vitest-covered). The busy events — a shell command before and after it runs, and every turn end — register one `guardrail-dispatch.sh` command listing their gates, which reads the payload once, asks every gate's pre-filter in that one process, and starts Node once (`guardrails.mjs run <gates...>`) for just the gates that could fire, merging their responses. Rarer events call a gate script directly. Per-session state in `~/.muggle-ai/guardrails/<session_id>.json` tracks what fired. Every hook commits to it under the session's lock, re-reading inside it, because of two hooks that read, change and write it unlocked the later erases the earlier; a hook that cannot take the lock within its wait saves its change beside the state file for a detached job to commit, and one with nothing new to record takes no lock. A lock is broken when its recorded holder is dead, or, when it names none (a hook killed between creating it and recording itself), once it is 10 seconds old. Any *failure* degrades to `{}` (allow) — a gate blocks only by an explicit, tested decision, never by accident; `guardrail-lib.sh` is the one place that falls back, and `run` isolates gates from each other.
|
|
30
30
|
|
|
31
31
|
Every `hooks.json` command enters through `../scripts/hook-entry.sh`, with the hook's script and arguments set as positional parameters first: `set -- guardrail-dispatch.sh guardrail-e2e-gate.sh …; . "${CLAUDE_PLUGIN_ROOT}/scripts/hook-entry.sh"`. Claude Code runs a hook command through Git Bash on Windows and through `/bin/sh` elsewhere. Inside Git Bash the entry sources the script into that same shell, so a hook costs the one Git Bash start Claude Code makes rather than a second `bash` with its own MSYS runtime init — measured at 0.3–1.2 s per firing on a loaded box. Anywhere else `/bin/sh` may be dash, so the entry is POSIX sh and hands the script to a bash of its own, as `bash "<script>"` did. A gate script tells it is the hook's own script — not one `guardrail-dispatch.sh` sourced for its pre-filter — through `guardrail_is_hook_script`, which reads the script path the entry records, or `$0` when bash ran the script.
|
|
32
32
|
|
|
@@ -84,6 +84,7 @@ var SESSION_STATE_LOCK_WAIT_MS = 250;
|
|
|
84
84
|
var DEFERRED_STATE_LOCK_WAIT_MS = 3e4;
|
|
85
85
|
var DEFERRED_STATE_MUTATION_INFIX = ".deferred-";
|
|
86
86
|
var LOCK_POLL_INTERVAL_MS = 10;
|
|
87
|
+
var UNRECORDED_LOCK_HOLDER_GRACE_MS = 1e4;
|
|
87
88
|
var CALL_FAILURE_SIGNALS = [
|
|
88
89
|
/\bgh:\s/,
|
|
89
90
|
/\bglab:\s/,
|
|
@@ -121,10 +122,17 @@ function recordedHolder(lockPath) {
|
|
|
121
122
|
return void 0;
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
|
-
function
|
|
125
|
+
function isUnrecordedLockPastGrace(lockPath) {
|
|
126
|
+
try {
|
|
127
|
+
return Date.now() - statSync(lockPath).mtimeMs > UNRECORDED_LOCK_HOLDER_GRACE_MS;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function breakAbandonedLock(lockPath) {
|
|
125
133
|
const holder = recordedHolder(lockPath);
|
|
126
|
-
|
|
127
|
-
if (
|
|
134
|
+
const isAbandoned = holder ? holder.host === hostname() && !isProcessAlive(holder.pid) : isUnrecordedLockPastGrace(lockPath);
|
|
135
|
+
if (!isAbandoned) return;
|
|
128
136
|
try {
|
|
129
137
|
unlinkSync(lockPath);
|
|
130
138
|
} catch {
|
|
@@ -149,7 +157,7 @@ function withFileLock(lockPath, waitMs, run) {
|
|
|
149
157
|
})
|
|
150
158
|
);
|
|
151
159
|
} catch {
|
|
152
|
-
|
|
160
|
+
breakAbandonedLock(lockPath);
|
|
153
161
|
if (Date.now() >= deadline) return void 0;
|
|
154
162
|
pauseBetweenAttempts();
|
|
155
163
|
continue;
|
|
@@ -265,11 +273,6 @@ function commitDeferredStateMutations(sessionId2, applyMutation, dirOverride) {
|
|
|
265
273
|
return committed;
|
|
266
274
|
});
|
|
267
275
|
}
|
|
268
|
-
function markPrHandled(sessionId2, prUrl, dirOverride) {
|
|
269
|
-
const state = readState(sessionId2, dirOverride);
|
|
270
|
-
if (!state.prsHandled.includes(prUrl)) state.prsHandled.push(prUrl);
|
|
271
|
-
writeState(state, dirOverride);
|
|
272
|
-
}
|
|
273
276
|
|
|
274
277
|
// src/guardrails/types.ts
|
|
275
278
|
var GuardrailBackgroundJob = /* @__PURE__ */ ((GuardrailBackgroundJob2) => {
|
|
@@ -1614,67 +1617,6 @@ function stageGateDecision(state, unreadStagePaths, maxBlocks = MAX_STAGE_BLOCKS
|
|
|
1614
1617
|
return { action: "block" /* Block */, blockCount: blockCount + 1, unread: unreadStagePaths };
|
|
1615
1618
|
}
|
|
1616
1619
|
|
|
1617
|
-
// src/guardrails/stateCommit.ts
|
|
1618
|
-
function applyToolOutcome(state, outcome) {
|
|
1619
|
-
const declared = applyE2eSkipDeclaration(applyRecordedRun(state, outcome.run), outcome.acceptedSkip);
|
|
1620
|
-
const recorded = applyE2eSkipScope(declared, outcome.skipVerifiedChange);
|
|
1621
|
-
const withWatchSkip = applyWatchSkip(recorded, outcome.isWatchSkip);
|
|
1622
|
-
const withBuildSkip = applyBuildSkip(withWatchSkip, outcome.isBuildSkip);
|
|
1623
|
-
const withWalkthroughPost = applyWalkthroughPosted(
|
|
1624
|
-
withBuildSkip,
|
|
1625
|
-
outcome.isWalkthroughPosted,
|
|
1626
|
-
outcome.walkthroughPostedAt,
|
|
1627
|
-
outcome.walkthroughPostTargets
|
|
1628
|
-
);
|
|
1629
|
-
const withWalkthroughSkip = applyWalkthroughSkip(withWalkthroughPost, outcome.isWalkthroughSkip);
|
|
1630
|
-
return outcome.failedRunId ? applyFailedRun(withWalkthroughSkip, outcome.failedRunId) : withWalkthroughSkip;
|
|
1631
|
-
}
|
|
1632
|
-
function applyFields(state, fields) {
|
|
1633
|
-
const isUnchanged = Object.entries(fields).every(
|
|
1634
|
-
([field, value]) => state[field] === value
|
|
1635
|
-
);
|
|
1636
|
-
return isUnchanged ? state : { ...state, ...fields };
|
|
1637
|
-
}
|
|
1638
|
-
function applyStateMutation(state, mutation) {
|
|
1639
|
-
switch (mutation.kind) {
|
|
1640
|
-
case "tool-outcome" /* ToolOutcome */:
|
|
1641
|
-
return applyToolOutcome(state, mutation);
|
|
1642
|
-
case "skill-invocation" /* SkillInvocation */:
|
|
1643
|
-
return applyRecordedRun(applySkillInvocation(state, mutation.skillName, mutation.stagePaths), mutation.emittedRun);
|
|
1644
|
-
case "pr-opened" /* PrOpened */:
|
|
1645
|
-
return applyPrOpenedToSkipScope(applyPrOpenedToWalkthroughs(state), mutation.prUrl, mutation.prChange);
|
|
1646
|
-
case "e2e-delegation" /* E2eDelegation */:
|
|
1647
|
-
return applyE2eDelegation(state, mutation.delegation);
|
|
1648
|
-
case "watch-follow-through" /* WatchFollowThrough */:
|
|
1649
|
-
return { ...applyWatchHolds(state, mutation.heldPrNumbers), watchFollowThroughBlockCount: mutation.blockCount };
|
|
1650
|
-
case "fields" /* Fields */:
|
|
1651
|
-
return applyFields(state, mutation.fields);
|
|
1652
|
-
}
|
|
1653
|
-
}
|
|
1654
|
-
function commitStateMutation(sessionId2, mutation) {
|
|
1655
|
-
const committed = commitState(sessionId2, (state) => applyStateMutation(state, mutation));
|
|
1656
|
-
if (committed) return committed;
|
|
1657
|
-
deferStateMutation(sessionId2, mutation);
|
|
1658
|
-
startBackgroundJob("commit-deferred-state" /* CommitDeferredState */, [sessionId2]);
|
|
1659
|
-
return applyStateMutation(readState(sessionId2), mutation);
|
|
1660
|
-
}
|
|
1661
|
-
function commitDeferredSessionState(sessionId2) {
|
|
1662
|
-
commitDeferredStateMutations(sessionId2, applyStateMutation);
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
// src/guardrails/prOpened.ts
|
|
1666
|
-
var CREATE_CMD = /\bgh\s+pr\s+(create|ready)\b/;
|
|
1667
|
-
var MR_CREATE_CMD = /\bglab\s+mr\s+create\b|\bglab\s+mr\s+update\b.*--ready\b/;
|
|
1668
|
-
function detectPrOpened(input2) {
|
|
1669
|
-
if (!isShellToolCall(input2)) return null;
|
|
1670
|
-
const cmd = input2.tool_input?.command ?? "";
|
|
1671
|
-
if (!CREATE_CMD.test(cmd) && !MR_CREATE_CMD.test(cmd)) return null;
|
|
1672
|
-
const out = `${input2.tool_response?.stdout ?? ""}
|
|
1673
|
-
${input2.tool_response?.output ?? ""}`;
|
|
1674
|
-
const m = out.match(GITHUB_PR_URL) ?? out.match(GITLAB_MR_URL);
|
|
1675
|
-
return m ? m[0] : null;
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
1620
|
// src/guardrails/prTerminal.ts
|
|
1679
1621
|
function terminalProvenance(input2) {
|
|
1680
1622
|
const command = input2.tool_input?.command;
|
|
@@ -1754,39 +1696,6 @@ function prTerminalGateDecision(state, maxBlocks = MAX_PR_TERMINAL_BLOCKS) {
|
|
|
1754
1696
|
return { action: "block" /* Block */, blockCount: blockCount + 1 };
|
|
1755
1697
|
}
|
|
1756
1698
|
|
|
1757
|
-
// src/guardrails/capabilityClaim.ts
|
|
1758
|
-
var SENTENCE = /[^.!?\n]+/g;
|
|
1759
|
-
var IMPOSSIBILITY = /\b(?:can(?:'|’)?t|cannot|can not|could ?n(?:'|’)?t|unable to|no way to|not able to|impossible|infeasible|untestable|unverifiable|not testable|blocked from|no (?:local )?(?:mail|email|smtp) path)\b/i;
|
|
1760
|
-
var MUGGLE_CLEARS = /\b(?:magic[- ]?link|sign[- ]?in link|login link|email link|one[- ]time (?:code|password)|otp|verification (?:code|link|email|mail)|email verification|confirmation (?:code|link|email|mail)|2fa|two[- ]factor|password reset|reset link|inbox|mailbox|mailhog|mailpit|mailtrap|mailcatcher|smtp|transactional email|captcha|recaptcha|hcaptcha|log ?in|logged[- ]?in|sign ?in|signed[- ]in|authenticate|credential|sms|text message|texted code|authenticator app|google authenticator|authy|totp)/i;
|
|
1761
|
-
var TESTING_CONTEXT = /\b(?:e2e|end[- ]to[- ]end|test(?:s|ed|ing|able)?|verif(?:y|ied|ication)|validat(?:e|ed|ion)|automat(?:e|ed|ion)|coverage|covered|exercis(?:e|ed)|reproduc(?:e|ed)|replay|scripts?|scripted|muggle|playwright|cypress|selenium)\b/i;
|
|
1762
|
-
var GENUINELY_UNSUPPORTED = /\b(?:phone call|voice call|oauth|social (?:login|sign[- ]?in)|sign in with (?:google|github|apple|facebook)|no (?:phone )?number (?:is )?provisioned|no provisioned (?:phone )?number)\b/i;
|
|
1763
|
-
function detectFalseCapabilityClaim(text) {
|
|
1764
|
-
const sentences = (text ?? "").match(SENTENCE) ?? [];
|
|
1765
|
-
return sentences.some(
|
|
1766
|
-
(sentence) => IMPOSSIBILITY.test(sentence) && MUGGLE_CLEARS.test(sentence) && TESTING_CONTEXT.test(sentence) && !GENUINELY_UNSUPPORTED.test(sentence)
|
|
1767
|
-
);
|
|
1768
|
-
}
|
|
1769
|
-
function lastAssistantText(transcriptJsonl) {
|
|
1770
|
-
const lines = (transcriptJsonl ?? "").split("\n");
|
|
1771
|
-
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
1772
|
-
const line = lines[index]?.trim();
|
|
1773
|
-
if (!line) continue;
|
|
1774
|
-
let entry;
|
|
1775
|
-
try {
|
|
1776
|
-
entry = JSON.parse(line);
|
|
1777
|
-
} catch {
|
|
1778
|
-
continue;
|
|
1779
|
-
}
|
|
1780
|
-
if (entry.type !== "assistant") continue;
|
|
1781
|
-
const content = entry.message?.content;
|
|
1782
|
-
if (typeof content === "string") return content;
|
|
1783
|
-
if (!Array.isArray(content)) continue;
|
|
1784
|
-
const prose = content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n");
|
|
1785
|
-
if (prose) return prose;
|
|
1786
|
-
}
|
|
1787
|
-
return "";
|
|
1788
|
-
}
|
|
1789
|
-
|
|
1790
1699
|
// src/guardrails/preExecutionClassification.ts
|
|
1791
1700
|
var CLASSIFICATION_SKIP_MARKER = /^\s*echo\s+["']?MUGGLE_CLASSIFY_SKIP\b/;
|
|
1792
1701
|
function detectClassifiedTestCaseId(input2) {
|
|
@@ -1820,6 +1729,125 @@ function classificationGateDecision(state, input2) {
|
|
|
1820
1729
|
return { deny: true, testCaseId };
|
|
1821
1730
|
}
|
|
1822
1731
|
|
|
1732
|
+
// src/guardrails/stateCommit.ts
|
|
1733
|
+
function applyToolOutcome(state, outcome) {
|
|
1734
|
+
const declared = applyE2eSkipDeclaration(applyRecordedRun(state, outcome.run), outcome.acceptedSkip);
|
|
1735
|
+
const recorded = applyE2eSkipScope(declared, outcome.skipVerifiedChange);
|
|
1736
|
+
const withWatchSkip = applyWatchSkip(recorded, outcome.isWatchSkip);
|
|
1737
|
+
const withBuildSkip = applyBuildSkip(withWatchSkip, outcome.isBuildSkip);
|
|
1738
|
+
const withWalkthroughPost = applyWalkthroughPosted(
|
|
1739
|
+
withBuildSkip,
|
|
1740
|
+
outcome.isWalkthroughPosted,
|
|
1741
|
+
outcome.walkthroughPostedAt,
|
|
1742
|
+
outcome.walkthroughPostTargets
|
|
1743
|
+
);
|
|
1744
|
+
const withWalkthroughSkip = applyWalkthroughSkip(withWalkthroughPost, outcome.isWalkthroughSkip);
|
|
1745
|
+
return outcome.failedRunId ? applyFailedRun(withWalkthroughSkip, outcome.failedRunId) : withWalkthroughSkip;
|
|
1746
|
+
}
|
|
1747
|
+
function applyStageSignals(state, signals) {
|
|
1748
|
+
const withStageSkip = applyStageSkip(state, signals.isStageSkip);
|
|
1749
|
+
const withClassificationSkip = applyClassificationSkip(withStageSkip, signals.isClassificationSkip);
|
|
1750
|
+
const withDebugSkip = applyDebugSkip(withClassificationSkip, signals.debugSkipCommand ?? "");
|
|
1751
|
+
const withClassification = signals.classifiedTestCaseId ? applyClassifiedTestCase(withDebugSkip, signals.classifiedTestCaseId) : withDebugSkip;
|
|
1752
|
+
return applyDebugEvidence(withClassification, signals.debugEvidenceRunIds);
|
|
1753
|
+
}
|
|
1754
|
+
function applyPrHandled(state, prUrl) {
|
|
1755
|
+
return state.prsHandled.includes(prUrl) ? state : { ...state, prsHandled: [...state.prsHandled, prUrl] };
|
|
1756
|
+
}
|
|
1757
|
+
function applyFields(state, fields) {
|
|
1758
|
+
const isUnchanged = Object.entries(fields).every(
|
|
1759
|
+
([field, value]) => state[field] === value
|
|
1760
|
+
);
|
|
1761
|
+
return isUnchanged ? state : { ...state, ...fields };
|
|
1762
|
+
}
|
|
1763
|
+
function applyStateMutation(state, mutation) {
|
|
1764
|
+
switch (mutation.kind) {
|
|
1765
|
+
case "tool-outcome" /* ToolOutcome */:
|
|
1766
|
+
return applyToolOutcome(state, mutation);
|
|
1767
|
+
case "skill-invocation" /* SkillInvocation */:
|
|
1768
|
+
return applyRecordedRun(applySkillInvocation(state, mutation.skillName, mutation.stagePaths), mutation.emittedRun);
|
|
1769
|
+
case "pr-opened" /* PrOpened */:
|
|
1770
|
+
return applyPrOpenedToSkipScope(
|
|
1771
|
+
applyPrOpenedToWalkthroughs(applyPrHandled(state, mutation.prUrl)),
|
|
1772
|
+
mutation.prUrl,
|
|
1773
|
+
mutation.prChange
|
|
1774
|
+
);
|
|
1775
|
+
case "pr-terminal" /* PrTerminal */:
|
|
1776
|
+
return applyPrTerminalDetected(state, mutation.prNumber);
|
|
1777
|
+
case "pr-reopened" /* PrReopened */:
|
|
1778
|
+
return applyPrReopened(state, mutation.prNumber);
|
|
1779
|
+
case "next-options-offered" /* NextOptionsOffered */:
|
|
1780
|
+
return applyNextOptionsOffered(state);
|
|
1781
|
+
case "e2e-delegation" /* E2eDelegation */:
|
|
1782
|
+
return applyE2eDelegation(state, mutation.delegation);
|
|
1783
|
+
case "watch-follow-through" /* WatchFollowThrough */:
|
|
1784
|
+
return { ...applyWatchHolds(state, mutation.heldPrNumbers), watchFollowThroughBlockCount: mutation.blockCount };
|
|
1785
|
+
case "stage-reads" /* StageReads */:
|
|
1786
|
+
return mutation.readPaths.reduce(applyStageRead, state);
|
|
1787
|
+
case "stage-signals" /* StageSignals */:
|
|
1788
|
+
return applyStageSignals(state, mutation);
|
|
1789
|
+
case "fields" /* Fields */:
|
|
1790
|
+
return applyFields(state, mutation.fields);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
function commitStateMutation(sessionId2, mutation, observedState) {
|
|
1794
|
+
if (observedState && applyStateMutation(observedState, mutation) === observedState) return observedState;
|
|
1795
|
+
const committed = commitState(sessionId2, (state) => applyStateMutation(state, mutation));
|
|
1796
|
+
if (committed) return committed;
|
|
1797
|
+
deferStateMutation(sessionId2, mutation);
|
|
1798
|
+
startBackgroundJob("commit-deferred-state" /* CommitDeferredState */, [sessionId2]);
|
|
1799
|
+
return applyStateMutation(readState(sessionId2), mutation);
|
|
1800
|
+
}
|
|
1801
|
+
function commitDeferredSessionState(sessionId2) {
|
|
1802
|
+
commitDeferredStateMutations(sessionId2, applyStateMutation);
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
// src/guardrails/prOpened.ts
|
|
1806
|
+
var CREATE_CMD = /\bgh\s+pr\s+(create|ready)\b/;
|
|
1807
|
+
var MR_CREATE_CMD = /\bglab\s+mr\s+create\b|\bglab\s+mr\s+update\b.*--ready\b/;
|
|
1808
|
+
function detectPrOpened(input2) {
|
|
1809
|
+
if (!isShellToolCall(input2)) return null;
|
|
1810
|
+
const cmd = input2.tool_input?.command ?? "";
|
|
1811
|
+
if (!CREATE_CMD.test(cmd) && !MR_CREATE_CMD.test(cmd)) return null;
|
|
1812
|
+
const out = `${input2.tool_response?.stdout ?? ""}
|
|
1813
|
+
${input2.tool_response?.output ?? ""}`;
|
|
1814
|
+
const m = out.match(GITHUB_PR_URL) ?? out.match(GITLAB_MR_URL);
|
|
1815
|
+
return m ? m[0] : null;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// src/guardrails/capabilityClaim.ts
|
|
1819
|
+
var SENTENCE = /[^.!?\n]+/g;
|
|
1820
|
+
var IMPOSSIBILITY = /\b(?:can(?:'|’)?t|cannot|can not|could ?n(?:'|’)?t|unable to|no way to|not able to|impossible|infeasible|untestable|unverifiable|not testable|blocked from|no (?:local )?(?:mail|email|smtp) path)\b/i;
|
|
1821
|
+
var MUGGLE_CLEARS = /\b(?:magic[- ]?link|sign[- ]?in link|login link|email link|one[- ]time (?:code|password)|otp|verification (?:code|link|email|mail)|email verification|confirmation (?:code|link|email|mail)|2fa|two[- ]factor|password reset|reset link|inbox|mailbox|mailhog|mailpit|mailtrap|mailcatcher|smtp|transactional email|captcha|recaptcha|hcaptcha|log ?in|logged[- ]?in|sign ?in|signed[- ]in|authenticate|credential|sms|text message|texted code|authenticator app|google authenticator|authy|totp)/i;
|
|
1822
|
+
var TESTING_CONTEXT = /\b(?:e2e|end[- ]to[- ]end|test(?:s|ed|ing|able)?|verif(?:y|ied|ication)|validat(?:e|ed|ion)|automat(?:e|ed|ion)|coverage|covered|exercis(?:e|ed)|reproduc(?:e|ed)|replay|scripts?|scripted|muggle|playwright|cypress|selenium)\b/i;
|
|
1823
|
+
var GENUINELY_UNSUPPORTED = /\b(?:phone call|voice call|oauth|social (?:login|sign[- ]?in)|sign in with (?:google|github|apple|facebook)|no (?:phone )?number (?:is )?provisioned|no provisioned (?:phone )?number)\b/i;
|
|
1824
|
+
function detectFalseCapabilityClaim(text) {
|
|
1825
|
+
const sentences = (text ?? "").match(SENTENCE) ?? [];
|
|
1826
|
+
return sentences.some(
|
|
1827
|
+
(sentence) => IMPOSSIBILITY.test(sentence) && MUGGLE_CLEARS.test(sentence) && TESTING_CONTEXT.test(sentence) && !GENUINELY_UNSUPPORTED.test(sentence)
|
|
1828
|
+
);
|
|
1829
|
+
}
|
|
1830
|
+
function lastAssistantText(transcriptJsonl) {
|
|
1831
|
+
const lines = (transcriptJsonl ?? "").split("\n");
|
|
1832
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
1833
|
+
const line = lines[index]?.trim();
|
|
1834
|
+
if (!line) continue;
|
|
1835
|
+
let entry;
|
|
1836
|
+
try {
|
|
1837
|
+
entry = JSON.parse(line);
|
|
1838
|
+
} catch {
|
|
1839
|
+
continue;
|
|
1840
|
+
}
|
|
1841
|
+
if (entry.type !== "assistant") continue;
|
|
1842
|
+
const content = entry.message?.content;
|
|
1843
|
+
if (typeof content === "string") return content;
|
|
1844
|
+
if (!Array.isArray(content)) continue;
|
|
1845
|
+
const prose = content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n");
|
|
1846
|
+
if (prose) return prose;
|
|
1847
|
+
}
|
|
1848
|
+
return "";
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1823
1851
|
// src/guardrails/testsGreen.ts
|
|
1824
1852
|
var FAIL = /\b\d+\s+failed\b|\bFAIL\b|✗/;
|
|
1825
1853
|
var TEST_SCRIPT = /^test(:|$)/;
|
|
@@ -2617,6 +2645,9 @@ function releaseGate(field) {
|
|
|
2617
2645
|
commitStateMutation(sessionId, { kind: "fields" /* Fields */, fields: { [field]: true } });
|
|
2618
2646
|
return "{}";
|
|
2619
2647
|
}
|
|
2648
|
+
function recordGateBlock(field, blockCount) {
|
|
2649
|
+
commitStateMutation(sessionId, { kind: "fields" /* Fields */, fields: { [field]: blockCount } });
|
|
2650
|
+
}
|
|
2620
2651
|
function muggleSkill(slashCommand) {
|
|
2621
2652
|
return skillInvocation(slashCommand, host);
|
|
2622
2653
|
}
|
|
@@ -2631,7 +2662,6 @@ function prOpened() {
|
|
|
2631
2662
|
const url = detectPrOpened(input);
|
|
2632
2663
|
if (!url) return "{}";
|
|
2633
2664
|
if (readState(sessionId).prsHandled.includes(url)) return "{}";
|
|
2634
|
-
markPrHandled(sessionId, url);
|
|
2635
2665
|
const prChange = resolveOpenedPrChange(input.tool_input?.command ?? "", url, input.cwd);
|
|
2636
2666
|
const stateAfterOpen = commitStateMutation(sessionId, {
|
|
2637
2667
|
kind: "pr-opened" /* PrOpened */,
|
|
@@ -2657,37 +2687,30 @@ function prOpened() {
|
|
|
2657
2687
|
function prTerminal() {
|
|
2658
2688
|
const reopenedPrNumber = detectPrReopened(input);
|
|
2659
2689
|
if (reopenedPrNumber !== null) {
|
|
2660
|
-
|
|
2661
|
-
const next2 = applyPrReopened(state2, reopenedPrNumber);
|
|
2662
|
-
if (next2 !== state2) writeState(next2);
|
|
2690
|
+
commitStateMutation(sessionId, { kind: "pr-reopened" /* PrReopened */, prNumber: reopenedPrNumber }, readState(sessionId));
|
|
2663
2691
|
return "{}";
|
|
2664
2692
|
}
|
|
2665
2693
|
const terminalEvent = detectPrTerminal(input);
|
|
2666
2694
|
if (!terminalEvent) return "{}";
|
|
2667
2695
|
const state = readState(sessionId);
|
|
2668
|
-
const
|
|
2669
|
-
if (
|
|
2670
|
-
writeState(next);
|
|
2696
|
+
const terminalMutation = { kind: "pr-terminal" /* PrTerminal */, prNumber: terminalEvent.prNumber };
|
|
2697
|
+
if (commitStateMutation(sessionId, terminalMutation, state) === state) return "{}";
|
|
2671
2698
|
const ctx = `PR #${terminalEvent.prNumber} went terminal (${terminalEvent.verdict}). Run the post-merge handoff now: finalize the watcher slot, tear down per autoCleanup, then OFFER NEXT OPTIONS to the user ${nextOptionsOfferMethod(isOfferAskedInClosingMessage())} \u2014 release, queued work, deferred items. The stop gate holds until the offer runs.`;
|
|
2672
2699
|
return envelope("PostToolUse", ctx, host);
|
|
2673
2700
|
}
|
|
2674
2701
|
function offerRan() {
|
|
2675
2702
|
if (!NEXT_OPTIONS_OFFER_TOOL_NAMES.includes(input.tool_name ?? "")) return "{}";
|
|
2676
|
-
|
|
2677
|
-
const next = applyNextOptionsOffered(state);
|
|
2678
|
-
if (next !== state) writeState(next);
|
|
2703
|
+
commitStateMutation(sessionId, { kind: "next-options-offered" /* NextOptionsOffered */ }, readState(sessionId));
|
|
2679
2704
|
return "{}";
|
|
2680
2705
|
}
|
|
2681
2706
|
function terminalGate() {
|
|
2682
2707
|
const pendingState = readState(sessionId);
|
|
2683
2708
|
const isAskedInClosingMessage = isOfferAskedInClosingMessage();
|
|
2684
|
-
const state = isAskedInClosingMessage && closingMessageAsksUser(closingAssistantText()) ?
|
|
2685
|
-
if (state !== pendingState) writeState(state);
|
|
2709
|
+
const state = isAskedInClosingMessage && closingMessageAsksUser(closingAssistantText()) ? commitStateMutation(sessionId, { kind: "next-options-offered" /* NextOptionsOffered */ }, pendingState) : pendingState;
|
|
2686
2710
|
const decision = prTerminalGateDecision(state);
|
|
2687
2711
|
if (decision.action === "release" /* Release */) return releaseGate("terminalReleased");
|
|
2688
2712
|
if (decision.action !== "block" /* Block */) return "{}";
|
|
2689
|
-
|
|
2690
|
-
writeState(state);
|
|
2713
|
+
recordGateBlock("terminalBlockCount", decision.blockCount);
|
|
2691
2714
|
const pendingPrList = (state.terminalPending ?? []).map((prNumber) => `#${prNumber}`).join(", ");
|
|
2692
2715
|
const reason = decision.blockCount === 1 ? `Do not end the turn yet. PR ${pendingPrList} went terminal (merged/closed) but the post-merge handoff has not run. Finalize the watcher slot, tear down per autoCleanup, then offer next options to the user ${nextOptionsOfferMethod(isAskedInClosingMessage)} \u2014 release, queued work, deferred items. Only that offer clears this gate.` : `Post-merge handoff still owed for PR ${pendingPrList} (reminder ${decision.blockCount}/${MAX_PR_TERMINAL_BLOCKS}): finalize + tear down, then offer the next options ${nextOptionsOfferMethod(isAskedInClosingMessage)}.`;
|
|
2693
2716
|
return blockStop(reason, host);
|
|
@@ -2771,27 +2794,25 @@ function recordStageRead() {
|
|
|
2771
2794
|
const readPaths = filePath ? [filePath].filter(isSkillTreeMarkdown) : isShellToolCall(input) ? detectShellStageReads(input.tool_input?.command ?? "", input.cwd) : [];
|
|
2772
2795
|
if (readPaths.length === 0) return "{}";
|
|
2773
2796
|
if (callFailed(input)) return "{}";
|
|
2774
|
-
|
|
2775
|
-
const next = readPaths.reduce(applyStageRead, state);
|
|
2776
|
-
if (next !== state) writeState(next);
|
|
2797
|
+
commitStateMutation(sessionId, { kind: "stage-reads" /* StageReads */, readPaths }, readState(sessionId));
|
|
2777
2798
|
return "{}";
|
|
2778
2799
|
}
|
|
2779
2800
|
function recordStageSignals() {
|
|
2780
2801
|
const cmd = input.tool_input?.command ?? "";
|
|
2781
2802
|
const state = readState(sessionId);
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2803
|
+
commitStateMutation(
|
|
2804
|
+
sessionId,
|
|
2805
|
+
{
|
|
2806
|
+
kind: "stage-signals" /* StageSignals */,
|
|
2807
|
+
isStageSkip: isStageSkipMarker(cmd),
|
|
2808
|
+
isClassificationSkip: isClassificationSkipMarker(cmd),
|
|
2809
|
+
debugSkipCommand: isDebugSkipMarker(cmd) ? cmd : void 0,
|
|
2810
|
+
classifiedTestCaseId: detectClassifiedTestCaseId(input),
|
|
2811
|
+
// Matched against the runs owed as this hook read them; evidence for a run already debugged commits nothing.
|
|
2812
|
+
debugEvidenceRunIds: detectDebugEvidenceRunIds(input, undebuggedFailedRuns(state))
|
|
2813
|
+
},
|
|
2814
|
+
state
|
|
2793
2815
|
);
|
|
2794
|
-
if (next !== state) writeState(next);
|
|
2795
2816
|
return "{}";
|
|
2796
2817
|
}
|
|
2797
2818
|
function classifyGate() {
|
|
@@ -2809,8 +2830,7 @@ function stageGate() {
|
|
|
2809
2830
|
const decision = stageGateDecision(state, unreadMandatoryStages(state));
|
|
2810
2831
|
if (decision.action === "release" /* Release */) return releaseGate("stageReleased");
|
|
2811
2832
|
if (decision.action !== "block" /* Block */) return "{}";
|
|
2812
|
-
|
|
2813
|
-
writeState(state);
|
|
2833
|
+
recordGateBlock("stageBlockCount", decision.blockCount);
|
|
2814
2834
|
const stageList = decision.unread.map(stageLabel).join(", ");
|
|
2815
2835
|
const reason = decision.blockCount === 1 ? `Do not end the turn yet. A skill invoked this session declares mandatory stages that were never opened: ${stageList}. Read them and carry out what they require \u2014 they are steps, not background reading, and SKILL.md only links to them. If they genuinely do not apply to this run, say why and run \`echo "MUGGLE_STAGE_SKIP: <reason>"\` \u2014 that records the skip and keeps this gate quiet for the rest of the session.` : `Mandatory stages still unread (reminder ${decision.blockCount}/${MAX_STAGE_BLOCKS}): ${stageList}. Read them, or record a legitimate skip via \`echo "MUGGLE_STAGE_SKIP: <reason>"\`.`;
|
|
2816
2836
|
return blockStop(reason, host);
|
|
@@ -2820,8 +2840,7 @@ function debugPathGate() {
|
|
|
2820
2840
|
const decision = debugGateDecision(state);
|
|
2821
2841
|
if (decision.action === "release" /* Release */) return releaseGate("debugReleased");
|
|
2822
2842
|
if (decision.action !== "block" /* Block */) return "{}";
|
|
2823
|
-
|
|
2824
|
-
writeState(state);
|
|
2843
|
+
recordGateBlock("debugBlockCount", decision.blockCount);
|
|
2825
2844
|
const runList = decision.undebugged.join(", ");
|
|
2826
2845
|
const reason = decision.blockCount === 1 ? `Do not end the turn yet. These runs failed and never went through the debug path: ${runList}. muggle-test Step 7C makes that mandatory \u2014 route each through _shared/debug-failed-run.md: gather the attempted steps and the failing screenshot, diagnose the bucket per _shared/failure-mode-handling.md \xA7B/\xA7C with its classified telemetry emit, and present the offer in which "give feedback & rerun" is always available. A summarized-and-dropped failure is the run a reviewer most needs to see. If a run genuinely cannot be debugged, run \`echo "MUGGLE_DEBUG_SKIP: <runId> <reason>"\` \u2014 that clears just that run.` : `Failed runs still owe the debug path (reminder ${decision.blockCount}/${MAX_DEBUG_BLOCKS}): ${runList}. Route each through _shared/debug-failed-run.md, or record a legitimate skip via \`echo "MUGGLE_DEBUG_SKIP: <runId> <reason>"\`.`;
|
|
2827
2846
|
return blockStop(reason, host);
|
|
@@ -2836,8 +2855,7 @@ function e2eGate() {
|
|
|
2836
2855
|
return releaseGate("e2eReleased");
|
|
2837
2856
|
}
|
|
2838
2857
|
if (decision.action === "none" /* None */) return "{}";
|
|
2839
|
-
|
|
2840
|
-
writeState(state);
|
|
2858
|
+
recordGateBlock("e2eBlockCount", decision.blockCount);
|
|
2841
2859
|
const reason = decision.blockCount === 1 ? `Do not end the turn yet. This session went unit-green or opened a PR, but no E2E acceptance run has happened. Per the autoE2ETest preference (default: always), run change-driven E2E now via ${muggleSkill("/muggle:muggle-test")}, then finish. To skip, run \`echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"\` citing one of ${E2E_SKIP_CODES.join(", ")}. Muggle verifies each code itself, so one that does not hold leaves this gate blocked. Only when no verifiable code fits and a browser run genuinely cannot exercise the change, cite ${"NOT_APPLICABLE" /* NotApplicable */} with the reason \u2014 the PR shows it as your unverified judgment.` : `E2E acceptance run still owed (reminder ${decision.blockCount}/${MAX_E2E_BLOCKS}): run ${muggleSkill("/muggle:muggle-test")}, or cite a verifiable code via \`echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"\`.`;
|
|
2842
2860
|
return blockStop(reason, host);
|
|
2843
2861
|
}
|
|
@@ -2851,8 +2869,7 @@ function watchGate() {
|
|
|
2851
2869
|
const decision = watchGateDecision(state, resolveOwedWatchPrs(unwatchedPrUrls, state, prStateScan));
|
|
2852
2870
|
if (decision.action === "release" /* Release */) return releaseGate("watchReleased");
|
|
2853
2871
|
if (decision.action === "none" /* None */) return "{}";
|
|
2854
|
-
|
|
2855
|
-
writeState(state);
|
|
2872
|
+
recordGateBlock("watchBlockCount", decision.blockCount);
|
|
2856
2873
|
const prList = decision.unwatched.join(", ");
|
|
2857
2874
|
const staleMinutes = WATCH_HEARTBEAT_STALE_MS / 6e4;
|
|
2858
2875
|
const armCommand = `${muggleSkill("/muggle:muggle-pr-followup")} ${decision.unwatched[0]}`;
|
|
@@ -2902,8 +2919,7 @@ function buildFollowthroughGate() {
|
|
|
2902
2919
|
const decision = buildFollowthroughDecision(state);
|
|
2903
2920
|
if (decision.action === "release" /* Release */) return releaseGate("buildSkipped");
|
|
2904
2921
|
if (decision.action === "none" /* None */) return "{}";
|
|
2905
|
-
|
|
2906
|
-
writeState(state);
|
|
2922
|
+
recordGateBlock("buildBlockCount", decision.blockCount);
|
|
2907
2923
|
const reason = decision.blockCount === 1 ? `Do not end the turn yet. This session took a build/implement/fix request but no PR was opened. A root cause written into the transcript ships nothing \u2014 once the session ends it is gone, and the watcher gate never fires because it only looks at PRs that exist. Carry the work to a PR via ${muggleSkill("/muggle-do")}, which runs requirements \u2192 build \u2192 impact \u2192 unit tests \u2192 E2E \u2192 PR \u2192 watcher. If no PR is owed here (the user changed their mind, the fix landed in another repo, the answer was advice rather than a change), tell the user why and run \`echo "MUGGLE_BUILD_SKIP: <reason>"\` \u2014 that records the skip and keeps this gate quiet for the rest of the session.` : `Build request still unanswered \u2014 no PR opened (reminder ${decision.blockCount}/${MAX_BUILD_BLOCKS}): carry it to a PR via ${muggleSkill("/muggle-do")}, or record a legitimate skip via \`echo "MUGGLE_BUILD_SKIP: <reason>"\`.`;
|
|
2908
2924
|
return blockStop(reason, host);
|
|
2909
2925
|
}
|
|
@@ -2916,15 +2932,21 @@ function walkthroughGate() {
|
|
|
2916
2932
|
const unposted = withoutPostedPrs(scan.owed, state);
|
|
2917
2933
|
if (unposted.length === 0) {
|
|
2918
2934
|
if (scan.verified.length + scan.owed.length > 0) {
|
|
2919
|
-
|
|
2935
|
+
commitStateMutation(
|
|
2936
|
+
sessionId,
|
|
2937
|
+
{
|
|
2938
|
+
kind: "fields" /* Fields */,
|
|
2939
|
+
fields: state.e2eRun === true ? { walkthroughsVerified: true } : { e2eSkipSettledOnPrs: true }
|
|
2940
|
+
},
|
|
2941
|
+
state
|
|
2942
|
+
);
|
|
2920
2943
|
}
|
|
2921
2944
|
return "{}";
|
|
2922
2945
|
}
|
|
2923
2946
|
const decision = walkthroughGateDecision(state, withoutDelegatedPrs(unposted, state, Date.now()));
|
|
2924
2947
|
if (decision.action === "release" /* Release */) return releaseGate("walkthroughReleased");
|
|
2925
2948
|
if (decision.action !== "block" /* Block */) return "{}";
|
|
2926
|
-
|
|
2927
|
-
writeState(state);
|
|
2949
|
+
recordGateBlock("walkthroughBlockCount", decision.blockCount);
|
|
2928
2950
|
const declaredSkip = state.e2eSkipDeclaration;
|
|
2929
2951
|
const skipOwedPrUrls = declaredSkip ? decision.owed.filter((prUrl) => isPrCoveredBySkip(state, prUrl)) : [];
|
|
2930
2952
|
if (declaredSkip && skipOwedPrUrls.length > 0) {
|
|
@@ -2994,10 +3016,7 @@ function commentReplyGate() {
|
|
|
2994
3016
|
const decision = commentReplyGateDecision(state, overdueThreads(slotPath, sessionId));
|
|
2995
3017
|
if (decision.action === "release" /* Release */) return releaseGate("commentReplyReleased");
|
|
2996
3018
|
if (decision.action !== "block" /* Block */) return "{}";
|
|
2997
|
-
|
|
2998
|
-
kind: "fields" /* Fields */,
|
|
2999
|
-
fields: { commentReplyBlockCount: decision.blockCount }
|
|
3000
|
-
});
|
|
3019
|
+
recordGateBlock("commentReplyBlockCount", decision.blockCount);
|
|
3001
3020
|
const commentList = decision.unanswered.join(", ");
|
|
3002
3021
|
const reason = decision.blockCount === 1 ? `Do not end the turn yet. This session took these review comments as work and never answered them: ${commentList}. Post one reply per comment in its own thread per muggle-do do/per-comment-replies.md \u2014 "Addressed in <short-sha>: <what changed for THIS comment>", signed via sign-body.sh --mode loop. A silent round leaves the reviewer with no answer in the thread, and the loop marker that reply carries is the only thing that stops the watcher re-dispatching the same thread next tick. If a comment was escalated to the user instead of answered, record it as deferred rather than replying.` : `Review comments still owe a threaded reply (reminder ${decision.blockCount}/${MAX_REPLY_BLOCKS}): ${commentList}. Reply per do/per-comment-replies.md, or record the deferral.`;
|
|
3003
3022
|
return blockStop(reason, host);
|
|
@@ -3021,10 +3040,8 @@ function buildRouter() {
|
|
|
3021
3040
|
const prompt = input.prompt ?? "";
|
|
3022
3041
|
const isMuggleDoInvoked = isMuggleDoForwardInvocation(prompt);
|
|
3023
3042
|
if (!isMuggleDoInvoked && !detectBuildIntent(prompt)) return "{}";
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
state.buildIntentRouted = true;
|
|
3027
|
-
writeState(state);
|
|
3043
|
+
if (readState(sessionId).buildIntentRouted) return "{}";
|
|
3044
|
+
commitStateMutation(sessionId, { kind: "fields" /* Fields */, fields: { buildIntentRouted: true } });
|
|
3028
3045
|
if (isMuggleDoInvoked) return "{}";
|
|
3029
3046
|
const ctx = `This looks like a build/implement/fix request. Per the autoRouteBuildToMuggleDo preference, route it through ${muggleSkill("/muggle-do")} \u2014 which runs requirements \u2192 build (delegated to superpowers' design\u2192plan\u2192review) \u2192 impact \u2192 unit tests \u2192 E2E \u2192 PR \u2192 watcher. If autoRouteBuildToMuggleDo=always, enter that flow; if =ask, offer it; if =never, proceed normally.`;
|
|
3030
3047
|
return envelope("UserPromptSubmit", ctx, host);
|
|
@@ -3042,11 +3059,9 @@ function closingAssistantText() {
|
|
|
3042
3059
|
}
|
|
3043
3060
|
}
|
|
3044
3061
|
function capabilityClaimGate() {
|
|
3045
|
-
|
|
3046
|
-
if (state.capabilityClaimNudged) return "{}";
|
|
3062
|
+
if (readState(sessionId).capabilityClaimNudged) return "{}";
|
|
3047
3063
|
if (!detectFalseCapabilityClaim(closingAssistantText())) return "{}";
|
|
3048
|
-
|
|
3049
|
-
writeState(state);
|
|
3064
|
+
commitStateMutation(sessionId, { kind: "fields" /* Fields */, fields: { capabilityClaimNudged: true } });
|
|
3050
3065
|
const reason = `Do not end the turn yet. This turn tells the user that a login- or code-gated flow cannot be tested, reached, or verified \u2014 the one class of blocker Muggle clears. Every managed login profile owns a live inbox, so magic-link and sign-in-link logins, emailed OTPs, email-based 2FA, verification mail, and password resets run end to end with no mail stub and no test-only backdoor. An SMS code is read from the profile's provisioned number, an authenticator code is derived offline from its stored TOTP secret, and stored credentials and CAPTCHA solving are covered too. Re-read plugin/skills/_shared/identity-and-inbox.md, then correct the claim or run the flow. Genuine limits stay sayable: a voice call, an OAuth-only provider, or an account with no phone number provisioned (the runner cannot mint one) \u2014 name the one that applies and move on.`;
|
|
3051
3066
|
return blockStop(reason, host);
|
|
3052
3067
|
}
|