@muggleai/works 5.20.0-staging.142 → 5.20.0-staging.143

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.
Files changed (60) hide show
  1. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  2. package/dist/plugin/.cursor-plugin/plugin.json +1 -1
  3. package/dist/plugin/hooks/README.md +17 -2
  4. package/dist/plugin/hooks/hooks.json +4 -88
  5. package/dist/plugin/scripts/guardrail-build-followthrough-gate.sh +13 -23
  6. package/dist/plugin/scripts/guardrail-build-router.sh +12 -7
  7. package/dist/plugin/scripts/guardrail-capability-claim.sh +27 -17
  8. package/dist/plugin/scripts/guardrail-classify-gate.sh +12 -7
  9. package/dist/plugin/scripts/guardrail-comment-reply-gate.sh +15 -26
  10. package/dist/plugin/scripts/guardrail-debug-path-gate.sh +14 -24
  11. package/dist/plugin/scripts/guardrail-dispatch.sh +34 -0
  12. package/dist/plugin/scripts/guardrail-e2e-gate.sh +13 -21
  13. package/dist/plugin/scripts/guardrail-lib.sh +91 -0
  14. package/dist/plugin/scripts/guardrail-offer-ran.sh +12 -20
  15. package/dist/plugin/scripts/guardrail-pr-opened.sh +12 -7
  16. package/dist/plugin/scripts/guardrail-pr-terminal.sh +12 -7
  17. package/dist/plugin/scripts/guardrail-record-comment-replies.sh +12 -7
  18. package/dist/plugin/scripts/guardrail-record-stage-read.sh +12 -7
  19. package/dist/plugin/scripts/guardrail-record-stage-signals.sh +12 -7
  20. package/dist/plugin/scripts/guardrail-record-tests.sh +12 -7
  21. package/dist/plugin/scripts/guardrail-report-format.sh +12 -7
  22. package/dist/plugin/scripts/guardrail-resolve-gate.sh +12 -7
  23. package/dist/plugin/scripts/guardrail-skill-stages.sh +27 -18
  24. package/dist/plugin/scripts/guardrail-stage-gate.sh +14 -24
  25. package/dist/plugin/scripts/guardrail-terminal-gate.sh +13 -20
  26. package/dist/plugin/scripts/guardrail-walkthrough-gate.sh +14 -22
  27. package/dist/plugin/scripts/guardrail-walkthrough-heading.sh +12 -7
  28. package/dist/plugin/scripts/guardrail-watch-gate.sh +14 -25
  29. package/dist/plugin/scripts/guardrails.mjs +29 -0
  30. package/dist/release-manifest.json +3 -3
  31. package/package.json +1 -1
  32. package/plugin/.claude-plugin/plugin.json +1 -1
  33. package/plugin/.cursor-plugin/plugin.json +1 -1
  34. package/plugin/hooks/README.md +17 -2
  35. package/plugin/hooks/hooks.json +4 -88
  36. package/plugin/scripts/guardrail-build-followthrough-gate.sh +13 -23
  37. package/plugin/scripts/guardrail-build-router.sh +12 -7
  38. package/plugin/scripts/guardrail-capability-claim.sh +27 -17
  39. package/plugin/scripts/guardrail-classify-gate.sh +12 -7
  40. package/plugin/scripts/guardrail-comment-reply-gate.sh +15 -26
  41. package/plugin/scripts/guardrail-debug-path-gate.sh +14 -24
  42. package/plugin/scripts/guardrail-dispatch.sh +34 -0
  43. package/plugin/scripts/guardrail-e2e-gate.sh +13 -21
  44. package/plugin/scripts/guardrail-lib.sh +91 -0
  45. package/plugin/scripts/guardrail-offer-ran.sh +12 -20
  46. package/plugin/scripts/guardrail-pr-opened.sh +12 -7
  47. package/plugin/scripts/guardrail-pr-terminal.sh +12 -7
  48. package/plugin/scripts/guardrail-record-comment-replies.sh +12 -7
  49. package/plugin/scripts/guardrail-record-stage-read.sh +12 -7
  50. package/plugin/scripts/guardrail-record-stage-signals.sh +12 -7
  51. package/plugin/scripts/guardrail-record-tests.sh +12 -7
  52. package/plugin/scripts/guardrail-report-format.sh +12 -7
  53. package/plugin/scripts/guardrail-resolve-gate.sh +12 -7
  54. package/plugin/scripts/guardrail-skill-stages.sh +27 -18
  55. package/plugin/scripts/guardrail-stage-gate.sh +14 -24
  56. package/plugin/scripts/guardrail-terminal-gate.sh +13 -20
  57. package/plugin/scripts/guardrail-walkthrough-gate.sh +14 -22
  58. package/plugin/scripts/guardrail-walkthrough-heading.sh +12 -7
  59. package/plugin/scripts/guardrail-watch-gate.sh +14 -25
  60. package/plugin/scripts/guardrails.mjs +29 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "muggle",
3
3
  "description": "Run real-browser end-to-end (E2E) acceptance tests on your web app from any AI coding agent. Generate test scripts from plain English, replay them on localhost, capture screenshots, and validate user flows like signup, checkout, and dashboards. Works across Claude Code, Cursor, Codex, and Windsurf.",
4
- "version": "5.20.0-staging.142",
4
+ "version": "5.20.0-staging.143",
5
5
  "author": {
6
6
  "name": "Muggle AI",
7
7
  "email": "support@muggle-ai.com"
@@ -2,7 +2,7 @@
2
2
  "name": "muggle",
3
3
  "displayName": "Muggle AI",
4
4
  "description": "Ship quality products with AI-powered end-to-end (E2E) acceptance testing that validates your web app like a real user — from Claude Code and Cursor to PR.",
5
- "version": "5.20.0-staging.142",
5
+ "version": "5.20.0-staging.143",
6
6
  "author": {
7
7
  "name": "Muggle AI",
8
8
  "email": "support@muggle-ai.com"
@@ -26,9 +26,9 @@ Enforcement is reserved for the handoffs that were being skipped: the E2E accept
26
26
 
27
27
  A recorder that *clears* an obligation — a walkthrough posted, a failure diagnosed, a test case classified — records only when the call did not visibly fail (`callOutcome.ts`). Recording from the request alone let a rejected `gh pr comment` mark the walkthrough posted, so the gate went quiet on a PR that never received it.
28
28
 
29
- Each guardrail is a thin bash wrapper in `../scripts/` registered in `hooks.json`. The wrapper pipes the event payload (stdin JSON) to the bundled `../scripts/guardrails.mjs <subcommand>`, which holds the decision logic (built from `src/guardrails/`, vitest-covered). Per-session state in `~/.muggle-ai/guardrails/<session_id>.json` tracks what fired. Any *failure* degrades to `{}` (allow) — a gate blocks only by an explicit, tested decision, never by accident.
29
+ Each guardrail is a bash gate script in `../scripts/` holding a `guardrail_wants` pre-filter and the `guardrails.mjs` subcommand it feeds; the bundled `../scripts/guardrails.mjs` holds the decision logic (built from `src/guardrails/`, vitest-covered). The busy events — a shell command before and after it runs, and every turn end — register one `guardrail-dispatch.sh` command listing their gates, which reads the payload once, asks every gate's pre-filter in that one process, and starts Node once (`guardrails.mjs run <gates...>`) for just the gates that could fire, merging their responses. Rarer events call a gate script directly. Per-session state in `~/.muggle-ai/guardrails/<session_id>.json` tracks what fired. Any *failure* degrades to `{}` (allow) — a gate blocks only by an explicit, tested decision, never by accident; `guardrail-lib.sh` is the one place that falls back, and `run` isolates gates from each other.
30
30
 
31
- Each wrapper short-circuits in shell first, so the common case never pays Node cold-start. A gate that has spent its block budget stamps a `<gate>Released` flag, which its wrapper then pre-filters on — without it a released gate keeps cold-starting Node on every remaining turn end to answer `{}`, and the walkthrough gate keeps making provider calls to do it. That pre-filter is a second, looser copy of what `guardrails.mjs` matches, and it is the one place a guardrail can fail *silently*: a payload it drops — a skip marker, a reopen line, a comment edit — reaches no recorder, and the gate keeps demanding an action the user already took. Over-matching is free; under-matching is a dead escape hatch. `src/test/guardrails/hook-prefilter.test.ts` pins every payload each subcommand acts on against the wrapper guarding it, and derives the skip-marker tokens from source so a new marker is covered the moment it exists.
31
+ Pre-filters are bash builtins only (`guardrail-lib.sh`), so an irrelevant event starts no process at all. A gate that has spent its block budget stamps a `<gate>Released` flag, which its wrapper then pre-filters on — without it a released gate keeps cold-starting Node on every remaining turn end to answer `{}`, and the walkthrough gate keeps making provider calls to do it. That pre-filter is a second, looser copy of what `guardrails.mjs` matches, and it is the one place a guardrail can fail *silently*: a payload it drops — a skip marker, a reopen line, a comment edit — reaches no recorder, and the gate keeps demanding an action the user already took. Over-matching is free; under-matching is a dead escape hatch. `src/test/guardrails/hook-prefilter.test.ts` pins every payload each subcommand acts on against the wrapper guarding it, and derives the skip-marker tokens from source so a new marker is covered the moment it exists.
32
32
 
33
33
  ## Guardrails
34
34
 
@@ -70,3 +70,18 @@ The nudge counts only slots **this session owns**, and reports the rest as orpha
70
70
  - Finalized watcher slots (`result.md` present) are pruned 30 days after finalize; their `followup.log` is forensic-only. An **open** slot — a PR watched for any length of time — has no `result.md` and is never touched.
71
71
 
72
72
  Both windows are overridable (`MUGGLE_GUARDRAILS_TTL_DAYS`, `MUGGLE_SLOT_TTL_DAYS`). TTL-gated to once per day via a `~/.cache/muggle/state-gc-checked` marker (the current-session refresh runs every start regardless); silent and best-effort, never blocks session start. Never touches an open slot or the current session's own state.
73
+
74
+ ## Footprint caps
75
+
76
+ Every hook here fires on every tool call, prompt or turn end in every open session, so its cost is paid thousands of times a day on the user's machine; on Windows each process start costs 50 ms healthy and seconds under load. The budget is lightweight by construction: one hook command per busy event, and nothing started beyond it when the event is irrelevant. `src/test/guardrails/footprint/` holds CI to it:
77
+
78
+ | Cap | Limit |
79
+ | :-- | :-- |
80
+ | Hook commands per busy slot (PreToolUse/PostToolUse on `Bash` and `Read`, Stop, UserPromptSubmit) | 1 |
81
+ | Programs started beyond each hook's own launch on an irrelevant event | 0 (Stop: 1, a transcript `tail` until the capability-claim gate has nudged; bash cannot seek) |
82
+ | `node`, `gh`, `python` on that path | 0 |
83
+ | Median wall time of one irrelevant firing, Linux/macOS | 100 ms |
84
+ | Hook commands in the whole manifest | 15 |
85
+ | Hook timeout | declared on every hook; 30 s, SessionStart 120 s |
86
+
87
+ Counts are exec counts, so they are identical on every runner. Every count is exact: CI fails when a change exceeds a limit, and also when it beats one until `footprint/constants.ts` is lowered to match, so a gain cannot be given back later.
@@ -31,19 +31,7 @@
31
31
  "hooks": [
32
32
  {
33
33
  "type": "command",
34
- "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-report-format.sh\"",
35
- "async": false,
36
- "timeout": 10
37
- },
38
- {
39
- "type": "command",
40
- "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-resolve-gate.sh\"",
41
- "async": false,
42
- "timeout": 10
43
- },
44
- {
45
- "type": "command",
46
- "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-walkthrough-heading.sh\"",
34
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-dispatch.sh\" guardrail-report-format.sh guardrail-resolve-gate.sh guardrail-walkthrough-heading.sh",
47
35
  "async": false,
48
36
  "timeout": 10
49
37
  }
@@ -67,33 +55,9 @@
67
55
  "hooks": [
68
56
  {
69
57
  "type": "command",
70
- "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-pr-opened.sh\"",
58
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-dispatch.sh\" guardrail-pr-opened.sh guardrail-record-tests.sh guardrail-pr-terminal.sh guardrail-record-stage-signals.sh guardrail-record-comment-replies.sh",
71
59
  "async": false,
72
60
  "timeout": 25
73
- },
74
- {
75
- "type": "command",
76
- "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-record-tests.sh\"",
77
- "async": false,
78
- "timeout": 10
79
- },
80
- {
81
- "type": "command",
82
- "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-pr-terminal.sh\"",
83
- "async": false,
84
- "timeout": 10
85
- },
86
- {
87
- "type": "command",
88
- "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-record-stage-signals.sh\"",
89
- "async": false,
90
- "timeout": 10
91
- },
92
- {
93
- "type": "command",
94
- "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-record-comment-replies.sh\"",
95
- "async": false,
96
- "timeout": 10
97
61
  }
98
62
  ]
99
63
  },
@@ -180,57 +144,9 @@
180
144
  "hooks": [
181
145
  {
182
146
  "type": "command",
183
- "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-e2e-gate.sh\"",
147
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-dispatch.sh\" guardrail-e2e-gate.sh guardrail-terminal-gate.sh guardrail-watch-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",
184
148
  "async": false,
185
- "timeout": 10
186
- },
187
- {
188
- "type": "command",
189
- "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-terminal-gate.sh\"",
190
- "async": false,
191
- "timeout": 10
192
- },
193
- {
194
- "type": "command",
195
- "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-watch-gate.sh\"",
196
- "async": false,
197
- "timeout": 10
198
- },
199
- {
200
- "type": "command",
201
- "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-build-followthrough-gate.sh\"",
202
- "async": false,
203
- "timeout": 10
204
- },
205
- {
206
- "type": "command",
207
- "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-walkthrough-gate.sh\"",
208
- "async": false,
209
- "timeout": 15
210
- },
211
- {
212
- "type": "command",
213
- "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-comment-reply-gate.sh\"",
214
- "async": false,
215
- "timeout": 10
216
- },
217
- {
218
- "type": "command",
219
- "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-stage-gate.sh\"",
220
- "async": false,
221
- "timeout": 10
222
- },
223
- {
224
- "type": "command",
225
- "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-debug-path-gate.sh\"",
226
- "async": false,
227
- "timeout": 10
228
- },
229
- {
230
- "type": "command",
231
- "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-capability-claim.sh\"",
232
- "async": false,
233
- "timeout": 10
149
+ "timeout": 30
234
150
  }
235
151
  ]
236
152
  }
@@ -15,30 +15,20 @@ set -uo pipefail
15
15
  # overwhelming majority of turns no build intent was detected, so the state file
16
16
  # is absent or the flag is unset and we return {} in-shell, never paying Node
17
17
  # cold-start. Degrades to {}.
18
- payload="$(cat)"
19
18
 
20
- raw_sid="$(printf '%s' "$payload" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')"
21
- [ -n "$raw_sid" ] || raw_sid="unknown"
22
- sid="$(printf '%s' "$raw_sid" | sed 's/[^A-Za-z0-9_-]/_/g')"
19
+ guardrail_script_dir="${BASH_SOURCE[0]%/*}"
20
+ [[ $guardrail_script_dir == "${BASH_SOURCE[0]}" ]] && guardrail_script_dir=.
21
+ . "$guardrail_script_dir/guardrail-lib.sh"
23
22
 
24
- # Resolve the same home dir Node's os.homedir() uses. HOME is correct on
25
- # macOS/Linux and on most Git Bash setups; fall back to converting USERPROFILE
26
- # when HOME doesn't hold the state dir (some Windows shells point HOME elsewhere).
27
- home="${HOME:-}"
28
- if [ ! -d "$home/.muggle-ai" ] && command -v cygpath >/dev/null 2>&1 && [ -n "${USERPROFILE:-}" ]; then
29
- home="$(cygpath -u "$USERPROFILE" 2>/dev/null || printf '%s' "$home")"
30
- fi
23
+ GUARDRAIL_SUBCOMMAND="build-followthrough-gate"
31
24
 
32
- # A non-empty prsHandled array spans lines, so the empty match reliably says no
33
- # PR was opened. Skip Node unless a build request was routed and nothing shipped.
34
- state_file="$home/.muggle-ai/guardrails/$sid.json"
35
- if [ ! -f "$state_file" ] \
36
- || ! grep -q '"buildIntentRouted": true' "$state_file" \
37
- || ! grep -q '"prsHandled": \[\]' "$state_file" \
38
- || grep -q '"buildSkipped": true' "$state_file"; then
39
- printf '{}'
40
- exit 0
41
- fi
25
+ guardrail_wants() {
26
+ guardrail_load_state \
27
+ && guardrail_state_has '"buildIntentRouted": true' \
28
+ && guardrail_state_has '"prsHandled": []' \
29
+ && ! guardrail_state_has '"buildSkipped": true'
30
+ }
42
31
 
43
- root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
44
- printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" build-followthrough-gate 2>/dev/null || printf '{}'
32
+ if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
33
+ guardrail_run_standalone "$GUARDRAIL_SUBCOMMAND"
34
+ fi
@@ -12,12 +12,17 @@ set -uo pipefail
12
12
  # then applies the real detectBuildIntent logic (question/slash exclusions,
13
13
  # once-per-session dedupe). Over-matching here only costs an occasional needless
14
14
  # spawn; it can never emit a spurious offer. Degrades to {} so it never blocks.
15
- payload="$(cat)"
16
15
 
17
- if ! grep -Eiq '(implement|build|add|create|write|fix|refactor|wire up|hook up|make|change the|conflict|merged|passing|green)' <<<"$payload"; then
18
- printf '{}'
19
- exit 0
20
- fi
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_SUBCOMMAND="build-router"
21
21
 
22
- root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
23
- printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" build-router 2>/dev/null || printf '{}'
22
+ guardrail_wants() {
23
+ guardrail_payload_matches '(implement|build|add|create|write|fix|refactor|wire up|hook up|make|change the|conflict|merged|passing|green)'
24
+ }
25
+
26
+ if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
27
+ guardrail_run_standalone "$GUARDRAIL_SUBCOMMAND"
28
+ fi
@@ -15,24 +15,34 @@ set -uo pipefail
15
15
  # from being "corrected". Over-matching here costs a needless spawn; it can
16
16
  # never emit a spurious nudge. Degrades to {} so it never blocks on its own
17
17
  # failure.
18
- payload="$(cat)"
19
18
 
20
- transcript="$(printf '%s' "$payload" \
21
- | grep -oE '"transcript_path"[[:space:]]*:[[:space:]]*"[^"]*"' \
22
- | head -1 \
23
- | sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/' \
24
- | sed 's/\\\\/\//g')"
19
+ guardrail_script_dir="${BASH_SOURCE[0]%/*}"
20
+ [[ $guardrail_script_dir == "${BASH_SOURCE[0]}" ]] && guardrail_script_dir=.
21
+ . "$guardrail_script_dir/guardrail-lib.sh"
25
22
 
26
- if [ -z "$transcript" ] || [ ! -f "$transcript" ]; then
27
- printf '{}'
28
- exit 0
29
- fi
23
+ GUARDRAIL_SUBCOMMAND="capability-claim-gate"
30
24
 
31
- if ! tail -c 20000 "$transcript" \
32
- | grep -Eiq 'can.t|cannot|unable to|no way to|untestable|unverifiable|impossible|infeasible'; then
33
- printf '{}'
34
- exit 0
35
- fi
25
+ guardrail_wants() {
26
+ if guardrail_load_state && guardrail_state_has '"capabilityClaimNudged": true'; then
27
+ return 1
28
+ fi
29
+ local transcript_pattern='"transcript_path"[[:space:]]*:[[:space:]]*"([^"]*)"'
30
+ [[ $GUARDRAIL_PAYLOAD =~ $transcript_pattern ]] || return 1
31
+ local json_escaped_separator='\\'
32
+ local transcript="${BASH_REMATCH[1]//"$json_escaped_separator"//}"
33
+ [[ -f $transcript ]] || return 1
34
+ # The one spawn left on a turn end: bash cannot seek, and reading a multi-MB transcript
35
+ # in-shell costs more than a tail. Gone for the session once the gate has nudged.
36
+ local transcript_tail
37
+ transcript_tail="$(tail -c 20000 "$transcript" 2>/dev/null)"
38
+ local claim_pattern='can.t|cannot|unable to|no way to|untestable|unverifiable|impossible|infeasible'
39
+ local matched=1
40
+ shopt -s nocasematch
41
+ [[ $transcript_tail =~ $claim_pattern ]] && matched=0
42
+ shopt -u nocasematch
43
+ return "$matched"
44
+ }
36
45
 
37
- root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
38
- printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" capability-claim-gate 2>/dev/null || printf '{}'
46
+ if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
47
+ guardrail_run_standalone "$GUARDRAIL_SUBCOMMAND"
48
+ fi
@@ -12,12 +12,17 @@ set -uo pipefail
12
12
  # unrelated payload never pays Node cold-start. It stays cheap by construction:
13
13
  # an execution call happens minutes apart and burns a real browser, so the spawn
14
14
  # is noise against what it protects. Degrades to {}.
15
- payload="$(cat)"
16
15
 
17
- if ! grep -Eiq 'muggle-local-(execute-test-generation|execute-replay)' <<<"$payload"; then
18
- printf '{}'
19
- exit 0
20
- fi
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_SUBCOMMAND="classify-gate"
21
21
 
22
- root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
23
- printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" classify-gate 2>/dev/null || printf '{}'
22
+ guardrail_wants() {
23
+ guardrail_payload_matches 'muggle-local-(execute-test-generation|execute-replay)'
24
+ }
25
+
26
+ if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
27
+ guardrail_run_standalone "$GUARDRAIL_SUBCOMMAND"
28
+ fi
@@ -10,33 +10,22 @@ set -uo pipefail
10
10
  # than the session state file, so the pre-filter keys on a ledger existing at
11
11
  # all: with no ledger anywhere there is nothing this gate could owe, and we
12
12
  # return {} in-shell without paying Node cold-start. Degrades to {}.
13
- payload="$(cat)"
14
13
 
15
- raw_sid="$(printf '%s' "$payload" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')"
16
- [ -n "$raw_sid" ] || raw_sid="unknown"
17
- sid="$(printf '%s' "$raw_sid" | sed 's/[^A-Za-z0-9_-]/_/g')"
14
+ guardrail_script_dir="${BASH_SOURCE[0]%/*}"
15
+ [[ $guardrail_script_dir == "${BASH_SOURCE[0]}" ]] && guardrail_script_dir=.
16
+ . "$guardrail_script_dir/guardrail-lib.sh"
18
17
 
19
- # Resolve the same home dir Node's os.homedir() uses. HOME is correct on
20
- # macOS/Linux and on most Git Bash setups; fall back to converting USERPROFILE
21
- # when HOME doesn't hold the state dir (some Windows shells point HOME elsewhere).
22
- home="${HOME:-}"
23
- if [ ! -d "$home/.muggle-ai" ] && command -v cygpath >/dev/null 2>&1 && [ -n "${USERPROFILE:-}" ]; then
24
- home="$(cygpath -u "$USERPROFILE" 2>/dev/null || printf '%s' "$home")"
25
- fi
18
+ GUARDRAIL_SUBCOMMAND="comment-reply-gate"
26
19
 
27
- state_file="$home/.muggle-ai/guardrails/$sid.json"
28
- if ! ls "$home"/.muggle-ai/muggle-do/sessions/*/comment-ledger.json >/dev/null 2>&1; then
29
- printf '{}'
30
- exit 0
31
- fi
32
- if [ -f "$state_file" ] && grep -q '"commentReplySkipped": true' "$state_file"; then
33
- printf '{}'
34
- exit 0
35
- fi
36
- if [ -f "$state_file" ] && grep -q '"commentReplyReleased": true' "$state_file"; then
37
- printf '{}'
38
- exit 0
39
- fi
20
+ guardrail_wants() {
21
+ guardrail_resolve_home
22
+ local ledgers=("$GUARDRAIL_HOME"/.muggle-ai/muggle-do/sessions/*/comment-ledger.json)
23
+ [[ -e ${ledgers[0]} ]] || return 1
24
+ if guardrail_load_state; then
25
+ ! guardrail_state_has '"commentReplySkipped": true' && ! guardrail_state_has '"commentReplyReleased": true'
26
+ fi
27
+ }
40
28
 
41
- root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
42
- printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" comment-reply-gate 2>/dev/null || printf '{}'
29
+ if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
30
+ guardrail_run_standalone "$GUARDRAIL_SUBCOMMAND"
31
+ fi
@@ -12,31 +12,21 @@ set -uo pipefail
12
12
  # only when a failed run is recorded and unresolved. On the overwhelming majority
13
13
  # of turns nothing failed, so the state file is absent or failedRuns is empty and
14
14
  # we return {} in-shell. The evidence join runs in guardrails.mjs. Degrades to {}.
15
- payload="$(cat)"
16
15
 
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')"
16
+ guardrail_script_dir="${BASH_SOURCE[0]%/*}"
17
+ [[ $guardrail_script_dir == "${BASH_SOURCE[0]}" ]] && guardrail_script_dir=.
18
+ . "$guardrail_script_dir/guardrail-lib.sh"
20
19
 
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
20
+ GUARDRAIL_SUBCOMMAND="debug-path-gate"
28
21
 
29
- # Empty array serializes as `"failedRuns": []` (one line); a non-empty array
30
- # spans lines, so the empty match reliably tells them apart.
31
- state_file="$home/.muggle-ai/guardrails/$sid.json"
32
- if [ ! -f "$state_file" ] \
33
- || ! grep -q '"failedRuns"' "$state_file" \
34
- || grep -q '"failedRuns": \[\]' "$state_file" \
35
- || grep -q '"debugReleased": true' "$state_file" \
36
- || grep -q '"debugSkipped": true' "$state_file"; then
37
- printf '{}'
38
- exit 0
39
- fi
22
+ guardrail_wants() {
23
+ guardrail_load_state \
24
+ && guardrail_state_has '"failedRuns"' \
25
+ && ! guardrail_state_has '"failedRuns": []' \
26
+ && ! guardrail_state_has '"debugReleased": true' \
27
+ && ! guardrail_state_has '"debugSkipped": true'
28
+ }
40
29
 
41
- root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
42
- printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" debug-path-gate 2>/dev/null || printf '{}'
30
+ if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
31
+ guardrail_run_standalone "$GUARDRAIL_SUBCOMMAND"
32
+ fi
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+
4
+ # One hook command per event instead of one per gate. hooks.json calls this with the gate
5
+ # scripts that event feeds, in order:
6
+ #
7
+ # guardrail-dispatch.sh guardrail-e2e-gate.sh guardrail-watch-gate.sh ...
8
+ #
9
+ # It reads the payload once, sources each gate (defining its guardrail_wants pre-filter and
10
+ # GUARDRAIL_SUBCOMMAND without running it), and asks every pre-filter in this one process.
11
+ # Irrelevant events — nearly all of them — end here with {} and no process started. When any
12
+ # gate could fire, Node runs once for all of them (`guardrails.mjs run <gates...>`), merging
13
+ # their outputs the way the harness merged separate hooks.
14
+
15
+ guardrail_script_dir="${BASH_SOURCE[0]%/*}"
16
+ [[ $guardrail_script_dir == "${BASH_SOURCE[0]}" ]] && guardrail_script_dir=.
17
+ . "$guardrail_script_dir/guardrail-lib.sh"
18
+
19
+ guardrail_read_payload
20
+
21
+ firing_subcommands=()
22
+ for gate_script in "$@"; do
23
+ GUARDRAIL_SUBCOMMAND=""
24
+ . "$guardrail_script_dir/$gate_script" || continue
25
+ if [[ -n $GUARDRAIL_SUBCOMMAND ]] && guardrail_wants; then
26
+ firing_subcommands+=("$GUARDRAIL_SUBCOMMAND")
27
+ fi
28
+ done
29
+
30
+ if [[ ${#firing_subcommands[@]} -eq 0 ]]; then
31
+ printf '{}'
32
+ exit 0
33
+ fi
34
+ guardrail_run_node run "${firing_subcommands[@]}"
@@ -14,28 +14,20 @@ set -uo pipefail
14
14
  # overwhelming majority of turns (no test run, no PR) the state file is absent,
15
15
  # or unitTestsGreen is unset while prsHandled is empty, so we return {} in-shell
16
16
  # and never pay Node cold-start. Degrades to {}.
17
- payload="$(cat)"
18
17
 
19
- raw_sid="$(printf '%s' "$payload" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')"
20
- [ -n "$raw_sid" ] || raw_sid="unknown"
21
- sid="$(printf '%s' "$raw_sid" | sed 's/[^A-Za-z0-9_-]/_/g')"
18
+ guardrail_script_dir="${BASH_SOURCE[0]%/*}"
19
+ [[ $guardrail_script_dir == "${BASH_SOURCE[0]}" ]] && guardrail_script_dir=.
20
+ . "$guardrail_script_dir/guardrail-lib.sh"
22
21
 
23
- # Resolve the same home dir Node's os.homedir() uses. HOME is correct on
24
- # macOS/Linux and on most Git Bash setups; fall back to converting USERPROFILE
25
- # when HOME doesn't hold the state dir (some Windows shells point HOME elsewhere).
26
- home="${HOME:-}"
27
- if [ ! -d "$home/.muggle-ai" ] && command -v cygpath >/dev/null 2>&1 && [ -n "${USERPROFILE:-}" ]; then
28
- home="$(cygpath -u "$USERPROFILE" 2>/dev/null || printf '%s' "$home")"
29
- fi
22
+ GUARDRAIL_SUBCOMMAND="e2e-gate"
30
23
 
31
- state_file="$home/.muggle-ai/guardrails/$sid.json"
32
- if [ ! -f "$state_file" ] \
33
- || { ! grep -q '"unitTestsGreen": true' "$state_file" && grep -q '"prsHandled": \[\]' "$state_file"; } \
34
- || grep -q '"e2eReleased": true' "$state_file" \
35
- || grep -q '"e2eRun": true' "$state_file"; then
36
- printf '{}'
37
- exit 0
38
- fi
24
+ guardrail_wants() {
25
+ guardrail_load_state \
26
+ && { guardrail_state_has '"unitTestsGreen": true' || ! guardrail_state_has '"prsHandled": []'; } \
27
+ && ! guardrail_state_has '"e2eReleased": true' \
28
+ && ! guardrail_state_has '"e2eRun": true'
29
+ }
39
30
 
40
- root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
41
- printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" e2e-gate 2>/dev/null || printf '{}'
31
+ if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
32
+ guardrail_run_standalone "$GUARDRAIL_SUBCOMMAND"
33
+ fi
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env bash
2
+ # Spawn-free plumbing shared by every guardrail hook, sourced by the gate scripts and by
3
+ # guardrail-dispatch.sh. Hooks fire on every tool call, prompt and turn end in every open
4
+ # session, and on Windows each process start costs 50ms healthy and seconds under load, so
5
+ # everything on the "this event is irrelevant" path is a bash builtin: payload and state are
6
+ # read with `read`, matched with [[ =~ ]] and glob tests, and the session id is sanitised with
7
+ # parameter expansion. Node starts only once a gate's pre-filter says it could fire.
8
+ # src/test/guardrails/footprint caps this in CI.
9
+
10
+ [[ -n ${GUARDRAIL_LIB_LOADED:-} ]] && return 0
11
+ GUARDRAIL_LIB_LOADED=1
12
+
13
+ GUARDRAIL_PAYLOAD=""
14
+ GUARDRAIL_STATE=""
15
+ GUARDRAIL_STATE_LOADED=""
16
+ GUARDRAIL_HAS_STATE=""
17
+
18
+ guardrail_read_payload() {
19
+ IFS= read -r -d '' GUARDRAIL_PAYLOAD || true
20
+ }
21
+
22
+ # Case-insensitive ERE over the whole payload. Unanchored, so it matches everything the
23
+ # per-line `grep -Eiq` it replaced did; an over-match only costs a Node start.
24
+ guardrail_payload_matches() {
25
+ local pattern="$1"
26
+ local matched=1
27
+ shopt -s nocasematch
28
+ [[ $GUARDRAIL_PAYLOAD =~ $pattern ]] && matched=0
29
+ shopt -u nocasematch
30
+ return "$matched"
31
+ }
32
+
33
+ # The home Node's os.homedir() resolves. HOME is right on macOS/Linux and most Git Bash
34
+ # setups; when it doesn't hold the state dir, USERPROFILE (C:\Users\x) is converted to its
35
+ # Git Bash form (/c/Users/x) in-shell rather than through cygpath.
36
+ guardrail_resolve_home() {
37
+ GUARDRAIL_HOME="${HOME:-}"
38
+ local profile="${USERPROFILE:-}"
39
+ if [[ ! -d $GUARDRAIL_HOME/.muggle-ai && $profile == [A-Za-z]:* ]]; then
40
+ local drive="${profile:0:1}"
41
+ local rest="${profile:2}"
42
+ # Lower-cased by lookup, not ${drive,,}: macOS ships bash 3.2.
43
+ local upper_letters="ABCDEFGHIJKLMNOPQRSTUVWXYZ"
44
+ local lower_letters="abcdefghijklmnopqrstuvwxyz"
45
+ local letters_before="${upper_letters%%"$drive"*}"
46
+ if [[ $letters_before != "$upper_letters" ]]; then
47
+ drive="${lower_letters:${#letters_before}:1}"
48
+ fi
49
+ GUARDRAIL_HOME="/${drive}${rest//\\//}"
50
+ fi
51
+ }
52
+
53
+ # Loads this session's guardrail state once per process; returns 1 when the session has none.
54
+ guardrail_load_state() {
55
+ if [[ -z $GUARDRAIL_STATE_LOADED ]]; then
56
+ GUARDRAIL_STATE_LOADED=1
57
+ local session_id_pattern='"session_id"[[:space:]]*:[[:space:]]*"([^"]*)"'
58
+ local raw_session_id="unknown"
59
+ if [[ $GUARDRAIL_PAYLOAD =~ $session_id_pattern ]] && [[ -n ${BASH_REMATCH[1]} ]]; then
60
+ raw_session_id="${BASH_REMATCH[1]}"
61
+ fi
62
+ guardrail_resolve_home
63
+ local state_file="$GUARDRAIL_HOME/.muggle-ai/guardrails/${raw_session_id//[^A-Za-z0-9_-]/_}.json"
64
+ if [[ -f $state_file ]]; then
65
+ GUARDRAIL_HAS_STATE=1
66
+ IFS= read -r -d '' GUARDRAIL_STATE <"$state_file" || true
67
+ fi
68
+ fi
69
+ [[ -n $GUARDRAIL_HAS_STATE ]]
70
+ }
71
+
72
+ # Fixed-string test against the loaded state (which sessionState writes pretty-printed).
73
+ guardrail_state_has() {
74
+ [[ $GUARDRAIL_STATE == *"$1"* ]]
75
+ }
76
+
77
+ # Runs guardrails.mjs on the payload. Never blocks a turn: any failure degrades to {}.
78
+ guardrail_run_node() {
79
+ local root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
80
+ printf '%s' "$GUARDRAIL_PAYLOAD" | node "${root}/scripts/guardrails.mjs" "$@" 2>/dev/null || printf '{}'
81
+ }
82
+
83
+ # Entry for a gate script executed directly: its guardrail_wants decides whether Node runs.
84
+ guardrail_run_standalone() {
85
+ guardrail_read_payload
86
+ if guardrail_wants; then
87
+ guardrail_run_node "$1"
88
+ else
89
+ printf '{}'
90
+ fi
91
+ }
@@ -9,27 +9,19 @@ set -uo pipefail
9
9
  # Pre-filter: only spawn Node when the per-session state actually has a
10
10
  # pending terminal PR, so the ordinary AskUserQuestion (no PR merged this
11
11
  # session) never pays Node cold-start. Degrades to {}.
12
- payload="$(cat)"
13
12
 
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')"
13
+ guardrail_script_dir="${BASH_SOURCE[0]%/*}"
14
+ [[ $guardrail_script_dir == "${BASH_SOURCE[0]}" ]] && guardrail_script_dir=.
15
+ . "$guardrail_script_dir/guardrail-lib.sh"
17
16
 
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
17
+ GUARDRAIL_SUBCOMMAND="offer-ran"
25
18
 
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
19
+ guardrail_wants() {
20
+ guardrail_load_state \
21
+ && guardrail_state_has '"terminalPending"' \
22
+ && ! guardrail_state_has '"terminalPending": []'
23
+ }
33
24
 
34
- root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
35
- printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" offer-ran 2>/dev/null || printf '{}'
25
+ if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
26
+ guardrail_run_standalone "$GUARDRAIL_SUBCOMMAND"
27
+ fi