@muggleai/works 5.30.0-staging.263 → 5.30.0-staging.265
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/.codex-mcp.json +1 -1
- package/dist/plugin/.codex-plugin/plugin.json +1 -1
- package/dist/plugin/.cursor-plugin/plugin.json +1 -1
- package/dist/plugin/.mcp.json +1 -1
- package/dist/plugin/hooks/hooks.json +14 -0
- package/dist/plugin/scripts/guardrail-lib.sh +7 -0
- package/dist/plugin/scripts/guardrail-rewake.sh +40 -0
- package/dist/plugin/scripts/guardrails.mjs +267 -8
- package/dist/release-manifest.json +3 -3
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-mcp.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.cursor-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -1
- package/plugin/hooks/hooks.json +14 -0
- package/plugin/scripts/guardrail-lib.sh +7 -0
- package/plugin/scripts/guardrail-rewake.sh +40 -0
- package/plugin/scripts/guardrails.mjs +267 -8
|
@@ -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.
|
|
12
|
+
"version": "5.30.0-staging.265"
|
|
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.
|
|
4
|
+
"version": "5.30.0-staging.265",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Muggle AI",
|
|
7
7
|
"email": "support@muggle-ai.com"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muggle",
|
|
3
|
-
"version": "5.30.0-staging.
|
|
3
|
+
"version": "5.30.0-staging.265",
|
|
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.
|
|
5
|
+
"version": "5.30.0-staging.265",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Muggle AI",
|
|
8
8
|
"email": "support@muggle-ai.com"
|
package/dist/plugin/.mcp.json
CHANGED
|
@@ -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 {
|
|
1
|
+
import { spawnSync, execFileSync, spawn } from 'child_process';
|
|
2
|
+
import { readFileSync, rmSync, existsSync, readdirSync, mkdirSync, openSync, fstatSync, readSync, closeSync, writeFileSync, renameSync, statSync, writeSync, unlinkSync } from 'fs';
|
|
2
3
|
import { join, isAbsolute, resolve, basename, dirname } from 'path';
|
|
3
4
|
import { homedir, tmpdir, hostname } from 'os';
|
|
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,32 @@ 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
|
+
};
|
|
134
|
+
var RECONCILED_HOOK_EVENT = "PostToolUse";
|
|
135
|
+
var RECONCILED_RECORD_SUFFIX = ".reconciled-record";
|
|
136
|
+
var RECONCILE_REPLAY_TIMEOUT_MS = 15e3;
|
|
137
|
+
var GUARDRAIL_SUBCOMMAND_ASSIGNMENT = /^GUARDRAIL_SUBCOMMAND="([\w-]+)"/m;
|
|
138
|
+
var HOOK_COMMAND_SCRIPTS = /^set -- ([^;]+);/;
|
|
139
|
+
var GUARDRAIL_DISPATCH_SCRIPT = "guardrail-dispatch.sh";
|
|
113
140
|
|
|
114
141
|
// src/guardrails/store/fileLock.ts
|
|
115
142
|
function isProcessAlive(pid) {
|
|
@@ -244,6 +271,35 @@ function readE2eShellBlockCount(sessionId2, dirOverride) {
|
|
|
244
271
|
function clearE2eShellBlocks(sessionId2, dirOverride) {
|
|
245
272
|
rmSync(fileFor(sessionId2, dirOverride, E2E_SHELL_BLOCKS_SUFFIX), { force: true });
|
|
246
273
|
}
|
|
274
|
+
var syncHookOutputFile = (sessionId2, hookEventName, dirOverride) => fileFor(sessionId2, dirOverride, `.${hookEventName}${SYNC_HOOK_OUTPUT_SUFFIX}`);
|
|
275
|
+
function writeSyncHookOutput(sessionId2, hookEventName, record, dirOverride) {
|
|
276
|
+
writeAtomically(syncHookOutputFile(sessionId2, hookEventName, dirOverride), record, dirOverride);
|
|
277
|
+
}
|
|
278
|
+
function takeSyncHookOutput(sessionId2, hookEventName, payloadDigest2, dirOverride) {
|
|
279
|
+
const target = syncHookOutputFile(sessionId2, hookEventName, dirOverride);
|
|
280
|
+
try {
|
|
281
|
+
const record = JSON.parse(readFileSync(target, "utf-8"));
|
|
282
|
+
if (record.payloadDigest !== payloadDigest2) return null;
|
|
283
|
+
rmSync(target, { force: true });
|
|
284
|
+
return record;
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function readReconciledRecord(sessionId2, dirOverride) {
|
|
290
|
+
try {
|
|
291
|
+
return readFileSync(fileFor(sessionId2, dirOverride, RECONCILED_RECORD_SUFFIX), "utf-8").trim() || void 0;
|
|
292
|
+
} catch {
|
|
293
|
+
return void 0;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function writeReconciledRecord(sessionId2, recordUuid, dirOverride) {
|
|
297
|
+
mkdirSync(baseDir(dirOverride), { recursive: true });
|
|
298
|
+
const target = fileFor(sessionId2, dirOverride, RECONCILED_RECORD_SUFFIX);
|
|
299
|
+
const staging = `${target}.${process.pid}.tmp`;
|
|
300
|
+
writeFileSync(staging, recordUuid);
|
|
301
|
+
renameSync(staging, target);
|
|
302
|
+
}
|
|
247
303
|
var lockFor = (sessionId2, dirOverride) => `${fileFor(sessionId2, dirOverride)}.lock`;
|
|
248
304
|
function commitUnderHeldLock(sessionId2, mutate, dirOverride) {
|
|
249
305
|
const current = readState(sessionId2, dirOverride);
|
|
@@ -2829,6 +2885,132 @@ function normalizeHookInput(raw) {
|
|
|
2829
2885
|
}
|
|
2830
2886
|
return payload;
|
|
2831
2887
|
}
|
|
2888
|
+
function payloadDigest(rawPayload2) {
|
|
2889
|
+
return createHash("sha1").update(rawPayload2).digest("hex");
|
|
2890
|
+
}
|
|
2891
|
+
function transcriptAnchor(input2) {
|
|
2892
|
+
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;
|
|
2893
|
+
if (!anchorText?.trim()) return void 0;
|
|
2894
|
+
return JSON.stringify(anchorText).slice(1, -1);
|
|
2895
|
+
}
|
|
2896
|
+
var jsonFieldPattern = (field, value) => new RegExp(`"${field}"\\s*:\\s*"${value}"`);
|
|
2897
|
+
function parseHookRecord(line, hookEventName) {
|
|
2898
|
+
if (!jsonFieldPattern("hookEvent", hookEventName).test(line)) return void 0;
|
|
2899
|
+
try {
|
|
2900
|
+
const record = JSON.parse(line).attachment;
|
|
2901
|
+
if (!record?.type?.startsWith(TRANSCRIPT_HOOK_RECORD_TYPE_PREFIX) || record.hookEvent !== hookEventName) return void 0;
|
|
2902
|
+
return record;
|
|
2903
|
+
} catch {
|
|
2904
|
+
return void 0;
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
function isSyncGuardrailCommand(command) {
|
|
2908
|
+
return command.includes(GUARDRAIL_HOOK_COMMAND_MARKER) && !command.includes(REWAKE_HOOK_SCRIPT);
|
|
2909
|
+
}
|
|
2910
|
+
function findSyncHookOutcome(transcriptTail, hookEventName, anchor) {
|
|
2911
|
+
const lines = transcriptTail.split("\n");
|
|
2912
|
+
const anchorRolePattern = jsonFieldPattern("type", REWAKE_ANCHOR_ROLE_BY_EVENT[hookEventName] ?? "");
|
|
2913
|
+
let anchorIndex = -1;
|
|
2914
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
2915
|
+
if (lines[index].includes(anchor) && anchorRolePattern.test(lines[index])) {
|
|
2916
|
+
anchorIndex = index;
|
|
2917
|
+
break;
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
if (anchorIndex < 0) return "unrecorded" /* Unrecorded */;
|
|
2921
|
+
for (const line of lines.slice(anchorIndex + 1)) {
|
|
2922
|
+
const record = parseHookRecord(line, hookEventName);
|
|
2923
|
+
if (!record) continue;
|
|
2924
|
+
if (typeof record.command === "string") {
|
|
2925
|
+
if (!isSyncGuardrailCommand(record.command)) continue;
|
|
2926
|
+
return record.type === TRANSCRIPT_HOOK_CANCELLED_TYPE ? "cancelled" /* Cancelled */ : "delivered" /* Delivered */;
|
|
2927
|
+
}
|
|
2928
|
+
if (COMMANDLESS_HOOK_DELIVERY_TYPES.includes(record.type)) return "delivered" /* Delivered */;
|
|
2929
|
+
}
|
|
2930
|
+
return "unrecorded" /* Unrecorded */;
|
|
2931
|
+
}
|
|
2932
|
+
function rewakeMessage(hookEventName, hookOutput) {
|
|
2933
|
+
let parsedOutput;
|
|
2934
|
+
try {
|
|
2935
|
+
parsedOutput = JSON.parse(hookOutput);
|
|
2936
|
+
} catch {
|
|
2937
|
+
return "";
|
|
2938
|
+
}
|
|
2939
|
+
const deliveredText = hookEventName === "Stop" ? parsedOutput.decision === "block" ? parsedOutput.reason : void 0 : parsedOutput.hookSpecificOutput?.additionalContext ?? parsedOutput.additional_context;
|
|
2940
|
+
if (!deliveredText) return "";
|
|
2941
|
+
return `${REWAKE_MESSAGE_PREFIX[hookEventName] ?? ""}
|
|
2942
|
+
|
|
2943
|
+
${deliveredText}`;
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
// src/guardrails/reconcile.ts
|
|
2947
|
+
function parseTranscriptLines(transcriptTail) {
|
|
2948
|
+
return transcriptTail.split("\n").flatMap((line) => {
|
|
2949
|
+
try {
|
|
2950
|
+
return [JSON.parse(line)];
|
|
2951
|
+
} catch {
|
|
2952
|
+
return [];
|
|
2953
|
+
}
|
|
2954
|
+
});
|
|
2955
|
+
}
|
|
2956
|
+
function contentBlocks(line) {
|
|
2957
|
+
const content = line.message?.content;
|
|
2958
|
+
return Array.isArray(content) ? content : [];
|
|
2959
|
+
}
|
|
2960
|
+
function toolResponseFor(line, block) {
|
|
2961
|
+
if (line.toolUseResult && typeof line.toolUseResult === "object") return line.toolUseResult;
|
|
2962
|
+
if (typeof block.content === "string") return { output: block.content };
|
|
2963
|
+
return { content: block.content };
|
|
2964
|
+
}
|
|
2965
|
+
function isCancelledSyncRecorderRecord(record) {
|
|
2966
|
+
if (record?.type !== TRANSCRIPT_HOOK_CANCELLED_TYPE || record.hookEvent !== RECONCILED_HOOK_EVENT) return false;
|
|
2967
|
+
const command = record.command ?? "";
|
|
2968
|
+
return command.includes(GUARDRAIL_HOOK_COMMAND_MARKER) && !command.includes(REWAKE_HOOK_SCRIPT);
|
|
2969
|
+
}
|
|
2970
|
+
function findCancelledRecorderEvents(transcriptTail, sessionInput, afterRecordUuid) {
|
|
2971
|
+
const lines = parseTranscriptLines(transcriptTail);
|
|
2972
|
+
const toolCalls = /* @__PURE__ */ new Map();
|
|
2973
|
+
const toolResults = /* @__PURE__ */ new Map();
|
|
2974
|
+
for (const line of lines) {
|
|
2975
|
+
for (const block of contentBlocks(line)) {
|
|
2976
|
+
if (block.type === "tool_use" && block.id) toolCalls.set(block.id, { name: block.name, input: block.input });
|
|
2977
|
+
if (block.type === "tool_result" && block.tool_use_id) toolResults.set(block.tool_use_id, { line, block });
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
const watermarkIndex = afterRecordUuid ? lines.findIndex((line) => line.uuid === afterRecordUuid) : -1;
|
|
2981
|
+
return lines.slice(watermarkIndex + 1).flatMap((line) => {
|
|
2982
|
+
const record = line.attachment;
|
|
2983
|
+
if (!line.uuid || !record || !isCancelledSyncRecorderRecord(record) || !record.toolUseID) return [];
|
|
2984
|
+
const toolCall = toolCalls.get(record.toolUseID);
|
|
2985
|
+
const toolResult = toolResults.get(record.toolUseID);
|
|
2986
|
+
if (!toolCall || !toolResult) return [];
|
|
2987
|
+
return [
|
|
2988
|
+
{
|
|
2989
|
+
recordUuid: line.uuid,
|
|
2990
|
+
command: record.command ?? "",
|
|
2991
|
+
replayPayload: {
|
|
2992
|
+
session_id: sessionInput.session_id,
|
|
2993
|
+
transcript_path: sessionInput.transcript_path,
|
|
2994
|
+
cwd: toolResult.line.cwd ?? line.cwd ?? sessionInput.cwd,
|
|
2995
|
+
hook_event_name: RECONCILED_HOOK_EVENT,
|
|
2996
|
+
tool_name: toolCall.name,
|
|
2997
|
+
tool_input: toolCall.input,
|
|
2998
|
+
tool_response: toolResponseFor(toolResult.line, toolResult.block),
|
|
2999
|
+
tool_use_id: record.toolUseID
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
];
|
|
3003
|
+
});
|
|
3004
|
+
}
|
|
3005
|
+
function recorderSubcommands(command, readScript) {
|
|
3006
|
+
const scriptList = HOOK_COMMAND_SCRIPTS.exec(command)?.[1] ?? "";
|
|
3007
|
+
const scriptNames = scriptList.split(/\s+/).filter((scriptName) => scriptName && scriptName !== GUARDRAIL_DISPATCH_SCRIPT);
|
|
3008
|
+
const subcommands = scriptNames.flatMap((scriptName) => {
|
|
3009
|
+
const subcommand = GUARDRAIL_SUBCOMMAND_ASSIGNMENT.exec(readScript(scriptName) ?? "")?.[1];
|
|
3010
|
+
return subcommand ? [subcommand] : [];
|
|
3011
|
+
});
|
|
3012
|
+
return [...new Set(subcommands)];
|
|
3013
|
+
}
|
|
2832
3014
|
|
|
2833
3015
|
// src/guardrails/hookHost.ts
|
|
2834
3016
|
function resolveHookHost(env = process.env) {
|
|
@@ -2842,17 +3024,25 @@ function skillInvocation(slashCommand, host2) {
|
|
|
2842
3024
|
}
|
|
2843
3025
|
|
|
2844
3026
|
// src/guardrails/cli.ts
|
|
2845
|
-
function
|
|
3027
|
+
function readStdinText() {
|
|
3028
|
+
try {
|
|
3029
|
+
return readFileSync(0, "utf-8");
|
|
3030
|
+
} catch {
|
|
3031
|
+
return "";
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
function parseHookInput(rawHookPayload) {
|
|
2846
3035
|
try {
|
|
2847
|
-
return normalizeHookInput(JSON.parse(
|
|
3036
|
+
return normalizeHookInput(JSON.parse(rawHookPayload));
|
|
2848
3037
|
} catch {
|
|
2849
3038
|
return {};
|
|
2850
3039
|
}
|
|
2851
3040
|
}
|
|
2852
3041
|
var host = resolveHookHost();
|
|
2853
3042
|
var sub = process.argv[2];
|
|
2854
|
-
if (!isBackgroundJob(sub)) enforceHookChildProcessDeadline();
|
|
2855
|
-
var
|
|
3043
|
+
if (!isBackgroundJob(sub) && sub !== REWAKE_SUBCOMMAND) enforceHookChildProcessDeadline();
|
|
3044
|
+
var rawPayload = readStdinText();
|
|
3045
|
+
var input = parseHookInput(rawPayload);
|
|
2856
3046
|
var sessionId = input.session_id ?? "unknown";
|
|
2857
3047
|
function releaseGate(field) {
|
|
2858
3048
|
commitStateMutation(sessionId, { kind: "fields" /* Fields */, fields: { [field]: true } });
|
|
@@ -3422,5 +3612,74 @@ var backgroundJobs = {
|
|
|
3422
3612
|
["scan-watch-pr-states" /* ScanWatchPrStates */]: scanWatchPrStatesJob,
|
|
3423
3613
|
["commit-deferred-state" /* CommitDeferredState */]: commitDeferredStateJob
|
|
3424
3614
|
};
|
|
3425
|
-
|
|
3426
|
-
|
|
3615
|
+
function waitSync(milliseconds) {
|
|
3616
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
3617
|
+
}
|
|
3618
|
+
function awaitSyncHookOutcome(hookEventName) {
|
|
3619
|
+
const anchor = transcriptAnchor(
|
|
3620
|
+
hookEventName === "Stop" ? { ...input, last_assistant_message: closingAssistantText() } : input
|
|
3621
|
+
);
|
|
3622
|
+
const transcriptPath = input.transcript_path;
|
|
3623
|
+
if (!anchor || !transcriptPath) return "unrecorded" /* Unrecorded */;
|
|
3624
|
+
const deadline = Date.now() + REWAKE_OUTCOME_WAIT_MS;
|
|
3625
|
+
for (; ; ) {
|
|
3626
|
+
const transcriptTail = readFileTail(transcriptPath, REWAKE_TRANSCRIPT_TAIL_BYTES) ?? "";
|
|
3627
|
+
const outcome = findSyncHookOutcome(transcriptTail, hookEventName, anchor);
|
|
3628
|
+
if (outcome !== "unrecorded" /* Unrecorded */ || Date.now() >= deadline) return outcome;
|
|
3629
|
+
waitSync(REWAKE_OUTCOME_POLL_MS);
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
function readGuardrailScript(scriptName) {
|
|
3633
|
+
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT ?? process.env.CURSOR_PLUGIN_ROOT;
|
|
3634
|
+
const scriptsDir = pluginRoot ? join(pluginRoot, "scripts") : dirname(process.argv[1]);
|
|
3635
|
+
try {
|
|
3636
|
+
return readFileSync(join(scriptsDir, scriptName), "utf-8");
|
|
3637
|
+
} catch {
|
|
3638
|
+
return null;
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3641
|
+
function reconcileCancelledRecorders() {
|
|
3642
|
+
const transcriptPath = input.transcript_path;
|
|
3643
|
+
if (!transcriptPath) return;
|
|
3644
|
+
const transcriptTail = readFileTail(transcriptPath, REWAKE_TRANSCRIPT_TAIL_BYTES) ?? "";
|
|
3645
|
+
for (const event of findCancelledRecorderEvents(transcriptTail, input, readReconciledRecord(sessionId))) {
|
|
3646
|
+
const subcommands = recorderSubcommands(event.command, readGuardrailScript);
|
|
3647
|
+
if (subcommands.length > 0) {
|
|
3648
|
+
spawnSync(process.execPath, [...process.execArgv, process.argv[1], "run", ...subcommands], {
|
|
3649
|
+
input: JSON.stringify(event.replayPayload),
|
|
3650
|
+
timeout: RECONCILE_REPLAY_TIMEOUT_MS,
|
|
3651
|
+
windowsHide: true,
|
|
3652
|
+
stdio: ["pipe", "ignore", "ignore"]
|
|
3653
|
+
});
|
|
3654
|
+
}
|
|
3655
|
+
writeReconciledRecord(sessionId, event.recordUuid);
|
|
3656
|
+
}
|
|
3657
|
+
}
|
|
3658
|
+
function rewake() {
|
|
3659
|
+
const hookEventName = input.hook_event_name ?? "";
|
|
3660
|
+
if (hookEventName === "Stop") reconcileCancelledRecorders();
|
|
3661
|
+
const outcome = awaitSyncHookOutcome(hookEventName);
|
|
3662
|
+
const savedOutput = takeSyncHookOutput(sessionId, hookEventName, payloadDigest(rawPayload));
|
|
3663
|
+
if (outcome === "delivered" /* Delivered */) return "";
|
|
3664
|
+
return rewakeMessage(hookEventName, savedOutput ? savedOutput.hookOutput : runFiringGates());
|
|
3665
|
+
}
|
|
3666
|
+
function saveSyncHookOutput(hookOutput) {
|
|
3667
|
+
const hookEventName = input.hook_event_name ?? "";
|
|
3668
|
+
if (!REWAKE_HOOK_EVENTS.includes(hookEventName)) return;
|
|
3669
|
+
try {
|
|
3670
|
+
writeSyncHookOutput(sessionId, hookEventName, { payloadDigest: payloadDigest(rawPayload), hookOutput });
|
|
3671
|
+
} catch {
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3674
|
+
if (sub === REWAKE_SUBCOMMAND) {
|
|
3675
|
+
const rewakeText = rewake();
|
|
3676
|
+
if (rewakeText) {
|
|
3677
|
+
process.stderr.write(rewakeText);
|
|
3678
|
+
process.exitCode = 2;
|
|
3679
|
+
}
|
|
3680
|
+
} else {
|
|
3681
|
+
const runSubcommand = isBackgroundJob(sub) ? backgroundJobs[sub] : handlers[sub] ?? (() => "{}");
|
|
3682
|
+
const hookOutput = runSubcommand();
|
|
3683
|
+
if (!isBackgroundJob(sub)) saveSyncHookOutput(hookOutput);
|
|
3684
|
+
process.stdout.write(hookOutput);
|
|
3685
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"release": "5.26.0",
|
|
3
|
-
"buildId": "run-
|
|
4
|
-
"commitSha": "
|
|
5
|
-
"buildTime": "2026-10-
|
|
3
|
+
"buildId": "run-265-1",
|
|
4
|
+
"commitSha": "c79ba47696868d4d7e5d1d884f4edf77ee3fc393",
|
|
5
|
+
"buildTime": "2026-10-31T06:04:17Z",
|
|
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.
|
|
4
|
+
"version": "5.30.0-staging.265",
|
|
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.
|
|
4
|
+
"version": "5.30.0-staging.265",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Muggle AI",
|
|
7
7
|
"email": "support@muggle-ai.com"
|
package/plugin/.codex-mcp.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muggle",
|
|
3
|
-
"version": "5.30.0-staging.
|
|
3
|
+
"version": "5.30.0-staging.265",
|
|
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.
|
|
5
|
+
"version": "5.30.0-staging.265",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Muggle AI",
|
|
8
8
|
"email": "support@muggle-ai.com"
|
package/plugin/.mcp.json
CHANGED
package/plugin/hooks/hooks.json
CHANGED
|
@@ -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 {
|
|
1
|
+
import { spawnSync, execFileSync, spawn } from 'child_process';
|
|
2
|
+
import { readFileSync, rmSync, existsSync, readdirSync, mkdirSync, openSync, fstatSync, readSync, closeSync, writeFileSync, renameSync, statSync, writeSync, unlinkSync } from 'fs';
|
|
2
3
|
import { join, isAbsolute, resolve, basename, dirname } from 'path';
|
|
3
4
|
import { homedir, tmpdir, hostname } from 'os';
|
|
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,32 @@ 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
|
+
};
|
|
134
|
+
var RECONCILED_HOOK_EVENT = "PostToolUse";
|
|
135
|
+
var RECONCILED_RECORD_SUFFIX = ".reconciled-record";
|
|
136
|
+
var RECONCILE_REPLAY_TIMEOUT_MS = 15e3;
|
|
137
|
+
var GUARDRAIL_SUBCOMMAND_ASSIGNMENT = /^GUARDRAIL_SUBCOMMAND="([\w-]+)"/m;
|
|
138
|
+
var HOOK_COMMAND_SCRIPTS = /^set -- ([^;]+);/;
|
|
139
|
+
var GUARDRAIL_DISPATCH_SCRIPT = "guardrail-dispatch.sh";
|
|
113
140
|
|
|
114
141
|
// src/guardrails/store/fileLock.ts
|
|
115
142
|
function isProcessAlive(pid) {
|
|
@@ -244,6 +271,35 @@ function readE2eShellBlockCount(sessionId2, dirOverride) {
|
|
|
244
271
|
function clearE2eShellBlocks(sessionId2, dirOverride) {
|
|
245
272
|
rmSync(fileFor(sessionId2, dirOverride, E2E_SHELL_BLOCKS_SUFFIX), { force: true });
|
|
246
273
|
}
|
|
274
|
+
var syncHookOutputFile = (sessionId2, hookEventName, dirOverride) => fileFor(sessionId2, dirOverride, `.${hookEventName}${SYNC_HOOK_OUTPUT_SUFFIX}`);
|
|
275
|
+
function writeSyncHookOutput(sessionId2, hookEventName, record, dirOverride) {
|
|
276
|
+
writeAtomically(syncHookOutputFile(sessionId2, hookEventName, dirOverride), record, dirOverride);
|
|
277
|
+
}
|
|
278
|
+
function takeSyncHookOutput(sessionId2, hookEventName, payloadDigest2, dirOverride) {
|
|
279
|
+
const target = syncHookOutputFile(sessionId2, hookEventName, dirOverride);
|
|
280
|
+
try {
|
|
281
|
+
const record = JSON.parse(readFileSync(target, "utf-8"));
|
|
282
|
+
if (record.payloadDigest !== payloadDigest2) return null;
|
|
283
|
+
rmSync(target, { force: true });
|
|
284
|
+
return record;
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function readReconciledRecord(sessionId2, dirOverride) {
|
|
290
|
+
try {
|
|
291
|
+
return readFileSync(fileFor(sessionId2, dirOverride, RECONCILED_RECORD_SUFFIX), "utf-8").trim() || void 0;
|
|
292
|
+
} catch {
|
|
293
|
+
return void 0;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function writeReconciledRecord(sessionId2, recordUuid, dirOverride) {
|
|
297
|
+
mkdirSync(baseDir(dirOverride), { recursive: true });
|
|
298
|
+
const target = fileFor(sessionId2, dirOverride, RECONCILED_RECORD_SUFFIX);
|
|
299
|
+
const staging = `${target}.${process.pid}.tmp`;
|
|
300
|
+
writeFileSync(staging, recordUuid);
|
|
301
|
+
renameSync(staging, target);
|
|
302
|
+
}
|
|
247
303
|
var lockFor = (sessionId2, dirOverride) => `${fileFor(sessionId2, dirOverride)}.lock`;
|
|
248
304
|
function commitUnderHeldLock(sessionId2, mutate, dirOverride) {
|
|
249
305
|
const current = readState(sessionId2, dirOverride);
|
|
@@ -2829,6 +2885,132 @@ function normalizeHookInput(raw) {
|
|
|
2829
2885
|
}
|
|
2830
2886
|
return payload;
|
|
2831
2887
|
}
|
|
2888
|
+
function payloadDigest(rawPayload2) {
|
|
2889
|
+
return createHash("sha1").update(rawPayload2).digest("hex");
|
|
2890
|
+
}
|
|
2891
|
+
function transcriptAnchor(input2) {
|
|
2892
|
+
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;
|
|
2893
|
+
if (!anchorText?.trim()) return void 0;
|
|
2894
|
+
return JSON.stringify(anchorText).slice(1, -1);
|
|
2895
|
+
}
|
|
2896
|
+
var jsonFieldPattern = (field, value) => new RegExp(`"${field}"\\s*:\\s*"${value}"`);
|
|
2897
|
+
function parseHookRecord(line, hookEventName) {
|
|
2898
|
+
if (!jsonFieldPattern("hookEvent", hookEventName).test(line)) return void 0;
|
|
2899
|
+
try {
|
|
2900
|
+
const record = JSON.parse(line).attachment;
|
|
2901
|
+
if (!record?.type?.startsWith(TRANSCRIPT_HOOK_RECORD_TYPE_PREFIX) || record.hookEvent !== hookEventName) return void 0;
|
|
2902
|
+
return record;
|
|
2903
|
+
} catch {
|
|
2904
|
+
return void 0;
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
function isSyncGuardrailCommand(command) {
|
|
2908
|
+
return command.includes(GUARDRAIL_HOOK_COMMAND_MARKER) && !command.includes(REWAKE_HOOK_SCRIPT);
|
|
2909
|
+
}
|
|
2910
|
+
function findSyncHookOutcome(transcriptTail, hookEventName, anchor) {
|
|
2911
|
+
const lines = transcriptTail.split("\n");
|
|
2912
|
+
const anchorRolePattern = jsonFieldPattern("type", REWAKE_ANCHOR_ROLE_BY_EVENT[hookEventName] ?? "");
|
|
2913
|
+
let anchorIndex = -1;
|
|
2914
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
2915
|
+
if (lines[index].includes(anchor) && anchorRolePattern.test(lines[index])) {
|
|
2916
|
+
anchorIndex = index;
|
|
2917
|
+
break;
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
if (anchorIndex < 0) return "unrecorded" /* Unrecorded */;
|
|
2921
|
+
for (const line of lines.slice(anchorIndex + 1)) {
|
|
2922
|
+
const record = parseHookRecord(line, hookEventName);
|
|
2923
|
+
if (!record) continue;
|
|
2924
|
+
if (typeof record.command === "string") {
|
|
2925
|
+
if (!isSyncGuardrailCommand(record.command)) continue;
|
|
2926
|
+
return record.type === TRANSCRIPT_HOOK_CANCELLED_TYPE ? "cancelled" /* Cancelled */ : "delivered" /* Delivered */;
|
|
2927
|
+
}
|
|
2928
|
+
if (COMMANDLESS_HOOK_DELIVERY_TYPES.includes(record.type)) return "delivered" /* Delivered */;
|
|
2929
|
+
}
|
|
2930
|
+
return "unrecorded" /* Unrecorded */;
|
|
2931
|
+
}
|
|
2932
|
+
function rewakeMessage(hookEventName, hookOutput) {
|
|
2933
|
+
let parsedOutput;
|
|
2934
|
+
try {
|
|
2935
|
+
parsedOutput = JSON.parse(hookOutput);
|
|
2936
|
+
} catch {
|
|
2937
|
+
return "";
|
|
2938
|
+
}
|
|
2939
|
+
const deliveredText = hookEventName === "Stop" ? parsedOutput.decision === "block" ? parsedOutput.reason : void 0 : parsedOutput.hookSpecificOutput?.additionalContext ?? parsedOutput.additional_context;
|
|
2940
|
+
if (!deliveredText) return "";
|
|
2941
|
+
return `${REWAKE_MESSAGE_PREFIX[hookEventName] ?? ""}
|
|
2942
|
+
|
|
2943
|
+
${deliveredText}`;
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
// src/guardrails/reconcile.ts
|
|
2947
|
+
function parseTranscriptLines(transcriptTail) {
|
|
2948
|
+
return transcriptTail.split("\n").flatMap((line) => {
|
|
2949
|
+
try {
|
|
2950
|
+
return [JSON.parse(line)];
|
|
2951
|
+
} catch {
|
|
2952
|
+
return [];
|
|
2953
|
+
}
|
|
2954
|
+
});
|
|
2955
|
+
}
|
|
2956
|
+
function contentBlocks(line) {
|
|
2957
|
+
const content = line.message?.content;
|
|
2958
|
+
return Array.isArray(content) ? content : [];
|
|
2959
|
+
}
|
|
2960
|
+
function toolResponseFor(line, block) {
|
|
2961
|
+
if (line.toolUseResult && typeof line.toolUseResult === "object") return line.toolUseResult;
|
|
2962
|
+
if (typeof block.content === "string") return { output: block.content };
|
|
2963
|
+
return { content: block.content };
|
|
2964
|
+
}
|
|
2965
|
+
function isCancelledSyncRecorderRecord(record) {
|
|
2966
|
+
if (record?.type !== TRANSCRIPT_HOOK_CANCELLED_TYPE || record.hookEvent !== RECONCILED_HOOK_EVENT) return false;
|
|
2967
|
+
const command = record.command ?? "";
|
|
2968
|
+
return command.includes(GUARDRAIL_HOOK_COMMAND_MARKER) && !command.includes(REWAKE_HOOK_SCRIPT);
|
|
2969
|
+
}
|
|
2970
|
+
function findCancelledRecorderEvents(transcriptTail, sessionInput, afterRecordUuid) {
|
|
2971
|
+
const lines = parseTranscriptLines(transcriptTail);
|
|
2972
|
+
const toolCalls = /* @__PURE__ */ new Map();
|
|
2973
|
+
const toolResults = /* @__PURE__ */ new Map();
|
|
2974
|
+
for (const line of lines) {
|
|
2975
|
+
for (const block of contentBlocks(line)) {
|
|
2976
|
+
if (block.type === "tool_use" && block.id) toolCalls.set(block.id, { name: block.name, input: block.input });
|
|
2977
|
+
if (block.type === "tool_result" && block.tool_use_id) toolResults.set(block.tool_use_id, { line, block });
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
const watermarkIndex = afterRecordUuid ? lines.findIndex((line) => line.uuid === afterRecordUuid) : -1;
|
|
2981
|
+
return lines.slice(watermarkIndex + 1).flatMap((line) => {
|
|
2982
|
+
const record = line.attachment;
|
|
2983
|
+
if (!line.uuid || !record || !isCancelledSyncRecorderRecord(record) || !record.toolUseID) return [];
|
|
2984
|
+
const toolCall = toolCalls.get(record.toolUseID);
|
|
2985
|
+
const toolResult = toolResults.get(record.toolUseID);
|
|
2986
|
+
if (!toolCall || !toolResult) return [];
|
|
2987
|
+
return [
|
|
2988
|
+
{
|
|
2989
|
+
recordUuid: line.uuid,
|
|
2990
|
+
command: record.command ?? "",
|
|
2991
|
+
replayPayload: {
|
|
2992
|
+
session_id: sessionInput.session_id,
|
|
2993
|
+
transcript_path: sessionInput.transcript_path,
|
|
2994
|
+
cwd: toolResult.line.cwd ?? line.cwd ?? sessionInput.cwd,
|
|
2995
|
+
hook_event_name: RECONCILED_HOOK_EVENT,
|
|
2996
|
+
tool_name: toolCall.name,
|
|
2997
|
+
tool_input: toolCall.input,
|
|
2998
|
+
tool_response: toolResponseFor(toolResult.line, toolResult.block),
|
|
2999
|
+
tool_use_id: record.toolUseID
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
];
|
|
3003
|
+
});
|
|
3004
|
+
}
|
|
3005
|
+
function recorderSubcommands(command, readScript) {
|
|
3006
|
+
const scriptList = HOOK_COMMAND_SCRIPTS.exec(command)?.[1] ?? "";
|
|
3007
|
+
const scriptNames = scriptList.split(/\s+/).filter((scriptName) => scriptName && scriptName !== GUARDRAIL_DISPATCH_SCRIPT);
|
|
3008
|
+
const subcommands = scriptNames.flatMap((scriptName) => {
|
|
3009
|
+
const subcommand = GUARDRAIL_SUBCOMMAND_ASSIGNMENT.exec(readScript(scriptName) ?? "")?.[1];
|
|
3010
|
+
return subcommand ? [subcommand] : [];
|
|
3011
|
+
});
|
|
3012
|
+
return [...new Set(subcommands)];
|
|
3013
|
+
}
|
|
2832
3014
|
|
|
2833
3015
|
// src/guardrails/hookHost.ts
|
|
2834
3016
|
function resolveHookHost(env = process.env) {
|
|
@@ -2842,17 +3024,25 @@ function skillInvocation(slashCommand, host2) {
|
|
|
2842
3024
|
}
|
|
2843
3025
|
|
|
2844
3026
|
// src/guardrails/cli.ts
|
|
2845
|
-
function
|
|
3027
|
+
function readStdinText() {
|
|
3028
|
+
try {
|
|
3029
|
+
return readFileSync(0, "utf-8");
|
|
3030
|
+
} catch {
|
|
3031
|
+
return "";
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
function parseHookInput(rawHookPayload) {
|
|
2846
3035
|
try {
|
|
2847
|
-
return normalizeHookInput(JSON.parse(
|
|
3036
|
+
return normalizeHookInput(JSON.parse(rawHookPayload));
|
|
2848
3037
|
} catch {
|
|
2849
3038
|
return {};
|
|
2850
3039
|
}
|
|
2851
3040
|
}
|
|
2852
3041
|
var host = resolveHookHost();
|
|
2853
3042
|
var sub = process.argv[2];
|
|
2854
|
-
if (!isBackgroundJob(sub)) enforceHookChildProcessDeadline();
|
|
2855
|
-
var
|
|
3043
|
+
if (!isBackgroundJob(sub) && sub !== REWAKE_SUBCOMMAND) enforceHookChildProcessDeadline();
|
|
3044
|
+
var rawPayload = readStdinText();
|
|
3045
|
+
var input = parseHookInput(rawPayload);
|
|
2856
3046
|
var sessionId = input.session_id ?? "unknown";
|
|
2857
3047
|
function releaseGate(field) {
|
|
2858
3048
|
commitStateMutation(sessionId, { kind: "fields" /* Fields */, fields: { [field]: true } });
|
|
@@ -3422,5 +3612,74 @@ var backgroundJobs = {
|
|
|
3422
3612
|
["scan-watch-pr-states" /* ScanWatchPrStates */]: scanWatchPrStatesJob,
|
|
3423
3613
|
["commit-deferred-state" /* CommitDeferredState */]: commitDeferredStateJob
|
|
3424
3614
|
};
|
|
3425
|
-
|
|
3426
|
-
|
|
3615
|
+
function waitSync(milliseconds) {
|
|
3616
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
3617
|
+
}
|
|
3618
|
+
function awaitSyncHookOutcome(hookEventName) {
|
|
3619
|
+
const anchor = transcriptAnchor(
|
|
3620
|
+
hookEventName === "Stop" ? { ...input, last_assistant_message: closingAssistantText() } : input
|
|
3621
|
+
);
|
|
3622
|
+
const transcriptPath = input.transcript_path;
|
|
3623
|
+
if (!anchor || !transcriptPath) return "unrecorded" /* Unrecorded */;
|
|
3624
|
+
const deadline = Date.now() + REWAKE_OUTCOME_WAIT_MS;
|
|
3625
|
+
for (; ; ) {
|
|
3626
|
+
const transcriptTail = readFileTail(transcriptPath, REWAKE_TRANSCRIPT_TAIL_BYTES) ?? "";
|
|
3627
|
+
const outcome = findSyncHookOutcome(transcriptTail, hookEventName, anchor);
|
|
3628
|
+
if (outcome !== "unrecorded" /* Unrecorded */ || Date.now() >= deadline) return outcome;
|
|
3629
|
+
waitSync(REWAKE_OUTCOME_POLL_MS);
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
function readGuardrailScript(scriptName) {
|
|
3633
|
+
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT ?? process.env.CURSOR_PLUGIN_ROOT;
|
|
3634
|
+
const scriptsDir = pluginRoot ? join(pluginRoot, "scripts") : dirname(process.argv[1]);
|
|
3635
|
+
try {
|
|
3636
|
+
return readFileSync(join(scriptsDir, scriptName), "utf-8");
|
|
3637
|
+
} catch {
|
|
3638
|
+
return null;
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3641
|
+
function reconcileCancelledRecorders() {
|
|
3642
|
+
const transcriptPath = input.transcript_path;
|
|
3643
|
+
if (!transcriptPath) return;
|
|
3644
|
+
const transcriptTail = readFileTail(transcriptPath, REWAKE_TRANSCRIPT_TAIL_BYTES) ?? "";
|
|
3645
|
+
for (const event of findCancelledRecorderEvents(transcriptTail, input, readReconciledRecord(sessionId))) {
|
|
3646
|
+
const subcommands = recorderSubcommands(event.command, readGuardrailScript);
|
|
3647
|
+
if (subcommands.length > 0) {
|
|
3648
|
+
spawnSync(process.execPath, [...process.execArgv, process.argv[1], "run", ...subcommands], {
|
|
3649
|
+
input: JSON.stringify(event.replayPayload),
|
|
3650
|
+
timeout: RECONCILE_REPLAY_TIMEOUT_MS,
|
|
3651
|
+
windowsHide: true,
|
|
3652
|
+
stdio: ["pipe", "ignore", "ignore"]
|
|
3653
|
+
});
|
|
3654
|
+
}
|
|
3655
|
+
writeReconciledRecord(sessionId, event.recordUuid);
|
|
3656
|
+
}
|
|
3657
|
+
}
|
|
3658
|
+
function rewake() {
|
|
3659
|
+
const hookEventName = input.hook_event_name ?? "";
|
|
3660
|
+
if (hookEventName === "Stop") reconcileCancelledRecorders();
|
|
3661
|
+
const outcome = awaitSyncHookOutcome(hookEventName);
|
|
3662
|
+
const savedOutput = takeSyncHookOutput(sessionId, hookEventName, payloadDigest(rawPayload));
|
|
3663
|
+
if (outcome === "delivered" /* Delivered */) return "";
|
|
3664
|
+
return rewakeMessage(hookEventName, savedOutput ? savedOutput.hookOutput : runFiringGates());
|
|
3665
|
+
}
|
|
3666
|
+
function saveSyncHookOutput(hookOutput) {
|
|
3667
|
+
const hookEventName = input.hook_event_name ?? "";
|
|
3668
|
+
if (!REWAKE_HOOK_EVENTS.includes(hookEventName)) return;
|
|
3669
|
+
try {
|
|
3670
|
+
writeSyncHookOutput(sessionId, hookEventName, { payloadDigest: payloadDigest(rawPayload), hookOutput });
|
|
3671
|
+
} catch {
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3674
|
+
if (sub === REWAKE_SUBCOMMAND) {
|
|
3675
|
+
const rewakeText = rewake();
|
|
3676
|
+
if (rewakeText) {
|
|
3677
|
+
process.stderr.write(rewakeText);
|
|
3678
|
+
process.exitCode = 2;
|
|
3679
|
+
}
|
|
3680
|
+
} else {
|
|
3681
|
+
const runSubcommand = isBackgroundJob(sub) ? backgroundJobs[sub] : handlers[sub] ?? (() => "{}");
|
|
3682
|
+
const hookOutput = runSubcommand();
|
|
3683
|
+
if (!isBackgroundJob(sub)) saveSyncHookOutput(hookOutput);
|
|
3684
|
+
process.stdout.write(hookOutput);
|
|
3685
|
+
}
|