@muggleai/works 5.4.2 → 5.5.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.
Files changed (74) 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/hooks.json +14 -7
  4. package/dist/plugin/scripts/guardrail-build-router.sh +17 -3
  5. package/dist/plugin/scripts/guardrail-e2e-gate.sh +33 -3
  6. package/dist/plugin/scripts/guardrail-pr-opened.sh +15 -4
  7. package/dist/plugin/scripts/guardrail-record-tests.sh +19 -6
  8. package/dist/plugin/scripts/guardrail-report-format.sh +17 -3
  9. package/dist/plugin/skills/CLAUDE.md +6 -0
  10. package/dist/plugin/skills/_shared/dev-loop/run.md +1 -1
  11. package/dist/plugin/skills/_shared/rebase-before-e2e.md +1 -1
  12. package/dist/plugin/skills/_shared/resolve-e2e-validation-context.md +1 -1
  13. package/dist/plugin/skills/_shared/resolve-rebase-conflicts.md +9 -33
  14. package/dist/plugin/skills/_shared/session-state-writes.md +1 -1
  15. package/dist/plugin/skills/_shared/telemetry-events/pr-followup-tick.md +2 -0
  16. package/dist/plugin/skills/_shared/use-worktrees.md +0 -1
  17. package/dist/plugin/skills/_shared/vcs/github/verify-working-tree.md +1 -1
  18. package/dist/plugin/skills/_shared/verify-or-rollback-gate.md +39 -0
  19. package/dist/plugin/skills/do/address-reviews.md +2 -11
  20. package/dist/plugin/skills/do/fix-ci.md +3 -2
  21. package/dist/plugin/skills/do/resolve-conflicts.md +3 -9
  22. package/dist/plugin/skills/do/respawn-watcher.md +27 -0
  23. package/dist/plugin/skills/muggle-pr-followup/CLAUDE.md +5 -3
  24. package/dist/plugin/skills/muggle-pr-followup/SKILL.md +5 -1
  25. package/dist/plugin/skills/muggle-pr-followup/auto-track.md +1 -1
  26. package/dist/plugin/skills/muggle-pr-followup/blocked-tick.md +38 -0
  27. package/dist/plugin/skills/muggle-pr-followup/bootstrap.md +3 -1
  28. package/dist/plugin/skills/muggle-pr-followup/cancel-cron.md +6 -5
  29. package/dist/plugin/skills/muggle-pr-followup/contract.md +22 -4
  30. package/dist/plugin/skills/muggle-pr-followup/output-templates/blocked-reminder.md +30 -0
  31. package/dist/plugin/skills/muggle-pr-followup/output-templates/watcher-log.md +11 -1
  32. package/dist/plugin/skills/muggle-pr-followup/output-templates.md +2 -1
  33. package/dist/plugin/skills/muggle-pr-followup/reconcile.md +28 -5
  34. package/dist/plugin/skills/muggle-pr-followup/record-cron-id.md +12 -0
  35. package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +31 -1
  36. package/dist/plugin/skills/skill-deps.config.json +8 -0
  37. package/dist/release-manifest.json +4 -4
  38. package/package.json +7 -6
  39. package/plugin/.claude-plugin/plugin.json +1 -1
  40. package/plugin/.cursor-plugin/plugin.json +1 -1
  41. package/plugin/hooks/hooks.json +14 -7
  42. package/plugin/scripts/guardrail-build-router.sh +17 -3
  43. package/plugin/scripts/guardrail-e2e-gate.sh +33 -3
  44. package/plugin/scripts/guardrail-pr-opened.sh +15 -4
  45. package/plugin/scripts/guardrail-record-tests.sh +19 -6
  46. package/plugin/scripts/guardrail-report-format.sh +17 -3
  47. package/plugin/skills/CLAUDE.md +6 -0
  48. package/plugin/skills/_shared/dev-loop/run.md +1 -1
  49. package/plugin/skills/_shared/rebase-before-e2e.md +1 -1
  50. package/plugin/skills/_shared/resolve-e2e-validation-context.md +1 -1
  51. package/plugin/skills/_shared/resolve-rebase-conflicts.md +9 -33
  52. package/plugin/skills/_shared/session-state-writes.md +1 -1
  53. package/plugin/skills/_shared/telemetry-events/pr-followup-tick.md +2 -0
  54. package/plugin/skills/_shared/use-worktrees.md +0 -1
  55. package/plugin/skills/_shared/vcs/github/verify-working-tree.md +1 -1
  56. package/plugin/skills/_shared/verify-or-rollback-gate.md +39 -0
  57. package/plugin/skills/do/address-reviews.md +2 -11
  58. package/plugin/skills/do/fix-ci.md +3 -2
  59. package/plugin/skills/do/resolve-conflicts.md +3 -9
  60. package/plugin/skills/do/respawn-watcher.md +27 -0
  61. package/plugin/skills/muggle-pr-followup/CLAUDE.md +5 -3
  62. package/plugin/skills/muggle-pr-followup/SKILL.md +5 -1
  63. package/plugin/skills/muggle-pr-followup/auto-track.md +1 -1
  64. package/plugin/skills/muggle-pr-followup/blocked-tick.md +38 -0
  65. package/plugin/skills/muggle-pr-followup/bootstrap.md +3 -1
  66. package/plugin/skills/muggle-pr-followup/cancel-cron.md +6 -5
  67. package/plugin/skills/muggle-pr-followup/contract.md +22 -4
  68. package/plugin/skills/muggle-pr-followup/output-templates/blocked-reminder.md +30 -0
  69. package/plugin/skills/muggle-pr-followup/output-templates/watcher-log.md +11 -1
  70. package/plugin/skills/muggle-pr-followup/output-templates.md +2 -1
  71. package/plugin/skills/muggle-pr-followup/reconcile.md +28 -5
  72. package/plugin/skills/muggle-pr-followup/record-cron-id.md +12 -0
  73. package/plugin/skills/muggle-pr-followup/state-schemas.md +31 -1
  74. package/plugin/skills/skill-deps.config.json +8 -0
@@ -0,0 +1,38 @@
1
+ # Blocked-tick procedure
2
+
3
+ The watcher's conditional path for a PR **blocked pending a human** — a durable block only the user can clear (an escalated rebase or CI budget spent, or an ambiguous review awaiting direction). Entered from [`contract.md`](contract.md) Step 7 (flag), then driven each subsequent tick by Step 2.5 (remind-or-resume). None of this runs on a normal tick: when `last_seen.blocked` is absent, the watcher skips straight through.
4
+
5
+ **Governing rule — remind at the normal `1m` cadence, never stop.** The poll stays at `1m` whether or not the PR is blocked — a `1m` tick is cheap (one fingerprint check, one line out) and keeps the owner nudged and an external unblock caught within a minute. The `blocked` state changes no cadence; its only job is (a) the reason-specific one-line reminder each tick and (b) fingerprint-based auto-resume. No cadence swap, no separate `reminded` flag — while `blocked` is set, the watcher reminds every tick by definition.
6
+
7
+ ## The fingerprint
8
+
9
+ The external state a block is waiting on. Recompute from live state each blocked tick:
10
+
11
+ - `head_sha` — from the tick's [`contract.md`](contract.md) Step 1 refresh.
12
+ - `latest_review_id` — `max(id)` over submitted reviews per [`../_shared/vcs/github/submitted-reviews.md`](../_shared/vcs/github/submitted-reviews.md) (`0` if none).
13
+ - `ci_digest` — the CI rollup digest for `head_sha` per [`../_shared/vcs/github/pr-checks.md`](../_shared/vcs/github/pr-checks.md): the bucket plus each check's name and conclusion, sorted into one stable string.
14
+
15
+ Any component moving means the block may have cleared — a new push (`head_sha`, which also clears the per-SHA escalation sets), a new review (`latest_review_id`), or a CI/deploy state change (`ci_digest`, which is how an external staging deploy is caught).
16
+
17
+ ## Flag the block (from Step 7)
18
+
19
+ When an idle tick is a durable human-block and `last_seen.blocked` is not already set:
20
+
21
+ 1. Increment `last_seen.idle_tick_count`.
22
+ 2. Write `last_seen.blocked = { reason, since: <now>, fingerprint }` (reuse the `latest_review_id` / `ci_digest` already fetched this tick).
23
+ 3. **Remind the owner** — emit the one-line reminder per [`output-templates/blocked-reminder.md`](output-templates/blocked-reminder.md): the pending act plus a reference back to the decision context.
24
+ 4. Append a `blocked reason=<reason>` line to `followup.log` per [`output-templates/watcher-log.md`](output-templates/watcher-log.md); emit a `tick` event with `idle: true`, `blocked: true`, and the same other fields as a transient idle. Exit. The `1m` cron is unchanged — no swap.
25
+
26
+ ## Remind or resume (the Step 2.5 gate)
27
+
28
+ Every subsequent tick while `last_seen.blocked` is present: recompute the fingerprint and compare to `last_seen.blocked.fingerprint`.
29
+
30
+ - **Unchanged** → still blocked. Re-emit the one-line reminder per [`output-templates/blocked-reminder.md`](output-templates/blocked-reminder.md). Increment `last_seen.idle_tick_count`, append a `blocked reason=<reason>` line to `followup.log`, emit a `tick` event with `idle: true`, `blocked: true`. Exit. The `1m` cron is unchanged.
31
+ - **Changed** → clear `last_seen.blocked` and **fall through to [`contract.md`](contract.md) Step 3** to re-evaluate against the moved state this same tick. The cron is already `1m`, so no swap is needed: if Step 3–6 dispatches, that cancels the `1m` cron and `/muggle-do` respawns `1m` (normal single-thread); if it idles transient, the `1m` cron is already correct; if it idles back into the block, Step 7 re-flags.
32
+
33
+ ## Invariants
34
+
35
+ - Cadence is `1m` whether blocked or active — the block never changes the poll interval. There is no cadence swap, so a blocked slot is never left cron-less by one.
36
+ - The poll never stops — a blocked tick keeps firing and reminding; only a terminal PR or an explicit teardown removes the cron.
37
+ - Every blocked tick emits exactly one owner reminder (implied by `blocked: true`, no separate flag) and no PR-side post.
38
+ - The block clears the instant any fingerprint component moves; an external unblock is caught within one `1m` tick.
@@ -71,7 +71,9 @@ Write under `~/.muggle-ai/muggle-do/sessions/<slug>/`:
71
71
 
72
72
  **`prs.json`** — see [`state-schemas.md`](state-schemas.md#prsjson). One entry, `state` = `"open"`, `head_sha` from Step 2's `headRefOid`.
73
73
 
74
- **`last_seen.json`** — see [`state-schemas.md`](state-schemas.md#last_seenjson). One key (`"<owner>/<repo>#<n>"`), `lastBodyReviewId` from Step 6, `last_pushed_sha: null`, `idle_tick_count: 0`, `cycles_completed: 0`, `escalated_review_ids: []`, `pushed_shas: []`.
74
+ **`last_seen.json`** — see [`state-schemas.md`](state-schemas.md#last_seenjson). One key (`"<owner>/<repo>#<n>"`), `lastBodyReviewId` from Step 6, `last_pushed_sha: null`, `idle_tick_count: 0`, `cycles_completed: 0`, `escalated_review_ids: []`, `pushed_shas: []`. Omit `blocked` — the watcher starts unblocked.
75
+
76
+ **`cron.json`** — see [`state-schemas.md`](state-schemas.md#cronjson). `cron_id: null` (Step 8 dispatches `/loop` as the last action, so the id isn't observable yet — the first tick self-records it per [`record-cron-id.md`](record-cron-id.md)), `command: "/muggle:muggle-pr-followup <slug> <n>"`, `interval: "1m"`, `recorded_at: <now>`.
75
77
 
76
78
  **`state.md`** — see [`state-schemas.md`](state-schemas.md#statemd). `Bootstrapped from URL: yes`. Cache the loop-user login. If Step 6.5 resolved a validation context, append the `## Pre-flight answers` block with its fields, per [`../_shared/resolve-e2e-validation-context.md`](../_shared/resolve-e2e-validation-context.md#persisted-fields). If it seeded poll-only, write **no** such block — a missing block is a clean E2E skip.
77
79
 
@@ -1,11 +1,12 @@
1
1
  # Cancel the watcher's cron
2
2
 
3
- The find-and-delete every tick uses to stop its own loop: the stale-fire guard and terminal unschedule ([`finalize.md`](finalize.md) Step 4), and each single-thread "stop this watcher" before a `/muggle-do` dispatch ([`contract.md`](contract.md) Steps 4–6).
3
+ Deletes this slot's watcher cron, and nothing more — the caller owns whatever comes next (respawn or terminal teardown). Used by the stale-fire guard and terminal unschedule ([`finalize.md`](finalize.md) Step 4) and each single-thread "stop this watcher" before a `/muggle-do` dispatch ([`contract.md`](contract.md) Steps 4–6).
4
4
 
5
5
  > **`CronList` and `CronDelete` are Claude Code tool calls, not shell commands.** Invoke them directly through the tool system. Never wrap them in a Bash/shell call: `bash -c "CronDelete …"` fails with "command not found", which a `2>/dev/null` on the line swallows, so the delete silently no-ops and the per-minute cron keeps firing — every later tick hits the stale-fire guard and re-fires until the 7-day expiry.
6
6
 
7
- 1. Call the `CronList` tool.
8
- 2. Find the job whose command ends with `/muggle:muggle-pr-followup <slug> <n>` — the exact two-arg match for this slot's PR.
9
- 3. Call the `CronDelete` tool with that job's id.
7
+ Two lookups, recorded-id first so the delete still works when `CronList` has gone blind to the cron (survived a session continue / compaction — see [`state-schemas.md`](state-schemas.md#cronjson)):
10
8
 
11
- No-op when none matches — a manually-run tick, or a cron that already expired.
9
+ 1. **By recorded id.** Read `cron.json` ([`state-schemas.md`](state-schemas.md#cronjson)). If `cron_id` is non-null, call `CronDelete` with it. This is the only handle that survives `CronList` blindness.
10
+ 2. **By `CronList` match (fallback).** Call `CronList`, find the job whose command ends with `/muggle:muggle-pr-followup <slug> <n>` — the exact two-arg match for this slot's PR — and `CronDelete` its id. This catches a stale recorded id (a since-respawned cron whose new id no tick has recorded yet) and the case where `cron.json` is absent.
11
+
12
+ Both lookups are no-ops when nothing matches — a manually-run tick, or a cron that already expired. Deleting an already-gone id is harmless. Do **not** delete `cron.json` itself here; a terminal slot keeps it as a record, and reconcile ([`reconcile.md`](reconcile.md)) may still read it to sweep a straggler.
@@ -29,10 +29,12 @@ Every `increment`/`reset` this procedure applies to `last_seen.json`, and the `p
29
29
 
30
30
  ## Procedure
31
31
 
32
- ### Step 0 — Stale-fire guard
32
+ ### Step 0 — Stale-fire guard, then record this cron's id
33
33
 
34
34
  If `prs.json[0].state` on disk is already `merged` or `closed`, this slot was finalized by a prior tick and this is a stale (queued) fire — per-minute cron fires enqueued while the session was busy still drain after the cron is cancelled. Defensively cancel any lingering cron for this slug per [`cancel-cron.md`](cancel-cron.md) (no-op if none), append a `stale-tick` line to `followup.log`, and exit. Do not re-fetch or re-finalize.
35
35
 
36
+ Otherwise, self-record this watcher's cron id per [`record-cron-id.md`](record-cron-id.md) before proceeding. Recording every tick — while `CronList` can still see the cron — is what keeps the id a valid `CronDelete` target after a session continue / compaction blinds `CronList` to it, so teardown ([`finalize.md`](finalize.md), [`reconcile.md`](reconcile.md)) can always kill the orphan.
37
+
36
38
  ### Step 1 — Refresh PR state
37
39
 
38
40
  Per [`../_shared/vcs/github/pr-metadata.md`](../_shared/vcs/github/pr-metadata.md). Update `prs.json[0].head_sha` and `prs.json[0].state` from the response; keep `mergeable` (conflict signal) for Step 5, and run the recipe's `compare` call to capture `behind_by` (out-of-date signal) for Step 5.
@@ -52,6 +54,12 @@ If `state` is `MERGED` or `CLOSED`:
52
54
  3. Exit. The watcher has unscheduled itself; no future ticks fire for this PR.
53
55
 
54
56
 
57
+ ### Step 2.5 — Blocked-tick gate
58
+
59
+ Only when `last_seen.blocked` is present (the watcher is awaiting the owner on a durable human-block, flagged in Step 7). When absent, skip straight to Step 3.
60
+
61
+ Run the remind-or-resume gate per [`blocked-tick.md`](blocked-tick.md): recompute the fingerprint, re-emit the one-line owner reminder and stay blocked while it holds, or clear the block and fall through to Step 3 the moment it moves. While blocked the watcher keeps the normal `1m` cadence and reminds the owner each tick — the block reminds rather than backs off, and never changes the poll interval.
62
+
55
63
  ### Step 3 — Compute the actionable set from live thread state
56
64
 
57
65
  The watcher's dispatch trigger is **derived from current provider state**, not a stored review-id cursor — see the [thread-state baseline design](../../../../muggle-ai-brain/architecture/2026-06-06-pr-followup-thread-state-baseline-design.md). Resolve the provider per [`../_shared/vcs/detect-vcs.md`](../_shared/vcs/detect-vcs.md), then:
@@ -125,10 +133,20 @@ Fetch the CI rollup for `prs.json[0].head_sha`, provider resolved as in Step 3
125
133
  5. Exit. The dev cycle owns the PR; its respawn restarts the watcher, whose next tick re-checks CI on the new head SHA — CI itself is the verify loop.
126
134
  - **One or more red, but `ci_fix_attempts[head_sha] >= 3` or `head_sha` ∈ `ci_escalated_shas`** → idle. The fix budget is spent; `/muggle-do`'s fix-ci stage already recorded the escalation. The watcher does not re-dispatch.
127
135
 
128
- ### Step 7 — Idle
136
+ ### Step 7 — Idle (remind when blocked pending a human)
137
+
138
+ Any idle branch (Steps 4–6 that did not dispatch). First classify **why** this tick idled. It is **blocked pending a human** when the head is under a durable block that only the user can clear:
139
+
140
+ - `head_sha` ∈ `conflict_escalated_shas` — a rebase `/muggle-do` gave up on (a semantic conflict, or `autoResolveConflicts=never`), reason `conflict_escalated`; or
141
+ - `head_sha` ∈ `ci_escalated_shas` — CI the fix-ci stage gave up on, reason `ci_escalated`; or
142
+ - `last_seen.escalated_review_ids` is non-empty with the actionable set empty — an ambiguous review awaiting the user's direction, reason `reviews_escalated`.
143
+
144
+ Everything else that idles is **transient** — green and waiting for the next review, CI still pending, or `mergeable == UNKNOWN` — and must keep the responsive `1m` cadence; those turn a state on their own and the watcher should catch it promptly.
145
+
146
+ **Transient idle** (no durable block): unchanged — increment `last_seen.idle_tick_count`, append an idle line to `followup.log` per [`output-templates/watcher-log.md`](output-templates/watcher-log.md), emit a `tick` event with `idle: true`, `blocked: false`, `actionable_threads: 0`, `dispatched_review_ids: []`, `rebase_needed: <bool>`, `dispatched_rebase: false`, `checks_red: <count or 0>`, `dispatched_ci_fix: false`. Exit. The next tick fires in 1 min via `/loop`.
129
147
 
130
- Any idle branch (Steps 4–6 that did not dispatch): increment `last_seen.idle_tick_count`, append an idle line to `followup.log` per [`output-templates/watcher-log.md`](output-templates/watcher-log.md), emit a `tick` event with `idle: true`, `actionable_threads: 0`, `dispatched_review_ids: []`, `rebase_needed: <bool>`, `dispatched_rebase: false`, `checks_red: <count or 0>`, `dispatched_ci_fix: false`. Exit. The next tick fires in 1 min via `/loop`.
148
+ **Blocked pending a human** (a durable block, and `last_seen.blocked` not already set): enter the blocked path per [`blocked-tick.md`](blocked-tick.md) — flag `last_seen.blocked` and emit the one-line owner reminder (the watcher reminds each tick at the normal `1m` cadence rather than backing off). From the next tick on, the Step 2.5 gate carries the block. Exit.
131
149
 
132
150
  ## Output
133
151
 
134
- No console output beyond the turn preamble and (if Step 5 fires) the `/muggle-do` dispatch. The watcher is invisible to the reviewer.
152
+ The watcher stays invisible to the **GitHub reviewer** — it never posts to the PR from a tick. To the **loop owner** in the session it emits: the turn preamble, the one-line blocked reminder while awaiting the owner (Steps 2.5 / 7), and (when a dispatch fires) the `/muggle-do` directive. Nothing else.
@@ -0,0 +1,30 @@
1
+ # Blocked reminder
2
+
3
+ The watcher's one-line nudge to the loop owner, emitted every tick a PR sits blocked pending a human ([`../contract.md`](../contract.md) Steps 2.5 / 7). It goes to the **session** — the owner running the loop — never to the PR, so it never notifies the GitHub reviewer. Keep it to **one line**: the pending act plus a reference to trace back to the decision context.
4
+
5
+ ## Shape
6
+
7
+ ```
8
+ ⏳ <owner>/<repo>#<n> awaiting you (<duration>): <pending act>. → <reference>
9
+ ```
10
+
11
+ - `<duration>` — how long the block has stood, from `last_seen.blocked.since` (e.g. `2h`, `1d`).
12
+ - `<pending act>` — the one thing the owner must do, keyed by `reason`.
13
+ - `<reference>` — where to trace the decision back to: the review, the PR checks, or the blocked SHA.
14
+
15
+ ## By reason
16
+
17
+ - **`reviews_escalated`** — an ambiguous review awaits your direction:
18
+ ```
19
+ ⏳ acme/widget#142 awaiting you (2h): pick a direction on ambiguous review #<id> from <login>. → reply on the review or tell me here: <review-url>
20
+ ```
21
+ - **`conflict_escalated`** — a rebase the loop gave up on:
22
+ ```
23
+ ⏳ acme/widget#142 awaiting you (2h): resolve the rebase conflict on <short-sha> (autoResolveConflicts=never), then push — or set the pref to always. → <pr-url>
24
+ ```
25
+ - **`ci_escalated`** — the CI fix budget is spent:
26
+ ```
27
+ ⏳ acme/widget#142 awaiting you (2h): the failing checks on <short-sha> need your call — fix and push, or advise here. → <pr-url>/checks
28
+ ```
29
+
30
+ The reminder repeats every blocked tick (at the normal `1m` cadence) until the owner acts; the watcher stops it the moment the fingerprint moves (a push, a new review, or a CI/deploy change).
@@ -1,6 +1,6 @@
1
1
  # Watcher tick log lines
2
2
 
3
- The watcher does **not** print to the user during normal operation. It only appends to `followup.log`. All user-facing escalations come from `/muggle-do`.
3
+ The watcher only appends to `followup.log` during normal operation; the one visible exception is the one-line owner reminder on a blocked tick ([`blocked-reminder.md`](blocked-reminder.md)). All user-facing escalations come from `/muggle-do`.
4
4
 
5
5
  ## Idle tick
6
6
 
@@ -14,6 +14,16 @@ The watcher does **not** print to the user during normal operation. It only appe
14
14
  <ISO-8601> tick pr=<n> threads=<count> dispatched=<id1>,<id2>,...
15
15
  ```
16
16
 
17
+ ## Blocked tick
18
+
19
+ The tick idled on a durable human-block ([`../contract.md`](../contract.md) Step 7) and emitted the one-line owner reminder ([`blocked-reminder.md`](blocked-reminder.md)). One per tick for as long as the block stands, at the normal `1m` cadence:
20
+
21
+ ```
22
+ <ISO-8601> tick pr=<n> blocked reason=<conflict_escalated|ci_escalated|reviews_escalated>
23
+ ```
24
+
25
+ When the fingerprint moves the block clears and the tick logs a normal idle or dispatching line — there is no separate unblock line.
26
+
17
27
  ## Terminal tick
18
28
 
19
29
  ```
@@ -7,7 +7,8 @@ All user-facing message text used by the watcher, bootstrap, and `/muggle-do` in
7
7
  | Group | Use case |
8
8
  | :---- | :------- |
9
9
  | [`bootstrap`](output-templates/bootstrap.md) | Bootstrap success summary + all bootstrap aborts. |
10
- | [`watcher-log`](output-templates/watcher-log.md) | The three `followup.log` line shapes (idle, dispatching, terminal). |
10
+ | [`watcher-log`](output-templates/watcher-log.md) | The `followup.log` line shapes (idle, dispatching, blocked, terminal). |
11
+ | [`blocked-reminder`](output-templates/blocked-reminder.md) | The watcher's one-line owner reminder while a PR is blocked pending a human. |
11
12
  | [`escalation`](output-templates/escalation.md) | `/muggle-do` terminal escalation messages (ambiguous, design-adjustment). |
12
13
  | [`inline-reply`](output-templates/inline-reply.md) | Per-comment inline reply + top-level fallback for body-only reviews. |
13
14
  | [`resolve-reminder`](output-templates/resolve-reminder.md) | Top-level PR comment for the resolve-reminder stage. |
@@ -1,6 +1,6 @@
1
1
  # Reconcile Procedure
2
2
 
3
- The procedure for the **reconcile mode** of `muggle-pr-followup` — a sweep that finalizes session slots whose PR went terminal while polling was lapsed. Routing is in [`SKILL.md`](SKILL.md#routing).
3
+ The procedure for the **reconcile mode** of `muggle-pr-followup` — a sweep that finalizes session slots whose PR went terminal while polling was lapsed, and re-arms open slots whose watcher stopped silently (a dropped respawn). Routing is in [`SKILL.md`](SKILL.md#routing).
4
4
 
5
5
  Termination is otherwise tick-driven ([`contract.md`](contract.md) Step 2): a slot finalizes only when a tick fires and observes `MERGED` / `CLOSED`. If the tick stream stops first — the recurring `/loop` cron auto-expires after 7 days, the session ends, or the machine is off when the PR merges — no tick catches the transition, and the slot is left un-finalized: no `result.md`, no post-merge cleanup, and a surviving cron would keep polling a dead PR. Reconcile is the catch-up.
6
6
 
@@ -20,14 +20,37 @@ For each candidate, fetch the PR per [`../_shared/vcs/github/pr-metadata.md`](..
20
20
 
21
21
  ### Step 3 — Finalize the terminal ones
22
22
 
23
- For each candidate whose live `state` is `MERGED` or `CLOSED`, run [`finalize.md`](finalize.md). `finalize.md` dispatches nothing, so a backfilled merge gets no post-merge cleanup — its branch is typically long gone, and the `autoCleanup` gate governs if the user runs cleanup later. Slots still `open` are left untouched — reconcile finalizes, it does not re-arm a watcher (re-arming an open PR is [`auto-track.md`](auto-track.md)'s job).
23
+ For each candidate whose live `state` is `MERGED` or `CLOSED`, run [`finalize.md`](finalize.md) — which unschedules the cron recorded-id-first per [`cancel-cron.md`](cancel-cron.md), killing it even when `CronList` has gone blind. `finalize.md` dispatches nothing, so a backfilled merge gets no post-merge cleanup — its branch is typically long gone, and the `autoCleanup` gate governs if the user runs cleanup later. Slots still `open` are carried to Step 3.6, which re-arms any whose watcher went silent.
24
+
25
+ ### Step 3.5 — Sweep orphaned crons
26
+
27
+ Step 3 kills the cron of every slot it finalized this run. This step catches the crons **finalize can't reach through a slot** — a watcher cron whose session slot was deleted out from under it, or one already-finalized (`result.md` present, skipped in Step 1) whose cron outlived the finalize. Both keep polling a dead or absent PR until the 7-day `/loop` expiry.
28
+
29
+ Call `CronList`. For every job whose command ends with `/muggle:muggle-pr-followup <slug> <n>`:
30
+
31
+ - **No session slot for `<slug>`** (`~/.muggle-ai/muggle-do/sessions/<slug>/` is gone) → `CronDelete` it. The slot it belonged to was removed; the cron is a pure orphan.
32
+ - **Slot present and terminal** (`result.md` exists, or `prs.json[0].state` is `merged`/`closed`) → `CronDelete` it. A straggler the finalize missed.
33
+ - **Slot present and open** → leave it. A live watcher.
34
+
35
+ This reaches only crons `CronList` still enumerates. A cron that both survived a compaction (invisible to `CronList`) **and** lost its slot (recorded id gone with it) is beyond either mechanism — see the residual note below.
36
+
37
+ ### Step 3.6 — Re-arm a silently-stopped open watcher
38
+
39
+ The recovery net for a **dropped respawn**: a `/muggle-do` cycle cancels the watcher's cron when it dispatches ([`contract.md`](contract.md) Steps 4 / 5 / 5b) and is responsible for respawning it when the cycle ends, but a cycle that crashes or errors out before it respawns can leave an open slot with no cron and no next tick — the poller stops silently. This step re-arms it.
40
+
41
+ For each candidate still `open` after Step 3, check when its watcher last ticked — the newest ISO-8601 line in `followup.log` (or `cron.json.recorded_at` if the log is empty). If that is **older than 15 minutes** (comfortably beyond the `1m` cadence, so a live cron would have logged many times inside the window), the poller is gone → re-arm:
42
+
43
+ - `CronCreate` a recurring cron (call the **tool**, never a shell) with `cron: "* * * * *"` and prompt `/muggle:muggle-pr-followup <slug> <n>`, then record its id and `interval: "1m"` to `cron.json` (whole-file rewrite per [`state-schemas.md`](state-schemas.md#cronjson)). Append a `re-armed (silent watcher)` line to the slot's `followup.log`.
44
+
45
+ A fresh log line (within the window) means the cron is alive — even one `CronList` has gone blind to — so this step leaves it untouched; re-arming can never double an already-live poller. This recovers only a slot that was **already being watched**; a PR that never had a watcher is seeded by [`auto-track.md`](auto-track.md) / bootstrap, not here.
24
46
 
25
47
  ### Step 4 — Report
26
48
 
27
- One line: slots scanned, finalized (with final state each), and left open. Silent only when zero slots exist.
49
+ One line: slots scanned, finalized (with final state each), left open, re-armed (silently-stopped watchers recovered), and orphan crons swept. Silent only when zero slots exist and nothing was swept.
28
50
 
29
51
  ## Invariants
30
52
 
31
- - **Idempotent.** A slot with `result.md` is never re-finalized; once everything terminal is swept, re-running is a no-op.
32
- - **Finalize-only.** Reconcile never seeds, re-arms, or dispatches a watcher. Open slots pass through untouched.
53
+ - **Idempotent.** A slot with `result.md` is never re-finalized; a still-ticking open slot is never re-armed; once everything terminal is swept and every open watcher is live, re-running is a no-op.
54
+ - **Recover, don't seed.** Reconcile finalizes terminal slots, sweeps orphan crons, and re-arms an open slot whose watcher went silent (a dropped respawn — Step 3.6). It never arms a PR that was never watched — seeding a first watcher is [`auto-track.md`](auto-track.md)'s / bootstrap's job.
33
55
  - **Per-slot isolation.** One slot's `gh` failure never blocks finalizing the others.
56
+ - **Residual orphan.** A cron that survived a compaction (blind to `CronList`) whose slot was also deleted has no on-disk id left to `CronDelete` and no `CronList` entry to match — only a session restart clears it. Recording the id durably while the slot lives ([`record-cron-id.md`](record-cron-id.md)) shrinks this window to slots removed before their first tick.
@@ -0,0 +1,12 @@
1
+ # Record the watcher's cron id
2
+
3
+ The self-record every tick runs so this slot's cron stays deletable after `CronList` goes blind to it. `CronList` stops enumerating a `/loop` cron once its session is continued or compacted, but the cron keeps firing; a cron id captured to `cron.json` **while the cron was still visible** remains a valid `CronDelete` target for teardown ([`cancel-cron.md`](cancel-cron.md), [`finalize.md`](finalize.md), [`reconcile.md`](reconcile.md)). Recording early — every tick, starting with the first — is what makes the id durable.
4
+
5
+ > **`CronList` is a Claude Code tool call, not a shell command.** Invoke it through the tool system. Never wrap it in Bash.
6
+
7
+ 1. Call the `CronList` tool.
8
+ 2. Find the job whose command ends with `/muggle:muggle-pr-followup <slug> <n>` — the exact two-arg match for this slot's PR.
9
+ 3. If found and its id differs from `cron.json.cron_id`: rewrite `cron.json` (whole-file Write per [`../_shared/session-state-writes.md`](../_shared/session-state-writes.md)) with the observed `cron_id`, the current `interval`, and a fresh `recorded_at`.
10
+ 4. If `CronList` returns nothing (already blind) and `cron.json` holds a non-null `cron_id`: **leave it** — the previously-recorded id is the only handle left, so never overwrite it with `null`.
11
+
12
+ Skip in the stale-fire path ([`contract.md`](contract.md) Step 0): a stale slot is being torn down, not re-recorded.
@@ -30,6 +30,23 @@ A list of one entry. (Historical: the file is an array for forward-compat with t
30
30
  - `state` is the **observed** state from the last `gh pr view`. The watcher refreshes it each tick.
31
31
  - Terminal states (`merged`, `closed`) are sticky — once set, the watcher writes `result.md` and exits without rescheduling.
32
32
 
33
+ ## `cron.json`
34
+
35
+ A durable, on-disk handle to this slot's watcher cron. Its whole reason to exist: `CronList` goes **blind to crons that outlive a session continue / compaction** (the watcher's `/loop` cron survives, but the tool can no longer enumerate it), so a teardown that can only find crons through `CronList` can never delete the orphan — it re-fires until the 7-day `/loop` expiry. A cron id recorded to disk **while the cron was still visible** stays a valid `CronDelete` target afterward. See [`record-cron-id.md`](record-cron-id.md) (who writes it) and [`cancel-cron.md`](cancel-cron.md) (who deletes by it).
36
+
37
+ ```json
38
+ {
39
+ "cron_id": "<scheduler-id-or-null>",
40
+ "command": "/muggle:muggle-pr-followup <slug> <n>",
41
+ "interval": "1m",
42
+ "recorded_at": "<ISO-8601>"
43
+ }
44
+ ```
45
+
46
+ - `cron_id`: the scheduler id of the live `/loop` cron for this slot. Bootstrap seeds `null` (it dispatches `/loop` as its last action and cannot yet see the id); the first tick self-records the real id per [`record-cron-id.md`](record-cron-id.md). `null` again for the one tick after `/muggle-do` respawns the watcher (a dispatch cancels the old cron and the respawn arms a new one whose id is unknown until the next tick observes it).
47
+ - `command`: the exact two-arg dispatch, the same string [`cancel-cron.md`](cancel-cron.md) matches on as its `CronList` fallback.
48
+ - `interval`: the poll cadence — always `1m`. The watcher polls at `1m` whether or not the PR is blocked; a blocked PR reminds at `1m`, it does not back off (see [`blocked-tick.md`](blocked-tick.md) and [`contract.md`](contract.md) Steps 2.5 / 7). Recorded for teardown/forensics.
49
+
33
50
  ## `last_seen.json`
34
51
 
35
52
  Keyed by `"<owner>/<repo>#<n>"`. One key per PR in the slot.
@@ -46,7 +63,16 @@ Keyed by `"<owner>/<repo>#<n>"`. One key per PR in the slot.
46
63
  "ci_fix_attempts": { "<sha>": <int> },
47
64
  "ci_escalated_shas": ["<sha>", ...],
48
65
  "conflict_resolve_attempts": { "<sha>": <int> },
49
- "conflict_escalated_shas": ["<sha>", ...]
66
+ "conflict_escalated_shas": ["<sha>", ...],
67
+ "blocked": {
68
+ "reason": "conflict_escalated" | "ci_escalated" | "reviews_escalated",
69
+ "since": "<ISO-8601>",
70
+ "fingerprint": {
71
+ "head_sha": "<sha>",
72
+ "latest_review_id": <int>,
73
+ "ci_digest": "<string>"
74
+ }
75
+ }
50
76
  }
51
77
  }
52
78
  ```
@@ -61,6 +87,10 @@ Keyed by `"<owner>/<repo>#<n>"`. One key per PR in the slot.
61
87
  - `ci_escalated_shas`: head SHAs whose CI the fix-ci stage gave up on (attempts exhausted or only out-of-scope checks). The watcher excludes these from CI dispatch so a hopeless SHA is never re-fixed.
62
88
  - `conflict_resolve_attempts`: per-SHA count of rebase cycles `/muggle-do` has run for this SHA (behind-only or conflicting — both rebase onto the base). The watcher stops dispatching a rebase for a SHA once its count reaches 2. Keyed by head SHA. A clean behind-only rebase produces a new SHA, so the cap only bites a SHA that keeps failing to rebase-and-verify.
63
89
  - `conflict_escalated_shas`: head SHAs whose rebase `/muggle-do` gave up on (attempts exhausted, or a conflict under `autoResolveConflicts=never`). The watcher excludes these from rebase dispatch so a hopeless SHA is never re-attempted.
90
+ - `blocked`: present only while the watcher is **awaiting the owner** on a PR that cannot progress without a human ([`contract.md`](contract.md) Step 7). Absent ⇒ the watcher is in its normal dispatch flow. When present, the watcher **keeps the normal `1m` cadence** and each tick is a reminder-or-resume check ([`contract.md`](contract.md) Step 2.5): it re-emits a one-line reminder to the owner, recomputes the `fingerprint`, and clears the block the moment any component moves. Its value is the reason-specific reminder plus fingerprint auto-resume.
91
+ - `reason`: which durable block is being awaited — `conflict_escalated` (`head_sha` ∈ `conflict_escalated_shas`), `ci_escalated` (`head_sha` ∈ `ci_escalated_shas`), or `reviews_escalated` (a review sits in `escalated_review_ids` awaiting the user, actionable set empty). Selects the reminder wording; the resume decision is fingerprint-driven, not reason-driven.
92
+ - `since`: when the block was first flagged — lets the reminder state how long the owner has been the blocker.
93
+ - `fingerprint`: the external state the block is waiting on. `head_sha` moves on a new push (which also clears the per-SHA escalation sets, keyed by SHA); `latest_review_id` is `max(id)` over submitted reviews and moves when a reviewer submits anything new; `ci_digest` is a stable digest of the head SHA's CI rollup (bucket + each check's name/conclusion, sorted) and moves when a check flips, a rerun lands, or an external check such as a staging deploy posts. Any change clears the block and resumes evaluation.
64
94
 
65
95
  ## `state.md`
66
96
 
@@ -0,0 +1,8 @@
1
+ {
2
+ "supportDirs": { "do": "muggle-do" },
3
+ "sharedNamespaces": ["_shared"],
4
+ "knownReverseDeps": {
5
+ "comment": "Reverse dependencies grandfathered so CI stays green — each is a genuine violation of plugin/skills/CLAUDE.md (a shared module or lower-level skill linking UP into a caller). Currently empty: the tree is one-way. If an entry is ever added here, it is debt to fix and delete, not a pattern to copy. The guard blocks any NEW cycle regardless of this list.",
6
+ "edges": []
7
+ }
8
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release": "5.4.2",
3
- "buildId": "run-62-1",
4
- "commitSha": "d87dd32f6b01e48c82ff97aa568835bd2e5a7fc3",
5
- "buildTime": "2026-07-05T08:59:08Z",
2
+ "release": "5.5.0",
3
+ "buildId": "run-63-1",
4
+ "commitSha": "47b7ae51ea3c831a6abbcbe230405cb92486f381",
5
+ "buildTime": "2026-07-18T02:08:15Z",
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.2",
4
+ "version": "5.5.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",
@@ -22,6 +22,7 @@
22
22
  "build:release": "npm run build",
23
23
  "verify:plugin": "node scripts/verify-plugin-marketplace.mjs",
24
24
  "verify:contracts": "node scripts/verify-compatibility-contracts.mjs",
25
+ "verify:skill-deps": "node scripts/check-skill-deps.mjs",
25
26
  "smoke:cli": "node scripts/smoke-cli.mjs",
26
27
  "verify:electron-release-checksums": "node scripts/verify-electron-release-checksums.mjs",
27
28
  "verify:upgrade-experience": "node scripts/verify-upgrade-experience.mjs",
@@ -45,14 +46,14 @@
45
46
  "eval:studio-gen": "tsx internal/studio-gen-eval/src/run.ts"
46
47
  },
47
48
  "muggleConfig": {
48
- "electronAppVersion": "1.6.6",
49
+ "electronAppVersion": "1.6.9",
49
50
  "downloadBaseUrl": "https://github.com/multiplex-ai/muggle-ai-works/releases/download",
50
51
  "runtimeTargetDefault": "production",
51
52
  "checksums": {
52
- "darwin-arm64": "c905921dda5412bdbb66735cdb81b0e0f76f4211c9ec129eae599d910213547e",
53
- "darwin-x64": "3ad51b6babadf73ae607e965dd713fb9ad8a530e6c15c47779c34a96ea90dab4",
54
- "linux-x64": "444c603453967f91b4a4ccb12f4344d7bed8d83b5b99d04fc43924c6c45b08f7",
55
- "win32-x64": "ab7864489da53920af13905671aca4960cc141e41637590044757e47a439a9d7"
53
+ "darwin-arm64": "293f4ca402ab4132946801b9a3a354818e431f4839b1f91f40e0890f4477b3c5",
54
+ "darwin-x64": "c34f923f7fd3b37e60d2d335d01069e117632113ec0c811c573d219f0109291e",
55
+ "linux-x64": "d7ce0910043f5b557448dc800bcba8cf86b663df961959603deb6f6451b4904f",
56
+ "win32-x64": "c376de74e1e3bd34206aa002107b789f47a15972d91a888ae12d0eb5385bbf5f"
56
57
  }
57
58
  },
58
59
  "dependencies": {
@@ -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.4.2",
4
+ "version": "5.5.0",
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.4.2",
5
+ "version": "5.5.0",
6
6
  "author": {
7
7
  "name": "Muggle AI",
8
8
  "email": "support@muggle-ai.com"
@@ -7,7 +7,8 @@
7
7
  {
8
8
  "type": "command",
9
9
  "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/ensure-electron-app.sh\"",
10
- "async": false
10
+ "async": false,
11
+ "timeout": 120
11
12
  }
12
13
  ]
13
14
  }
@@ -19,7 +20,8 @@
19
20
  {
20
21
  "type": "command",
21
22
  "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-report-format.sh\"",
22
- "async": false
23
+ "async": false,
24
+ "timeout": 10
23
25
  }
24
26
  ]
25
27
  }
@@ -31,12 +33,14 @@
31
33
  {
32
34
  "type": "command",
33
35
  "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-pr-opened.sh\"",
34
- "async": false
36
+ "async": false,
37
+ "timeout": 10
35
38
  },
36
39
  {
37
40
  "type": "command",
38
41
  "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-record-tests.sh\"",
39
- "async": false
42
+ "async": false,
43
+ "timeout": 10
40
44
  }
41
45
  ]
42
46
  },
@@ -46,7 +50,8 @@
46
50
  {
47
51
  "type": "command",
48
52
  "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-record-tests.sh\"",
49
- "async": false
53
+ "async": false,
54
+ "timeout": 10
50
55
  }
51
56
  ]
52
57
  }
@@ -57,7 +62,8 @@
57
62
  {
58
63
  "type": "command",
59
64
  "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-e2e-gate.sh\"",
60
- "async": false
65
+ "async": false,
66
+ "timeout": 10
61
67
  }
62
68
  ]
63
69
  }
@@ -68,7 +74,8 @@
68
74
  {
69
75
  "type": "command",
70
76
  "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-build-router.sh\"",
71
- "async": false
77
+ "async": false,
78
+ "timeout": 10
72
79
  }
73
80
  ]
74
81
  }
@@ -1,9 +1,23 @@
1
1
  #!/usr/bin/env bash
2
- set -euo pipefail
2
+ set -uo pipefail
3
3
 
4
4
  # Front-door router (UserPromptSubmit). On the first build/implement/fix prompt
5
5
  # of a session, offers to route the work through /muggle-do (build delegated to
6
6
  # superpowers), gated by autoRouteBuildToMuggleDo. Fires once per session.
7
- # Degrades to {} so it never blocks a turn.
7
+ #
8
+ # Node cold-start (spawn + module load) stalls the turn on a loaded box, and this
9
+ # hook runs on EVERY prompt. A cheap in-shell keyword pre-filter mirrors the build
10
+ # verbs guardrails.mjs looks for, so the vast majority of prompts (questions,
11
+ # status checks, chit-chat) never spawn Node. Node runs only on a keyword hit,
12
+ # then applies the real detectBuildIntent logic (question/slash exclusions,
13
+ # once-per-session dedupe). Over-matching here only costs an occasional needless
14
+ # spawn; it can never emit a spurious offer. Degrades to {} so it never blocks.
15
+ payload="$(cat)"
16
+
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
21
+
8
22
  root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
9
- node "${root}/scripts/guardrails.mjs" build-router 2>/dev/null || printf '{}'
23
+ printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" build-router 2>/dev/null || printf '{}'
@@ -1,8 +1,38 @@
1
1
  #!/usr/bin/env bash
2
- set -euo pipefail
2
+ set -uo pipefail
3
3
 
4
4
  # tests-green → E2E gate (Stop). When unit tests passed this session and no E2E
5
5
  # acceptance run has happened, offer to run change-driven E2E (gated by
6
- # autoE2ETest). Fires once per session. Degrades to {} so it never blocks a turn.
6
+ # autoE2ETest). Fires once per session.
7
+ #
8
+ # This must stay synchronous (only a sync Stop hook can block the turn end), and
9
+ # it fires on EVERY turn end. There is no command payload to key off, so the
10
+ # pre-filter reads the same per-session state file guardrails.mjs uses and only
11
+ # spawns Node when the gate could actually fire — i.e. shouldRunE2E: unit tests
12
+ # went green and no E2E run is recorded yet. On the overwhelming majority of
13
+ # turns (no test run this session) the state file is absent or unitTestsGreen is
14
+ # unset, so we return {} in-shell and never pay Node cold-start. 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 '"unitTestsGreen": true' "$state_file" \
32
+ || grep -q '"e2eRun": true' "$state_file"; then
33
+ printf '{}'
34
+ exit 0
35
+ fi
36
+
7
37
  root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
8
- node "${root}/scripts/guardrails.mjs" e2e-gate 2>/dev/null || printf '{}'
38
+ printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" e2e-gate 2>/dev/null || printf '{}'
@@ -1,10 +1,21 @@
1
1
  #!/usr/bin/env bash
2
- set -euo pipefail
2
+ set -uo pipefail
3
3
 
4
4
  # PR-opened guardrail (PostToolUse/Bash). When a `gh pr create`/`gh pr ready`
5
5
  # just succeeded, offer to start a muggle-pr-followup watcher on the new PR
6
6
  # (gated by autoWatchPR, deduped per session). Decision logic lives in the
7
- # bundled guardrails.mjs; this wrapper just pipes the event payload through and
8
- # degrades to {} so a guardrail can never block a turn.
7
+ # bundled guardrails.mjs.
8
+ #
9
+ # This fires after EVERY Bash call, so a keyword pre-filter for the PR-open
10
+ # commands keeps Node off the hot path — only a `gh pr create|ready` or
11
+ # `glab mr create|update` even reaches guardrails.mjs, which then confirms the
12
+ # command succeeded and extracts the URL. Degrades to {} so it never blocks.
13
+ payload="$(cat)"
14
+
15
+ if ! grep -Eiq 'gh[[:space:]]+pr[[:space:]]+(create|ready)|glab[[:space:]]+mr[[:space:]]+(create|update)' <<<"$payload"; then
16
+ printf '{}'
17
+ exit 0
18
+ fi
19
+
9
20
  root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
10
- node "${root}/scripts/guardrails.mjs" pr-opened 2>/dev/null || printf '{}'
21
+ printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" pr-opened 2>/dev/null || printf '{}'
@@ -1,9 +1,22 @@
1
1
  #!/usr/bin/env bash
2
- set -euo pipefail
2
+ set -uo pipefail
3
+
4
+ # tests-green observer (PostToolUse/Bash + muggle E2E tools). Records in
5
+ # per-session state when a unit-test command passed (and when a muggle E2E run
6
+ # happened). Emits no directive — the Stop gate (guardrail-e2e-gate.sh) reads
7
+ # the state.
8
+ #
9
+ # Fires after every Bash call and every muggle execute/replay, so a keyword
10
+ # pre-filter for test runners and the muggle E2E tool names keeps Node off the
11
+ # hot path. Only a `test` command (npm/pnpm/yarn/jest/vitest/pytest/go/cargo) or
12
+ # a muggle execute/replay/test-generation event reaches guardrails.mjs, which
13
+ # then inspects the output for pass/fail and updates state. Degrades to {}.
14
+ payload="$(cat)"
15
+
16
+ if ! grep -Eiq '(pnpm|npm|yarn)[[:space:]]+(run[[:space:]]+)?test|jest|vitest|pytest|go[[:space:]]+test|cargo[[:space:]]+test|muggle.*(execute|test-generation|replay)' <<<"$payload"; then
17
+ printf '{}'
18
+ exit 0
19
+ fi
3
20
 
4
- # tests-green observer (PostToolUse/Bash). Records in per-session state when a
5
- # unit-test command passed (and when a muggle E2E run happened). Emits no
6
- # directive — the Stop gate (guardrail-e2e-gate.sh) reads the state. Degrades
7
- # to {} so it never blocks a turn.
8
21
  root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
9
- node "${root}/scripts/guardrails.mjs" record-tests 2>/dev/null || printf '{}'
22
+ printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" record-tests 2>/dev/null || printf '{}'