@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.
@@ -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.241"
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 breakLockIfHolderIsDead(lockPath) {
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
- if (!holder) return;
5110
- if (holder.host !== hostname() || isProcessAlive(holder.pid)) return;
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
- breakLockIfHolderIsDead(lockPath);
5143
+ breakAbandonedLock(lockPath);
5136
5144
  if (Date.now() >= deadline) return void 0;
5137
5145
  pauseBetweenAttempts();
5138
5146
  continue;
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { runCli } from './chunk-EK5DJBSY.js';
2
+ import { runCli } from './chunk-EPIM27JX.js';
3
3
  import './chunk-FI6VHHZN.js';
4
4
 
5
5
  // src/cli/main.ts
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-EK5DJBSY.js';
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.241",
4
+ "version": "5.27.0-staging.243",
5
5
  "author": {
6
6
  "name": "Muggle AI",
7
7
  "email": "support@muggle-ai.com"
@@ -4,7 +4,7 @@
4
4
  "command": "npx",
5
5
  "args": [
6
6
  "-y",
7
- "@muggleai/works@5.27.0-staging.241",
7
+ "@muggleai/works@5.27.0-staging.243",
8
8
  "serve"
9
9
  ],
10
10
  "env": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muggle",
3
- "version": "5.27.0-staging.241",
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.241",
5
+ "version": "5.27.0-staging.243",
6
6
  "author": {
7
7
  "name": "Muggle AI",
8
8
  "email": "support@muggle-ai.com"
@@ -4,7 +4,7 @@
4
4
  "command": "npx",
5
5
  "args": [
6
6
  "-y",
7
- "@muggleai/works@5.27.0-staging.241",
7
+ "@muggleai/works@5.27.0-staging.243",
8
8
  "serve"
9
9
  ]
10
10
  }
@@ -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 breakLockIfHolderIsDead(lockPath) {
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
- if (!holder) return;
127
- if (holder.host !== hostname() || isProcessAlive(holder.pid)) return;
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
- breakLockIfHolderIsDead(lockPath);
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
- const state2 = readState(sessionId);
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 next = applyPrTerminalDetected(state, terminalEvent.prNumber);
2669
- if (next === state) return "{}";
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
- const state = readState(sessionId);
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()) ? applyNextOptionsOffered(pendingState) : pendingState;
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
- state.terminalBlockCount = decision.blockCount;
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
- const state = readState(sessionId);
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
- const withStageSkip = applyStageSkip(state, isStageSkipMarker(cmd));
2783
- const withClassificationSkip = applyClassificationSkip(
2784
- withStageSkip,
2785
- isClassificationSkipMarker(cmd)
2786
- );
2787
- const withDebugSkip = applyDebugSkip(withClassificationSkip, cmd);
2788
- const classifiedTestCaseId = detectClassifiedTestCaseId(input);
2789
- const withClassification = classifiedTestCaseId ? applyClassifiedTestCase(withDebugSkip, classifiedTestCaseId) : withDebugSkip;
2790
- const next = applyDebugEvidence(
2791
- withClassification,
2792
- detectDebugEvidenceRunIds(input, undebuggedFailedRuns(withClassification))
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
- state.stageBlockCount = decision.blockCount;
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
- state.debugBlockCount = decision.blockCount;
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
- state.e2eBlockCount = decision.blockCount;
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
- state.watchBlockCount = decision.blockCount;
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
- state.buildBlockCount = decision.blockCount;
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
- writeState(state.e2eRun === true ? { ...state, walkthroughsVerified: true } : { ...state, e2eSkipSettledOnPrs: true });
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
- state.walkthroughBlockCount = decision.blockCount;
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
- commitStateMutation(sessionId, {
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
- const state = readState(sessionId);
3025
- if (state.buildIntentRouted) return "{}";
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
- const state = readState(sessionId);
3046
- if (state.capabilityClaimNudged) return "{}";
3062
+ if (readState(sessionId).capabilityClaimNudged) return "{}";
3047
3063
  if (!detectFalseCapabilityClaim(closingAssistantText())) return "{}";
3048
- state.capabilityClaimNudged = true;
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-241-1",
4
- "commitSha": "fa1eb9c0cd008cb69c080264a6b10735cf0dcc79",
5
- "buildTime": "2026-10-27T22:57:54Z",
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.241",
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.241",
4
+ "version": "5.27.0-staging.243",
5
5
  "author": {
6
6
  "name": "Muggle AI",
7
7
  "email": "support@muggle-ai.com"
@@ -4,7 +4,7 @@
4
4
  "command": "npx",
5
5
  "args": [
6
6
  "-y",
7
- "@muggleai/works@5.27.0-staging.241",
7
+ "@muggleai/works@5.27.0-staging.243",
8
8
  "serve"
9
9
  ],
10
10
  "env": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muggle",
3
- "version": "5.27.0-staging.241",
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.241",
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
@@ -4,7 +4,7 @@
4
4
  "command": "npx",
5
5
  "args": [
6
6
  "-y",
7
- "@muggleai/works@5.27.0-staging.241",
7
+ "@muggleai/works@5.27.0-staging.243",
8
8
  "serve"
9
9
  ]
10
10
  }
@@ -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 breakLockIfHolderIsDead(lockPath) {
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
- if (!holder) return;
127
- if (holder.host !== hostname() || isProcessAlive(holder.pid)) return;
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
- breakLockIfHolderIsDead(lockPath);
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
- const state2 = readState(sessionId);
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 next = applyPrTerminalDetected(state, terminalEvent.prNumber);
2669
- if (next === state) return "{}";
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
- const state = readState(sessionId);
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()) ? applyNextOptionsOffered(pendingState) : pendingState;
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
- state.terminalBlockCount = decision.blockCount;
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
- const state = readState(sessionId);
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
- const withStageSkip = applyStageSkip(state, isStageSkipMarker(cmd));
2783
- const withClassificationSkip = applyClassificationSkip(
2784
- withStageSkip,
2785
- isClassificationSkipMarker(cmd)
2786
- );
2787
- const withDebugSkip = applyDebugSkip(withClassificationSkip, cmd);
2788
- const classifiedTestCaseId = detectClassifiedTestCaseId(input);
2789
- const withClassification = classifiedTestCaseId ? applyClassifiedTestCase(withDebugSkip, classifiedTestCaseId) : withDebugSkip;
2790
- const next = applyDebugEvidence(
2791
- withClassification,
2792
- detectDebugEvidenceRunIds(input, undebuggedFailedRuns(withClassification))
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
- state.stageBlockCount = decision.blockCount;
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
- state.debugBlockCount = decision.blockCount;
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
- state.e2eBlockCount = decision.blockCount;
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
- state.watchBlockCount = decision.blockCount;
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
- state.buildBlockCount = decision.blockCount;
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
- writeState(state.e2eRun === true ? { ...state, walkthroughsVerified: true } : { ...state, e2eSkipSettledOnPrs: true });
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
- state.walkthroughBlockCount = decision.blockCount;
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
- commitStateMutation(sessionId, {
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
- const state = readState(sessionId);
3025
- if (state.buildIntentRouted) return "{}";
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
- const state = readState(sessionId);
3046
- if (state.capabilityClaimNudged) return "{}";
3062
+ if (readState(sessionId).capabilityClaimNudged) return "{}";
3047
3063
  if (!detectFalseCapabilityClaim(closingAssistantText())) return "{}";
3048
- state.capabilityClaimNudged = true;
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
  }