@muggleai/works 5.30.0-staging.263 → 5.30.0-staging.264

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.30.0-staging.263"
12
+ "version": "5.30.0-staging.264"
13
13
  }
14
14
  ]
15
15
  }
@@ -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.30.0-staging.263",
4
+ "version": "5.30.0-staging.264",
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.30.0-staging.263",
7
+ "@muggleai/works@5.30.0-staging.264",
8
8
  "serve"
9
9
  ],
10
10
  "env": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muggle",
3
- "version": "5.30.0-staging.263",
3
+ "version": "5.30.0-staging.264",
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.30.0-staging.263",
5
+ "version": "5.30.0-staging.264",
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.30.0-staging.263",
7
+ "@muggleai/works@5.30.0-staging.264",
8
8
  "serve"
9
9
  ]
10
10
  }
@@ -244,6 +244,13 @@
244
244
  "command": "set -- guardrail-dispatch.sh guardrail-e2e-gate.sh guardrail-terminal-gate.sh guardrail-watch-gate.sh guardrail-watch-follow-through-gate.sh guardrail-build-followthrough-gate.sh guardrail-walkthrough-gate.sh guardrail-comment-reply-gate.sh guardrail-stage-gate.sh guardrail-debug-path-gate.sh guardrail-capability-claim.sh; . \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-entry.sh\"",
245
245
  "async": false,
246
246
  "timeout": 1
247
+ },
248
+ {
249
+ "type": "command",
250
+ "command": "set -- guardrail-rewake.sh guardrail-e2e-gate.sh guardrail-terminal-gate.sh guardrail-watch-gate.sh guardrail-watch-follow-through-gate.sh guardrail-build-followthrough-gate.sh guardrail-walkthrough-gate.sh guardrail-comment-reply-gate.sh guardrail-stage-gate.sh guardrail-debug-path-gate.sh guardrail-capability-claim.sh; . \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-entry.sh\"",
251
+ "async": true,
252
+ "asyncRewake": true,
253
+ "timeout": 30
247
254
  }
248
255
  ]
249
256
  }
@@ -256,6 +263,13 @@
256
263
  "command": "set -- guardrail-build-router.sh; . \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-entry.sh\"",
257
264
  "async": false,
258
265
  "timeout": 1
266
+ },
267
+ {
268
+ "type": "command",
269
+ "command": "set -- guardrail-rewake.sh guardrail-build-router.sh; . \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-entry.sh\"",
270
+ "async": true,
271
+ "asyncRewake": true,
272
+ "timeout": 30
259
273
  }
260
274
  ]
261
275
  }
@@ -150,6 +150,13 @@ guardrail_run_node() {
150
150
  node "${root}/scripts/guardrails.mjs" "$@" <<<"$GUARDRAIL_PAYLOAD" 2>/dev/null || printf '{}'
151
151
  }
152
152
 
153
+ # Runs guardrails.mjs for an asyncRewake companion, which answers through its stderr and exit status rather than
154
+ # stdout: exit 2 is what makes Claude Code wake the session with that stderr, so neither is swallowed here.
155
+ guardrail_run_rewake_node() {
156
+ local root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
157
+ node "${root}/scripts/guardrails.mjs" "$@" <<<"$GUARDRAIL_PAYLOAD"
158
+ }
159
+
153
160
  # Entry for a gate script executed directly: its guardrail_wants decides whether Node runs.
154
161
  guardrail_run_standalone() {
155
162
  guardrail_read_payload
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+
4
+ # Async companion of a synchronous guardrail hook (Stop, UserPromptSubmit). hooks.json runs it beside that hook with
5
+ # `async` and `asyncRewake`, handing it the same gate scripts:
6
+ #
7
+ # guardrail-rewake.sh guardrail-e2e-gate.sh guardrail-watch-gate.sh ...
8
+ #
9
+ # Claude Code cancels the synchronous hook at its one-second cap and discards what it printed, and on a loaded Windows
10
+ # machine its own Git Bash start can spend that second, so a block or offer decided there can silently never land.
11
+ # Nothing waits on this one. When a gate's pre-filter could fire, or the synchronous run saved an output for the event,
12
+ # guardrails.mjs waits for the transcript to record how the synchronous run ended and, only when it was cancelled,
13
+ # prints what it would have said to stderr and exits 2, which wakes Claude with it. Every other firing ends here in
14
+ # shell, with no process started.
15
+
16
+ guardrail_script_dir="${BASH_SOURCE[0]%/*}"
17
+ [[ $guardrail_script_dir == "${BASH_SOURCE[0]}" ]] && guardrail_script_dir=.
18
+ . "$guardrail_script_dir/guardrail-lib.sh"
19
+
20
+ guardrail_read_payload
21
+
22
+ firing_subcommands=()
23
+ for gate_script in "$@"; do
24
+ GUARDRAIL_SUBCOMMAND=""
25
+ . "$guardrail_script_dir/$gate_script" || continue
26
+ if [[ -n $GUARDRAIL_SUBCOMMAND ]] && guardrail_wants; then
27
+ firing_subcommands+=("$GUARDRAIL_SUBCOMMAND")
28
+ fi
29
+ done
30
+
31
+ guardrail_payload_field hook_event_name || exit 0
32
+ guardrail_resolve_state_file
33
+ # Named as sessionState.ts names the sidecar SYNC_HOOK_OUTPUT_SUFFIX describes.
34
+ sync_output_file="${GUARDRAIL_STATE_FILE%.json}.${GUARDRAIL_FIELD_VALUE}-sync-output.json"
35
+ if [[ ${#firing_subcommands[@]} -eq 0 && ! -f $sync_output_file ]]; then
36
+ exit 0
37
+ fi
38
+
39
+ # The expansion guard keeps an empty list from tripping `set -u` on bash 3.2.
40
+ guardrail_run_rewake_node rewake ${firing_subcommands[@]+"${firing_subcommands[@]}"}
@@ -1,8 +1,9 @@
1
- import { readFileSync, existsSync, readdirSync, mkdirSync, openSync, fstatSync, readSync, closeSync, statSync, writeFileSync, renameSync, writeSync, unlinkSync, rmSync } from 'fs';
1
+ import { readFileSync, rmSync, existsSync, readdirSync, mkdirSync, openSync, fstatSync, readSync, closeSync, statSync, writeFileSync, renameSync, writeSync, unlinkSync } from 'fs';
2
2
  import { join, isAbsolute, resolve, basename, dirname } from 'path';
3
3
  import { homedir, tmpdir, hostname } from 'os';
4
4
  import { execFileSync, spawn } from 'child_process';
5
5
  import { fileURLToPath } from 'url';
6
+ import { createHash } from 'crypto';
6
7
 
7
8
  // src/guardrails/cli.ts
8
9
 
@@ -110,6 +111,26 @@ var E2E_STOP_VERDICT_SUFFIX = ".e2e-stop-verdict";
110
111
  var E2E_SHELL_BLOCKS_SUFFIX = ".e2e-shell-blocks";
111
112
  var AUTO_E2E_TEST_PREFERENCE_KEY = "autoE2ETest";
112
113
  var POST_PR_VISUAL_WALKTHROUGH_PREFERENCE_KEY = "postPRVisualWalkthrough";
114
+ var REWAKE_HOOK_EVENTS = ["Stop", "UserPromptSubmit"];
115
+ var REWAKE_SUBCOMMAND = "rewake";
116
+ var REWAKE_HOOK_SCRIPT = "guardrail-rewake.sh";
117
+ var GUARDRAIL_HOOK_COMMAND_MARKER = "guardrail-";
118
+ var SYNC_HOOK_OUTPUT_SUFFIX = "-sync-output.json";
119
+ var TRANSCRIPT_HOOK_CANCELLED_TYPE = "hook_cancelled";
120
+ var TRANSCRIPT_HOOK_RECORD_TYPE_PREFIX = "hook_";
121
+ var REWAKE_OUTCOME_WAIT_MS = 2e4;
122
+ var REWAKE_OUTCOME_POLL_MS = 250;
123
+ var REWAKE_TRANSCRIPT_TAIL_BYTES = 512e3;
124
+ var REWAKE_ANCHOR_CHARS = 120;
125
+ var REWAKE_MESSAGE_PREFIX = {
126
+ Stop: "Muggle Stop check, delivered late because the turn-end hook outlasted its 1 s budget and was cancelled \u2014 act on it now:",
127
+ UserPromptSubmit: "Muggle prompt check, delivered late because the prompt hook outlasted its 1 s budget and was cancelled:"
128
+ };
129
+ var COMMANDLESS_HOOK_DELIVERY_TYPES = ["hook_additional_context", "hook_blocking_error"];
130
+ var REWAKE_ANCHOR_ROLE_BY_EVENT = {
131
+ Stop: "assistant",
132
+ UserPromptSubmit: "user"
133
+ };
113
134
 
114
135
  // src/guardrails/store/fileLock.ts
115
136
  function isProcessAlive(pid) {
@@ -244,6 +265,21 @@ function readE2eShellBlockCount(sessionId2, dirOverride) {
244
265
  function clearE2eShellBlocks(sessionId2, dirOverride) {
245
266
  rmSync(fileFor(sessionId2, dirOverride, E2E_SHELL_BLOCKS_SUFFIX), { force: true });
246
267
  }
268
+ var syncHookOutputFile = (sessionId2, hookEventName, dirOverride) => fileFor(sessionId2, dirOverride, `.${hookEventName}${SYNC_HOOK_OUTPUT_SUFFIX}`);
269
+ function writeSyncHookOutput(sessionId2, hookEventName, record, dirOverride) {
270
+ writeAtomically(syncHookOutputFile(sessionId2, hookEventName, dirOverride), record, dirOverride);
271
+ }
272
+ function takeSyncHookOutput(sessionId2, hookEventName, payloadDigest2, dirOverride) {
273
+ const target = syncHookOutputFile(sessionId2, hookEventName, dirOverride);
274
+ try {
275
+ const record = JSON.parse(readFileSync(target, "utf-8"));
276
+ if (record.payloadDigest !== payloadDigest2) return null;
277
+ rmSync(target, { force: true });
278
+ return record;
279
+ } catch {
280
+ return null;
281
+ }
282
+ }
247
283
  var lockFor = (sessionId2, dirOverride) => `${fileFor(sessionId2, dirOverride)}.lock`;
248
284
  function commitUnderHeldLock(sessionId2, mutate, dirOverride) {
249
285
  const current = readState(sessionId2, dirOverride);
@@ -2829,6 +2865,63 @@ function normalizeHookInput(raw) {
2829
2865
  }
2830
2866
  return payload;
2831
2867
  }
2868
+ function payloadDigest(rawPayload2) {
2869
+ return createHash("sha1").update(rawPayload2).digest("hex");
2870
+ }
2871
+ function transcriptAnchor(input2) {
2872
+ const anchorText = input2.hook_event_name === "Stop" ? input2.last_assistant_message?.trimEnd().slice(-REWAKE_ANCHOR_CHARS) : input2.hook_event_name === "UserPromptSubmit" ? input2.prompt?.trimStart().slice(0, REWAKE_ANCHOR_CHARS) : void 0;
2873
+ if (!anchorText?.trim()) return void 0;
2874
+ return JSON.stringify(anchorText).slice(1, -1);
2875
+ }
2876
+ var jsonFieldPattern = (field, value) => new RegExp(`"${field}"\\s*:\\s*"${value}"`);
2877
+ function parseHookRecord(line, hookEventName) {
2878
+ if (!jsonFieldPattern("hookEvent", hookEventName).test(line)) return void 0;
2879
+ try {
2880
+ const record = JSON.parse(line).attachment;
2881
+ if (!record?.type?.startsWith(TRANSCRIPT_HOOK_RECORD_TYPE_PREFIX) || record.hookEvent !== hookEventName) return void 0;
2882
+ return record;
2883
+ } catch {
2884
+ return void 0;
2885
+ }
2886
+ }
2887
+ function isSyncGuardrailCommand(command) {
2888
+ return command.includes(GUARDRAIL_HOOK_COMMAND_MARKER) && !command.includes(REWAKE_HOOK_SCRIPT);
2889
+ }
2890
+ function findSyncHookOutcome(transcriptTail, hookEventName, anchor) {
2891
+ const lines = transcriptTail.split("\n");
2892
+ const anchorRolePattern = jsonFieldPattern("type", REWAKE_ANCHOR_ROLE_BY_EVENT[hookEventName] ?? "");
2893
+ let anchorIndex = -1;
2894
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
2895
+ if (lines[index].includes(anchor) && anchorRolePattern.test(lines[index])) {
2896
+ anchorIndex = index;
2897
+ break;
2898
+ }
2899
+ }
2900
+ if (anchorIndex < 0) return "unrecorded" /* Unrecorded */;
2901
+ for (const line of lines.slice(anchorIndex + 1)) {
2902
+ const record = parseHookRecord(line, hookEventName);
2903
+ if (!record) continue;
2904
+ if (typeof record.command === "string") {
2905
+ if (!isSyncGuardrailCommand(record.command)) continue;
2906
+ return record.type === TRANSCRIPT_HOOK_CANCELLED_TYPE ? "cancelled" /* Cancelled */ : "delivered" /* Delivered */;
2907
+ }
2908
+ if (COMMANDLESS_HOOK_DELIVERY_TYPES.includes(record.type)) return "delivered" /* Delivered */;
2909
+ }
2910
+ return "unrecorded" /* Unrecorded */;
2911
+ }
2912
+ function rewakeMessage(hookEventName, hookOutput) {
2913
+ let parsedOutput;
2914
+ try {
2915
+ parsedOutput = JSON.parse(hookOutput);
2916
+ } catch {
2917
+ return "";
2918
+ }
2919
+ const deliveredText = hookEventName === "Stop" ? parsedOutput.decision === "block" ? parsedOutput.reason : void 0 : parsedOutput.hookSpecificOutput?.additionalContext ?? parsedOutput.additional_context;
2920
+ if (!deliveredText) return "";
2921
+ return `${REWAKE_MESSAGE_PREFIX[hookEventName] ?? ""}
2922
+
2923
+ ${deliveredText}`;
2924
+ }
2832
2925
 
2833
2926
  // src/guardrails/hookHost.ts
2834
2927
  function resolveHookHost(env = process.env) {
@@ -2842,17 +2935,25 @@ function skillInvocation(slashCommand, host2) {
2842
2935
  }
2843
2936
 
2844
2937
  // src/guardrails/cli.ts
2845
- function readStdin() {
2938
+ function readStdinText() {
2939
+ try {
2940
+ return readFileSync(0, "utf-8");
2941
+ } catch {
2942
+ return "";
2943
+ }
2944
+ }
2945
+ function parseHookInput(rawHookPayload) {
2846
2946
  try {
2847
- return normalizeHookInput(JSON.parse(readFileSync(0, "utf-8")));
2947
+ return normalizeHookInput(JSON.parse(rawHookPayload));
2848
2948
  } catch {
2849
2949
  return {};
2850
2950
  }
2851
2951
  }
2852
2952
  var host = resolveHookHost();
2853
2953
  var sub = process.argv[2];
2854
- if (!isBackgroundJob(sub)) enforceHookChildProcessDeadline();
2855
- var input = readStdin();
2954
+ if (!isBackgroundJob(sub) && sub !== REWAKE_SUBCOMMAND) enforceHookChildProcessDeadline();
2955
+ var rawPayload = readStdinText();
2956
+ var input = parseHookInput(rawPayload);
2856
2957
  var sessionId = input.session_id ?? "unknown";
2857
2958
  function releaseGate(field) {
2858
2959
  commitStateMutation(sessionId, { kind: "fields" /* Fields */, fields: { [field]: true } });
@@ -3422,5 +3523,47 @@ var backgroundJobs = {
3422
3523
  ["scan-watch-pr-states" /* ScanWatchPrStates */]: scanWatchPrStatesJob,
3423
3524
  ["commit-deferred-state" /* CommitDeferredState */]: commitDeferredStateJob
3424
3525
  };
3425
- var runSubcommand = isBackgroundJob(sub) ? backgroundJobs[sub] : handlers[sub] ?? (() => "{}");
3426
- process.stdout.write(runSubcommand());
3526
+ function waitSync(milliseconds) {
3527
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
3528
+ }
3529
+ function awaitSyncHookOutcome(hookEventName) {
3530
+ const anchor = transcriptAnchor(
3531
+ hookEventName === "Stop" ? { ...input, last_assistant_message: closingAssistantText() } : input
3532
+ );
3533
+ const transcriptPath = input.transcript_path;
3534
+ if (!anchor || !transcriptPath) return "unrecorded" /* Unrecorded */;
3535
+ const deadline = Date.now() + REWAKE_OUTCOME_WAIT_MS;
3536
+ for (; ; ) {
3537
+ const transcriptTail = readFileTail(transcriptPath, REWAKE_TRANSCRIPT_TAIL_BYTES) ?? "";
3538
+ const outcome = findSyncHookOutcome(transcriptTail, hookEventName, anchor);
3539
+ if (outcome !== "unrecorded" /* Unrecorded */ || Date.now() >= deadline) return outcome;
3540
+ waitSync(REWAKE_OUTCOME_POLL_MS);
3541
+ }
3542
+ }
3543
+ function rewake() {
3544
+ const hookEventName = input.hook_event_name ?? "";
3545
+ const outcome = awaitSyncHookOutcome(hookEventName);
3546
+ const savedOutput = takeSyncHookOutput(sessionId, hookEventName, payloadDigest(rawPayload));
3547
+ if (outcome === "delivered" /* Delivered */) return "";
3548
+ return rewakeMessage(hookEventName, savedOutput ? savedOutput.hookOutput : runFiringGates());
3549
+ }
3550
+ function saveSyncHookOutput(hookOutput) {
3551
+ const hookEventName = input.hook_event_name ?? "";
3552
+ if (!REWAKE_HOOK_EVENTS.includes(hookEventName)) return;
3553
+ try {
3554
+ writeSyncHookOutput(sessionId, hookEventName, { payloadDigest: payloadDigest(rawPayload), hookOutput });
3555
+ } catch {
3556
+ }
3557
+ }
3558
+ if (sub === REWAKE_SUBCOMMAND) {
3559
+ const rewakeText = rewake();
3560
+ if (rewakeText) {
3561
+ process.stderr.write(rewakeText);
3562
+ process.exitCode = 2;
3563
+ }
3564
+ } else {
3565
+ const runSubcommand = isBackgroundJob(sub) ? backgroundJobs[sub] : handlers[sub] ?? (() => "{}");
3566
+ const hookOutput = runSubcommand();
3567
+ if (!isBackgroundJob(sub)) saveSyncHookOutput(hookOutput);
3568
+ process.stdout.write(hookOutput);
3569
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "release": "5.26.0",
3
- "buildId": "run-263-1",
4
- "commitSha": "6a3249334b037fa0fa14f446e6345aa1c3a0fe01",
5
- "buildTime": "2026-10-30T23:12:33Z",
3
+ "buildId": "run-264-1",
4
+ "commitSha": "31c63b7f92a9fbe2a2cf3bfc1bcf4c4828f93e49",
5
+ "buildTime": "2026-10-31T04:22:47Z",
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.30.0-staging.263",
4
+ "version": "5.30.0-staging.264",
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.30.0-staging.263",
4
+ "version": "5.30.0-staging.264",
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.30.0-staging.263",
7
+ "@muggleai/works@5.30.0-staging.264",
8
8
  "serve"
9
9
  ],
10
10
  "env": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muggle",
3
- "version": "5.30.0-staging.263",
3
+ "version": "5.30.0-staging.264",
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.30.0-staging.263",
5
+ "version": "5.30.0-staging.264",
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.30.0-staging.263",
7
+ "@muggleai/works@5.30.0-staging.264",
8
8
  "serve"
9
9
  ]
10
10
  }
@@ -244,6 +244,13 @@
244
244
  "command": "set -- guardrail-dispatch.sh guardrail-e2e-gate.sh guardrail-terminal-gate.sh guardrail-watch-gate.sh guardrail-watch-follow-through-gate.sh guardrail-build-followthrough-gate.sh guardrail-walkthrough-gate.sh guardrail-comment-reply-gate.sh guardrail-stage-gate.sh guardrail-debug-path-gate.sh guardrail-capability-claim.sh; . \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-entry.sh\"",
245
245
  "async": false,
246
246
  "timeout": 1
247
+ },
248
+ {
249
+ "type": "command",
250
+ "command": "set -- guardrail-rewake.sh guardrail-e2e-gate.sh guardrail-terminal-gate.sh guardrail-watch-gate.sh guardrail-watch-follow-through-gate.sh guardrail-build-followthrough-gate.sh guardrail-walkthrough-gate.sh guardrail-comment-reply-gate.sh guardrail-stage-gate.sh guardrail-debug-path-gate.sh guardrail-capability-claim.sh; . \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-entry.sh\"",
251
+ "async": true,
252
+ "asyncRewake": true,
253
+ "timeout": 30
247
254
  }
248
255
  ]
249
256
  }
@@ -256,6 +263,13 @@
256
263
  "command": "set -- guardrail-build-router.sh; . \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-entry.sh\"",
257
264
  "async": false,
258
265
  "timeout": 1
266
+ },
267
+ {
268
+ "type": "command",
269
+ "command": "set -- guardrail-rewake.sh guardrail-build-router.sh; . \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-entry.sh\"",
270
+ "async": true,
271
+ "asyncRewake": true,
272
+ "timeout": 30
259
273
  }
260
274
  ]
261
275
  }
@@ -150,6 +150,13 @@ guardrail_run_node() {
150
150
  node "${root}/scripts/guardrails.mjs" "$@" <<<"$GUARDRAIL_PAYLOAD" 2>/dev/null || printf '{}'
151
151
  }
152
152
 
153
+ # Runs guardrails.mjs for an asyncRewake companion, which answers through its stderr and exit status rather than
154
+ # stdout: exit 2 is what makes Claude Code wake the session with that stderr, so neither is swallowed here.
155
+ guardrail_run_rewake_node() {
156
+ local root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
157
+ node "${root}/scripts/guardrails.mjs" "$@" <<<"$GUARDRAIL_PAYLOAD"
158
+ }
159
+
153
160
  # Entry for a gate script executed directly: its guardrail_wants decides whether Node runs.
154
161
  guardrail_run_standalone() {
155
162
  guardrail_read_payload
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+
4
+ # Async companion of a synchronous guardrail hook (Stop, UserPromptSubmit). hooks.json runs it beside that hook with
5
+ # `async` and `asyncRewake`, handing it the same gate scripts:
6
+ #
7
+ # guardrail-rewake.sh guardrail-e2e-gate.sh guardrail-watch-gate.sh ...
8
+ #
9
+ # Claude Code cancels the synchronous hook at its one-second cap and discards what it printed, and on a loaded Windows
10
+ # machine its own Git Bash start can spend that second, so a block or offer decided there can silently never land.
11
+ # Nothing waits on this one. When a gate's pre-filter could fire, or the synchronous run saved an output for the event,
12
+ # guardrails.mjs waits for the transcript to record how the synchronous run ended and, only when it was cancelled,
13
+ # prints what it would have said to stderr and exits 2, which wakes Claude with it. Every other firing ends here in
14
+ # shell, with no process started.
15
+
16
+ guardrail_script_dir="${BASH_SOURCE[0]%/*}"
17
+ [[ $guardrail_script_dir == "${BASH_SOURCE[0]}" ]] && guardrail_script_dir=.
18
+ . "$guardrail_script_dir/guardrail-lib.sh"
19
+
20
+ guardrail_read_payload
21
+
22
+ firing_subcommands=()
23
+ for gate_script in "$@"; do
24
+ GUARDRAIL_SUBCOMMAND=""
25
+ . "$guardrail_script_dir/$gate_script" || continue
26
+ if [[ -n $GUARDRAIL_SUBCOMMAND ]] && guardrail_wants; then
27
+ firing_subcommands+=("$GUARDRAIL_SUBCOMMAND")
28
+ fi
29
+ done
30
+
31
+ guardrail_payload_field hook_event_name || exit 0
32
+ guardrail_resolve_state_file
33
+ # Named as sessionState.ts names the sidecar SYNC_HOOK_OUTPUT_SUFFIX describes.
34
+ sync_output_file="${GUARDRAIL_STATE_FILE%.json}.${GUARDRAIL_FIELD_VALUE}-sync-output.json"
35
+ if [[ ${#firing_subcommands[@]} -eq 0 && ! -f $sync_output_file ]]; then
36
+ exit 0
37
+ fi
38
+
39
+ # The expansion guard keeps an empty list from tripping `set -u` on bash 3.2.
40
+ guardrail_run_rewake_node rewake ${firing_subcommands[@]+"${firing_subcommands[@]}"}
@@ -1,8 +1,9 @@
1
- import { readFileSync, existsSync, readdirSync, mkdirSync, openSync, fstatSync, readSync, closeSync, statSync, writeFileSync, renameSync, writeSync, unlinkSync, rmSync } from 'fs';
1
+ import { readFileSync, rmSync, existsSync, readdirSync, mkdirSync, openSync, fstatSync, readSync, closeSync, statSync, writeFileSync, renameSync, writeSync, unlinkSync } from 'fs';
2
2
  import { join, isAbsolute, resolve, basename, dirname } from 'path';
3
3
  import { homedir, tmpdir, hostname } from 'os';
4
4
  import { execFileSync, spawn } from 'child_process';
5
5
  import { fileURLToPath } from 'url';
6
+ import { createHash } from 'crypto';
6
7
 
7
8
  // src/guardrails/cli.ts
8
9
 
@@ -110,6 +111,26 @@ var E2E_STOP_VERDICT_SUFFIX = ".e2e-stop-verdict";
110
111
  var E2E_SHELL_BLOCKS_SUFFIX = ".e2e-shell-blocks";
111
112
  var AUTO_E2E_TEST_PREFERENCE_KEY = "autoE2ETest";
112
113
  var POST_PR_VISUAL_WALKTHROUGH_PREFERENCE_KEY = "postPRVisualWalkthrough";
114
+ var REWAKE_HOOK_EVENTS = ["Stop", "UserPromptSubmit"];
115
+ var REWAKE_SUBCOMMAND = "rewake";
116
+ var REWAKE_HOOK_SCRIPT = "guardrail-rewake.sh";
117
+ var GUARDRAIL_HOOK_COMMAND_MARKER = "guardrail-";
118
+ var SYNC_HOOK_OUTPUT_SUFFIX = "-sync-output.json";
119
+ var TRANSCRIPT_HOOK_CANCELLED_TYPE = "hook_cancelled";
120
+ var TRANSCRIPT_HOOK_RECORD_TYPE_PREFIX = "hook_";
121
+ var REWAKE_OUTCOME_WAIT_MS = 2e4;
122
+ var REWAKE_OUTCOME_POLL_MS = 250;
123
+ var REWAKE_TRANSCRIPT_TAIL_BYTES = 512e3;
124
+ var REWAKE_ANCHOR_CHARS = 120;
125
+ var REWAKE_MESSAGE_PREFIX = {
126
+ Stop: "Muggle Stop check, delivered late because the turn-end hook outlasted its 1 s budget and was cancelled \u2014 act on it now:",
127
+ UserPromptSubmit: "Muggle prompt check, delivered late because the prompt hook outlasted its 1 s budget and was cancelled:"
128
+ };
129
+ var COMMANDLESS_HOOK_DELIVERY_TYPES = ["hook_additional_context", "hook_blocking_error"];
130
+ var REWAKE_ANCHOR_ROLE_BY_EVENT = {
131
+ Stop: "assistant",
132
+ UserPromptSubmit: "user"
133
+ };
113
134
 
114
135
  // src/guardrails/store/fileLock.ts
115
136
  function isProcessAlive(pid) {
@@ -244,6 +265,21 @@ function readE2eShellBlockCount(sessionId2, dirOverride) {
244
265
  function clearE2eShellBlocks(sessionId2, dirOverride) {
245
266
  rmSync(fileFor(sessionId2, dirOverride, E2E_SHELL_BLOCKS_SUFFIX), { force: true });
246
267
  }
268
+ var syncHookOutputFile = (sessionId2, hookEventName, dirOverride) => fileFor(sessionId2, dirOverride, `.${hookEventName}${SYNC_HOOK_OUTPUT_SUFFIX}`);
269
+ function writeSyncHookOutput(sessionId2, hookEventName, record, dirOverride) {
270
+ writeAtomically(syncHookOutputFile(sessionId2, hookEventName, dirOverride), record, dirOverride);
271
+ }
272
+ function takeSyncHookOutput(sessionId2, hookEventName, payloadDigest2, dirOverride) {
273
+ const target = syncHookOutputFile(sessionId2, hookEventName, dirOverride);
274
+ try {
275
+ const record = JSON.parse(readFileSync(target, "utf-8"));
276
+ if (record.payloadDigest !== payloadDigest2) return null;
277
+ rmSync(target, { force: true });
278
+ return record;
279
+ } catch {
280
+ return null;
281
+ }
282
+ }
247
283
  var lockFor = (sessionId2, dirOverride) => `${fileFor(sessionId2, dirOverride)}.lock`;
248
284
  function commitUnderHeldLock(sessionId2, mutate, dirOverride) {
249
285
  const current = readState(sessionId2, dirOverride);
@@ -2829,6 +2865,63 @@ function normalizeHookInput(raw) {
2829
2865
  }
2830
2866
  return payload;
2831
2867
  }
2868
+ function payloadDigest(rawPayload2) {
2869
+ return createHash("sha1").update(rawPayload2).digest("hex");
2870
+ }
2871
+ function transcriptAnchor(input2) {
2872
+ const anchorText = input2.hook_event_name === "Stop" ? input2.last_assistant_message?.trimEnd().slice(-REWAKE_ANCHOR_CHARS) : input2.hook_event_name === "UserPromptSubmit" ? input2.prompt?.trimStart().slice(0, REWAKE_ANCHOR_CHARS) : void 0;
2873
+ if (!anchorText?.trim()) return void 0;
2874
+ return JSON.stringify(anchorText).slice(1, -1);
2875
+ }
2876
+ var jsonFieldPattern = (field, value) => new RegExp(`"${field}"\\s*:\\s*"${value}"`);
2877
+ function parseHookRecord(line, hookEventName) {
2878
+ if (!jsonFieldPattern("hookEvent", hookEventName).test(line)) return void 0;
2879
+ try {
2880
+ const record = JSON.parse(line).attachment;
2881
+ if (!record?.type?.startsWith(TRANSCRIPT_HOOK_RECORD_TYPE_PREFIX) || record.hookEvent !== hookEventName) return void 0;
2882
+ return record;
2883
+ } catch {
2884
+ return void 0;
2885
+ }
2886
+ }
2887
+ function isSyncGuardrailCommand(command) {
2888
+ return command.includes(GUARDRAIL_HOOK_COMMAND_MARKER) && !command.includes(REWAKE_HOOK_SCRIPT);
2889
+ }
2890
+ function findSyncHookOutcome(transcriptTail, hookEventName, anchor) {
2891
+ const lines = transcriptTail.split("\n");
2892
+ const anchorRolePattern = jsonFieldPattern("type", REWAKE_ANCHOR_ROLE_BY_EVENT[hookEventName] ?? "");
2893
+ let anchorIndex = -1;
2894
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
2895
+ if (lines[index].includes(anchor) && anchorRolePattern.test(lines[index])) {
2896
+ anchorIndex = index;
2897
+ break;
2898
+ }
2899
+ }
2900
+ if (anchorIndex < 0) return "unrecorded" /* Unrecorded */;
2901
+ for (const line of lines.slice(anchorIndex + 1)) {
2902
+ const record = parseHookRecord(line, hookEventName);
2903
+ if (!record) continue;
2904
+ if (typeof record.command === "string") {
2905
+ if (!isSyncGuardrailCommand(record.command)) continue;
2906
+ return record.type === TRANSCRIPT_HOOK_CANCELLED_TYPE ? "cancelled" /* Cancelled */ : "delivered" /* Delivered */;
2907
+ }
2908
+ if (COMMANDLESS_HOOK_DELIVERY_TYPES.includes(record.type)) return "delivered" /* Delivered */;
2909
+ }
2910
+ return "unrecorded" /* Unrecorded */;
2911
+ }
2912
+ function rewakeMessage(hookEventName, hookOutput) {
2913
+ let parsedOutput;
2914
+ try {
2915
+ parsedOutput = JSON.parse(hookOutput);
2916
+ } catch {
2917
+ return "";
2918
+ }
2919
+ const deliveredText = hookEventName === "Stop" ? parsedOutput.decision === "block" ? parsedOutput.reason : void 0 : parsedOutput.hookSpecificOutput?.additionalContext ?? parsedOutput.additional_context;
2920
+ if (!deliveredText) return "";
2921
+ return `${REWAKE_MESSAGE_PREFIX[hookEventName] ?? ""}
2922
+
2923
+ ${deliveredText}`;
2924
+ }
2832
2925
 
2833
2926
  // src/guardrails/hookHost.ts
2834
2927
  function resolveHookHost(env = process.env) {
@@ -2842,17 +2935,25 @@ function skillInvocation(slashCommand, host2) {
2842
2935
  }
2843
2936
 
2844
2937
  // src/guardrails/cli.ts
2845
- function readStdin() {
2938
+ function readStdinText() {
2939
+ try {
2940
+ return readFileSync(0, "utf-8");
2941
+ } catch {
2942
+ return "";
2943
+ }
2944
+ }
2945
+ function parseHookInput(rawHookPayload) {
2846
2946
  try {
2847
- return normalizeHookInput(JSON.parse(readFileSync(0, "utf-8")));
2947
+ return normalizeHookInput(JSON.parse(rawHookPayload));
2848
2948
  } catch {
2849
2949
  return {};
2850
2950
  }
2851
2951
  }
2852
2952
  var host = resolveHookHost();
2853
2953
  var sub = process.argv[2];
2854
- if (!isBackgroundJob(sub)) enforceHookChildProcessDeadline();
2855
- var input = readStdin();
2954
+ if (!isBackgroundJob(sub) && sub !== REWAKE_SUBCOMMAND) enforceHookChildProcessDeadline();
2955
+ var rawPayload = readStdinText();
2956
+ var input = parseHookInput(rawPayload);
2856
2957
  var sessionId = input.session_id ?? "unknown";
2857
2958
  function releaseGate(field) {
2858
2959
  commitStateMutation(sessionId, { kind: "fields" /* Fields */, fields: { [field]: true } });
@@ -3422,5 +3523,47 @@ var backgroundJobs = {
3422
3523
  ["scan-watch-pr-states" /* ScanWatchPrStates */]: scanWatchPrStatesJob,
3423
3524
  ["commit-deferred-state" /* CommitDeferredState */]: commitDeferredStateJob
3424
3525
  };
3425
- var runSubcommand = isBackgroundJob(sub) ? backgroundJobs[sub] : handlers[sub] ?? (() => "{}");
3426
- process.stdout.write(runSubcommand());
3526
+ function waitSync(milliseconds) {
3527
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
3528
+ }
3529
+ function awaitSyncHookOutcome(hookEventName) {
3530
+ const anchor = transcriptAnchor(
3531
+ hookEventName === "Stop" ? { ...input, last_assistant_message: closingAssistantText() } : input
3532
+ );
3533
+ const transcriptPath = input.transcript_path;
3534
+ if (!anchor || !transcriptPath) return "unrecorded" /* Unrecorded */;
3535
+ const deadline = Date.now() + REWAKE_OUTCOME_WAIT_MS;
3536
+ for (; ; ) {
3537
+ const transcriptTail = readFileTail(transcriptPath, REWAKE_TRANSCRIPT_TAIL_BYTES) ?? "";
3538
+ const outcome = findSyncHookOutcome(transcriptTail, hookEventName, anchor);
3539
+ if (outcome !== "unrecorded" /* Unrecorded */ || Date.now() >= deadline) return outcome;
3540
+ waitSync(REWAKE_OUTCOME_POLL_MS);
3541
+ }
3542
+ }
3543
+ function rewake() {
3544
+ const hookEventName = input.hook_event_name ?? "";
3545
+ const outcome = awaitSyncHookOutcome(hookEventName);
3546
+ const savedOutput = takeSyncHookOutput(sessionId, hookEventName, payloadDigest(rawPayload));
3547
+ if (outcome === "delivered" /* Delivered */) return "";
3548
+ return rewakeMessage(hookEventName, savedOutput ? savedOutput.hookOutput : runFiringGates());
3549
+ }
3550
+ function saveSyncHookOutput(hookOutput) {
3551
+ const hookEventName = input.hook_event_name ?? "";
3552
+ if (!REWAKE_HOOK_EVENTS.includes(hookEventName)) return;
3553
+ try {
3554
+ writeSyncHookOutput(sessionId, hookEventName, { payloadDigest: payloadDigest(rawPayload), hookOutput });
3555
+ } catch {
3556
+ }
3557
+ }
3558
+ if (sub === REWAKE_SUBCOMMAND) {
3559
+ const rewakeText = rewake();
3560
+ if (rewakeText) {
3561
+ process.stderr.write(rewakeText);
3562
+ process.exitCode = 2;
3563
+ }
3564
+ } else {
3565
+ const runSubcommand = isBackgroundJob(sub) ? backgroundJobs[sub] : handlers[sub] ?? (() => "{}");
3566
+ const hookOutput = runSubcommand();
3567
+ if (!isBackgroundJob(sub)) saveSyncHookOutput(hookOutput);
3568
+ process.stdout.write(hookOutput);
3569
+ }