@muggleai/works 5.6.0 → 5.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/plugin/hooks/README.md +4 -1
- package/dist/plugin/hooks/hooks.json +34 -0
- package/dist/plugin/scripts/guardrail-offer-ran.sh +35 -0
- package/dist/plugin/scripts/guardrail-pr-terminal.sh +21 -0
- package/dist/plugin/scripts/guardrail-terminal-gate.sh +38 -0
- package/dist/plugin/scripts/guardrails.mjs +85 -0
- package/dist/release-manifest.json +3 -3
- package/package.json +1 -1
- package/plugin/hooks/README.md +4 -1
- package/plugin/hooks/hooks.json +34 -0
- package/plugin/scripts/guardrail-offer-ran.sh +35 -0
- package/plugin/scripts/guardrail-pr-terminal.sh +21 -0
- package/plugin/scripts/guardrail-terminal-gate.sh +38 -0
- package/plugin/scripts/guardrails.mjs +85 -0
|
@@ -20,7 +20,7 @@ A guardrail emits one of two strengths:
|
|
|
20
20
|
- **Advise** — `additionalContext` (PostToolUse/UserPromptSubmit) or a plain Stop message. A soft nudge the model can ignore.
|
|
21
21
|
- **Enforce** — a `Stop` `decision: "block"` that refuses to end the turn, or a `PreToolUse` `permissionDecision: "deny"` that refuses a tool call. The model cannot proceed until the condition is met.
|
|
22
22
|
|
|
23
|
-
Enforcement is reserved for the handoffs that were being skipped: the E2E acceptance run
|
|
23
|
+
Enforcement is reserved for the handoffs that were being skipped: the E2E acceptance run, posting a deterministically-rendered report, and the post-merge handoff. Each enforcing gate carries an escape so it can't trap a turn — the E2E gate accepts an explicit skip declaration (`echo "MUGGLE_E2E_SKIP: <reason>"`, session-durable) and hard-releases after `MAX_E2E_BLOCKS` (3) blocks; the report gate only denies a body it can positively see is a hand-written report and fails open otherwise; the post-merge gate hard-releases after `MAX_PR_TERMINAL_BLOCKS` (3) blocks, and only the AskUserQuestion next-options offer clears it — nothing else resets its counter.
|
|
24
24
|
|
|
25
25
|
## Mechanism
|
|
26
26
|
|
|
@@ -32,8 +32,11 @@ Each guardrail is a thin bash wrapper in `../scripts/` registered in `hooks.json
|
|
|
32
32
|
| :--------- | :------ | :------- | :-------- | :--------- | :----- |
|
|
33
33
|
| `PostToolUse` (Bash) | `guardrail-pr-opened.sh` | advise | a `gh pr create`/`gh pr ready` just succeeded | `autoWatchPR` | start a `muggle-pr-followup` watcher on the new PR |
|
|
34
34
|
| `PostToolUse` (Bash + muggle execute/replay MCP tools) | `guardrail-record-tests.sh` | record | a unit-test command passed, an E2E run happened, or an `echo "MUGGLE_E2E_SKIP: <reason>"` marker declared E2E un-runnable | — | set `unitTestsGreen` / `e2eRun` / `e2eSkipped` session state |
|
|
35
|
+
| `PostToolUse` (Bash + Monitor) | `guardrail-pr-terminal.sh` | advise | a PR just went terminal — a `gh pr merge`/`gh pr close` success line or the watch monitor's `TERMINAL pr=N` exit line (never bare `"state":"MERGED"` metadata) | — | record `terminalPending`, direct the post-merge handoff: finalize the watcher slot, tear down per `autoCleanup`, offer next options via AskUserQuestion |
|
|
36
|
+
| `PostToolUse` (AskUserQuestion) | `guardrail-offer-ran.sh` | record | a next-options offer ran while a terminal PR was pending | — | clear `terminalPending` — the only exit for the post-merge Stop gate |
|
|
35
37
|
| `PreToolUse` (Bash) | `guardrail-report-format.sh` | **enforce** | a `gh pr comment\|create\|edit` body reads like an E2E report but lacks the `build-pr-section` sentinel | — | **deny** — render via `muggle build-pr-section` instead |
|
|
36
38
|
| `Stop` | `guardrail-e2e-gate.sh` | **enforce** | unit tests passed this session, no E2E ran yet, and no skip was recorded | `autoE2ETest` | **block** the turn until E2E runs via `muggle-test` or a `MUGGLE_E2E_SKIP` marker records a legitimate skip (full message once, one-line reminders after; releases after 3 blocks) |
|
|
39
|
+
| `Stop` | `guardrail-terminal-gate.sh` | **enforce** | a PR went terminal this session and the AskUserQuestion next-options offer hasn't run since | — | **block** the turn until the post-merge handoff runs (full message once, one-line reminders after; releases after 3 blocks; nothing but the offer resets the counter) |
|
|
37
40
|
| `UserPromptSubmit` | `guardrail-build-router.sh` | advise | a build/implement/fix request (first one this session) | `autoRouteBuildToMuggleDo` | route the work through `muggle-do` (build delegated to superpowers) |
|
|
38
41
|
|
|
39
42
|
## Session-start reconcile nudge
|
|
@@ -47,6 +47,12 @@
|
|
|
47
47
|
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-record-tests.sh\"",
|
|
48
48
|
"async": false,
|
|
49
49
|
"timeout": 10
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"type": "command",
|
|
53
|
+
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-pr-terminal.sh\"",
|
|
54
|
+
"async": false,
|
|
55
|
+
"timeout": 10
|
|
50
56
|
}
|
|
51
57
|
]
|
|
52
58
|
},
|
|
@@ -60,6 +66,28 @@
|
|
|
60
66
|
"timeout": 10
|
|
61
67
|
}
|
|
62
68
|
]
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"matcher": "Monitor",
|
|
72
|
+
"hooks": [
|
|
73
|
+
{
|
|
74
|
+
"type": "command",
|
|
75
|
+
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-pr-terminal.sh\"",
|
|
76
|
+
"async": false,
|
|
77
|
+
"timeout": 10
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"matcher": "AskUserQuestion",
|
|
83
|
+
"hooks": [
|
|
84
|
+
{
|
|
85
|
+
"type": "command",
|
|
86
|
+
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-offer-ran.sh\"",
|
|
87
|
+
"async": false,
|
|
88
|
+
"timeout": 10
|
|
89
|
+
}
|
|
90
|
+
]
|
|
63
91
|
}
|
|
64
92
|
],
|
|
65
93
|
"Stop": [
|
|
@@ -70,6 +98,12 @@
|
|
|
70
98
|
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-e2e-gate.sh\"",
|
|
71
99
|
"async": false,
|
|
72
100
|
"timeout": 10
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"type": "command",
|
|
104
|
+
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-terminal-gate.sh\"",
|
|
105
|
+
"async": false,
|
|
106
|
+
"timeout": 10
|
|
73
107
|
}
|
|
74
108
|
]
|
|
75
109
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -uo pipefail
|
|
3
|
+
|
|
4
|
+
# Next-options-offer observer (PostToolUse/AskUserQuestion). An AskUserQuestion
|
|
5
|
+
# call while a terminal PR is pending IS the post-merge handoff's exit: it
|
|
6
|
+
# clears terminalPending so the Stop gate (guardrail-terminal-gate.sh)
|
|
7
|
+
# releases. Emits no directive.
|
|
8
|
+
#
|
|
9
|
+
# Pre-filter: only spawn Node when the per-session state actually has a
|
|
10
|
+
# pending terminal PR, so the ordinary AskUserQuestion (no PR merged this
|
|
11
|
+
# session) never pays Node cold-start. Degrades to {}.
|
|
12
|
+
payload="$(cat)"
|
|
13
|
+
|
|
14
|
+
raw_sid="$(printf '%s' "$payload" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')"
|
|
15
|
+
[ -n "$raw_sid" ] || raw_sid="unknown"
|
|
16
|
+
sid="$(printf '%s' "$raw_sid" | sed 's/[^A-Za-z0-9_-]/_/g')"
|
|
17
|
+
|
|
18
|
+
# Resolve the same home dir Node's os.homedir() uses. HOME is correct on
|
|
19
|
+
# macOS/Linux and on most Git Bash setups; fall back to converting USERPROFILE
|
|
20
|
+
# when HOME doesn't hold the state dir (some Windows shells point HOME elsewhere).
|
|
21
|
+
home="${HOME:-}"
|
|
22
|
+
if [ ! -d "$home/.muggle-ai" ] && command -v cygpath >/dev/null 2>&1 && [ -n "${USERPROFILE:-}" ]; then
|
|
23
|
+
home="$(cygpath -u "$USERPROFILE" 2>/dev/null || printf '%s' "$home")"
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
state_file="$home/.muggle-ai/guardrails/$sid.json"
|
|
27
|
+
if [ ! -f "$state_file" ] \
|
|
28
|
+
|| ! grep -q '"terminalPending"' "$state_file" \
|
|
29
|
+
|| grep -q '"terminalPending": \[\]' "$state_file"; then
|
|
30
|
+
printf '{}'
|
|
31
|
+
exit 0
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
|
|
35
|
+
printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" offer-ran 2>/dev/null || printf '{}'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -uo pipefail
|
|
3
|
+
|
|
4
|
+
# PR-terminal guardrail (PostToolUse/Bash + Monitor). When a PR just went
|
|
5
|
+
# terminal — a `gh pr merge`/`gh pr close` success line or the pr-followup
|
|
6
|
+
# watch monitor's `TERMINAL pr=N` exit line — arm the post-merge handoff:
|
|
7
|
+
# record the PR as pending, nudge the model to finalize/tear down and offer
|
|
8
|
+
# next options, and hold the Stop gate (guardrail-terminal-gate.sh) until the
|
|
9
|
+
# AskUserQuestion offer runs. Decision logic lives in the bundled guardrails.mjs.
|
|
10
|
+
#
|
|
11
|
+
# Fires after every Bash call, so a keyword pre-filter for the terminal output
|
|
12
|
+
# shapes keeps Node off the hot path. Degrades to {} so it never blocks.
|
|
13
|
+
payload="$(cat)"
|
|
14
|
+
|
|
15
|
+
if ! grep -Eiq 'merged pull request|closed pull request|TERMINAL pr=' <<<"$payload"; then
|
|
16
|
+
printf '{}'
|
|
17
|
+
exit 0
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
|
|
21
|
+
printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" pr-terminal 2>/dev/null || printf '{}'
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -uo pipefail
|
|
3
|
+
|
|
4
|
+
# PR-terminal → post-merge handoff gate (Stop). When a PR went terminal this
|
|
5
|
+
# session (merged/closed) and the next-options offer hasn't run, block the
|
|
6
|
+
# turn end until the handoff — finalize, teardown, AskUserQuestion offer —
|
|
7
|
+
# happens. Releases unconditionally after 3 blocks.
|
|
8
|
+
#
|
|
9
|
+
# This must stay synchronous (only a sync Stop hook can block the turn end),
|
|
10
|
+
# and it fires on EVERY turn end. There is no command payload to key off, so
|
|
11
|
+
# the pre-filter reads the same per-session state file guardrails.mjs uses and
|
|
12
|
+
# only spawns Node when a terminal PR is actually pending. On the overwhelming
|
|
13
|
+
# majority of turns no PR went terminal, so the state file is absent or
|
|
14
|
+
# terminalPending is empty and we return {} in-shell. Degrades to {}.
|
|
15
|
+
payload="$(cat)"
|
|
16
|
+
|
|
17
|
+
raw_sid="$(printf '%s' "$payload" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')"
|
|
18
|
+
[ -n "$raw_sid" ] || raw_sid="unknown"
|
|
19
|
+
sid="$(printf '%s' "$raw_sid" | sed 's/[^A-Za-z0-9_-]/_/g')"
|
|
20
|
+
|
|
21
|
+
# Resolve the same home dir Node's os.homedir() uses. HOME is correct on
|
|
22
|
+
# macOS/Linux and on most Git Bash setups; fall back to converting USERPROFILE
|
|
23
|
+
# when HOME doesn't hold the state dir (some Windows shells point HOME elsewhere).
|
|
24
|
+
home="${HOME:-}"
|
|
25
|
+
if [ ! -d "$home/.muggle-ai" ] && command -v cygpath >/dev/null 2>&1 && [ -n "${USERPROFILE:-}" ]; then
|
|
26
|
+
home="$(cygpath -u "$USERPROFILE" 2>/dev/null || printf '%s' "$home")"
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
state_file="$home/.muggle-ai/guardrails/$sid.json"
|
|
30
|
+
if [ ! -f "$state_file" ] \
|
|
31
|
+
|| ! grep -q '"terminalPending"' "$state_file" \
|
|
32
|
+
|| grep -q '"terminalPending": \[\]' "$state_file"; then
|
|
33
|
+
printf '{}'
|
|
34
|
+
exit 0
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
|
|
38
|
+
printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" terminal-gate 2>/dev/null || printf '{}'
|
|
@@ -40,6 +40,61 @@ ${input2.tool_response?.output ?? ""}`;
|
|
|
40
40
|
return m ? m[0] : null;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// src/guardrails/constants.ts
|
|
44
|
+
var GH_PR_MERGED_LINE = /\b(?:Merged|Squashed and merged|Rebased and merged) pull request [\w./-]*#(\d+)/;
|
|
45
|
+
var GH_PR_CLOSED_LINE = /\bClosed pull request [\w./-]*#(\d+)/;
|
|
46
|
+
var PR_MONITOR_TERMINAL_LINE = /\bTERMINAL pr=(\d+): (MERGED|CLOSED)\b/;
|
|
47
|
+
var MAX_PR_TERMINAL_BLOCKS = 3;
|
|
48
|
+
|
|
49
|
+
// src/guardrails/prTerminal.ts
|
|
50
|
+
function detectPrTerminal(input2) {
|
|
51
|
+
if (input2.tool_name !== "Bash" && input2.tool_name !== "Monitor") return null;
|
|
52
|
+
const response = input2.tool_response;
|
|
53
|
+
const haystack = [response?.stdout, response?.stderr, response?.output, response?.content].filter((part) => typeof part === "string").join("\n");
|
|
54
|
+
const mergedMatch = haystack.match(GH_PR_MERGED_LINE);
|
|
55
|
+
if (mergedMatch) {
|
|
56
|
+
return { prNumber: Number(mergedMatch[1]), verdict: "merged" /* Merged */ };
|
|
57
|
+
}
|
|
58
|
+
const closedMatch = haystack.match(GH_PR_CLOSED_LINE);
|
|
59
|
+
if (closedMatch) {
|
|
60
|
+
return { prNumber: Number(closedMatch[1]), verdict: "closed" /* Closed */ };
|
|
61
|
+
}
|
|
62
|
+
const monitorMatch = haystack.match(PR_MONITOR_TERMINAL_LINE);
|
|
63
|
+
if (monitorMatch) {
|
|
64
|
+
return {
|
|
65
|
+
prNumber: Number(monitorMatch[1]),
|
|
66
|
+
verdict: monitorMatch[2] === "MERGED" ? "merged" /* Merged */ : "closed" /* Closed */
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
function applyPrTerminalDetected(state, prNumber) {
|
|
72
|
+
const pending = state.terminalPending ?? [];
|
|
73
|
+
const handled = state.terminalHandled ?? [];
|
|
74
|
+
if (pending.includes(prNumber) || handled.includes(prNumber)) return state;
|
|
75
|
+
return { ...state, terminalPending: [...pending, prNumber] };
|
|
76
|
+
}
|
|
77
|
+
function applyNextOptionsOffered(state) {
|
|
78
|
+
const pending = state.terminalPending ?? [];
|
|
79
|
+
if (pending.length === 0) return state;
|
|
80
|
+
return {
|
|
81
|
+
...state,
|
|
82
|
+
terminalPending: [],
|
|
83
|
+
terminalHandled: [...state.terminalHandled ?? [], ...pending],
|
|
84
|
+
terminalBlockCount: 0
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function prTerminalGateDecision(state, maxBlocks = MAX_PR_TERMINAL_BLOCKS) {
|
|
88
|
+
const blockCount = state.terminalBlockCount ?? 0;
|
|
89
|
+
if ((state.terminalPending ?? []).length === 0) {
|
|
90
|
+
return { action: "none" /* None */, blockCount };
|
|
91
|
+
}
|
|
92
|
+
if (blockCount >= maxBlocks) {
|
|
93
|
+
return { action: "release" /* Release */, blockCount };
|
|
94
|
+
}
|
|
95
|
+
return { action: "block" /* Block */, blockCount: blockCount + 1 };
|
|
96
|
+
}
|
|
97
|
+
|
|
43
98
|
// src/guardrails/testsGreen.ts
|
|
44
99
|
var TEST_CMD = /\b(pnpm|npm|yarn)\s+(run\s+)?test\b|\b(jest|vitest|pytest)\b|\bgo\s+test\b|\bcargo\s+test\b/;
|
|
45
100
|
var FAIL = /\b\d+\s+failed\b|\bFAIL\b|✗/;
|
|
@@ -197,6 +252,33 @@ function prOpened() {
|
|
|
197
252
|
Per the autoWatchPR preference, a muggle-pr-followup watcher should handle its incoming reviews. If autoWatchPR=always, start it now by invoking /muggle:muggle-pr-followup with the PR URL; if =ask, offer it to the user; if =never, do nothing.`;
|
|
198
253
|
return envelope("PostToolUse", ctx, host);
|
|
199
254
|
}
|
|
255
|
+
function prTerminal() {
|
|
256
|
+
const terminalEvent = detectPrTerminal(input);
|
|
257
|
+
if (!terminalEvent) return "{}";
|
|
258
|
+
const state = readState(sessionId);
|
|
259
|
+
const next = applyPrTerminalDetected(state, terminalEvent.prNumber);
|
|
260
|
+
if (next === state) return "{}";
|
|
261
|
+
writeState(next);
|
|
262
|
+
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 (AskUserQuestion) \u2014 release, queued work, deferred items. The stop gate holds until the offer runs.`;
|
|
263
|
+
return envelope("PostToolUse", ctx, host);
|
|
264
|
+
}
|
|
265
|
+
function offerRan() {
|
|
266
|
+
if (input.tool_name !== "AskUserQuestion") return "{}";
|
|
267
|
+
const state = readState(sessionId);
|
|
268
|
+
const next = applyNextOptionsOffered(state);
|
|
269
|
+
if (next !== state) writeState(next);
|
|
270
|
+
return "{}";
|
|
271
|
+
}
|
|
272
|
+
function terminalGate() {
|
|
273
|
+
const state = readState(sessionId);
|
|
274
|
+
const decision = prTerminalGateDecision(state);
|
|
275
|
+
if (decision.action !== "block" /* Block */) return "{}";
|
|
276
|
+
state.terminalBlockCount = decision.blockCount;
|
|
277
|
+
writeState(state);
|
|
278
|
+
const pendingPrList = (state.terminalPending ?? []).map((prNumber) => `#${prNumber}`).join(", ");
|
|
279
|
+
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 via AskUserQuestion \u2014 release, queued work, deferred items. Only the AskUserQuestion offer clears this gate.` : `Post-merge handoff still owed for PR ${pendingPrList} (reminder ${decision.blockCount}/${MAX_PR_TERMINAL_BLOCKS}): finalize + tear down, then run the AskUserQuestion next-options offer.`;
|
|
280
|
+
return blockStop(reason, host);
|
|
281
|
+
}
|
|
200
282
|
function recordTests() {
|
|
201
283
|
const cmd = input.tool_input?.command ?? "";
|
|
202
284
|
const state = readState(sessionId);
|
|
@@ -233,8 +315,11 @@ function buildRouter() {
|
|
|
233
315
|
}
|
|
234
316
|
var handlers = {
|
|
235
317
|
"pr-opened": prOpened,
|
|
318
|
+
"pr-terminal": prTerminal,
|
|
319
|
+
"offer-ran": offerRan,
|
|
236
320
|
"record-tests": recordTests,
|
|
237
321
|
"e2e-gate": e2eGate,
|
|
322
|
+
"terminal-gate": terminalGate,
|
|
238
323
|
"report-gate": reportGate,
|
|
239
324
|
"build-router": buildRouter
|
|
240
325
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"release": "5.6.0",
|
|
3
|
-
"buildId": "run-
|
|
4
|
-
"commitSha": "
|
|
5
|
-
"buildTime": "2026-07-
|
|
3
|
+
"buildId": "run-66-1",
|
|
4
|
+
"commitSha": "9be0ff6106411ab4ef213a48ffc23f600cbca7c8",
|
|
5
|
+
"buildTime": "2026-07-23T07:41:54Z",
|
|
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.
|
|
4
|
+
"version": "5.7.0",
|
|
5
5
|
"description": "Ship quality products with AI-powered E2E acceptance testing that validates your web app like a real user — from Claude Code and Cursor to PR.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
package/plugin/hooks/README.md
CHANGED
|
@@ -20,7 +20,7 @@ A guardrail emits one of two strengths:
|
|
|
20
20
|
- **Advise** — `additionalContext` (PostToolUse/UserPromptSubmit) or a plain Stop message. A soft nudge the model can ignore.
|
|
21
21
|
- **Enforce** — a `Stop` `decision: "block"` that refuses to end the turn, or a `PreToolUse` `permissionDecision: "deny"` that refuses a tool call. The model cannot proceed until the condition is met.
|
|
22
22
|
|
|
23
|
-
Enforcement is reserved for the handoffs that were being skipped: the E2E acceptance run
|
|
23
|
+
Enforcement is reserved for the handoffs that were being skipped: the E2E acceptance run, posting a deterministically-rendered report, and the post-merge handoff. Each enforcing gate carries an escape so it can't trap a turn — the E2E gate accepts an explicit skip declaration (`echo "MUGGLE_E2E_SKIP: <reason>"`, session-durable) and hard-releases after `MAX_E2E_BLOCKS` (3) blocks; the report gate only denies a body it can positively see is a hand-written report and fails open otherwise; the post-merge gate hard-releases after `MAX_PR_TERMINAL_BLOCKS` (3) blocks, and only the AskUserQuestion next-options offer clears it — nothing else resets its counter.
|
|
24
24
|
|
|
25
25
|
## Mechanism
|
|
26
26
|
|
|
@@ -32,8 +32,11 @@ Each guardrail is a thin bash wrapper in `../scripts/` registered in `hooks.json
|
|
|
32
32
|
| :--------- | :------ | :------- | :-------- | :--------- | :----- |
|
|
33
33
|
| `PostToolUse` (Bash) | `guardrail-pr-opened.sh` | advise | a `gh pr create`/`gh pr ready` just succeeded | `autoWatchPR` | start a `muggle-pr-followup` watcher on the new PR |
|
|
34
34
|
| `PostToolUse` (Bash + muggle execute/replay MCP tools) | `guardrail-record-tests.sh` | record | a unit-test command passed, an E2E run happened, or an `echo "MUGGLE_E2E_SKIP: <reason>"` marker declared E2E un-runnable | — | set `unitTestsGreen` / `e2eRun` / `e2eSkipped` session state |
|
|
35
|
+
| `PostToolUse` (Bash + Monitor) | `guardrail-pr-terminal.sh` | advise | a PR just went terminal — a `gh pr merge`/`gh pr close` success line or the watch monitor's `TERMINAL pr=N` exit line (never bare `"state":"MERGED"` metadata) | — | record `terminalPending`, direct the post-merge handoff: finalize the watcher slot, tear down per `autoCleanup`, offer next options via AskUserQuestion |
|
|
36
|
+
| `PostToolUse` (AskUserQuestion) | `guardrail-offer-ran.sh` | record | a next-options offer ran while a terminal PR was pending | — | clear `terminalPending` — the only exit for the post-merge Stop gate |
|
|
35
37
|
| `PreToolUse` (Bash) | `guardrail-report-format.sh` | **enforce** | a `gh pr comment\|create\|edit` body reads like an E2E report but lacks the `build-pr-section` sentinel | — | **deny** — render via `muggle build-pr-section` instead |
|
|
36
38
|
| `Stop` | `guardrail-e2e-gate.sh` | **enforce** | unit tests passed this session, no E2E ran yet, and no skip was recorded | `autoE2ETest` | **block** the turn until E2E runs via `muggle-test` or a `MUGGLE_E2E_SKIP` marker records a legitimate skip (full message once, one-line reminders after; releases after 3 blocks) |
|
|
39
|
+
| `Stop` | `guardrail-terminal-gate.sh` | **enforce** | a PR went terminal this session and the AskUserQuestion next-options offer hasn't run since | — | **block** the turn until the post-merge handoff runs (full message once, one-line reminders after; releases after 3 blocks; nothing but the offer resets the counter) |
|
|
37
40
|
| `UserPromptSubmit` | `guardrail-build-router.sh` | advise | a build/implement/fix request (first one this session) | `autoRouteBuildToMuggleDo` | route the work through `muggle-do` (build delegated to superpowers) |
|
|
38
41
|
|
|
39
42
|
## Session-start reconcile nudge
|
package/plugin/hooks/hooks.json
CHANGED
|
@@ -47,6 +47,12 @@
|
|
|
47
47
|
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-record-tests.sh\"",
|
|
48
48
|
"async": false,
|
|
49
49
|
"timeout": 10
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"type": "command",
|
|
53
|
+
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-pr-terminal.sh\"",
|
|
54
|
+
"async": false,
|
|
55
|
+
"timeout": 10
|
|
50
56
|
}
|
|
51
57
|
]
|
|
52
58
|
},
|
|
@@ -60,6 +66,28 @@
|
|
|
60
66
|
"timeout": 10
|
|
61
67
|
}
|
|
62
68
|
]
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"matcher": "Monitor",
|
|
72
|
+
"hooks": [
|
|
73
|
+
{
|
|
74
|
+
"type": "command",
|
|
75
|
+
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-pr-terminal.sh\"",
|
|
76
|
+
"async": false,
|
|
77
|
+
"timeout": 10
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"matcher": "AskUserQuestion",
|
|
83
|
+
"hooks": [
|
|
84
|
+
{
|
|
85
|
+
"type": "command",
|
|
86
|
+
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-offer-ran.sh\"",
|
|
87
|
+
"async": false,
|
|
88
|
+
"timeout": 10
|
|
89
|
+
}
|
|
90
|
+
]
|
|
63
91
|
}
|
|
64
92
|
],
|
|
65
93
|
"Stop": [
|
|
@@ -70,6 +98,12 @@
|
|
|
70
98
|
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-e2e-gate.sh\"",
|
|
71
99
|
"async": false,
|
|
72
100
|
"timeout": 10
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"type": "command",
|
|
104
|
+
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-terminal-gate.sh\"",
|
|
105
|
+
"async": false,
|
|
106
|
+
"timeout": 10
|
|
73
107
|
}
|
|
74
108
|
]
|
|
75
109
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -uo pipefail
|
|
3
|
+
|
|
4
|
+
# Next-options-offer observer (PostToolUse/AskUserQuestion). An AskUserQuestion
|
|
5
|
+
# call while a terminal PR is pending IS the post-merge handoff's exit: it
|
|
6
|
+
# clears terminalPending so the Stop gate (guardrail-terminal-gate.sh)
|
|
7
|
+
# releases. Emits no directive.
|
|
8
|
+
#
|
|
9
|
+
# Pre-filter: only spawn Node when the per-session state actually has a
|
|
10
|
+
# pending terminal PR, so the ordinary AskUserQuestion (no PR merged this
|
|
11
|
+
# session) never pays Node cold-start. Degrades to {}.
|
|
12
|
+
payload="$(cat)"
|
|
13
|
+
|
|
14
|
+
raw_sid="$(printf '%s' "$payload" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')"
|
|
15
|
+
[ -n "$raw_sid" ] || raw_sid="unknown"
|
|
16
|
+
sid="$(printf '%s' "$raw_sid" | sed 's/[^A-Za-z0-9_-]/_/g')"
|
|
17
|
+
|
|
18
|
+
# Resolve the same home dir Node's os.homedir() uses. HOME is correct on
|
|
19
|
+
# macOS/Linux and on most Git Bash setups; fall back to converting USERPROFILE
|
|
20
|
+
# when HOME doesn't hold the state dir (some Windows shells point HOME elsewhere).
|
|
21
|
+
home="${HOME:-}"
|
|
22
|
+
if [ ! -d "$home/.muggle-ai" ] && command -v cygpath >/dev/null 2>&1 && [ -n "${USERPROFILE:-}" ]; then
|
|
23
|
+
home="$(cygpath -u "$USERPROFILE" 2>/dev/null || printf '%s' "$home")"
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
state_file="$home/.muggle-ai/guardrails/$sid.json"
|
|
27
|
+
if [ ! -f "$state_file" ] \
|
|
28
|
+
|| ! grep -q '"terminalPending"' "$state_file" \
|
|
29
|
+
|| grep -q '"terminalPending": \[\]' "$state_file"; then
|
|
30
|
+
printf '{}'
|
|
31
|
+
exit 0
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
|
|
35
|
+
printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" offer-ran 2>/dev/null || printf '{}'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -uo pipefail
|
|
3
|
+
|
|
4
|
+
# PR-terminal guardrail (PostToolUse/Bash + Monitor). When a PR just went
|
|
5
|
+
# terminal — a `gh pr merge`/`gh pr close` success line or the pr-followup
|
|
6
|
+
# watch monitor's `TERMINAL pr=N` exit line — arm the post-merge handoff:
|
|
7
|
+
# record the PR as pending, nudge the model to finalize/tear down and offer
|
|
8
|
+
# next options, and hold the Stop gate (guardrail-terminal-gate.sh) until the
|
|
9
|
+
# AskUserQuestion offer runs. Decision logic lives in the bundled guardrails.mjs.
|
|
10
|
+
#
|
|
11
|
+
# Fires after every Bash call, so a keyword pre-filter for the terminal output
|
|
12
|
+
# shapes keeps Node off the hot path. Degrades to {} so it never blocks.
|
|
13
|
+
payload="$(cat)"
|
|
14
|
+
|
|
15
|
+
if ! grep -Eiq 'merged pull request|closed pull request|TERMINAL pr=' <<<"$payload"; then
|
|
16
|
+
printf '{}'
|
|
17
|
+
exit 0
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
|
|
21
|
+
printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" pr-terminal 2>/dev/null || printf '{}'
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -uo pipefail
|
|
3
|
+
|
|
4
|
+
# PR-terminal → post-merge handoff gate (Stop). When a PR went terminal this
|
|
5
|
+
# session (merged/closed) and the next-options offer hasn't run, block the
|
|
6
|
+
# turn end until the handoff — finalize, teardown, AskUserQuestion offer —
|
|
7
|
+
# happens. Releases unconditionally after 3 blocks.
|
|
8
|
+
#
|
|
9
|
+
# This must stay synchronous (only a sync Stop hook can block the turn end),
|
|
10
|
+
# and it fires on EVERY turn end. There is no command payload to key off, so
|
|
11
|
+
# the pre-filter reads the same per-session state file guardrails.mjs uses and
|
|
12
|
+
# only spawns Node when a terminal PR is actually pending. On the overwhelming
|
|
13
|
+
# majority of turns no PR went terminal, so the state file is absent or
|
|
14
|
+
# terminalPending is empty and we return {} in-shell. Degrades to {}.
|
|
15
|
+
payload="$(cat)"
|
|
16
|
+
|
|
17
|
+
raw_sid="$(printf '%s' "$payload" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')"
|
|
18
|
+
[ -n "$raw_sid" ] || raw_sid="unknown"
|
|
19
|
+
sid="$(printf '%s' "$raw_sid" | sed 's/[^A-Za-z0-9_-]/_/g')"
|
|
20
|
+
|
|
21
|
+
# Resolve the same home dir Node's os.homedir() uses. HOME is correct on
|
|
22
|
+
# macOS/Linux and on most Git Bash setups; fall back to converting USERPROFILE
|
|
23
|
+
# when HOME doesn't hold the state dir (some Windows shells point HOME elsewhere).
|
|
24
|
+
home="${HOME:-}"
|
|
25
|
+
if [ ! -d "$home/.muggle-ai" ] && command -v cygpath >/dev/null 2>&1 && [ -n "${USERPROFILE:-}" ]; then
|
|
26
|
+
home="$(cygpath -u "$USERPROFILE" 2>/dev/null || printf '%s' "$home")"
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
state_file="$home/.muggle-ai/guardrails/$sid.json"
|
|
30
|
+
if [ ! -f "$state_file" ] \
|
|
31
|
+
|| ! grep -q '"terminalPending"' "$state_file" \
|
|
32
|
+
|| grep -q '"terminalPending": \[\]' "$state_file"; then
|
|
33
|
+
printf '{}'
|
|
34
|
+
exit 0
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
|
|
38
|
+
printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" terminal-gate 2>/dev/null || printf '{}'
|
|
@@ -40,6 +40,61 @@ ${input2.tool_response?.output ?? ""}`;
|
|
|
40
40
|
return m ? m[0] : null;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// src/guardrails/constants.ts
|
|
44
|
+
var GH_PR_MERGED_LINE = /\b(?:Merged|Squashed and merged|Rebased and merged) pull request [\w./-]*#(\d+)/;
|
|
45
|
+
var GH_PR_CLOSED_LINE = /\bClosed pull request [\w./-]*#(\d+)/;
|
|
46
|
+
var PR_MONITOR_TERMINAL_LINE = /\bTERMINAL pr=(\d+): (MERGED|CLOSED)\b/;
|
|
47
|
+
var MAX_PR_TERMINAL_BLOCKS = 3;
|
|
48
|
+
|
|
49
|
+
// src/guardrails/prTerminal.ts
|
|
50
|
+
function detectPrTerminal(input2) {
|
|
51
|
+
if (input2.tool_name !== "Bash" && input2.tool_name !== "Monitor") return null;
|
|
52
|
+
const response = input2.tool_response;
|
|
53
|
+
const haystack = [response?.stdout, response?.stderr, response?.output, response?.content].filter((part) => typeof part === "string").join("\n");
|
|
54
|
+
const mergedMatch = haystack.match(GH_PR_MERGED_LINE);
|
|
55
|
+
if (mergedMatch) {
|
|
56
|
+
return { prNumber: Number(mergedMatch[1]), verdict: "merged" /* Merged */ };
|
|
57
|
+
}
|
|
58
|
+
const closedMatch = haystack.match(GH_PR_CLOSED_LINE);
|
|
59
|
+
if (closedMatch) {
|
|
60
|
+
return { prNumber: Number(closedMatch[1]), verdict: "closed" /* Closed */ };
|
|
61
|
+
}
|
|
62
|
+
const monitorMatch = haystack.match(PR_MONITOR_TERMINAL_LINE);
|
|
63
|
+
if (monitorMatch) {
|
|
64
|
+
return {
|
|
65
|
+
prNumber: Number(monitorMatch[1]),
|
|
66
|
+
verdict: monitorMatch[2] === "MERGED" ? "merged" /* Merged */ : "closed" /* Closed */
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
function applyPrTerminalDetected(state, prNumber) {
|
|
72
|
+
const pending = state.terminalPending ?? [];
|
|
73
|
+
const handled = state.terminalHandled ?? [];
|
|
74
|
+
if (pending.includes(prNumber) || handled.includes(prNumber)) return state;
|
|
75
|
+
return { ...state, terminalPending: [...pending, prNumber] };
|
|
76
|
+
}
|
|
77
|
+
function applyNextOptionsOffered(state) {
|
|
78
|
+
const pending = state.terminalPending ?? [];
|
|
79
|
+
if (pending.length === 0) return state;
|
|
80
|
+
return {
|
|
81
|
+
...state,
|
|
82
|
+
terminalPending: [],
|
|
83
|
+
terminalHandled: [...state.terminalHandled ?? [], ...pending],
|
|
84
|
+
terminalBlockCount: 0
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function prTerminalGateDecision(state, maxBlocks = MAX_PR_TERMINAL_BLOCKS) {
|
|
88
|
+
const blockCount = state.terminalBlockCount ?? 0;
|
|
89
|
+
if ((state.terminalPending ?? []).length === 0) {
|
|
90
|
+
return { action: "none" /* None */, blockCount };
|
|
91
|
+
}
|
|
92
|
+
if (blockCount >= maxBlocks) {
|
|
93
|
+
return { action: "release" /* Release */, blockCount };
|
|
94
|
+
}
|
|
95
|
+
return { action: "block" /* Block */, blockCount: blockCount + 1 };
|
|
96
|
+
}
|
|
97
|
+
|
|
43
98
|
// src/guardrails/testsGreen.ts
|
|
44
99
|
var TEST_CMD = /\b(pnpm|npm|yarn)\s+(run\s+)?test\b|\b(jest|vitest|pytest)\b|\bgo\s+test\b|\bcargo\s+test\b/;
|
|
45
100
|
var FAIL = /\b\d+\s+failed\b|\bFAIL\b|✗/;
|
|
@@ -197,6 +252,33 @@ function prOpened() {
|
|
|
197
252
|
Per the autoWatchPR preference, a muggle-pr-followup watcher should handle its incoming reviews. If autoWatchPR=always, start it now by invoking /muggle:muggle-pr-followup with the PR URL; if =ask, offer it to the user; if =never, do nothing.`;
|
|
198
253
|
return envelope("PostToolUse", ctx, host);
|
|
199
254
|
}
|
|
255
|
+
function prTerminal() {
|
|
256
|
+
const terminalEvent = detectPrTerminal(input);
|
|
257
|
+
if (!terminalEvent) return "{}";
|
|
258
|
+
const state = readState(sessionId);
|
|
259
|
+
const next = applyPrTerminalDetected(state, terminalEvent.prNumber);
|
|
260
|
+
if (next === state) return "{}";
|
|
261
|
+
writeState(next);
|
|
262
|
+
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 (AskUserQuestion) \u2014 release, queued work, deferred items. The stop gate holds until the offer runs.`;
|
|
263
|
+
return envelope("PostToolUse", ctx, host);
|
|
264
|
+
}
|
|
265
|
+
function offerRan() {
|
|
266
|
+
if (input.tool_name !== "AskUserQuestion") return "{}";
|
|
267
|
+
const state = readState(sessionId);
|
|
268
|
+
const next = applyNextOptionsOffered(state);
|
|
269
|
+
if (next !== state) writeState(next);
|
|
270
|
+
return "{}";
|
|
271
|
+
}
|
|
272
|
+
function terminalGate() {
|
|
273
|
+
const state = readState(sessionId);
|
|
274
|
+
const decision = prTerminalGateDecision(state);
|
|
275
|
+
if (decision.action !== "block" /* Block */) return "{}";
|
|
276
|
+
state.terminalBlockCount = decision.blockCount;
|
|
277
|
+
writeState(state);
|
|
278
|
+
const pendingPrList = (state.terminalPending ?? []).map((prNumber) => `#${prNumber}`).join(", ");
|
|
279
|
+
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 via AskUserQuestion \u2014 release, queued work, deferred items. Only the AskUserQuestion offer clears this gate.` : `Post-merge handoff still owed for PR ${pendingPrList} (reminder ${decision.blockCount}/${MAX_PR_TERMINAL_BLOCKS}): finalize + tear down, then run the AskUserQuestion next-options offer.`;
|
|
280
|
+
return blockStop(reason, host);
|
|
281
|
+
}
|
|
200
282
|
function recordTests() {
|
|
201
283
|
const cmd = input.tool_input?.command ?? "";
|
|
202
284
|
const state = readState(sessionId);
|
|
@@ -233,8 +315,11 @@ function buildRouter() {
|
|
|
233
315
|
}
|
|
234
316
|
var handlers = {
|
|
235
317
|
"pr-opened": prOpened,
|
|
318
|
+
"pr-terminal": prTerminal,
|
|
319
|
+
"offer-ran": offerRan,
|
|
236
320
|
"record-tests": recordTests,
|
|
237
321
|
"e2e-gate": e2eGate,
|
|
322
|
+
"terminal-gate": terminalGate,
|
|
238
323
|
"report-gate": reportGate,
|
|
239
324
|
"build-router": buildRouter
|
|
240
325
|
};
|