@muggleai/works 5.29.0-staging.255 → 5.29.0-staging.256

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 (47) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  3. package/dist/plugin/.codex-mcp.json +1 -1
  4. package/dist/plugin/.codex-plugin/plugin.json +1 -1
  5. package/dist/plugin/.cursor-plugin/plugin.json +1 -1
  6. package/dist/plugin/.mcp.json +1 -1
  7. package/dist/plugin/scripts/guardrail-dispatch.sh +6 -0
  8. package/dist/plugin/scripts/guardrail-e2e-gate.sh +24 -0
  9. package/dist/plugin/scripts/guardrail-lib.sh +1 -0
  10. package/dist/plugin/scripts/guardrails.mjs +99 -14
  11. package/dist/plugin/scripts/pr-watch-arm.sh +14 -10
  12. package/dist/plugin/scripts/pr-watch-events.sh +14 -5
  13. package/dist/plugin/scripts/pr-watch-fetch.sh +1 -1
  14. package/dist/plugin/scripts/pr-watch-loop.sh +6 -5
  15. package/dist/plugin/scripts/pr-watch-state.jq +15 -5
  16. package/dist/plugin/scripts/pr-watch-verdict.mjs +22 -7
  17. package/dist/plugin/skills/_shared/ci-check-to-command.md +1 -1
  18. package/dist/plugin/skills/do/e2e-acceptance.md +1 -1
  19. package/dist/plugin/skills/do/fix-ci.md +9 -1
  20. package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +2 -2
  21. package/dist/plugin/skills/muggle-pr-followup/bootstrap.md +2 -0
  22. package/dist/plugin/skills/muggle-pr-followup/contract.md +2 -2
  23. package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +12 -12
  24. package/dist/release-manifest.json +3 -3
  25. package/package.json +1 -1
  26. package/plugin/.claude-plugin/plugin.json +1 -1
  27. package/plugin/.codex-mcp.json +1 -1
  28. package/plugin/.codex-plugin/plugin.json +1 -1
  29. package/plugin/.cursor-plugin/plugin.json +1 -1
  30. package/plugin/.mcp.json +1 -1
  31. package/plugin/scripts/guardrail-dispatch.sh +6 -0
  32. package/plugin/scripts/guardrail-e2e-gate.sh +24 -0
  33. package/plugin/scripts/guardrail-lib.sh +1 -0
  34. package/plugin/scripts/guardrails.mjs +99 -14
  35. package/plugin/scripts/pr-watch-arm.sh +14 -10
  36. package/plugin/scripts/pr-watch-events.sh +14 -5
  37. package/plugin/scripts/pr-watch-fetch.sh +1 -1
  38. package/plugin/scripts/pr-watch-loop.sh +6 -5
  39. package/plugin/scripts/pr-watch-state.jq +15 -5
  40. package/plugin/scripts/pr-watch-verdict.mjs +22 -7
  41. package/plugin/skills/_shared/ci-check-to-command.md +1 -1
  42. package/plugin/skills/do/e2e-acceptance.md +1 -1
  43. package/plugin/skills/do/fix-ci.md +9 -1
  44. package/plugin/skills/muggle-pr-followup/arm-watcher.md +2 -2
  45. package/plugin/skills/muggle-pr-followup/bootstrap.md +2 -0
  46. package/plugin/skills/muggle-pr-followup/contract.md +2 -2
  47. package/plugin/skills/muggle-pr-followup/state-schemas.md +12 -12
@@ -13,7 +13,7 @@ The same argument that forbids authoring a per-slot `watch.sh` applies to arming
13
13
  The `DRAIN` lines it prints, in the watch's output, are the outstanding work as of the arm. Review ids and threads the drain read are floored, so the loop will *not* tell you about those again. A stale branch or a red head is floored only when `last_seen.json` records a rebase or fix-ci cycle already attempted for that key or head ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)); otherwise the drain line says `not floored: dispatch …` and the loop's first poll reports it as an event. A PR that spent its catch-up rebase budget is the exception: its drain line says the rebase is `held for the owner`, and nothing is dispatched — the tick would refuse it — until the owner raises [`maxCatchUpRebases`](../muggle-preferences/preference-gates/maxCatchUpRebases.md). Re-arming is never a way to set work aside — it neither handles nor silences anything. Steps 1-3 below are what the script does, and why.
14
14
 
15
15
  1. **Drain.** Run one tick per [`contract.md`](contract.md). It acts on everything already outstanding — actionable threads (`gitlab`: discussions), body-only reviews past the watermark (GitHub-only — GitLab has no review envelope), a stale branch, red CI — and finalizes a terminal PR. If the tick dispatched a cycle, stop here: the cycle's exit path settles the watch when it finishes.
16
- 2. **Seed the watermark.** *(`pr-watch-arm.sh` does this; the reasoning is kept because the floors are subtle and wrong ones are quiet.)* Resolve the provider once per [`../_shared/vcs/detect-vcs.md`](../_shared/vcs/detect-vcs.md) — every fetch in this sequence uses that provider's recipes. Write the slot's watch watermark ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)) to the ids the **drain itself read** — the max review-id and comment-id observed at the drain's own fetch (Step 1), snapshotted at that read. Never let the loop capture its own baseline — the arming session writes it; and **never** from a fresh fetch taken after the drain, which would include a comment that arrived after the drain read the wave and mark it seen unread. Seeded to the drain's floor, anything landing after that read stays above the watermark and the loop's first iteration surfaces it. Seed the CI floor (`CIRED`) from the same drain read: set it to the head SHA only when the checks have **already settled red** at that read (no check pending, one or more in the `fail` bucket per [`../_shared/vcs/common/ci-rollup.md`](../_shared/vcs/common/ci-rollup.md)) **and** `last_seen.json` records a fix-ci attempt for that head — a cycle already ran for that red, so its escalation or retry is not news — and empty otherwise. Seed the rebase floor (`REBASED`) the same way, from the drain's branch-standing read per [`../_shared/vcs/common/branch-standing.md`](../_shared/vcs/common/branch-standing.md): the current `rebase_key` (`<head_sha>..<base_tip_sha>`) only when the drain found the branch behind or conflicting **and** a rebase attempt is recorded for that key, empty otherwise. An escalation entry alone never floors anything: one with no attempt behind it records a decision nobody acted on, and flooring it is how a re-arming session once kept a stale branch and a red head silent for a day ([`state-schemas.md`](state-schemas.md#watch-watermarkenv) names the fields). Seed the blocked-CI floor (`BLOCKED_CIDIGEST`) to the blocked fingerprint's `ci_digest` when arming while `last_seen.blocked` is already set, and empty otherwise — empty is the not-blocked state, in which the loop's blocked-resume probe stays dormant. Seed the rebase hold (`REBASE_HELD`) to `1` when `last_seen.json`'s `catch_up_rebases` has reached `maxCatchUpRebases` — the same check the tick's verdict blocks on — and empty otherwise: while it is set the loop raises no rebase wake on any base move. The script reads all four facts from `last_seen.json` through [`../../scripts/pr-watch-verdict.mjs`](../../scripts/pr-watch-verdict.mjs) `arm-floors`, and records what it found due in `watch-due.env` ([`state-schemas.md`](state-schemas.md#watch-dueenv)).
16
+ 2. **Seed the watermark.** *(`pr-watch-arm.sh` does this; the reasoning is kept because the floors are subtle and wrong ones are quiet.)* Resolve the provider once per [`../_shared/vcs/detect-vcs.md`](../_shared/vcs/detect-vcs.md) — every fetch in this sequence uses that provider's recipes. Write the slot's watch watermark ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)) to the ids the **drain itself read** — the max review-id and comment-id observed at the drain's own fetch (Step 1), snapshotted at that read. Never let the loop capture its own baseline — the arming session writes it; and **never** from a fresh fetch taken after the drain, which would include a comment that arrived after the drain read the wave and mark it seen unread. Seeded to the drain's floor, anything landing after that read stays above the watermark and the loop's first iteration surfaces it. Seed the CI floor (`CIRED`) from the same drain read: set it to the red key (`<head_sha>@<red-check-runs>`) only when the checks have **already settled red** at that read (no check pending, one or more in the `fail` bucket per [`../_shared/vcs/common/ci-rollup.md`](../_shared/vcs/common/ci-rollup.md)) **and** fix-ci is finished with that head — it escalated, or spent the tick's per-head budget — and empty otherwise. A lone attempt does not floor: the count is written before the fix runs, so a cycle that recorded it and changed nothing would otherwise leave the red silent. Seed the rebase floor (`REBASED`) the same way, from the drain's branch-standing read per [`../_shared/vcs/common/branch-standing.md`](../_shared/vcs/common/branch-standing.md): the current `rebase_key` (`<head_sha>..<base_tip_sha>`) only when the drain found the branch behind or conflicting **and** a rebase attempt is recorded for that key, empty otherwise. An escalation entry alone never floors anything: one with no attempt behind it records a decision nobody acted on, and flooring it is how a re-arming session once kept a stale branch and a red head silent for a day ([`state-schemas.md`](state-schemas.md#watch-watermarkenv) names the fields). Seed the blocked-CI floor (`BLOCKED_CIDIGEST`) to the blocked fingerprint's `ci_digest` when arming while `last_seen.blocked` is already set, and empty otherwise — empty is the not-blocked state, in which the loop's blocked-resume probe stays dormant. Seed the rebase hold (`REBASE_HELD`) to `1` when `last_seen.json`'s `catch_up_rebases` has reached `maxCatchUpRebases` — the same check the tick's verdict blocks on — and empty otherwise: while it is set the loop raises no rebase wake on any base move. The script reads all four facts from `last_seen.json` through [`../../scripts/pr-watch-verdict.mjs`](../../scripts/pr-watch-verdict.mjs) `arm-floors`, and records what it found due in `watch-due.env` ([`state-schemas.md`](state-schemas.md#watch-dueenv)).
17
17
  3. **Dedup, then watch.** First read `<slot>/watch.pid` ([`state-schemas.md`](state-schemas.md#watchpid)): if it names a live process (`kill -0 "$pid"` succeeds) **and `owner.json` records this session**, a watcher already owns this slot — **skip arming, do not start a second**. This is what stops orphaned watchers from accumulating: the in-session task dying does not stop the OS loop it launched (on Windows a detached Git Bash loop keeps running and polling `gh` forever after the session ends), so checking a live task list is not enough — the PID lease is.
18
18
 
19
19
  **A live lease owned by a different session does not count as owned** — check it with `watcher_lease_is_foreign` from [`../../scripts/pr-watch-guards.sh`](../../scripts/pr-watch-guards.sh). A live PID proves the loop is running, never that anything is listening: an orphan left by a dead session keeps polling and keeps touching its heartbeat while its stdout is that session's closed pipe, so every liveness signal reads healthy and the events reach nobody. Skipping on the PID alone let such a corpse block this session from arming the PR for the whole lifetime cap — seven days, since the default moved off six hours. When the guard reports a foreign lease, kill that PID, delete `watch.pid`, and arm fresh; note it in the slot's `followup.log` so the reclaim is visible.
@@ -36,7 +36,7 @@ The `DRAIN` lines it prints, in the watch's output, are the outstanding work as
36
36
 
37
37
  **Never author a per-slot `watch.sh`.** The loop ships as [`../../scripts/pr-watch-loop.sh`](../../scripts/pr-watch-loop.sh), with its wake conditions in [`../../scripts/pr-watch-events.sh`](../../scripts/pr-watch-events.sh) and its state projection in [`../../scripts/pr-watch-state.jq`](../../scripts/pr-watch-state.jq); arming runs it and passes arguments. Writing the loop from this prose was how it drifted — each arm produced an independent derivation, and a derivation that quietly dropped a wake still ran, still heartbeat, still logged, and simply never fired for the signal it lost. Two slots on one machine ended up without the behind-base wake, which left their PRs unmergeable under watchers that looked healthy. The prose below says *why* each wake exists; the shipped files are the only definition of *what* fires. A slot holding a legacy generated `watch.sh` keeps it until re-armed, at which point the supersede guard retires the old loop.
38
38
 
39
- The task's description is `PR #<n> — <title>`. Description and command both matter: some task surfaces show one, some the other, and a slot-bearing command keeps the watch identifiable everywhere a raw script blob would not. One background task per PR at a time: it is the watch's visible handle, showing as a running task while the PR is polled, and replaced by a fresh one after each report. Its loop checks about every 60 seconds, re-reading the watermark and touching the slot's `watch-heartbeat` file each iteration — the liveness beacon that tells [`reconcile.md`](reconcile.md) a quiet watch is still alive; on a newer submitted review, a newer thread comment (`gitlab`: a newer discussion note), a thread newly unresolved (`gitlab`: discussion), **the head SHA's checks settling red** (no check pending and one or more in the `fail` bucket per [`../_shared/vcs/common/ci-rollup.md`](../_shared/vcs/common/ci-rollup.md)), **the branch falling behind or conflicting with its base** (`behind_by > 0` or the conflict signal per [`../_shared/vcs/common/branch-standing.md`](../_shared/vcs/common/branch-standing.md)), or — **only while the watch is blocked** (`BLOCKED_CIDIGEST` non-empty) — **the head's CI digest changing in any way** (not just to red) — it prints one line per finding and **exits**, recording what it reported in `watch-reported.env` ([`state-schemas.md`](state-schemas.md#watch-reportedenv)) so no later loop reports the same occurrence again. The review and thread floors are monotonic ids; the other three are not. The CI-red floor is the **head SHA**, because the check rollup is non-monotonic — it flips green↔red and resets on every push — so recording the red head SHA fires CI once per red head, and a later push re-arms it on the new SHA. The rebase floor (`REBASED`) is the **`rebase_key`** — `<head_sha>..<base_tip_sha>` — because staleness is a function of both sides: keying on the pair fires once per newly-due pair and re-arms when either the head or the base moves, where a head-only key would wedge permanently the first time the base advances (the head cannot change while nobody pushes). A head whose checks are still **pending** is never a red wake, and a branch with `behind_by == 0` and `mergeable == UNKNOWN` is never a rebase wake: pending checks may yet go green and conflict state is still computing, and the tick would idle on either (Steps 5–6) regardless. The blocked-CI signal is different in kind — a **resume** probe, live only while the watch is blocked: it wakes on any move of the head's CI digest (the same bucket-plus-sorted-name/conclusion signature the blocked fingerprint records — [`blocked-tick.md`](blocked-tick.md)) away from `BLOCKED_CIDIGEST`, so a block waiting on a green pass, a rerun, or an external deploy check resumes as promptly as one waiting on red. Quiet iterations print nothing and cost nothing — no model tokens are spent while the watch is quiet.
39
+ The task's description is `PR #<n> — <title>`. Description and command both matter: some task surfaces show one, some the other, and a slot-bearing command keeps the watch identifiable everywhere a raw script blob would not. One background task per PR at a time: it is the watch's visible handle, showing as a running task while the PR is polled, and replaced by a fresh one after each report. Its loop checks about every 60 seconds, re-reading the watermark and touching the slot's `watch-heartbeat` file each iteration — the liveness beacon that tells [`reconcile.md`](reconcile.md) a quiet watch is still alive; on a newer submitted review, a newer thread comment (`gitlab`: a newer discussion note), a thread newly unresolved (`gitlab`: discussion), **the head SHA's checks settling red** (no check pending and one or more in the `fail` bucket per [`../_shared/vcs/common/ci-rollup.md`](../_shared/vcs/common/ci-rollup.md)), **the branch falling behind or conflicting with its base** (`behind_by > 0` or the conflict signal per [`../_shared/vcs/common/branch-standing.md`](../_shared/vcs/common/branch-standing.md)), or — **only while the watch is blocked** (`BLOCKED_CIDIGEST` non-empty) — **the head's CI digest changing in any way** (not just to red) — it prints one line per finding and **exits**, recording what it reported in `watch-reported.env` ([`state-schemas.md`](state-schemas.md#watch-reportedenv)) so no later loop reports the same occurrence again. The review and thread floors are monotonic ids; the other three are not. The CI-red floor is the **red key** — the head SHA plus each failed check's run id — because the check rollup is non-monotonic: it flips green↔red and resets on every push, so the key fires once per red verdict and a later push re-arms it on the new SHA. The run ids matter because a settled walkthrough comment or a rerun publishes a new run on the same head; when that run is red too, it is the verdict fix-ci is waiting on, and a head-only floor swallowed it. The rebase floor (`REBASED`) is the **`rebase_key`** — `<head_sha>..<base_tip_sha>` — because staleness is a function of both sides: keying on the pair fires once per newly-due pair and re-arms when either the head or the base moves, where a head-only key would wedge permanently the first time the base advances (the head cannot change while nobody pushes). A head whose checks are still **pending** is never a red wake, and a branch with `behind_by == 0` and `mergeable == UNKNOWN` is never a rebase wake: pending checks may yet go green and conflict state is still computing, and the tick would idle on either (Steps 5–6) regardless. The blocked-CI signal is different in kind — a **resume** probe, live only while the watch is blocked: it wakes on any move of the head's CI digest (the same bucket-plus-sorted-name/conclusion signature the blocked fingerprint records — [`blocked-tick.md`](blocked-tick.md)) away from `BLOCKED_CIDIGEST`, so a block waiting on a green pass, a rerun, or an external deploy check resumes as promptly as one waiting on red. Quiet iterations print nothing and cost nothing — no model tokens are spent while the watch is quiet.
40
40
 
41
41
  Each poll also rewrites the slot's `watch-due.env` whenever the due work changes — the rebase key found due and the head whose checks settled red, or an empty file when neither — so the session's turn-end gate can see work the watch reported without a provider call ([`state-schemas.md`](state-schemas.md#watch-dueenv)).
42
42
 
@@ -61,6 +61,8 @@ Line-comment threads need no seeding — the watcher derives them from live thre
61
61
 
62
62
  The only step that may prompt the user, and only when the PR has a testable surface. Run silent detection from [`../_shared/resolve-e2e-validation-context.md`](../_shared/resolve-e2e-validation-context.md) first. If it finds **no dev server / no testable surface** — a docs, config, skill, or library change, or a non-web repo — seed the watcher **poll-only**: no prompt, no `## Pre-flight answers` block, continue to Step 7 (same as [`auto-track`](auto-track.md)).
63
63
 
64
+ Detection just established why E2E does not apply, so record it on the PR before arming. If the PR's walkthrough comment is still the placeholder, run `echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"` from Step 3's working tree: `NO_WEB_SURFACE` when the repo serves nothing to drive, `NO_APP_LOGIC_CHANGE` (detail opening with the PR's merge-base commit) when every changed path is docs, tests, lint or CI config, else `NOT_APPLICABLE` naming why no browser run reaches the change. Never `NO_PR` — the guardrail refutes it on a PR. Then run the foreground command the walkthrough gate's block names until it prints `{"settled":true}`. Left unsettled, the walkthrough check sits red with nothing in this watch able to clear it, and the PR can merge that way.
65
+
64
66
  Otherwise resolve the context per that file: reuse an existing one (gated by `autoReuseValidationContext`), else one `AskUserQuestion` (strategy, local URL, backend, project, credentials, re-auth) with detected values as defaults; record Step 3's verified working tree as `Working tree`. The user may pick `skip`/`unit-only` or decline the prompt outright — either way, seed poll-only and continue. Never abort the watcher over validation.
65
67
 
66
68
  Capture any resolved fields for Step 7. Do **not** run E2E now — the first watcher tick that dispatches `/muggle-do` does that.
@@ -135,7 +135,7 @@ It prints one line, `VERDICT=<verdict>`. A non-zero exit is a broken install: re
135
135
 
136
136
  1. A rebase is due and `catch_up_rebases >= maxCatchUpRebases` (the cap from the user's preferences; the `rebase_budget_exhausted` flag is not consulted) → `block-rebase-budget-exhausted`.
137
137
  2. A rebase is due, `conflict_resolve_attempts[rebase_key] < 2`, and `rebase_key` ∉ `conflict_escalated_keys` → `dispatch-rebase`. With the per-key budget spent — `conflict_resolve_attempts[rebase_key] >= 2` or `rebase_key` ∈ `conflict_escalated_keys` — it falls through to CI.
138
- 3. CI is `red`, `ci_fix_attempts[head_sha] < 3`, and `head_sha` ∉ `ci_escalated_shas` → `dispatch-fix-ci`. Red with `ci_fix_attempts[head_sha] >= 3` or `head_sha` ∈ `ci_escalated_shas` dispatches nothing — the fix budget is spent and the watcher does not re-dispatch. A check still pending (`bucket == "pending"`) never dispatches: the tick idles until CI settles, and a green head idles too.
138
+ 3. CI is `red`, `ci_fix_attempts[head_sha] < 3`, and `head_sha` ∉ `ci_escalated_shas` → `dispatch-fix-ci`. Red with `ci_fix_attempts[head_sha] >= 3` or `head_sha` ∈ `ci_escalated_shas` dispatches nothing — the fix budget is spent and the watcher does not re-dispatch; it holds as `block-ci-escalated` (Step 4) even when fix-ci never wrote the escalation, because idling there leaves a red PR with nobody told. A check still pending (`bucket == "pending"`) never dispatches: the tick idles until CI settles, and a green head idles too.
139
139
  4. Otherwise `block-conflict-escalated`, `block-ci-escalated`, or `block-reviews-escalated` when the matching escalation stands, else `idle`.
140
140
 
141
141
  **An escalation counts only with an attempt behind it.** An entry in `conflict_escalated_keys` suppresses a rebase only when `conflict_resolve_attempts[rebase_key] >= 1`, and an entry in `ci_escalated_shas` suppresses fix-ci only when `ci_fix_attempts[head_sha] >= 1`: a cycle ran and gave up. An entry with no attempt is ignored, and the work dispatches. Those fields are the executor's record of what its cycles did — never write an escalation entry or an attempt counter by hand.
@@ -172,7 +172,7 @@ Act on the verdict:
172
172
  Any tick that did not dispatch. The Step 6 verdict says **why** it idled. It is **blocked pending a human** on a `block-*` verdict, a durable block only the user can clear:
173
173
 
174
174
  - `block-conflict-escalated` — a rebase `/muggle-do` attempted and gave up on (a semantic conflict, or `autoResolveConflicts=never`), reason `conflict_escalated`. This block clears on its own when the base moves: the new `base_tip_sha` yields a key the set does not contain, and the branch re-arms for a fresh rebase attempt;
175
- - `block-ci-escalated` — CI the fix-ci stage attempted and gave up on, reason `ci_escalated`;
175
+ - `block-ci-escalated` — CI the fix-ci stage gave up on, or a red head that spent all 3 fix-ci attempts, reason `ci_escalated`;
176
176
  - `block-reviews-escalated` — an ambiguous review in `last_seen.escalated_review_ids` awaiting the user's direction, with the actionable set empty, reason `reviews_escalated`;
177
177
  - `block-rebase-budget-exhausted` — reason `rebase_budget_exhausted` (Step 6).
178
178
 
@@ -217,7 +217,7 @@ The watch loop's comparison floor — plain `KEY=VALUE` lines, one file per slot
217
217
  - `REV` — highest submitted-review id already handled
218
218
  - `COM` — highest thread-comment id already handled
219
219
  - `THREADS` — semicolon-joined ids of threads already known unresolved
220
- - `CIRED` — head SHA whose settled-red CI a fix-ci cycle already attempted (`ci_fix_attempts[sha] >= 1`); empty when the checks are green, still pending, unseen, or red with no attempt — the last so the loop reports it. The CI floor is a SHA rather than a monotonic id because the check rollup flips green↔red and resets on each push — keying on the head SHA fires the loop once per red head and re-arms on the next push ([`arm-watcher.md`](arm-watcher.md)).
220
+ - `CIRED` — the red key `<head_sha>@<red-check-runs>` (each failed check's `name#run-id`, sorted — the last field of [`../../scripts/pr-watch-state.jq`](../../scripts/pr-watch-state.jq)) of a red verdict fix-ci is finished with: `head_sha` ∈ `ci_escalated_shas` with an attempt behind it, or `ci_fix_attempts[sha] >= 3`. Empty when the checks are green, still pending, unseen, or red with fix-ci not finished — the last so the loop reports it. A lone attempt never floors: the count is written before the fix runs, so it proves a cycle started, not that anything on the PR changed. The floor is a red key rather than a monotonic id because the rollup flips green↔red and resets on each push, and it carries run ids because a settled walkthrough comment or a rerun publishes a new run on the **same** head — a red verdict from that run is what fix-ci waits on, so it wakes the loop again ([`arm-watcher.md`](arm-watcher.md)).
221
221
  - `REBASED` — the `rebase_key` (`<head_sha>..<base_tip_sha>`) whose behind/conflicting state a rebase cycle already attempted (`conflict_resolve_attempts[key] >= 1`); empty when the branch is current with its base, unseen, or stale with no attempt. Keyed on the pair, not a monotonic id, because staleness depends on both sides — the loop fires once per newly-due pair and re-arms when the head or the base moves. Same key [`contract.md`](contract.md) Steps 5–6 dedup rebase dispatch on; the loop only signals a due rebase, the tick's verdict applies the budgets.
222
222
  - `BLOCKED_CIDIGEST` — the head's `ci_digest` at the moment a block was flagged (the same signature `blocked.fingerprint.ci_digest` carries); empty whenever the watch is not blocked. Non-empty arms the loop's blocked-resume probe: while set, the loop wakes on any move of the live digest away from this value, so a block resumes on a green pass, a rerun, or an external deploy check — not only on red. Written by [`blocked-tick.md`](blocked-tick.md) when a block is flagged and cleared to empty when it resumes.
223
223
  - `REBASE_HELD` — `1` when the PR has spent its catch-up rebase budget (`catch_up_rebases` has reached [`maxCatchUpRebases`](../muggle-preferences/preference-gates/maxCatchUpRebases.md)), empty otherwise. While set, the loop raises no rebase wake and records no rebase as due however far the base moves: the tick's verdict refuses every rebase until the owner raises the cap, so a wake would only ask for work it turns down. Every arm recomputes it, so a raised cap lifts the hold at the next re-arm.
@@ -236,17 +236,17 @@ The work the watch last saw due on its PR, for the owning session's turn-end gat
236
236
 
237
237
  The file is **empty** when nothing is due. Written by [`../../scripts/pr-watch-arm.sh`](../../scripts/pr-watch-arm.sh) from the drain's read and rewritten by the loop whenever what is due changes; the session never writes it. The follow-through Stop gate ([`../../hooks/README.md`](../../hooks/README.md)) blocks the turn end while a non-empty record in a slot this session owns names work with no matching attempt in `last_seen.json`: a `REBASE_DUE` key with no `conflict_resolve_attempts` entry (under `autoRebase=always`, budget left), or a `CI_RED_HEAD` with no `ci_fix_attempts` entry. A later tick's `prs.json` naming another head supersedes the record.
238
238
 
239
- ## `watch-reported.env`
240
-
241
- What the slot's loops already reported to the session — `KEY="value"` lines like the watermark's, written only by the loop, and only on the poll that ends it with a report:
242
-
243
- - `REV` / `COM` — the newest review and comment ids that poll saw; each one at or below them was reported or already floored.
244
- - `THREADS` — the threads unresolved at that poll.
245
- - `CIRED` / `REBASED` — the red head and the rebase key a loop reported.
246
- - `BLOCKED_CIDIGEST` / `BLOCKED_FOR` — the CI digest a blocked-resume wake reported, and the watermark's `BLOCKED_CIDIGEST` it was reported under. It quiets only that block, so the same digest can resume a later one.
247
-
248
- A loop fires only on what sits above both this file and [`watch-watermark.env`](#watch-watermarkenv). Every report ends a loop, so no floor can live in its memory: without this file, anything the session saw and chose not to act on — a comment needing no reply, a red check under `HOLD WATCH` — would end every fresh loop on its first poll. Read it as the watermark is read, never by `source`.
249
-
239
+ ## `watch-reported.env`
240
+
241
+ What the slot's loops already reported to the session — `KEY="value"` lines like the watermark's, written only by the loop, and only on the poll that ends it with a report:
242
+
243
+ - `REV` / `COM` — the newest review and comment ids that poll saw; each one at or below them was reported or already floored.
244
+ - `THREADS` — the threads unresolved at that poll.
245
+ - `CIRED` / `REBASED` — the red key and the rebase key a loop reported.
246
+ - `BLOCKED_CIDIGEST` / `BLOCKED_FOR` — the CI digest a blocked-resume wake reported, and the watermark's `BLOCKED_CIDIGEST` it was reported under. It quiets only that block, so the same digest can resume a later one.
247
+
248
+ A loop fires only on what sits above both this file and [`watch-watermark.env`](#watch-watermarkenv). Every report ends a loop, so no floor can live in its memory: without this file, anything the session saw and chose not to act on — a comment needing no reply, a red check under `HOLD WATCH` — would end every fresh loop on its first poll. Read it as the watermark is read, never by `source`.
249
+
250
250
  ## `watch.pid`
251
251
 
252
252
  A single line: the process id of the watch loop that currently owns this slot. Written once by the loop itself on start (`echo "$$" > watch.pid`); read by two parties, never rewritten in place:
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "release": "5.26.0",
3
- "buildId": "run-255-1",
4
- "commitSha": "f4a6f4a4e2005d404c165622085b743e0612d0c3",
5
- "buildTime": "2026-10-30T19:07:28Z",
3
+ "buildId": "run-256-1",
4
+ "commitSha": "5838b08d25aebf0dda3aee280e619ba96c5799bc",
5
+ "buildTime": "2026-10-30T20:31:46Z",
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.29.0-staging.255",
4
+ "version": "5.29.0-staging.256",
5
5
  "description": "13 years of Microsoft hyperscale engineering, packaged as a harness for your AI coding agent.",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "muggle",
3
3
  "description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
4
- "version": "5.29.0-staging.255",
4
+ "version": "5.29.0-staging.256",
5
5
  "author": {
6
6
  "name": "Muggle AI",
7
7
  "email": "support@muggle-ai.com"
@@ -4,7 +4,7 @@
4
4
  "command": "npx",
5
5
  "args": [
6
6
  "-y",
7
- "@muggleai/works@5.29.0-staging.255",
7
+ "@muggleai/works@5.29.0-staging.256",
8
8
  "serve"
9
9
  ],
10
10
  "env": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muggle",
3
- "version": "5.29.0-staging.255",
3
+ "version": "5.29.0-staging.256",
4
4
  "description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
5
5
  "author": {
6
6
  "name": "Muggle AI",
@@ -2,7 +2,7 @@
2
2
  "name": "muggle",
3
3
  "displayName": "Muggle AI",
4
4
  "description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
5
- "version": "5.29.0-staging.255",
5
+ "version": "5.29.0-staging.256",
6
6
  "author": {
7
7
  "name": "Muggle AI",
8
8
  "email": "support@muggle-ai.com"
package/plugin/.mcp.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "command": "npx",
5
5
  "args": [
6
6
  "-y",
7
- "@muggleai/works@5.29.0-staging.255",
7
+ "@muggleai/works@5.29.0-staging.256",
8
8
  "serve"
9
9
  ]
10
10
  }
@@ -23,8 +23,14 @@ guardrail_read_payload
23
23
  firing_subcommands=()
24
24
  for gate_script in "$@"; do
25
25
  GUARDRAIL_SUBCOMMAND=""
26
+ unset -f guardrail_shell_verdict
26
27
  . "$guardrail_script_dir/$gate_script" || continue
27
28
  if [[ -n $GUARDRAIL_SUBCOMMAND ]] && guardrail_wants; then
29
+ # A gate that can answer in-shell blocks the turn here, before Node's cold start can outlast the hook's cap;
30
+ # the gates after it run on the turn end that follows.
31
+ if declare -F guardrail_shell_verdict >/dev/null && guardrail_shell_verdict; then
32
+ exit 0
33
+ fi
28
34
  firing_subcommands+=("$GUARDRAIL_SUBCOMMAND")
29
35
  fi
30
36
  done
@@ -28,6 +28,30 @@ guardrail_wants() {
28
28
  && ! guardrail_state_has '"e2eRun": true'
29
29
  }
30
30
 
31
+ # Returns the block guardrails.mjs rendered ahead of time (writeE2eStopVerdict), without starting Node, while the
32
+ # budget it shares with Node's own blocks lasts. A Stop hook that outlives its one-second cap is dropped and the turn
33
+ # ends unblocked, and Node's cold start alone has taken seconds on a loaded Windows machine, so a block that waits on
34
+ # Node is one that can silently never land. Returns 1, leaving the decision and the release to Node, when nothing is
35
+ # rendered or the budget is spent.
36
+ guardrail_shell_verdict() {
37
+ local verdict_file="${GUARDRAIL_STATE_FILE%.json}.e2e-stop-verdict"
38
+ local shell_blocks_file="${GUARDRAIL_STATE_FILE%.json}.e2e-shell-blocks"
39
+ [[ -f $verdict_file ]] || return 1
40
+ local verdict=""
41
+ IFS= read -r -d '' verdict <"$verdict_file" || true
42
+ local max_blocks="${verdict%%$'\n'*}"
43
+ local hook_output="${verdict#*$'\n'}"
44
+ [[ $max_blocks =~ ^[0-9]+$ && -n $hook_output && $hook_output != "$verdict" ]] || return 1
45
+ local recorded_blocks_pattern='"e2eBlockCount"[[:space:]]*:[[:space:]]*([0-9]+)'
46
+ local recorded_blocks=0
47
+ [[ $GUARDRAIL_STATE =~ $recorded_blocks_pattern ]] && recorded_blocks="${BASH_REMATCH[1]}"
48
+ local shell_blocks=""
49
+ [[ -f $shell_blocks_file ]] && { IFS= read -r -d '' shell_blocks <"$shell_blocks_file" || true; }
50
+ ((recorded_blocks + ${#shell_blocks} < max_blocks)) || return 1
51
+ { printf 'x' >>"$shell_blocks_file"; } 2>/dev/null || return 1
52
+ printf '%s' "$hook_output"
53
+ }
54
+
31
55
  if guardrail_is_hook_script; then
32
56
  guardrail_run_standalone "$GUARDRAIL_SUBCOMMAND"
33
57
  fi
@@ -154,6 +154,7 @@ guardrail_run_node() {
154
154
  guardrail_run_standalone() {
155
155
  guardrail_read_payload
156
156
  if guardrail_wants; then
157
+ declare -F guardrail_shell_verdict >/dev/null && guardrail_shell_verdict && return 0
157
158
  guardrail_run_node "$1"
158
159
  else
159
160
  printf '{}'
@@ -105,6 +105,10 @@ var SUBAGENT_DISPATCH_TOOL_NAMES = ["Agent", "Task"];
105
105
  var ACCEPTANCE_TESTER_SUBAGENT_TYPE = /(?:^|:)acceptance-tester$/;
106
106
  var ACCEPTANCE_REPORT_MARKERS = ["## Test Summary", "**Overall:**"];
107
107
  var DELEGATED_E2E_WINDOW_MS = 60 * 6e4;
108
+ var E2E_STOP_VERDICT_SUFFIX = ".e2e-stop-verdict";
109
+ var E2E_SHELL_BLOCKS_SUFFIX = ".e2e-shell-blocks";
110
+ var AUTO_E2E_TEST_PREFERENCE_KEY = "autoE2ETest";
111
+ var POST_PR_VISUAL_WALKTHROUGH_PREFERENCE_KEY = "postPRVisualWalkthrough";
108
112
 
109
113
  // src/guardrails/store/fileLock.ts
110
114
  function isProcessAlive(pid) {
@@ -220,6 +224,25 @@ function readPrStateScan(sessionId2, dirOverride) {
220
224
  function writePrStateScan(sessionId2, scan, dirOverride) {
221
225
  writeAtomically(fileFor(sessionId2, dirOverride, PR_STATE_SCAN_SUFFIX), scan, dirOverride);
222
226
  }
227
+ function writeE2eStopVerdict(sessionId2, maxBlocks, hookOutput, dirOverride) {
228
+ mkdirSync(baseDir(dirOverride), { recursive: true });
229
+ const target = fileFor(sessionId2, dirOverride, E2E_STOP_VERDICT_SUFFIX);
230
+ const staging = `${target}.${process.pid}.tmp`;
231
+ writeFileSync(staging, `${maxBlocks}
232
+ ${hookOutput}`);
233
+ renameSync(staging, target);
234
+ writeFileSync(fileFor(sessionId2, dirOverride, E2E_SHELL_BLOCKS_SUFFIX), "", { flag: "a" });
235
+ }
236
+ function readE2eShellBlockCount(sessionId2, dirOverride) {
237
+ try {
238
+ return readFileSync(fileFor(sessionId2, dirOverride, E2E_SHELL_BLOCKS_SUFFIX), "utf-8").length;
239
+ } catch {
240
+ return 0;
241
+ }
242
+ }
243
+ function clearE2eShellBlocks(sessionId2, dirOverride) {
244
+ rmSync(fileFor(sessionId2, dirOverride, E2E_SHELL_BLOCKS_SUFFIX), { force: true });
245
+ }
223
246
  var lockFor = (sessionId2, dirOverride) => `${fileFor(sessionId2, dirOverride)}.lock`;
224
247
  function commitUnderHeldLock(sessionId2, mutate, dirOverride) {
225
248
  const current = readState(sessionId2, dirOverride);
@@ -1816,10 +1839,14 @@ function applyStateMutation(state, mutation) {
1816
1839
  function commitStateMutation(sessionId2, mutation, observedState) {
1817
1840
  if (observedState && applyStateMutation(observedState, mutation) === observedState) return observedState;
1818
1841
  const committed = commitState(sessionId2, (state) => applyStateMutation(state, mutation));
1819
- if (committed) return committed;
1842
+ if (committed) return withShellBlocksReset(committed);
1820
1843
  deferStateMutation(sessionId2, mutation);
1821
1844
  startBackgroundJob("commit-deferred-state" /* CommitDeferredState */, [sessionId2]);
1822
- return applyStateMutation(readState(sessionId2), mutation);
1845
+ return withShellBlocksReset(applyStateMutation(readState(sessionId2), mutation));
1846
+ }
1847
+ function withShellBlocksReset(state) {
1848
+ if (state.e2eRun === true) clearE2eShellBlocks(state.sessionId);
1849
+ return state;
1823
1850
  }
1824
1851
  function commitDeferredSessionState(sessionId2) {
1825
1852
  commitDeferredStateMutations(sessionId2, applyStateMutation);
@@ -1925,19 +1952,52 @@ function isE2ERun(input2) {
1925
1952
  if (MUGGLE_E2E_RUN_TOOL.test(input2.tool_name ?? "")) return true;
1926
1953
  return MUGGLE_SKILL_EMIT_TOOL.test(input2.tool_name ?? "") && input2.tool_input?.skillName === MUGGLE_TEST_SKILL_NAME;
1927
1954
  }
1955
+
1956
+ // src/guardrails/e2eOwedReason.ts
1957
+ var describePreference = (preferenceKey, configuredValue) => `${preferenceKey}=${configuredValue ?? "unset"}`;
1958
+ function buildE2eOwedReason(input2) {
1959
+ const isE2eMandated = input2.isE2eMandatedByUser;
1960
+ const isWalkthroughMandated = input2.postPRVisualWalkthrough === ALWAYS_PREFERENCE_VALUE;
1961
+ const preferenceSummary = `${describePreference(AUTO_E2E_TEST_PREFERENCE_KEY, input2.autoE2ETest)}, ${describePreference(POST_PR_VISUAL_WALKTHROUGH_PREFERENCE_KEY, input2.postPRVisualWalkthrough)} (~/.muggle-ai/preferences.json)`;
1962
+ const walkthroughStep = isWalkthroughMandated ? `, then post the run's walkthrough to the PR via ${input2.walkthroughSkill}` : "";
1963
+ const offeredSkipCodes = isE2eMandated ? E2E_SKIP_CODES.filter((skipCode) => skipCode !== "NOT_APPLICABLE" /* NotApplicable */) : E2E_SKIP_CODES;
1964
+ const skipClause = `To skip, run \`echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"\` citing one of ${offeredSkipCodes.join(", ")}. Muggle verifies each code itself, so one that does not hold leaves this gate blocked.`;
1965
+ if (input2.blockCount > 1) {
1966
+ return `E2E acceptance run still owed (reminder ${input2.blockCount}/${input2.maxBlocks}; ${preferenceSummary}): run ${input2.muggleTestSkill}${walkthroughStep}, or cite a verifiable code via \`echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"\`.`;
1967
+ }
1968
+ const owedSentence = `Do not end the turn yet. This session went unit-green or opened a PR, but no E2E acceptance run has happened. Preferences: ${preferenceSummary}. `;
1969
+ if (isE2eMandated) {
1970
+ return owedSentence + `That is the user's standing answer: do not ask them whether to test, and do not end the turn handing the decision back. Run change-driven E2E now via ${input2.muggleTestSkill}${walkthroughStep}, then finish. ${skipClause} A judgment that the change is not worth a run is not a skip.`;
1971
+ }
1972
+ return owedSentence + `Run change-driven E2E now via ${input2.muggleTestSkill}${walkthroughStep}, then finish. ${skipClause} Only when no verifiable code fits and a browser run genuinely cannot exercise the change, cite ${"NOT_APPLICABLE" /* NotApplicable */} with the reason \u2014 the PR shows it as your unverified judgment.`;
1973
+ }
1928
1974
  function parseMaxCatchUpRebases(configuredValue) {
1929
1975
  if (configuredValue === UNBOUNDED_PREFERENCE_VALUE) return null;
1930
1976
  const spelled = typeof configuredValue === "number" ? String(configuredValue) : configuredValue;
1931
1977
  return typeof spelled === "string" && POSITIVE_INTEGER.test(spelled) ? Number(spelled) : void 0;
1932
1978
  }
1979
+ var readUserPreferences = (homeDir) => asRecord(readJsonRecord(join(homeDir, ...USER_PREFERENCES_PATH_SEGMENTS)).preferences);
1980
+ function readPreferenceLayers(homeDir, pluginRoot) {
1981
+ return [readUserPreferences(homeDir), readJsonRecord(join(pluginRoot, ...PREFERENCE_DEFAULTS_PATH_SEGMENTS))];
1982
+ }
1983
+ function pickPreferenceValue(preferenceLayers, preferenceKey) {
1984
+ const configuredValue = preferenceLayers.map((layer) => layer[preferenceKey]).find((layerValue) => layerValue != null);
1985
+ return typeof configuredValue === "string" ? configuredValue : void 0;
1986
+ }
1987
+ function resolvePreferenceValue(preferenceKey, homeDir = homedir(), pluginRoot = resolvePluginRoot()) {
1988
+ return pickPreferenceValue(readPreferenceLayers(homeDir, pluginRoot), preferenceKey);
1989
+ }
1990
+ function resolveUserPreferenceValue(preferenceKey, homeDir = homedir()) {
1991
+ return pickPreferenceValue([readUserPreferences(homeDir)], preferenceKey);
1992
+ }
1933
1993
  function resolveWatchPreferences(homeDir = homedir(), pluginRoot = resolvePluginRoot()) {
1934
- const shippedDefaults = readJsonRecord(join(pluginRoot, ...PREFERENCE_DEFAULTS_PATH_SEGMENTS));
1935
- const userPreferences = asRecord(readJsonRecord(join(homeDir, ...USER_PREFERENCES_PATH_SEGMENTS)).preferences);
1936
- const autoRebase = userPreferences[AUTO_REBASE_PREFERENCE_KEY] ?? shippedDefaults[AUTO_REBASE_PREFERENCE_KEY];
1994
+ const preferenceLayers = readPreferenceLayers(homeDir, pluginRoot);
1995
+ const [userPreferences, shippedDefaults] = preferenceLayers;
1996
+ const autoRebase = pickPreferenceValue(preferenceLayers, AUTO_REBASE_PREFERENCE_KEY);
1937
1997
  const userCap = parseMaxCatchUpRebases(userPreferences[MAX_CATCH_UP_REBASES_PREFERENCE_KEY]);
1938
1998
  const shippedCap = parseMaxCatchUpRebases(shippedDefaults[MAX_CATCH_UP_REBASES_PREFERENCE_KEY]);
1939
1999
  return {
1940
- autoRebase: typeof autoRebase === "string" ? autoRebase : void 0,
2000
+ autoRebase,
1941
2001
  maxCatchUpRebases: userCap !== void 0 ? userCap : shippedCap ?? null
1942
2002
  };
1943
2003
  }
@@ -2216,7 +2276,12 @@ var verifyNoAppLogicChange = (context, detail) => {
2216
2276
  if (appLogicPaths.length > 0) return refuted(`app-logic paths changed since ${sha}: ${namePaths(appLogicPaths)}`);
2217
2277
  return verified;
2218
2278
  };
2219
- var verifyNotApplicable = (_context, detail) => {
2279
+ var verifyNotApplicable = (context, detail) => {
2280
+ if (context.userAutoE2ETest === ALWAYS_PREFERENCE_VALUE) {
2281
+ return refuted(
2282
+ `autoE2ETest=always in ~/.muggle-ai/preferences.json, so a judgment that the change needs no run is not a skip the user allows \u2014 run the E2E, or cite a code Muggle can verify`
2283
+ );
2284
+ }
2220
2285
  const words = detail.trim().split(/\s+/).filter(Boolean).length;
2221
2286
  if (words >= MIN_NOT_APPLICABLE_REASON_WORDS) return verified;
2222
2287
  return refuted(
@@ -2261,12 +2326,14 @@ function explainRejection(judged) {
2261
2326
  return `The skip cited ${judged.claimedCode}, but that could not be verified: ${judged.failure}. E2E is still owed \u2014 cite a code that holds, or run it.`;
2262
2327
  }
2263
2328
  function judgeE2eSkip(input2, state, probes = defaultSkipProbes) {
2329
+ const homeDir = homedir();
2264
2330
  return resolveSkipDeclaration(input2.tool_input?.command ?? "", {
2265
2331
  cwd: input2.cwd,
2266
2332
  prsHandled: state.prsHandled,
2267
2333
  transcriptPath: input2.transcript_path,
2268
- homeDir: homedir(),
2269
- probe: probes
2334
+ homeDir,
2335
+ probe: probes,
2336
+ userAutoE2ETest: resolveUserPreferenceValue(AUTO_E2E_TEST_PREFERENCE_KEY, homeDir)
2270
2337
  });
2271
2338
  }
2272
2339
 
@@ -2688,6 +2755,7 @@ function prOpened() {
2688
2755
  prUrl: url,
2689
2756
  prChange
2690
2757
  });
2758
+ renderE2eStopVerdict(stateAfterOpen);
2691
2759
  const declaredSkip = isPrCoveredBySkip(stateAfterOpen, url) ? stateAfterOpen.e2eSkipDeclaration : void 0;
2692
2760
  if (declaredSkip) {
2693
2761
  startBackgroundJob("settle-walkthrough-skipped" /* SettleWalkthroughSkipped */, [url, declaredSkip.code, declaredSkip.detail]);
@@ -2766,6 +2834,7 @@ function recordTests() {
2766
2834
  failedRunId: detectFailedRunId(input)
2767
2835
  };
2768
2836
  const next = commitStateMutation(sessionId, outcome);
2837
+ if (outcome.run.unitTestPassed) renderE2eStopVerdict(next);
2769
2838
  startWalkthroughScanOnGateArming(stateBefore, next, walkthroughPosted);
2770
2839
  if (acceptedSkip) recordSkipReasonOnPrs(next, acceptedSkip);
2771
2840
  if (judgedSkip && !judgedSkip.accepted) {
@@ -2869,14 +2938,30 @@ function debugPathGate() {
2869
2938
  const reason = decision.blockCount === 1 ? `Do not end the turn yet. These runs failed and never went through the debug path: ${runList}. muggle-test Step 7C makes that mandatory \u2014 route each through _shared/debug-failed-run.md: gather the attempted steps and the failing screenshot, diagnose the bucket per _shared/failure-mode-handling.md \xA7B/\xA7C with its classified telemetry emit, and present the offer in which "give feedback & rerun" is always available. A summarized-and-dropped failure is the run a reviewer most needs to see. If a run genuinely cannot be debugged, run \`echo "MUGGLE_DEBUG_SKIP: <runId> <reason>"\` \u2014 that clears just that run.` : `Failed runs still owe the debug path (reminder ${decision.blockCount}/${MAX_DEBUG_BLOCKS}): ${runList}. Route each through _shared/debug-failed-run.md, or record a legitimate skip via \`echo "MUGGLE_DEBUG_SKIP: <runId> <reason>"\`.`;
2870
2939
  return blockStop(reason, host);
2871
2940
  }
2941
+ function e2eOwedReason(blockCount) {
2942
+ return buildE2eOwedReason({
2943
+ blockCount,
2944
+ maxBlocks: MAX_E2E_BLOCKS,
2945
+ autoE2ETest: resolvePreferenceValue(AUTO_E2E_TEST_PREFERENCE_KEY),
2946
+ postPRVisualWalkthrough: resolvePreferenceValue(POST_PR_VISUAL_WALKTHROUGH_PREFERENCE_KEY),
2947
+ isE2eMandatedByUser: resolveUserPreferenceValue(AUTO_E2E_TEST_PREFERENCE_KEY) === ALWAYS_PREFERENCE_VALUE,
2948
+ muggleTestSkill: muggleSkill("/muggle:muggle-test"),
2949
+ walkthroughSkill: muggleSkill("/muggle:muggle-pr-visual-walkthrough")
2950
+ });
2951
+ }
2952
+ function renderE2eStopVerdict(state) {
2953
+ if (!shouldRunE2E(state)) return;
2954
+ writeE2eStopVerdict(sessionId, MAX_E2E_BLOCKS, blockStop(e2eOwedReason(1), host));
2955
+ }
2872
2956
  function e2eGate() {
2873
2957
  const state = readState(sessionId);
2874
- const decision = e2eGateDecision(state);
2958
+ const shellBlockCount = readE2eShellBlockCount(sessionId);
2959
+ const decision = e2eGateDecision({ ...state, e2eBlockCount: (state.e2eBlockCount ?? 0) + shellBlockCount });
2875
2960
  if (decision.action === "release" /* Release */) return releaseGate("e2eReleased");
2876
2961
  if (decision.action === "none" /* None */) return "{}";
2877
- recordGateBlock("e2eBlockCount", decision.blockCount);
2878
- const reason = decision.blockCount === 1 ? `Do not end the turn yet. This session went unit-green or opened a PR, but no E2E acceptance run has happened. Per the autoE2ETest preference (default: always), run change-driven E2E now via ${muggleSkill("/muggle:muggle-test")}, then finish. To skip, run \`echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"\` citing one of ${E2E_SKIP_CODES.join(", ")}. Muggle verifies each code itself, so one that does not hold leaves this gate blocked. Only when no verifiable code fits and a browser run genuinely cannot exercise the change, cite ${"NOT_APPLICABLE" /* NotApplicable */} with the reason \u2014 the PR shows it as your unverified judgment.` : `E2E acceptance run still owed (reminder ${decision.blockCount}/${MAX_E2E_BLOCKS}): run ${muggleSkill("/muggle:muggle-test")}, or cite a verifiable code via \`echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"\`.`;
2879
- return blockStop(reason, host);
2962
+ recordGateBlock("e2eBlockCount", decision.blockCount - shellBlockCount);
2963
+ renderE2eStopVerdict(state);
2964
+ return blockStop(e2eOwedReason(decision.blockCount), host);
2880
2965
  }
2881
2966
  function watchGate() {
2882
2967
  const state = readState(sessionId);
@@ -2928,7 +3013,7 @@ function watchFollowThroughGate() {
2928
3013
  const holdInstruction = `Only the user can hold it: their own message containing \`${WATCH_HOLD_PHRASE_PREFIX}<pr-number>\` for that PR. Ask if you must; never decide it for them.`;
2929
3014
  const reason = decision.blockCount === 1 ? `Do not end the turn yet. A muggle-pr-followup watch this session owns found work due that nothing acted on:
2930
3015
  ${owedList}
2931
- autoRebase=always makes a due rebase binding, and how long a CI run or an eval takes is never a reason to wait. Act now \u2014 if the tick finds actionable review feedback it dispatches that first. A recorded attempt (conflict_resolve_attempts / ci_fix_attempts) or a new head clears this; an escalation entry with no attempt behind it does not, and neither does re-arming the watch. ${holdInstruction}` : `Watcher work still owed (reminder ${decision.blockCount}):
3016
+ autoRebase=always makes a due rebase binding, and how long a CI run or an eval takes is never a reason to wait. Act now: run the cycle the tick dispatches \u2014 if it finds actionable review feedback it dispatches that first. That cycle's first step records its attempt (conflict_resolve_attempts / ci_fix_attempts), which, like a new head, clears this. Writing the count without running the cycle \u2014 or for an E2E run queued for a different PR \u2014 leaves the PR red and the watch quiet. An escalation entry with no attempt behind it does not clear this, and neither does re-arming the watch. ${holdInstruction}` : `Watcher work still owed (reminder ${decision.blockCount}):
2932
3017
  ${owedList}
2933
3018
  ${holdInstruction}`;
2934
3019
  return blockStop(reason, host);
@@ -13,10 +13,12 @@
13
13
  # seen without ever being reported. A re-arm keeps the review, comment and thread
14
14
  # floors the session already holds, for the same reason.
15
15
  #
16
- # A stale branch or a red head is floored only when the slot proves a cycle ran
17
- # for it. Flooring whatever was outstanding at arm time let a session that never
18
- # rebased or fixed anything re-arm at every rollover and silence the loop each
19
- # time; the drain line said "handled" and nothing checked.
16
+ # A stale branch is floored only when the slot proves a rebase ran for it, and a
17
+ # red head only once fix-ci is finished with it — escalated, or out of attempts.
18
+ # Flooring whatever was outstanding at arm time let a session that never rebased
19
+ # or fixed anything re-arm at every rollover and silence the loop each time; the
20
+ # drain line said "handled" and nothing checked. A bare attempt count is no
21
+ # better proof for CI: writing it costs nothing and changes nothing on the PR.
20
22
  set -uo pipefail
21
23
 
22
24
  script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -77,6 +79,7 @@ latest_comment="${STATE_FIELDS[5]-}"
77
79
  unresolved_threads="${STATE_FIELDS[6]-}"
78
80
  pending_checks="${STATE_FIELDS[7]-}"
79
81
  failed_checks="${STATE_FIELDS[8]-}"
82
+ ci_red_key="$(watch_ci_red_key "$head_sha" "${STATE_FIELDS[10]-}")"
80
83
 
81
84
  if [ "$pr_state" = "MERGED" ] || [ "$pr_state" = "CLOSED" ]; then
82
85
  echo "TERMINAL pr=$pr_number state=$pr_state — nothing to arm"
@@ -92,7 +95,8 @@ rebase_due_key=""
92
95
  behind_count="$(watch_fetch_behind_count "$repo" "$base_branch" "$head_sha" "$slot")"
93
96
  watch_branch_needs_rebase "$mergeable" "$behind_count" && rebase_due_key="$rebase_key"
94
97
 
95
- # Whether a cycle already ran for this key and this head, and the blocked
98
+ # Whether a rebase already ran for this key, whether fix-ci is done with this
99
+ # head, and the blocked
96
100
  # fingerprint's digest. A slot re-armed while blocked keeps its resume probe
97
101
  # live: empty is the not-blocked state, so seeding it empty would leave the loop
98
102
  # asleep through the very CI move the block is waiting on.
@@ -105,14 +109,14 @@ watch_branch_needs_rebase "$mergeable" "$behind_count" && rebase_due_key="$rebas
105
109
  last_seen_file="${slot}/last_seen.json"
106
110
  [ -f "$last_seen_file" ] || last_seen_file=/dev/null
107
111
  rebase_attempted=0
108
- ci_attempted=0
112
+ ci_settled=0
109
113
  blocked_digest_floor=""
110
114
  rebase_budget_exhausted=0
111
115
  while IFS= read -r evidence_line; do
112
116
  evidence_line="${evidence_line%$'\r'}"
113
117
  case "$evidence_line" in
114
118
  REBASE_ATTEMPTED=*) rebase_attempted="${evidence_line#REBASE_ATTEMPTED=}" ;;
115
- CI_ATTEMPTED=*) ci_attempted="${evidence_line#CI_ATTEMPTED=}" ;;
119
+ CI_SETTLED=*) ci_settled="${evidence_line#CI_SETTLED=}" ;;
116
120
  BLOCKED_CIDIGEST=*) blocked_digest_floor="${evidence_line#BLOCKED_CIDIGEST=}" ;;
117
121
  REBASE_BUDGET_EXHAUSTED=*) rebase_budget_exhausted="${evidence_line#REBASE_BUDGET_EXHAUSTED=}" ;;
118
122
  esac
@@ -120,7 +124,7 @@ done < <(node "${script_dir}/pr-watch-verdict.mjs" arm-floors --pr-key "${repo}#
120
124
  --head "$head_sha" --rebase-key "$rebase_key" < "$last_seen_file" 2>/dev/null)
121
125
 
122
126
  ci_red_floor=""
123
- [ -n "$ci_red_head" ] && [ "$ci_attempted" = "1" ] && ci_red_floor="$ci_red_head"
127
+ [ -n "$ci_red_head" ] && [ "$ci_settled" = "1" ] && ci_red_floor="$ci_red_key"
124
128
  rebase_floor=""
125
129
  [ -n "$rebase_due_key" ] && [ "$rebase_attempted" = "1" ] && rebase_floor="$rebase_due_key"
126
130
 
@@ -145,9 +149,9 @@ echo "DRAIN unresolved-threads=$thread_count latest-review=${latest_review:-0} l
145
149
  echo "DRAIN checks pending=${pending_checks:-0} failed=${failed_checks:-0} behind-base=${behind_count:-unknown} mergeable=${mergeable:-UNKNOWN}"
146
150
  if [ -n "$ci_red_head" ]; then
147
151
  if [ -n "$ci_red_floor" ]; then
148
- echo "DRAIN ci red at arm time — a fix-ci cycle already ran for head ${head_sha:0:8}, floored"
152
+ echo "DRAIN ci red at arm time — fix-ci escalated or spent its attempts on head ${head_sha:0:8}, floored"
149
153
  else
150
- echo "DRAIN ci red at arm time — no fix-ci attempt recorded for head ${head_sha:0:8}, not floored: dispatch fix-ci"
154
+ echo "DRAIN ci red at arm time — fix-ci not finished with head ${head_sha:0:8}, not floored: dispatch fix-ci"
151
155
  fi
152
156
  fi
153
157
  if [ -n "$rebase_due_key" ]; then
@@ -80,15 +80,24 @@ watch_checks_settled_red() {
80
80
  [ "${failed_count:-0}" -gt 0 ] 2>/dev/null
81
81
  }
82
82
 
83
+ # The identity of one red verdict: the head plus the run ids of its failed
84
+ # checks (the state line's red-check-runs).
85
+ watch_ci_red_key() {
86
+ printf '%s@%s' "$1" "$2"
87
+ }
88
+
83
89
  # The head's checks having settled red (watch_checks_settled_red).
84
90
  #
85
- # The floor is the head SHA rather than a monotonic id because a check rollup is
86
- # not monotonic: it flips green to red and resets on every push. Keying on the
87
- # red head fires once per red head and re-arms on the next push.
91
+ # The floor is a red key (watch_ci_red_key) rather than a monotonic id because a
92
+ # check rollup is not monotonic: it flips green to red and resets on every push.
93
+ # The head alone is not enough: a fix-ci cycle that settles the walkthrough or
94
+ # reruns a check keeps the head, and when that new run comes back red too it is
95
+ # news the cycle is waiting on. A head-only floor swallowed it, so the re-run's
96
+ # verdict never reached anyone.
88
97
  watch_wake_ci_red() {
89
- local pr="$1" pending_count="$2" failed_count="$3" head_sha="$4" floor="$5"
98
+ local pr="$1" pending_count="$2" failed_count="$3" head_sha="$4" red_key="$5" floor="$6"
90
99
  [ -n "$head_sha" ] || return 1
91
- [ "$head_sha" != "$floor" ] || return 1
100
+ [ "$red_key" != "$floor" ] || return 1
92
101
  watch_checks_settled_red "$pending_count" "$failed_count" || return 1
93
102
  echo "EVENT pr=$pr checks settled red head=$head_sha"
94
103
  }
@@ -25,7 +25,7 @@ query($owner: String!, $name: String!, $number: Int!) {
25
25
  contexts(first: 100) {
26
26
  nodes {
27
27
  __typename
28
- ... on CheckRun { name status conclusion }
28
+ ... on CheckRun { databaseId name status conclusion }
29
29
  ... on StatusContext { context state }
30
30
  }
31
31
  }
@@ -178,6 +178,7 @@ while :; do
178
178
  pending_checks="${STATE_FIELDS[7]-}"
179
179
  failed_checks="${STATE_FIELDS[8]-}"
180
180
  ci_digest="${STATE_FIELDS[9]-}"
181
+ ci_red_key="$(watch_ci_red_key "$head_sha" "${STATE_FIELDS[10]-}")"
181
182
 
182
183
  if [ "$pr_state" = "MERGED" ] || [ "$pr_state" = "CLOSED" ]; then
183
184
  echo "TERMINAL pr=$pr_number state=$pr_state"
@@ -206,12 +207,12 @@ while :; do
206
207
  done
207
208
  fi
208
209
 
209
- # Either floor quiets the head: the watermark's when a fix already ran for
210
- # it, the reported one when a loop already told the session it went red.
210
+ # Either floor quiets the red verdict: the watermark's when fix-ci settled
211
+ # it, the reported one when a loop already told the session about it.
211
212
  ci_red_floor="$watermark_ci_red"
212
- [ "$floor_ci_red" = "$head_sha" ] && ci_red_floor="$floor_ci_red"
213
- if watch_wake_ci_red "$pr_number" "$pending_checks" "$failed_checks" "$head_sha" "$ci_red_floor"; then
214
- floor_ci_red="$head_sha"
213
+ [ "$floor_ci_red" = "$ci_red_key" ] && ci_red_floor="$floor_ci_red"
214
+ if watch_wake_ci_red "$pr_number" "$pending_checks" "$failed_checks" "$head_sha" "$ci_red_key" "$ci_red_floor"; then
215
+ floor_ci_red="$ci_red_key"
215
216
  has_reported=1
216
217
  fi
217
218