@muggleai/works 5.5.1 → 5.6.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 (36) hide show
  1. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  2. package/dist/plugin/.cursor-plugin/plugin.json +1 -1
  3. package/dist/plugin/hooks/README.md +3 -3
  4. package/dist/plugin/scripts/guardrail-record-tests.sh +5 -4
  5. package/dist/plugin/scripts/guardrails.mjs +11 -3
  6. package/dist/plugin/skills/do/e2e-acceptance.md +2 -0
  7. package/dist/plugin/skills/do/respawn-watcher.md +4 -14
  8. package/dist/plugin/skills/muggle-pr-followup/CLAUDE.md +2 -1
  9. package/dist/plugin/skills/muggle-pr-followup/SKILL.md +3 -1
  10. package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +12 -0
  11. package/dist/plugin/skills/muggle-pr-followup/auto-track.md +5 -11
  12. package/dist/plugin/skills/muggle-pr-followup/blocked-tick.md +10 -10
  13. package/dist/plugin/skills/muggle-pr-followup/bootstrap.md +4 -8
  14. package/dist/plugin/skills/muggle-pr-followup/contract.md +5 -3
  15. package/dist/plugin/skills/muggle-pr-followup/evals/evals.json +12 -0
  16. package/dist/plugin/skills/muggle-pr-followup/output-templates/bootstrap.md +2 -2
  17. package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +10 -0
  18. package/dist/release-manifest.json +4 -4
  19. package/package.json +1 -1
  20. package/plugin/.claude-plugin/plugin.json +1 -1
  21. package/plugin/.cursor-plugin/plugin.json +1 -1
  22. package/plugin/hooks/README.md +3 -3
  23. package/plugin/scripts/guardrail-record-tests.sh +5 -4
  24. package/plugin/scripts/guardrails.mjs +11 -3
  25. package/plugin/skills/do/e2e-acceptance.md +2 -0
  26. package/plugin/skills/do/respawn-watcher.md +4 -14
  27. package/plugin/skills/muggle-pr-followup/CLAUDE.md +2 -1
  28. package/plugin/skills/muggle-pr-followup/SKILL.md +3 -1
  29. package/plugin/skills/muggle-pr-followup/arm-watcher.md +12 -0
  30. package/plugin/skills/muggle-pr-followup/auto-track.md +5 -11
  31. package/plugin/skills/muggle-pr-followup/blocked-tick.md +10 -10
  32. package/plugin/skills/muggle-pr-followup/bootstrap.md +4 -8
  33. package/plugin/skills/muggle-pr-followup/contract.md +5 -3
  34. package/plugin/skills/muggle-pr-followup/evals/evals.json +12 -0
  35. package/plugin/skills/muggle-pr-followup/output-templates/bootstrap.md +2 -2
  36. package/plugin/skills/muggle-pr-followup/state-schemas.md +10 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "muggle",
3
3
  "description": "Run real-browser end-to-end (E2E) acceptance tests on your web app from any AI coding agent. Generate test scripts from plain English, replay them on localhost, capture screenshots, and validate user flows like signup, checkout, and dashboards. Works across Claude Code, Cursor, Codex, and Windsurf.",
4
- "version": "5.5.1",
4
+ "version": "5.6.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.5.1",
5
+ "version": "5.6.0",
6
6
  "author": {
7
7
  "name": "Muggle AI",
8
8
  "email": "support@muggle-ai.com"
@@ -20,7 +20,7 @@ A guardrail emits one of two strengths:
20
20
  - **Advise** — `additionalContext` (PostToolUse/UserPromptSubmit) or a plain Stop message. A soft nudge the model can ignore.
21
21
  - **Enforce** — a `Stop` `decision: "block"` that refuses to end the turn, or a `PreToolUse` `permissionDecision: "deny"` that refuses a tool call. The model cannot proceed until the condition is met.
22
22
 
23
- Enforcement is reserved for the handoffs that were being skipped: the E2E acceptance run and posting a deterministically-rendered report. Each enforcing gate carries an escape so it can't trap a turn — the E2E gate releases to advisory after `MAX_E2E_BLOCKS` (3) blocks; the report gate only denies a body it can positively see is a hand-written report and fails open otherwise.
23
+ Enforcement is reserved for the handoffs that were being skipped: the E2E acceptance run and posting a deterministically-rendered report. Each enforcing gate carries an escape so it can't trap a turn — the E2E gate accepts an explicit skip declaration (`echo "MUGGLE_E2E_SKIP: <reason>"`, session-durable) and hard-releases after `MAX_E2E_BLOCKS` (3) blocks; the report gate only denies a body it can positively see is a hand-written report and fails open otherwise.
24
24
 
25
25
  ## Mechanism
26
26
 
@@ -31,9 +31,9 @@ Each guardrail is a thin bash wrapper in `../scripts/` registered in `hooks.json
31
31
  | Hook event | Wrapper | Strength | Condition | Preference | Effect |
32
32
  | :--------- | :------ | :------- | :-------- | :--------- | :----- |
33
33
  | `PostToolUse` (Bash) | `guardrail-pr-opened.sh` | advise | a `gh pr create`/`gh pr ready` just succeeded | `autoWatchPR` | start a `muggle-pr-followup` watcher on the new PR |
34
- | `PostToolUse` (Bash + muggle execute/replay MCP tools) | `guardrail-record-tests.sh` | record | a unit-test command passed, or an E2E run happened | — | set `unitTestsGreen` / `e2eRun` session state |
34
+ | `PostToolUse` (Bash + muggle execute/replay MCP tools) | `guardrail-record-tests.sh` | record | a unit-test command passed, an E2E run happened, or an `echo "MUGGLE_E2E_SKIP: <reason>"` marker declared E2E un-runnable | — | set `unitTestsGreen` / `e2eRun` / `e2eSkipped` session state |
35
35
  | `PreToolUse` (Bash) | `guardrail-report-format.sh` | **enforce** | a `gh pr comment\|create\|edit` body reads like an E2E report but lacks the `build-pr-section` sentinel | — | **deny** — render via `muggle build-pr-section` instead |
36
- | `Stop` | `guardrail-e2e-gate.sh` | **enforce** | unit tests passed this session and no E2E ran yet | `autoE2ETest` | **block** the turn until E2E runs via `muggle-test` (releases after 3 blocks) |
36
+ | `Stop` | `guardrail-e2e-gate.sh` | **enforce** | unit tests passed this session, no E2E ran yet, and no skip was recorded | `autoE2ETest` | **block** the turn until E2E runs via `muggle-test` or a `MUGGLE_E2E_SKIP` marker records a legitimate skip (full message once, one-line reminders after; releases after 3 blocks) |
37
37
  | `UserPromptSubmit` | `guardrail-build-router.sh` | advise | a build/implement/fix request (first one this session) | `autoRouteBuildToMuggleDo` | route the work through `muggle-do` (build delegated to superpowers) |
38
38
 
39
39
  ## Session-start reconcile nudge
@@ -8,12 +8,13 @@ set -uo pipefail
8
8
  #
9
9
  # Fires after every Bash call and every muggle execute/replay, so a keyword
10
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 {}.
11
+ # hot path. Only a `test` command (npm/pnpm/yarn/jest/vitest/pytest/go/cargo),
12
+ # a muggle execute/replay/test-generation event, or an E2E skip marker reaches
13
+ # guardrails.mjs, which then inspects the output for pass/fail and updates
14
+ # state. Degrades to {}.
14
15
  payload="$(cat)"
15
16
 
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
+ if ! grep -Eiq '(pnpm|npm|yarn)[[:space:]]+(run[[:space:]]+)?test|jest|vitest|pytest|go[[:space:]]+test|cargo[[:space:]]+test|muggle.*(execute|test-generation|replay)|MUGGLE_E2E_SKIP' <<<"$payload"; then
17
18
  printf '{}'
18
19
  exit 0
19
20
  fi
@@ -44,9 +44,13 @@ ${input2.tool_response?.output ?? ""}`;
44
44
  var TEST_CMD = /\b(pnpm|npm|yarn)\s+(run\s+)?test\b|\b(jest|vitest|pytest)\b|\bgo\s+test\b|\bcargo\s+test\b/;
45
45
  var FAIL = /\b\d+\s+failed\b|\bFAIL\b|✗/;
46
46
  var E2E_TOOL = /muggle.*(execute|test-generation|replay)/i;
47
+ var E2E_SKIP_MARKER = /^\s*echo\s+["']?MUGGLE_E2E_SKIP\b/;
47
48
  function isTestCommand(cmd) {
48
49
  return TEST_CMD.test(cmd);
49
50
  }
51
+ function isE2ESkipMarker(cmd) {
52
+ return E2E_SKIP_MARKER.test(cmd);
53
+ }
50
54
  function testsPassed(input2) {
51
55
  const out = `${input2.tool_response?.stdout ?? ""}
52
56
  ${input2.tool_response?.stderr ?? ""}`;
@@ -60,7 +64,7 @@ function isE2ERun(input2) {
60
64
  // src/guardrails/shouldRunE2E.ts
61
65
  var MAX_E2E_BLOCKS = 3;
62
66
  function shouldRunE2E(state) {
63
- return state.unitTestsGreen === true && state.e2eRun !== true;
67
+ return state.unitTestsGreen === true && state.e2eRun !== true && state.e2eSkipped !== true;
64
68
  }
65
69
  function applyRecordedRun(state, run) {
66
70
  let next = state;
@@ -70,6 +74,9 @@ function applyRecordedRun(state, run) {
70
74
  if (run.e2eRan) {
71
75
  next = { ...next, e2eRun: true };
72
76
  }
77
+ if (run.e2eSkipped) {
78
+ next = { ...next, e2eSkipped: true };
79
+ }
73
80
  return next;
74
81
  }
75
82
  function e2eGateDecision(state, maxBlocks = MAX_E2E_BLOCKS) {
@@ -195,7 +202,8 @@ function recordTests() {
195
202
  const state = readState(sessionId);
196
203
  const next = applyRecordedRun(state, {
197
204
  unitTestPassed: isTestCommand(cmd) && testsPassed(input),
198
- e2eRan: isE2ERun(input)
205
+ e2eRan: isE2ERun(input),
206
+ e2eSkipped: isE2ESkipMarker(cmd)
199
207
  });
200
208
  if (next !== state) writeState(next);
201
209
  return "{}";
@@ -206,7 +214,7 @@ function e2eGate() {
206
214
  if (decision.action === "none" /* None */ || decision.action === "release" /* Release */) return "{}";
207
215
  state.e2eBlockCount = decision.blockCount;
208
216
  writeState(state);
209
- const reason = `Do not end the turn yet. Unit tests passed this session but no E2E acceptance run has happened. Per the autoE2ETest preference (default: always), run change-driven E2E now via /muggle:muggle-test, then finish. If E2E genuinely cannot run here (no app, services down, no PR), say so explicitly to the user \u2014 this gate releases after ${MAX_E2E_BLOCKS} attempts.`;
217
+ const reason = decision.blockCount === 1 ? `Do not end the turn yet. Unit tests passed this session but no E2E acceptance run has happened. Per the autoE2ETest preference (default: always), run change-driven E2E now via /muggle:muggle-test, then finish. If E2E genuinely cannot run here (no app to drive, services down, no PR), tell the user why and run \`echo "MUGGLE_E2E_SKIP: <reason>"\` \u2014 that records the skip and keeps this gate quiet for the rest of the session.` : `E2E acceptance run still owed (reminder ${decision.blockCount}/${MAX_E2E_BLOCKS}): run /muggle:muggle-test, or record a legitimate skip via \`echo "MUGGLE_E2E_SKIP: <reason>"\`.`;
210
218
  return blockStop(reason, host);
211
219
  }
212
220
  function reportGate() {
@@ -20,6 +20,8 @@ This stage is **mode-driven by pre-flight**:
20
20
 
21
21
  - `local-e2e` runs the local browser flow (`test-feature-local` approach).
22
22
  - `unit-only` or `skip` does not execute browser runs and must emit an explicit non-pass verdict (`SKIPPED` / `UNIT-ONLY` equivalent in downstream reporting).
23
+
24
+ **Every SKIPPED exit from this stage** (Step 0 poll-only, Step 1 `unit-only`/`skip`, Step 1.5 placeholder branch) also runs `echo "MUGGLE_E2E_SKIP: <one-line reason>"` as a single Bash call before exiting — it records the skip in guardrail session state so the Stop-hook E2E gate releases instead of blocking the turn.
23
25
  - `staging-replay` is not executed in this stage path and should be surfaced as `INCONCLUSIVE` unless the caller has already routed to a dedicated staging runner.
24
26
 
25
27
  For local runs, the tool boundaries are:
@@ -1,26 +1,16 @@
1
1
  # Respawn the watcher
2
2
 
3
- The single, guaranteed watcher restart shared by every watcher-dispatched `/muggle-do` mode — address-reviews, fix-ci, and rebase. The watcher cancels its own cron the moment it dispatches a cycle ([`../muggle-pr-followup/contract.md`](../muggle-pr-followup/contract.md) Steps 4 / 5 / 5b), so for the duration of the cycle **no cron is armed for this slot**. Respawning is what arms the next one. This is a runtime dispatch of the watcher's slash command, not a doc dependency on it — allowed per the one-way rule in [`../CLAUDE.md`](../CLAUDE.md).
3
+ The single, guaranteed watcher restart shared by every watcher-dispatched `/muggle-do` mode — address-reviews, fix-ci, and rebase. Dispatch ends the active watch ([`../muggle-pr-followup/contract.md`](../muggle-pr-followup/contract.md) Steps 4 / 5 / 6), so for the duration of the cycle nothing watches this PR. Respawning is what arms the next watch. This is a runtime dispatch of the watcher's arming, not a doc dependency on its caller — allowed per the one-way rule in [`../CLAUDE.md`](../CLAUDE.md).
4
4
 
5
5
  ## The guarantee
6
6
 
7
- Respawn is the **last action on every exit path that leaves the PR open** — the happy path *and* every escalation, early-exit, or abort. Since the dispatch already cancelled the cron, any open-PR exit that skips respawn leaves the slot with no cron and no next tick: the poller stops silently and stays stopped until a session restart or a reconcile sweep re-arms it. That silent stop is the exact failure this file exists to prevent, so treat "did I respawn on this branch?" as a checklist item on every exit, not only the success case.
8
-
9
- The one exception is a **terminal** PR (merged or closed): a terminal PR needs no watcher, so the terminal branch finalizes instead of respawning.
7
+ Respawn is the **last action on every exit path that leaves the PR open** — the happy path *and* every escalation, early-exit, or abort. Any open-PR exit that skips respawn leaves the slot with nothing watching: the PR sits silent until a session restart or a reconcile sweep re-arms it. That silent stop is the exact failure this file exists to prevent, so treat "did I respawn on this branch?" as a checklist item on every exit, not only the success case.
10
8
 
11
9
  ## Procedure
12
10
 
13
- Run as the final action of the turn:
14
-
15
- 1. **Refresh PR state** — `github` per [`../_shared/vcs/github/pr-metadata.md`](../_shared/vcs/github/pr-metadata.md), `gitlab` per [`../_shared/vcs/gitlab/mr-metadata.md`](../_shared/vcs/gitlab/mr-metadata.md).
16
- 2. **If merged or closed** → write `result.md` per [`../muggle-pr-followup/state-schemas.md`](../muggle-pr-followup/state-schemas.md#resultmd) and **do not respawn**. The PR is terminal; the watcher's job is done.
17
- 3. **Otherwise** → dispatch, verbatim, as the turn's last action:
18
-
19
- ```
20
- /loop 1m /muggle:muggle-pr-followup <slug> <n>
21
- ```
11
+ As the final action of the turn, settle the watch per [`../muggle-pr-followup/arm-watcher.md`](../muggle-pr-followup/arm-watcher.md): its drain tick finalizes a terminal PR (merged or closed — the monitor exits on its own at terminal), dispatches a follow-on cycle if feedback arrived while this one ran, or advances the watermark — arming a fresh monitor only if none is alive.
22
12
 
23
- Exactly one cron results — the dispatch already cancelled the prior one, so this is never a duplicate. The next tick self-records the new cron id ([`../muggle-pr-followup/record-cron-id.md`](../muggle-pr-followup/record-cron-id.md)).
13
+ A cycle is not finished while its per-comment replies are unposted. A blocked reply — e.g. GitHub refuses with 422 while the reviewer's own pending review is open — escalates to the owner and holds this arming until the replies land. Never resume the watch over unposted replies.
24
14
 
25
15
  ## Recovery net
26
16
 
@@ -6,7 +6,8 @@ This folder holds the watcher loop that drives one PR toward merge-ready. The wa
6
6
 
7
7
  - [`SKILL.md`](SKILL.md) — public entry. Routing between bootstrap (URL input), tick (slug + PR number), and auto-track (no args). Read first.
8
8
  - [`auto-track.md`](auto-track.md) — the no-args procedure: discovers PRs pushed this session (any repo) and seeds one poll-only watcher each. Seeds no E2E context — the watcher only watches.
9
- - [`bootstrap.md`](bootstrap.md) — the bootstrap procedure (resolves the validation context once when the PR has a testable surface — else seeds poll-only like auto-track — then dispatches the first watcher).
9
+ - [`bootstrap.md`](bootstrap.md) — the bootstrap procedure (resolves the validation context once when the PR has a testable surface — else seeds poll-only like auto-track — then arms the first watch).
10
+ - [`arm-watcher.md`](arm-watcher.md) — the drain-then-watch arming sequence shared by bootstrap, auto-track, and the executor's respawn: one tick, then a persistent labeled monitor.
10
11
  - [`contract.md`](contract.md) — the watcher per-tick procedure (poll → dispatch → exit; on a human-blocked PR it reminds the owner each tick at the normal `1m` cadence, resuming evaluation on external change).
11
12
  - [`blocked-tick.md`](blocked-tick.md) — the conditional blocked-path detail: fingerprint, flag-and-remind (Step 7), and remind-or-resume (Step 2.5), all at the normal `1m` cadence. Referenced by `contract.md`; runs only while `last_seen.blocked` is set.
12
13
  - [`finalize.md`](finalize.md) — shared termination sequence for a terminal PR (mark terminal, `result.md`, log/telemetry, unschedule cron, post-merge cleanup handoff). Called by `contract.md` and `reconcile.md`.
@@ -14,7 +14,9 @@ A watcher that babysits one open PR toward **merge-ready** — review threads ad
14
14
 
15
15
  **Per-PR isolation.** One watcher per PR. Multi-PR work runs N independent watchers.
16
16
 
17
- **Active reminders when blocked pending a human.** When a PR can't progress without the user — an escalated rebase/CI budget spent, or an ambiguous review awaiting direction — the watcher keeps its `1m` poll and turns each blocked tick into a **one-line reminder**: the pending act plus a reference back to the decision context, nudging the owner every poll until they act ([`contract.md`](contract.md) Steps 2.5, 7). The block reminds but never stops or slows: each tick stays cheap (a fingerprint check, one line out), and the block clears the instant a push, review, or CI/deploy state moves.
17
+ **Arming.** Bootstrap, auto-track, and the executor's post-cycle respawn all arm the watch the same way — one drain tick, then one persistent labeled monitor per PR — visible as a running task for as long as the PR is polled, gone at terminal ([`arm-watcher.md`](arm-watcher.md)). Crons are the recovery substrate only ([`reconcile.md`](reconcile.md)).
18
+
19
+ **One reminder when blocked pending a human.** When a PR can't progress without the user — an escalated rebase/CI budget spent, or an ambiguous review awaiting direction — the tick that flags the block emits **one** one-line reminder: the pending act plus a reference back to the decision context ([`contract.md`](contract.md) Steps 2.5, 7). After that the watch stays visible and silent — no repeat nagging — and the block clears the instant a wake finds a push, review, or CI/deploy state moved.
18
20
 
19
21
  **Cron lifecycle.** Each tick records its `/loop` cron id to `cron.json` while `CronList` can still see it ([`record-cron-id.md`](record-cron-id.md)), so teardown can delete the cron by id after a session continue / compaction blinds `CronList` to it. Reconcile ([`reconcile.md`](reconcile.md)) sweeps crons whose PR is terminal or whose slot is gone, and re-arms an open slot whose watcher stopped silently.
20
22
 
@@ -0,0 +1,12 @@
1
+ # Arming the Watch
2
+
3
+ How an orchestrating session starts the watch on one PR. Every arming point runs this same sequence: [`bootstrap.md`](bootstrap.md) Step 8, [`auto-track.md`](auto-track.md) Step 6, and the executor's post-cycle settle.
4
+
5
+ 1. **Drain.** Run one tick per [`contract.md`](contract.md). It acts on everything already outstanding — actionable threads, body-only reviews past the watermark, 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.
6
+ 2. **Seed the watermark.** Write the slot's watch watermark ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)) from a fetch taken after the drain completes. Never let the loop capture its own baseline — anything landing between drain and launch would be swallowed.
7
+ 3. **Watch.** Write the loop as `watch.sh` in the slot and start it as a **persistent background monitor** in the orchestrating session — the command is the one-line script path, and the label is `PR #<n> — <title>`. Both matter: some task surfaces show the label, others show the command, and a slot-path command keeps the watch identifiable everywhere a raw script blob would not. One monitor per PR, alive from arm to terminal: it is the watch's visible handle, showing as a running task the entire time the PR is polled. Its loop checks about every 60 seconds, re-reading the watermark each iteration; on a newer submitted review, a newer thread comment, or a thread newly unresolved it prints one line and **keeps watching** — advancing its in-memory floor so each event prints exactly once. It exits only when the PR goes terminal or after five consecutive failed fetches. Quiet iterations print nothing and cost nothing — no model tokens are spent while the watch is quiet.
8
+ 4. **On event.** The monitor's line wakes the session: run the tick (step 1), which derives everything from live provider state; a terminal PR finalizes there while the monitor exits on its own. Persistent fetch failure → surface the reason; [`reconcile.md`](reconcile.md) re-arms the slot on its next sweep.
9
+
10
+ **After a cycle** — advance the watermark file from a fetch taken after the replies land; that write is what stops the loop from reporting the loop's own reply. A cycle that skips the advance leaves the watermark stale, and the next event will be an echo. Arm a fresh monitor only when none is alive (session restart, killed task); the settle path never duplicates a running watch.
11
+
12
+ Drain-then-watch is the invariant: the tick handles everything up to the watermark with the real semantics — marker rule, watermark, attempt budgets — that the watch deliberately lacks, so the monitor only needs to see what arrives after it.
@@ -67,22 +67,16 @@ Already tracking (skipped):
67
67
  ...
68
68
  (or "none")
69
69
 
70
- Dispatching:
71
- /loop 1m /muggle:muggle-pr-followup <slug> <n>
70
+ Arming:
71
+ <owner>/<repo>#<n>
72
72
  ...
73
73
  ```
74
74
 
75
- Print the summary **before** the `/loop` dispatches so it stays visible.
75
+ Print the summary **before** arming the watchers so it stays visible.
76
76
 
77
- ### Step 6 — Dispatch the watchers
77
+ ### Step 6 — Arm the watchers
78
78
 
79
- As the last action of the turn, emit one `/loop` line per **newly tracked** PR (not the skipped ones):
80
-
81
- ```
82
- /loop 1m /muggle:muggle-pr-followup <slug> <n>
83
- ```
84
-
85
- Each registers an independent cron — the N-independent-watchers model from [`SKILL.md`](SKILL.md).
79
+ Arm one watch per **newly tracked** PR (not the skipped ones), per [`arm-watcher.md`](arm-watcher.md). Each watch is independent — one drain tick and one labeled monitor per PR.
86
80
 
87
81
  ### Step 7 — Emit telemetry
88
82
 
@@ -1,8 +1,8 @@
1
1
  # Blocked-tick procedure
2
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.
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 on each subsequent tick by Step 2.5 (resume gate). None of this runs on a normal tick: when `last_seen.blocked` is absent, the watcher skips straight through.
4
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.
5
+ **Governing rule — one reminder per block, and the watch never stops.** The tick that flags the block reminds the owner once; every later blocked tick is silent. The watch keeps standing — the monitor stays visible, and ticks still run at their normal cadence (historically `1m`) whenever a wake or recovery cron fires — with fingerprint-based auto-resume clearing the block the moment external state moves. No repeat nagging, no cadence swap, no separate `reminded` flag: `blocked` present means the reminder has already been sent.
6
6
 
7
7
  ## The fingerprint
8
8
 
@@ -20,19 +20,19 @@ When an idle tick is a durable human-block and `last_seen.blocked` is not alread
20
20
 
21
21
  1. Increment `last_seen.idle_tick_count`.
22
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.
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. This is the block's **only** reminder.
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.
25
25
 
26
26
  ## Remind or resume (the Step 2.5 gate)
27
27
 
28
28
  Every subsequent tick while `last_seen.blocked` is present: recompute the fingerprint and compare to `last_seen.blocked.fingerprint`.
29
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.
30
+ - **Unchanged** → still blocked. Stay **silent** — the reminder went out when the block was flagged. 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.
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: a dispatch hands the PR to the cycle (its exit settles the watch); a transient idle changes nothing; idling back into a block re-flags per Step 7 — a new block, which sends its own single reminder.
32
32
 
33
33
  ## Invariants
34
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.
35
+ - One reminder per block — sent when flagged, never repeated while the same block holds. A re-flag after a resume is a new block and sends its own single reminder.
36
+ - The watch never stops — a blocked PR stays visibly watched at the normal `1m` cadence; only a terminal PR or an explicit teardown ends it.
37
+ - The block clears the instant any fingerprint component moves, caught at the next wake or tick.
38
+ - The blocked path never posts to the PR.
@@ -73,7 +73,7 @@ Write under `~/.muggle-ai/muggle-do/sessions/<slug>/`:
73
73
 
74
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
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>`.
76
+ **`cron.json`** — see [`state-schemas.md`](state-schemas.md#cronjson). `cron_id: null` (bootstrap arms no cron; a tick running under one recorded by [`reconcile.md`](reconcile.md) self-records its id per [`record-cron-id.md`](record-cron-id.md)), `command: "/muggle:muggle-pr-followup <slug> <n>"`, `interval: "1m"`, `recorded_at: <now>`.
77
77
 
78
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.
79
79
 
@@ -81,17 +81,13 @@ Do **not** write `cycle.json` or `requirements.md` — those files are no longer
81
81
 
82
82
  Create `iterations/` subdir (empty) for future caller use.
83
83
 
84
- ### Step 8 — Dispatch the first watcher
84
+ ### Step 8 — Arm the watch
85
85
 
86
- The last action of this turn:
87
-
88
- ```
89
- /loop 1m /muggle:muggle-pr-followup <slug> <n>
90
- ```
86
+ Arm per [`arm-watcher.md`](arm-watcher.md) as the last action of the turn: one tick drains anything already actionable — this is the first tick Step 6 promises — then a persistent, labeled monitor keeps watch — visible until the PR terminates. The cron path stays as the recovery substrate ([`reconcile.md`](reconcile.md)), so `cron.json` is still seeded in Step 7.
91
87
 
92
88
  ### Step 9 — Print the success summary
93
89
 
94
- Use the success-summary template from [`output-templates/bootstrap.md`](output-templates/bootstrap.md). Print it **before** the `/loop` dispatch so it's visible.
90
+ Use the success-summary template from [`output-templates/bootstrap.md`](output-templates/bootstrap.md). Print it **before** arming the watch so it's visible.
95
91
 
96
92
  ### Step 10 — Emit telemetry
97
93
 
@@ -33,6 +33,8 @@ Every `increment`/`reset` this procedure applies to `last_seen.json`, and the `p
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
+ **Orphan escalation — on the third or later stale fire.** Count prior `stale-tick` lines for this slot in `followup.log` before appending this one. Three or more means both `cancel-cron.md` lookups have already run on earlier stale fires and the cron still fires — it is orphaned in a session runtime neither the recorded id nor `CronList` can reach, so it will keep firing until the 7-day expiry. Escalate to the owner **once**: if `followup.log` has no `stale-orphan-escalated` line, print one line — `orphaned cron: <slug> keeps firing after finalize; no in-session cancel can reach it — restarting the Claude session is the only clean clear` — and append a `stale-orphan-escalated` line so every later stale fire absorbs silently again. Never respond to an unreachable cron by guess-deleting ids `CronList` does surface: those belong to other live watchers, and deleting one silently kills a wanted loop.
37
+
36
38
  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
39
 
38
40
  ### Step 1 — Refresh PR state
@@ -58,7 +60,7 @@ If `state` is `MERGED` or `CLOSED`:
58
60
 
59
61
  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
62
 
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.
63
+ Run the resume gate per [`blocked-tick.md`](blocked-tick.md): recompute the fingerprint; while it holds, stay blocked **silently** — the single owner reminder went out when the block was flagged — and clear the block and fall through to Step 3 the moment it moves. The block never changes the poll; it only mutes dispatch until the state the user must act on moves.
62
64
 
63
65
  ### Step 3 — Compute the actionable set from live thread state
64
66
 
@@ -147,9 +149,9 @@ Any idle branch (Steps 4–6 that did not dispatch). First classify **why** this
147
149
 
148
150
  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.
149
151
 
150
- **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`.
152
+ **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 from the arming loop ([`arm-watcher.md`](arm-watcher.md)) — or, under a recovery cron, in 1 min via `/loop`.
151
153
 
152
- **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.
154
+ **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, **once per block**. From the next tick on, the Step 2.5 gate carries the block silently. Exit.
153
155
 
154
156
  ## Output
155
157
 
@@ -84,6 +84,18 @@
84
84
  { "name": "does_not_refetch_or_refinalize", "text": "Plan does NOT re-fetch the PR, re-write result.md, or re-run the terminal handoff." },
85
85
  { "name": "defensively_cancels_and_logs", "text": "Plan defensively cancels any lingering cron for the slug and appends a stale-tick line to followup.log, then exits." }
86
86
  ]
87
+ },
88
+ {
89
+ "id": 7,
90
+ "eval_name": "repeated-stale-fires-escalate-orphan-once",
91
+ "prompt": "Tick muggle-ai-works-pr507 507. prs.json records state merged and result.md exists. followup.log already holds four stale-tick lines for this slot and no stale-orphan-escalated line. Walk me through the plan for this tick.",
92
+ "files": [],
93
+ "assertions": [
94
+ { "name": "detects_orphaned_cron", "text": "Plan concludes the cron is orphaned — repeated stale fires after finalize mean both cancel-cron lookups (recorded id, CronList match) keep missing it — rather than treating this as a normal queued-fire drain." },
95
+ { "name": "escalates_to_owner_once", "text": "Plan surfaces a one-line owner notice that only a session restart clears the orphan, and appends a stale-orphan-escalated marker line so the notice never repeats." },
96
+ { "name": "later_fires_absorb_silently", "text": "Plan treats any stale fire after the marker exists as a silent absorb — log stale-tick and exit, no repeated notice." },
97
+ { "name": "never_guess_deletes_other_ids", "text": "Plan does NOT delete cron ids that CronList surfaces for other slugs — those belong to other live watchers." }
98
+ ]
87
99
  }
88
100
  ]
89
101
  }
@@ -1,13 +1,13 @@
1
1
  # Bootstrap output templates
2
2
 
3
- ## Success summary (printed just before dispatch)
3
+ ## Success summary (printed just before arming)
4
4
 
5
5
  ```
6
6
  Bootstrapped PR follow-up for <owner>/<repo>#<n>
7
7
  Slug: <slug>
8
8
  Baseline: thread-state (unresolved threads picked up live); lastBodyReviewId=0 | =<id> (forward-only)
9
9
  Working tree: <toplevel>
10
- Dispatching: /loop 1m /muggle:muggle-pr-followup <slug> <n>
10
+ Arming: <owner>/<repo>#<n>
11
11
  ```
12
12
 
13
13
  ## Aborts
@@ -163,3 +163,13 @@ Written exactly once when the PR's watcher exits terminally (PR merged or closed
163
163
  ## Not in the slot
164
164
 
165
165
  `cycle.json` and `requirements.md` are not seeded or read. `/muggle-do` reads reviews off GitHub each invocation.
166
+
167
+ ## `watch-watermark.env`
168
+
169
+ The watch loop's comparison floor — plain `KEY=VALUE` lines, one file per slot:
170
+
171
+ - `REV` — highest submitted-review id already handled
172
+ - `COM` — highest thread-comment id already handled
173
+ - `THREADS` — semicolon-joined ids of threads already known unresolved
174
+
175
+ Written whole-file by the orchestrating session — seeded at arm time from a post-drain fetch, advanced after every cycle from a post-replies fetch. Read by the watch loop each iteration; the loop never writes it. A stale watermark makes the next reported event the loop's own reply ([`arm-watcher.md`](arm-watcher.md)).
@@ -1,7 +1,7 @@
1
1
  {
2
- "release": "5.5.1",
3
- "buildId": "run-64-1",
4
- "commitSha": "60e0213974d01fae642c7b480589d2e6b512f2c7",
5
- "buildTime": "2026-07-20T23:29:20Z",
2
+ "release": "5.6.0",
3
+ "buildId": "run-65-1",
4
+ "commitSha": "5d15cf7c334a1714257f10f801ed41b9ec9e796b",
5
+ "buildTime": "2026-07-22T23:54:34Z",
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.5.1",
4
+ "version": "5.6.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",
@@ -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.5.1",
4
+ "version": "5.6.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.5.1",
5
+ "version": "5.6.0",
6
6
  "author": {
7
7
  "name": "Muggle AI",
8
8
  "email": "support@muggle-ai.com"
@@ -20,7 +20,7 @@ A guardrail emits one of two strengths:
20
20
  - **Advise** — `additionalContext` (PostToolUse/UserPromptSubmit) or a plain Stop message. A soft nudge the model can ignore.
21
21
  - **Enforce** — a `Stop` `decision: "block"` that refuses to end the turn, or a `PreToolUse` `permissionDecision: "deny"` that refuses a tool call. The model cannot proceed until the condition is met.
22
22
 
23
- Enforcement is reserved for the handoffs that were being skipped: the E2E acceptance run and posting a deterministically-rendered report. Each enforcing gate carries an escape so it can't trap a turn — the E2E gate releases to advisory after `MAX_E2E_BLOCKS` (3) blocks; the report gate only denies a body it can positively see is a hand-written report and fails open otherwise.
23
+ Enforcement is reserved for the handoffs that were being skipped: the E2E acceptance run and posting a deterministically-rendered report. Each enforcing gate carries an escape so it can't trap a turn — the E2E gate accepts an explicit skip declaration (`echo "MUGGLE_E2E_SKIP: <reason>"`, session-durable) and hard-releases after `MAX_E2E_BLOCKS` (3) blocks; the report gate only denies a body it can positively see is a hand-written report and fails open otherwise.
24
24
 
25
25
  ## Mechanism
26
26
 
@@ -31,9 +31,9 @@ Each guardrail is a thin bash wrapper in `../scripts/` registered in `hooks.json
31
31
  | Hook event | Wrapper | Strength | Condition | Preference | Effect |
32
32
  | :--------- | :------ | :------- | :-------- | :--------- | :----- |
33
33
  | `PostToolUse` (Bash) | `guardrail-pr-opened.sh` | advise | a `gh pr create`/`gh pr ready` just succeeded | `autoWatchPR` | start a `muggle-pr-followup` watcher on the new PR |
34
- | `PostToolUse` (Bash + muggle execute/replay MCP tools) | `guardrail-record-tests.sh` | record | a unit-test command passed, or an E2E run happened | — | set `unitTestsGreen` / `e2eRun` session state |
34
+ | `PostToolUse` (Bash + muggle execute/replay MCP tools) | `guardrail-record-tests.sh` | record | a unit-test command passed, an E2E run happened, or an `echo "MUGGLE_E2E_SKIP: <reason>"` marker declared E2E un-runnable | — | set `unitTestsGreen` / `e2eRun` / `e2eSkipped` session state |
35
35
  | `PreToolUse` (Bash) | `guardrail-report-format.sh` | **enforce** | a `gh pr comment\|create\|edit` body reads like an E2E report but lacks the `build-pr-section` sentinel | — | **deny** — render via `muggle build-pr-section` instead |
36
- | `Stop` | `guardrail-e2e-gate.sh` | **enforce** | unit tests passed this session and no E2E ran yet | `autoE2ETest` | **block** the turn until E2E runs via `muggle-test` (releases after 3 blocks) |
36
+ | `Stop` | `guardrail-e2e-gate.sh` | **enforce** | unit tests passed this session, no E2E ran yet, and no skip was recorded | `autoE2ETest` | **block** the turn until E2E runs via `muggle-test` or a `MUGGLE_E2E_SKIP` marker records a legitimate skip (full message once, one-line reminders after; releases after 3 blocks) |
37
37
  | `UserPromptSubmit` | `guardrail-build-router.sh` | advise | a build/implement/fix request (first one this session) | `autoRouteBuildToMuggleDo` | route the work through `muggle-do` (build delegated to superpowers) |
38
38
 
39
39
  ## Session-start reconcile nudge
@@ -8,12 +8,13 @@ set -uo pipefail
8
8
  #
9
9
  # Fires after every Bash call and every muggle execute/replay, so a keyword
10
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 {}.
11
+ # hot path. Only a `test` command (npm/pnpm/yarn/jest/vitest/pytest/go/cargo),
12
+ # a muggle execute/replay/test-generation event, or an E2E skip marker reaches
13
+ # guardrails.mjs, which then inspects the output for pass/fail and updates
14
+ # state. Degrades to {}.
14
15
  payload="$(cat)"
15
16
 
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
+ if ! grep -Eiq '(pnpm|npm|yarn)[[:space:]]+(run[[:space:]]+)?test|jest|vitest|pytest|go[[:space:]]+test|cargo[[:space:]]+test|muggle.*(execute|test-generation|replay)|MUGGLE_E2E_SKIP' <<<"$payload"; then
17
18
  printf '{}'
18
19
  exit 0
19
20
  fi
@@ -44,9 +44,13 @@ ${input2.tool_response?.output ?? ""}`;
44
44
  var TEST_CMD = /\b(pnpm|npm|yarn)\s+(run\s+)?test\b|\b(jest|vitest|pytest)\b|\bgo\s+test\b|\bcargo\s+test\b/;
45
45
  var FAIL = /\b\d+\s+failed\b|\bFAIL\b|✗/;
46
46
  var E2E_TOOL = /muggle.*(execute|test-generation|replay)/i;
47
+ var E2E_SKIP_MARKER = /^\s*echo\s+["']?MUGGLE_E2E_SKIP\b/;
47
48
  function isTestCommand(cmd) {
48
49
  return TEST_CMD.test(cmd);
49
50
  }
51
+ function isE2ESkipMarker(cmd) {
52
+ return E2E_SKIP_MARKER.test(cmd);
53
+ }
50
54
  function testsPassed(input2) {
51
55
  const out = `${input2.tool_response?.stdout ?? ""}
52
56
  ${input2.tool_response?.stderr ?? ""}`;
@@ -60,7 +64,7 @@ function isE2ERun(input2) {
60
64
  // src/guardrails/shouldRunE2E.ts
61
65
  var MAX_E2E_BLOCKS = 3;
62
66
  function shouldRunE2E(state) {
63
- return state.unitTestsGreen === true && state.e2eRun !== true;
67
+ return state.unitTestsGreen === true && state.e2eRun !== true && state.e2eSkipped !== true;
64
68
  }
65
69
  function applyRecordedRun(state, run) {
66
70
  let next = state;
@@ -70,6 +74,9 @@ function applyRecordedRun(state, run) {
70
74
  if (run.e2eRan) {
71
75
  next = { ...next, e2eRun: true };
72
76
  }
77
+ if (run.e2eSkipped) {
78
+ next = { ...next, e2eSkipped: true };
79
+ }
73
80
  return next;
74
81
  }
75
82
  function e2eGateDecision(state, maxBlocks = MAX_E2E_BLOCKS) {
@@ -195,7 +202,8 @@ function recordTests() {
195
202
  const state = readState(sessionId);
196
203
  const next = applyRecordedRun(state, {
197
204
  unitTestPassed: isTestCommand(cmd) && testsPassed(input),
198
- e2eRan: isE2ERun(input)
205
+ e2eRan: isE2ERun(input),
206
+ e2eSkipped: isE2ESkipMarker(cmd)
199
207
  });
200
208
  if (next !== state) writeState(next);
201
209
  return "{}";
@@ -206,7 +214,7 @@ function e2eGate() {
206
214
  if (decision.action === "none" /* None */ || decision.action === "release" /* Release */) return "{}";
207
215
  state.e2eBlockCount = decision.blockCount;
208
216
  writeState(state);
209
- const reason = `Do not end the turn yet. Unit tests passed this session but no E2E acceptance run has happened. Per the autoE2ETest preference (default: always), run change-driven E2E now via /muggle:muggle-test, then finish. If E2E genuinely cannot run here (no app, services down, no PR), say so explicitly to the user \u2014 this gate releases after ${MAX_E2E_BLOCKS} attempts.`;
217
+ const reason = decision.blockCount === 1 ? `Do not end the turn yet. Unit tests passed this session but no E2E acceptance run has happened. Per the autoE2ETest preference (default: always), run change-driven E2E now via /muggle:muggle-test, then finish. If E2E genuinely cannot run here (no app to drive, services down, no PR), tell the user why and run \`echo "MUGGLE_E2E_SKIP: <reason>"\` \u2014 that records the skip and keeps this gate quiet for the rest of the session.` : `E2E acceptance run still owed (reminder ${decision.blockCount}/${MAX_E2E_BLOCKS}): run /muggle:muggle-test, or record a legitimate skip via \`echo "MUGGLE_E2E_SKIP: <reason>"\`.`;
210
218
  return blockStop(reason, host);
211
219
  }
212
220
  function reportGate() {
@@ -20,6 +20,8 @@ This stage is **mode-driven by pre-flight**:
20
20
 
21
21
  - `local-e2e` runs the local browser flow (`test-feature-local` approach).
22
22
  - `unit-only` or `skip` does not execute browser runs and must emit an explicit non-pass verdict (`SKIPPED` / `UNIT-ONLY` equivalent in downstream reporting).
23
+
24
+ **Every SKIPPED exit from this stage** (Step 0 poll-only, Step 1 `unit-only`/`skip`, Step 1.5 placeholder branch) also runs `echo "MUGGLE_E2E_SKIP: <one-line reason>"` as a single Bash call before exiting — it records the skip in guardrail session state so the Stop-hook E2E gate releases instead of blocking the turn.
23
25
  - `staging-replay` is not executed in this stage path and should be surfaced as `INCONCLUSIVE` unless the caller has already routed to a dedicated staging runner.
24
26
 
25
27
  For local runs, the tool boundaries are:
@@ -1,26 +1,16 @@
1
1
  # Respawn the watcher
2
2
 
3
- The single, guaranteed watcher restart shared by every watcher-dispatched `/muggle-do` mode — address-reviews, fix-ci, and rebase. The watcher cancels its own cron the moment it dispatches a cycle ([`../muggle-pr-followup/contract.md`](../muggle-pr-followup/contract.md) Steps 4 / 5 / 5b), so for the duration of the cycle **no cron is armed for this slot**. Respawning is what arms the next one. This is a runtime dispatch of the watcher's slash command, not a doc dependency on it — allowed per the one-way rule in [`../CLAUDE.md`](../CLAUDE.md).
3
+ The single, guaranteed watcher restart shared by every watcher-dispatched `/muggle-do` mode — address-reviews, fix-ci, and rebase. Dispatch ends the active watch ([`../muggle-pr-followup/contract.md`](../muggle-pr-followup/contract.md) Steps 4 / 5 / 6), so for the duration of the cycle nothing watches this PR. Respawning is what arms the next watch. This is a runtime dispatch of the watcher's arming, not a doc dependency on its caller — allowed per the one-way rule in [`../CLAUDE.md`](../CLAUDE.md).
4
4
 
5
5
  ## The guarantee
6
6
 
7
- Respawn is the **last action on every exit path that leaves the PR open** — the happy path *and* every escalation, early-exit, or abort. Since the dispatch already cancelled the cron, any open-PR exit that skips respawn leaves the slot with no cron and no next tick: the poller stops silently and stays stopped until a session restart or a reconcile sweep re-arms it. That silent stop is the exact failure this file exists to prevent, so treat "did I respawn on this branch?" as a checklist item on every exit, not only the success case.
8
-
9
- The one exception is a **terminal** PR (merged or closed): a terminal PR needs no watcher, so the terminal branch finalizes instead of respawning.
7
+ Respawn is the **last action on every exit path that leaves the PR open** — the happy path *and* every escalation, early-exit, or abort. Any open-PR exit that skips respawn leaves the slot with nothing watching: the PR sits silent until a session restart or a reconcile sweep re-arms it. That silent stop is the exact failure this file exists to prevent, so treat "did I respawn on this branch?" as a checklist item on every exit, not only the success case.
10
8
 
11
9
  ## Procedure
12
10
 
13
- Run as the final action of the turn:
14
-
15
- 1. **Refresh PR state** — `github` per [`../_shared/vcs/github/pr-metadata.md`](../_shared/vcs/github/pr-metadata.md), `gitlab` per [`../_shared/vcs/gitlab/mr-metadata.md`](../_shared/vcs/gitlab/mr-metadata.md).
16
- 2. **If merged or closed** → write `result.md` per [`../muggle-pr-followup/state-schemas.md`](../muggle-pr-followup/state-schemas.md#resultmd) and **do not respawn**. The PR is terminal; the watcher's job is done.
17
- 3. **Otherwise** → dispatch, verbatim, as the turn's last action:
18
-
19
- ```
20
- /loop 1m /muggle:muggle-pr-followup <slug> <n>
21
- ```
11
+ As the final action of the turn, settle the watch per [`../muggle-pr-followup/arm-watcher.md`](../muggle-pr-followup/arm-watcher.md): its drain tick finalizes a terminal PR (merged or closed — the monitor exits on its own at terminal), dispatches a follow-on cycle if feedback arrived while this one ran, or advances the watermark — arming a fresh monitor only if none is alive.
22
12
 
23
- Exactly one cron results — the dispatch already cancelled the prior one, so this is never a duplicate. The next tick self-records the new cron id ([`../muggle-pr-followup/record-cron-id.md`](../muggle-pr-followup/record-cron-id.md)).
13
+ A cycle is not finished while its per-comment replies are unposted. A blocked reply — e.g. GitHub refuses with 422 while the reviewer's own pending review is open — escalates to the owner and holds this arming until the replies land. Never resume the watch over unposted replies.
24
14
 
25
15
  ## Recovery net
26
16
 
@@ -6,7 +6,8 @@ This folder holds the watcher loop that drives one PR toward merge-ready. The wa
6
6
 
7
7
  - [`SKILL.md`](SKILL.md) — public entry. Routing between bootstrap (URL input), tick (slug + PR number), and auto-track (no args). Read first.
8
8
  - [`auto-track.md`](auto-track.md) — the no-args procedure: discovers PRs pushed this session (any repo) and seeds one poll-only watcher each. Seeds no E2E context — the watcher only watches.
9
- - [`bootstrap.md`](bootstrap.md) — the bootstrap procedure (resolves the validation context once when the PR has a testable surface — else seeds poll-only like auto-track — then dispatches the first watcher).
9
+ - [`bootstrap.md`](bootstrap.md) — the bootstrap procedure (resolves the validation context once when the PR has a testable surface — else seeds poll-only like auto-track — then arms the first watch).
10
+ - [`arm-watcher.md`](arm-watcher.md) — the drain-then-watch arming sequence shared by bootstrap, auto-track, and the executor's respawn: one tick, then a persistent labeled monitor.
10
11
  - [`contract.md`](contract.md) — the watcher per-tick procedure (poll → dispatch → exit; on a human-blocked PR it reminds the owner each tick at the normal `1m` cadence, resuming evaluation on external change).
11
12
  - [`blocked-tick.md`](blocked-tick.md) — the conditional blocked-path detail: fingerprint, flag-and-remind (Step 7), and remind-or-resume (Step 2.5), all at the normal `1m` cadence. Referenced by `contract.md`; runs only while `last_seen.blocked` is set.
12
13
  - [`finalize.md`](finalize.md) — shared termination sequence for a terminal PR (mark terminal, `result.md`, log/telemetry, unschedule cron, post-merge cleanup handoff). Called by `contract.md` and `reconcile.md`.
@@ -14,7 +14,9 @@ A watcher that babysits one open PR toward **merge-ready** — review threads ad
14
14
 
15
15
  **Per-PR isolation.** One watcher per PR. Multi-PR work runs N independent watchers.
16
16
 
17
- **Active reminders when blocked pending a human.** When a PR can't progress without the user — an escalated rebase/CI budget spent, or an ambiguous review awaiting direction — the watcher keeps its `1m` poll and turns each blocked tick into a **one-line reminder**: the pending act plus a reference back to the decision context, nudging the owner every poll until they act ([`contract.md`](contract.md) Steps 2.5, 7). The block reminds but never stops or slows: each tick stays cheap (a fingerprint check, one line out), and the block clears the instant a push, review, or CI/deploy state moves.
17
+ **Arming.** Bootstrap, auto-track, and the executor's post-cycle respawn all arm the watch the same way — one drain tick, then one persistent labeled monitor per PR — visible as a running task for as long as the PR is polled, gone at terminal ([`arm-watcher.md`](arm-watcher.md)). Crons are the recovery substrate only ([`reconcile.md`](reconcile.md)).
18
+
19
+ **One reminder when blocked pending a human.** When a PR can't progress without the user — an escalated rebase/CI budget spent, or an ambiguous review awaiting direction — the tick that flags the block emits **one** one-line reminder: the pending act plus a reference back to the decision context ([`contract.md`](contract.md) Steps 2.5, 7). After that the watch stays visible and silent — no repeat nagging — and the block clears the instant a wake finds a push, review, or CI/deploy state moved.
18
20
 
19
21
  **Cron lifecycle.** Each tick records its `/loop` cron id to `cron.json` while `CronList` can still see it ([`record-cron-id.md`](record-cron-id.md)), so teardown can delete the cron by id after a session continue / compaction blinds `CronList` to it. Reconcile ([`reconcile.md`](reconcile.md)) sweeps crons whose PR is terminal or whose slot is gone, and re-arms an open slot whose watcher stopped silently.
20
22
 
@@ -0,0 +1,12 @@
1
+ # Arming the Watch
2
+
3
+ How an orchestrating session starts the watch on one PR. Every arming point runs this same sequence: [`bootstrap.md`](bootstrap.md) Step 8, [`auto-track.md`](auto-track.md) Step 6, and the executor's post-cycle settle.
4
+
5
+ 1. **Drain.** Run one tick per [`contract.md`](contract.md). It acts on everything already outstanding — actionable threads, body-only reviews past the watermark, 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.
6
+ 2. **Seed the watermark.** Write the slot's watch watermark ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)) from a fetch taken after the drain completes. Never let the loop capture its own baseline — anything landing between drain and launch would be swallowed.
7
+ 3. **Watch.** Write the loop as `watch.sh` in the slot and start it as a **persistent background monitor** in the orchestrating session — the command is the one-line script path, and the label is `PR #<n> — <title>`. Both matter: some task surfaces show the label, others show the command, and a slot-path command keeps the watch identifiable everywhere a raw script blob would not. One monitor per PR, alive from arm to terminal: it is the watch's visible handle, showing as a running task the entire time the PR is polled. Its loop checks about every 60 seconds, re-reading the watermark each iteration; on a newer submitted review, a newer thread comment, or a thread newly unresolved it prints one line and **keeps watching** — advancing its in-memory floor so each event prints exactly once. It exits only when the PR goes terminal or after five consecutive failed fetches. Quiet iterations print nothing and cost nothing — no model tokens are spent while the watch is quiet.
8
+ 4. **On event.** The monitor's line wakes the session: run the tick (step 1), which derives everything from live provider state; a terminal PR finalizes there while the monitor exits on its own. Persistent fetch failure → surface the reason; [`reconcile.md`](reconcile.md) re-arms the slot on its next sweep.
9
+
10
+ **After a cycle** — advance the watermark file from a fetch taken after the replies land; that write is what stops the loop from reporting the loop's own reply. A cycle that skips the advance leaves the watermark stale, and the next event will be an echo. Arm a fresh monitor only when none is alive (session restart, killed task); the settle path never duplicates a running watch.
11
+
12
+ Drain-then-watch is the invariant: the tick handles everything up to the watermark with the real semantics — marker rule, watermark, attempt budgets — that the watch deliberately lacks, so the monitor only needs to see what arrives after it.
@@ -67,22 +67,16 @@ Already tracking (skipped):
67
67
  ...
68
68
  (or "none")
69
69
 
70
- Dispatching:
71
- /loop 1m /muggle:muggle-pr-followup <slug> <n>
70
+ Arming:
71
+ <owner>/<repo>#<n>
72
72
  ...
73
73
  ```
74
74
 
75
- Print the summary **before** the `/loop` dispatches so it stays visible.
75
+ Print the summary **before** arming the watchers so it stays visible.
76
76
 
77
- ### Step 6 — Dispatch the watchers
77
+ ### Step 6 — Arm the watchers
78
78
 
79
- As the last action of the turn, emit one `/loop` line per **newly tracked** PR (not the skipped ones):
80
-
81
- ```
82
- /loop 1m /muggle:muggle-pr-followup <slug> <n>
83
- ```
84
-
85
- Each registers an independent cron — the N-independent-watchers model from [`SKILL.md`](SKILL.md).
79
+ Arm one watch per **newly tracked** PR (not the skipped ones), per [`arm-watcher.md`](arm-watcher.md). Each watch is independent — one drain tick and one labeled monitor per PR.
86
80
 
87
81
  ### Step 7 — Emit telemetry
88
82
 
@@ -1,8 +1,8 @@
1
1
  # Blocked-tick procedure
2
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.
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 on each subsequent tick by Step 2.5 (resume gate). None of this runs on a normal tick: when `last_seen.blocked` is absent, the watcher skips straight through.
4
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.
5
+ **Governing rule — one reminder per block, and the watch never stops.** The tick that flags the block reminds the owner once; every later blocked tick is silent. The watch keeps standing — the monitor stays visible, and ticks still run at their normal cadence (historically `1m`) whenever a wake or recovery cron fires — with fingerprint-based auto-resume clearing the block the moment external state moves. No repeat nagging, no cadence swap, no separate `reminded` flag: `blocked` present means the reminder has already been sent.
6
6
 
7
7
  ## The fingerprint
8
8
 
@@ -20,19 +20,19 @@ When an idle tick is a durable human-block and `last_seen.blocked` is not alread
20
20
 
21
21
  1. Increment `last_seen.idle_tick_count`.
22
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.
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. This is the block's **only** reminder.
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.
25
25
 
26
26
  ## Remind or resume (the Step 2.5 gate)
27
27
 
28
28
  Every subsequent tick while `last_seen.blocked` is present: recompute the fingerprint and compare to `last_seen.blocked.fingerprint`.
29
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.
30
+ - **Unchanged** → still blocked. Stay **silent** — the reminder went out when the block was flagged. 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.
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: a dispatch hands the PR to the cycle (its exit settles the watch); a transient idle changes nothing; idling back into a block re-flags per Step 7 — a new block, which sends its own single reminder.
32
32
 
33
33
  ## Invariants
34
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.
35
+ - One reminder per block — sent when flagged, never repeated while the same block holds. A re-flag after a resume is a new block and sends its own single reminder.
36
+ - The watch never stops — a blocked PR stays visibly watched at the normal `1m` cadence; only a terminal PR or an explicit teardown ends it.
37
+ - The block clears the instant any fingerprint component moves, caught at the next wake or tick.
38
+ - The blocked path never posts to the PR.
@@ -73,7 +73,7 @@ Write under `~/.muggle-ai/muggle-do/sessions/<slug>/`:
73
73
 
74
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
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>`.
76
+ **`cron.json`** — see [`state-schemas.md`](state-schemas.md#cronjson). `cron_id: null` (bootstrap arms no cron; a tick running under one recorded by [`reconcile.md`](reconcile.md) self-records its id per [`record-cron-id.md`](record-cron-id.md)), `command: "/muggle:muggle-pr-followup <slug> <n>"`, `interval: "1m"`, `recorded_at: <now>`.
77
77
 
78
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.
79
79
 
@@ -81,17 +81,13 @@ Do **not** write `cycle.json` or `requirements.md` — those files are no longer
81
81
 
82
82
  Create `iterations/` subdir (empty) for future caller use.
83
83
 
84
- ### Step 8 — Dispatch the first watcher
84
+ ### Step 8 — Arm the watch
85
85
 
86
- The last action of this turn:
87
-
88
- ```
89
- /loop 1m /muggle:muggle-pr-followup <slug> <n>
90
- ```
86
+ Arm per [`arm-watcher.md`](arm-watcher.md) as the last action of the turn: one tick drains anything already actionable — this is the first tick Step 6 promises — then a persistent, labeled monitor keeps watch — visible until the PR terminates. The cron path stays as the recovery substrate ([`reconcile.md`](reconcile.md)), so `cron.json` is still seeded in Step 7.
91
87
 
92
88
  ### Step 9 — Print the success summary
93
89
 
94
- Use the success-summary template from [`output-templates/bootstrap.md`](output-templates/bootstrap.md). Print it **before** the `/loop` dispatch so it's visible.
90
+ Use the success-summary template from [`output-templates/bootstrap.md`](output-templates/bootstrap.md). Print it **before** arming the watch so it's visible.
95
91
 
96
92
  ### Step 10 — Emit telemetry
97
93
 
@@ -33,6 +33,8 @@ Every `increment`/`reset` this procedure applies to `last_seen.json`, and the `p
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
+ **Orphan escalation — on the third or later stale fire.** Count prior `stale-tick` lines for this slot in `followup.log` before appending this one. Three or more means both `cancel-cron.md` lookups have already run on earlier stale fires and the cron still fires — it is orphaned in a session runtime neither the recorded id nor `CronList` can reach, so it will keep firing until the 7-day expiry. Escalate to the owner **once**: if `followup.log` has no `stale-orphan-escalated` line, print one line — `orphaned cron: <slug> keeps firing after finalize; no in-session cancel can reach it — restarting the Claude session is the only clean clear` — and append a `stale-orphan-escalated` line so every later stale fire absorbs silently again. Never respond to an unreachable cron by guess-deleting ids `CronList` does surface: those belong to other live watchers, and deleting one silently kills a wanted loop.
37
+
36
38
  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
39
 
38
40
  ### Step 1 — Refresh PR state
@@ -58,7 +60,7 @@ If `state` is `MERGED` or `CLOSED`:
58
60
 
59
61
  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
62
 
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.
63
+ Run the resume gate per [`blocked-tick.md`](blocked-tick.md): recompute the fingerprint; while it holds, stay blocked **silently** — the single owner reminder went out when the block was flagged — and clear the block and fall through to Step 3 the moment it moves. The block never changes the poll; it only mutes dispatch until the state the user must act on moves.
62
64
 
63
65
  ### Step 3 — Compute the actionable set from live thread state
64
66
 
@@ -147,9 +149,9 @@ Any idle branch (Steps 4–6 that did not dispatch). First classify **why** this
147
149
 
148
150
  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.
149
151
 
150
- **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`.
152
+ **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 from the arming loop ([`arm-watcher.md`](arm-watcher.md)) — or, under a recovery cron, in 1 min via `/loop`.
151
153
 
152
- **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.
154
+ **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, **once per block**. From the next tick on, the Step 2.5 gate carries the block silently. Exit.
153
155
 
154
156
  ## Output
155
157
 
@@ -84,6 +84,18 @@
84
84
  { "name": "does_not_refetch_or_refinalize", "text": "Plan does NOT re-fetch the PR, re-write result.md, or re-run the terminal handoff." },
85
85
  { "name": "defensively_cancels_and_logs", "text": "Plan defensively cancels any lingering cron for the slug and appends a stale-tick line to followup.log, then exits." }
86
86
  ]
87
+ },
88
+ {
89
+ "id": 7,
90
+ "eval_name": "repeated-stale-fires-escalate-orphan-once",
91
+ "prompt": "Tick muggle-ai-works-pr507 507. prs.json records state merged and result.md exists. followup.log already holds four stale-tick lines for this slot and no stale-orphan-escalated line. Walk me through the plan for this tick.",
92
+ "files": [],
93
+ "assertions": [
94
+ { "name": "detects_orphaned_cron", "text": "Plan concludes the cron is orphaned — repeated stale fires after finalize mean both cancel-cron lookups (recorded id, CronList match) keep missing it — rather than treating this as a normal queued-fire drain." },
95
+ { "name": "escalates_to_owner_once", "text": "Plan surfaces a one-line owner notice that only a session restart clears the orphan, and appends a stale-orphan-escalated marker line so the notice never repeats." },
96
+ { "name": "later_fires_absorb_silently", "text": "Plan treats any stale fire after the marker exists as a silent absorb — log stale-tick and exit, no repeated notice." },
97
+ { "name": "never_guess_deletes_other_ids", "text": "Plan does NOT delete cron ids that CronList surfaces for other slugs — those belong to other live watchers." }
98
+ ]
87
99
  }
88
100
  ]
89
101
  }
@@ -1,13 +1,13 @@
1
1
  # Bootstrap output templates
2
2
 
3
- ## Success summary (printed just before dispatch)
3
+ ## Success summary (printed just before arming)
4
4
 
5
5
  ```
6
6
  Bootstrapped PR follow-up for <owner>/<repo>#<n>
7
7
  Slug: <slug>
8
8
  Baseline: thread-state (unresolved threads picked up live); lastBodyReviewId=0 | =<id> (forward-only)
9
9
  Working tree: <toplevel>
10
- Dispatching: /loop 1m /muggle:muggle-pr-followup <slug> <n>
10
+ Arming: <owner>/<repo>#<n>
11
11
  ```
12
12
 
13
13
  ## Aborts
@@ -163,3 +163,13 @@ Written exactly once when the PR's watcher exits terminally (PR merged or closed
163
163
  ## Not in the slot
164
164
 
165
165
  `cycle.json` and `requirements.md` are not seeded or read. `/muggle-do` reads reviews off GitHub each invocation.
166
+
167
+ ## `watch-watermark.env`
168
+
169
+ The watch loop's comparison floor — plain `KEY=VALUE` lines, one file per slot:
170
+
171
+ - `REV` — highest submitted-review id already handled
172
+ - `COM` — highest thread-comment id already handled
173
+ - `THREADS` — semicolon-joined ids of threads already known unresolved
174
+
175
+ Written whole-file by the orchestrating session — seeded at arm time from a post-drain fetch, advanced after every cycle from a post-replies fetch. Read by the watch loop each iteration; the loop never writes it. A stale watermark makes the next reported event the loop's own reply ([`arm-watcher.md`](arm-watcher.md)).