@muggleai/works 5.8.1 → 5.9.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 (28) hide show
  1. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  2. package/dist/plugin/.cursor-plugin/plugin.json +1 -1
  3. package/dist/plugin/hooks/hooks.json +6 -0
  4. package/dist/plugin/scripts/guardrail-watch-gate.sh +40 -0
  5. package/dist/plugin/scripts/guardrails.mjs +84 -2
  6. package/dist/plugin/scripts/pr-watch-guards.sh +17 -0
  7. package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +5 -3
  8. package/dist/plugin/skills/muggle-pr-followup/blocked-tick.md +2 -2
  9. package/dist/plugin/skills/muggle-pr-followup/cancel-cron.md +5 -3
  10. package/dist/plugin/skills/muggle-pr-followup/contract.md +8 -2
  11. package/dist/plugin/skills/muggle-pr-followup/evals/evals.json +30 -3
  12. package/dist/plugin/skills/muggle-pr-followup/finalize.md +1 -1
  13. package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +3 -1
  14. package/dist/release-manifest.json +4 -4
  15. package/package.json +6 -6
  16. package/plugin/.claude-plugin/plugin.json +1 -1
  17. package/plugin/.cursor-plugin/plugin.json +1 -1
  18. package/plugin/hooks/hooks.json +6 -0
  19. package/plugin/scripts/guardrail-watch-gate.sh +40 -0
  20. package/plugin/scripts/guardrails.mjs +84 -2
  21. package/plugin/scripts/pr-watch-guards.sh +17 -0
  22. package/plugin/skills/muggle-pr-followup/arm-watcher.md +5 -3
  23. package/plugin/skills/muggle-pr-followup/blocked-tick.md +2 -2
  24. package/plugin/skills/muggle-pr-followup/cancel-cron.md +5 -3
  25. package/plugin/skills/muggle-pr-followup/contract.md +8 -2
  26. package/plugin/skills/muggle-pr-followup/evals/evals.json +30 -3
  27. package/plugin/skills/muggle-pr-followup/finalize.md +1 -1
  28. package/plugin/skills/muggle-pr-followup/state-schemas.md +3 -1
@@ -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.8.1",
4
+ "version": "5.9.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.8.1",
5
+ "version": "5.9.0",
6
6
  "author": {
7
7
  "name": "Muggle AI",
8
8
  "email": "support@muggle-ai.com"
@@ -110,6 +110,12 @@
110
110
  "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-terminal-gate.sh\"",
111
111
  "async": false,
112
112
  "timeout": 10
113
+ },
114
+ {
115
+ "type": "command",
116
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-watch-gate.sh\"",
117
+ "async": false,
118
+ "timeout": 10
113
119
  }
114
120
  ]
115
121
  }
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+
4
+ # watcher-arm gate (Stop). When a PR was opened this session but no watcher was
5
+ # armed for it, block the turn end and point at the Stage-8 hand-off (or the
6
+ # MUGGLE_WATCH_SKIP escape hatch). Mirrors guardrail-e2e-gate.sh: synchronous
7
+ # (only a sync Stop hook can block the turn end), fires on EVERY turn end, and
8
+ # pre-filters in shell so Node spawns only when a PR was opened this session and
9
+ # no skip was recorded. The real owed-vs-armed decision (a sessions/*/ slot scan)
10
+ # runs in guardrails.mjs. On the overwhelming majority of turns no PR was opened,
11
+ # so the state file is absent or prsHandled is empty and we return {} in-shell,
12
+ # never paying Node cold-start. Degrades to {}.
13
+ payload="$(cat)"
14
+
15
+ raw_sid="$(printf '%s' "$payload" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')"
16
+ [ -n "$raw_sid" ] || raw_sid="unknown"
17
+ sid="$(printf '%s' "$raw_sid" | sed 's/[^A-Za-z0-9_-]/_/g')"
18
+
19
+ # Resolve the same home dir Node's os.homedir() uses. HOME is correct on
20
+ # macOS/Linux and on most Git Bash setups; fall back to converting USERPROFILE
21
+ # when HOME doesn't hold the state dir (some Windows shells point HOME elsewhere).
22
+ home="${HOME:-}"
23
+ if [ ! -d "$home/.muggle-ai" ] && command -v cygpath >/dev/null 2>&1 && [ -n "${USERPROFILE:-}" ]; then
24
+ home="$(cygpath -u "$USERPROFILE" 2>/dev/null || printf '%s' "$home")"
25
+ fi
26
+
27
+ # Empty array serializes as `"prsHandled": []` (one line); a non-empty array spans
28
+ # lines, so the empty match reliably tells them apart. Skip Node unless a PR was
29
+ # opened this session and no watcher skip was recorded.
30
+ state_file="$home/.muggle-ai/guardrails/$sid.json"
31
+ if [ ! -f "$state_file" ] \
32
+ || ! grep -q '"prsHandled"' "$state_file" \
33
+ || grep -q '"prsHandled": \[\]' "$state_file" \
34
+ || grep -q '"watchSkipped": true' "$state_file"; then
35
+ printf '{}'
36
+ exit 0
37
+ fi
38
+
39
+ root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
40
+ printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" watch-gate 2>/dev/null || printf '{}'
@@ -1,4 +1,4 @@
1
- import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
1
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'fs';
2
2
  import { isAbsolute, resolve, join } from 'path';
3
3
  import { homedir } from 'os';
4
4
 
@@ -45,6 +45,7 @@ var GH_PR_MERGED_LINE = /\b(?:Merged|Squashed and merged|Rebased and merged) pul
45
45
  var GH_PR_CLOSED_LINE = /\bClosed pull request [\w./-]*#(\d+)/;
46
46
  var PR_MONITOR_TERMINAL_LINE = /\bTERMINAL pr=(\d+): (MERGED|CLOSED)\b/;
47
47
  var MAX_PR_TERMINAL_BLOCKS = 3;
48
+ var MAX_WATCH_BLOCKS = 3;
48
49
  var MUGGLE_SKILL_EMIT_TOOL = /muggle-local-telemetry-skill-emit/i;
49
50
  var MUGGLE_TEST_SKILL_NAME = "muggle-test";
50
51
 
@@ -143,6 +144,72 @@ function e2eGateDecision(state, maxBlocks = MAX_E2E_BLOCKS) {
143
144
  if (blockCount >= maxBlocks) return { action: "release" /* Release */, blockCount };
144
145
  return { action: "block" /* Block */, blockCount: blockCount + 1 };
145
146
  }
147
+ var HEARTBEAT_FRESH_MS = 15 * 60 * 1e3;
148
+ var WATCH_SKIP_MARKER = /^\s*echo\s+["']?MUGGLE_WATCH_SKIP\b/;
149
+ function isWatchSkipMarker(cmd) {
150
+ return WATCH_SKIP_MARKER.test(cmd);
151
+ }
152
+ function applyWatchSkip(state, skipped) {
153
+ if (!skipped || state.watchSkipped === true) return state;
154
+ return { ...state, watchSkipped: true };
155
+ }
156
+ function slotHasArmedWatcher(slotDir) {
157
+ if (existsSync(join(slotDir, "result.md"))) return true;
158
+ const pidFile = join(slotDir, "watch.pid");
159
+ if (existsSync(pidFile)) {
160
+ const pid = Number.parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
161
+ if (Number.isInteger(pid) && pid > 0) {
162
+ try {
163
+ process.kill(pid, 0);
164
+ return true;
165
+ } catch (err) {
166
+ if (err.code === "EPERM") return true;
167
+ }
168
+ }
169
+ }
170
+ const beat = join(slotDir, "watch-heartbeat");
171
+ if (existsSync(beat)) {
172
+ try {
173
+ if (Date.now() - statSync(beat).mtimeMs < HEARTBEAT_FRESH_MS) return true;
174
+ } catch {
175
+ return false;
176
+ }
177
+ }
178
+ return false;
179
+ }
180
+ function findUnarmedHandledPrs(handledUrls, sessionsDirOverride) {
181
+ if (handledUrls.length === 0) return [];
182
+ const sessionsDir = join(homedir(), ".muggle-ai", "muggle-do", "sessions");
183
+ if (!existsSync(sessionsDir)) return [...handledUrls];
184
+ const watchedUrls = /* @__PURE__ */ new Set();
185
+ for (const slug of readdirSync(sessionsDir)) {
186
+ const slotDir = join(sessionsDir, slug);
187
+ const prsFile = join(slotDir, "prs.json");
188
+ if (!existsSync(prsFile)) continue;
189
+ let slotUrl;
190
+ try {
191
+ const parsed = JSON.parse(readFileSync(prsFile, "utf-8"));
192
+ const entry = Array.isArray(parsed) ? parsed[0] : parsed;
193
+ slotUrl = entry?.url;
194
+ } catch {
195
+ continue;
196
+ }
197
+ if (slotUrl && handledUrls.includes(slotUrl) && slotHasArmedWatcher(slotDir)) {
198
+ watchedUrls.add(slotUrl);
199
+ }
200
+ }
201
+ return handledUrls.filter((url) => !watchedUrls.has(url));
202
+ }
203
+ function watchGateDecision(state, owedUrls, maxBlocks = MAX_WATCH_BLOCKS) {
204
+ const blockCount = state.watchBlockCount ?? 0;
205
+ if (state.watchSkipped === true || owedUrls.length === 0) {
206
+ return { action: "none" /* None */, blockCount, owed: owedUrls };
207
+ }
208
+ if (blockCount >= maxBlocks) {
209
+ return { action: "release" /* Release */, blockCount, owed: owedUrls };
210
+ }
211
+ return { action: "block" /* Block */, blockCount: blockCount + 1, owed: owedUrls };
212
+ }
146
213
 
147
214
  // src/guardrails/detectBuildIntent.ts
148
215
  var BUILD = /\b(implement|build|add|create|write|fix|refactor|wire up|hook up|make (a|the|it)|change the)\b/i;
@@ -285,11 +352,12 @@ function terminalGate() {
285
352
  function recordTests() {
286
353
  const cmd = input.tool_input?.command ?? "";
287
354
  const state = readState(sessionId);
288
- const next = applyRecordedRun(state, {
355
+ const recorded = applyRecordedRun(state, {
289
356
  unitTestPassed: isTestCommand(cmd) && testsPassed(input),
290
357
  e2eRan: isE2ERun(input),
291
358
  e2eSkipped: isE2ESkipMarker(cmd)
292
359
  });
360
+ const next = applyWatchSkip(recorded, isWatchSkipMarker(cmd));
293
361
  if (next !== state) writeState(next);
294
362
  return "{}";
295
363
  }
@@ -302,6 +370,19 @@ function e2eGate() {
302
370
  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>"\`.`;
303
371
  return blockStop(reason, host);
304
372
  }
373
+ function watchGate() {
374
+ const state = readState(sessionId);
375
+ const owed = findUnarmedHandledPrs(state.prsHandled);
376
+ const decision = watchGateDecision(state, owed);
377
+ if (decision.action === "none" /* None */ || decision.action === "release" /* Release */) {
378
+ return "{}";
379
+ }
380
+ state.watchBlockCount = decision.blockCount;
381
+ writeState(state);
382
+ const prList = decision.owed.join(", ");
383
+ const reason = decision.blockCount === 1 ? `Do not end the turn yet. A PR was opened this session but has no armed watcher: ${prList}. muggle-do Stage 8 seeds the watcher slot and arms one watcher per opened PR \u2014 arm it now with /muggle:muggle-pr-followup ${decision.owed[0]} (or reconcile). If this PR should NOT be watched (autoWatchPR=never, a manually-opened PR, one handed off elsewhere, or already merged/closed), tell the user why and run \`echo "MUGGLE_WATCH_SKIP: <reason>"\` \u2014 that records the skip and keeps this gate quiet for the rest of the session.` : `Watcher hand-off still owed for ${prList} (reminder ${decision.blockCount}/${MAX_WATCH_BLOCKS}): arm via /muggle:muggle-pr-followup, or record a legitimate skip via \`echo "MUGGLE_WATCH_SKIP: <reason>"\`.`;
384
+ return blockStop(reason, host);
385
+ }
305
386
  function reportGate() {
306
387
  const result = evaluateReportPost(input);
307
388
  if (!result.deny || !result.reason) return "{}";
@@ -323,6 +404,7 @@ var handlers = {
323
404
  "record-tests": recordTests,
324
405
  "e2e-gate": e2eGate,
325
406
  "terminal-gate": terminalGate,
407
+ "watch-gate": watchGate,
326
408
  "report-gate": reportGate,
327
409
  "build-router": buildRouter
328
410
  };
@@ -20,6 +20,23 @@
20
20
 
21
21
  MUGGLE_PR_WATCH_MAX_LIFETIME="${MUGGLE_PR_WATCH_MAX_LIFETIME:-21600}"
22
22
  MUGGLE_PR_WATCH_POLL_INTERVAL="${MUGGLE_PR_WATCH_POLL_INTERVAL:-60}"
23
+ # Consecutive failed fetches before a loop gives up. A watcher must ride through
24
+ # a GitHub / network outage — an observed drop lasted ~8 minutes — not die and
25
+ # leave the PR unwatched until the next session start. With the backoff below, 60
26
+ # spans hours; only a genuinely persistent unreachable slot (deleted repo,
27
+ # revoked auth) exhausts it.
28
+ MUGGLE_PR_WATCH_MAX_FETCH_FAILURES="${MUGGLE_PR_WATCH_MAX_FETCH_FAILURES:-60}"
29
+
30
+ # Seconds to sleep after `fails` consecutive failed fetches: the poll interval,
31
+ # then a linear back-off capped at 5 minutes so a sustained outage is retried
32
+ # calmly rather than hammered every 60s.
33
+ watcher_fetch_backoff() {
34
+ local fails="$1" base="${MUGGLE_PR_WATCH_POLL_INTERVAL}" step secs
35
+ step=$((fails * 30))
36
+ secs=$((base + step))
37
+ [ "$secs" -gt 300 ] && secs=300
38
+ echo "$secs"
39
+ }
23
40
 
24
41
  # True when watch.pid exists and names a PID other than this loop's — a newer arm
25
42
  # has taken ownership of the slot. Absent/empty watch.pid is not superseded: a
@@ -3,10 +3,10 @@
3
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
4
 
5
5
  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.
6
- 2. **Seed the watermark.** 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 monitor's first iteration surfaces it. Seed the CI floor (`CIRED`) from the same drain read: set it to the head SHA 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)) — that red is what the drain just handled — and empty otherwise, so an escalated red head the drain already saw does not re-fire on the loop's first iteration.
6
+ 2. **Seed the watermark.** 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 monitor's first iteration surfaces it. Seed the CI floor (`CIRED`) from the same drain read: set it to the head SHA 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)) — that red is what the drain just handled — and empty otherwise, so an escalated red head the drain already saw does not re-fire on the loop's first iteration. 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): set it to the current `rebase_key` (`<head_sha>..<base_tip_sha>`) when the drain found the branch already behind or conflicting — that staleness is what the drain just handled — and empty otherwise, so a branch the drain already rebased or escalated does not re-fire on the loop's first iteration. 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.
7
7
  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), a watcher already owns this slot — **skip arming, do not start a second**. This is what stops orphaned watchers from accumulating: the in-session monitor 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.
8
8
 
9
- Otherwise 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 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), or **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) — it prints one line and **keeps watching**, advancing its in-memory floor so each event fires the tick exactly once. The review and thread floors are monotonic ids; the CI 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. A head whose checks are still **pending** is never a wake: pending checks may yet go green, and the tick would idle on them (Step 6) regardless. Quiet iterations print nothing and cost nothing — no model tokens are spent while the watch is quiet.
9
+ Otherwise 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 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 and **keeps watching**, advancing its in-memory floor so each event fires the tick exactly once. 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.
10
10
 
11
11
  The loop is **self-terminating**, so it never becomes one of those orphans. It begins by sourcing the shipped guard library and claiming the slot, with the guard path resolved to an **absolute** path at arm time (from `${CLAUDE_PLUGIN_ROOT}/scripts/pr-watch-guards.sh`) so it still resolves after the arming session is gone:
12
12
 
@@ -17,6 +17,8 @@ How an orchestrating session starts the watch on one PR. Every arming point runs
17
17
  started=$(date +%s)
18
18
  ```
19
19
 
20
+ A failed fetch — a GitHub or network outage, which reaches the loop as an empty result and a `dial tcp … / error connecting to api.github.com` line — must not be mistaken for a terminal PR: the loop counts it toward `MUGGLE_PR_WATCH_MAX_FETCH_FAILURES` (60), sleeping `watcher_fetch_backoff "$fails"` between tries so a sustained outage is retried calmly, and appends the error to `<slot>/watch-fetch.log`. 60 backed-off failures span hours; only a genuinely persistent unreachable slot (deleted repo, revoked auth) exhausts the budget, and a successful fetch resets the counter. Terminal exit stays reserved for an observed `MERGED`/`CLOSED`.
21
+
20
22
  and the top of every iteration, before any `gh` call, checks the two guards from [`../../scripts/pr-watch-guards.sh`](../../scripts/pr-watch-guards.sh):
21
23
 
22
24
  ```sh
@@ -24,7 +26,7 @@ How an orchestrating session starts the watch on one PR. Every arming point runs
24
26
  watcher_lifetime_exceeded "$started" "$(date +%s)" && exit 0 # 6h cap → any orphan dies on its own; reconcile re-arms an open PR in a live session
25
27
  ```
26
28
 
27
- So it exits when the PR goes terminal, after five consecutive failed fetches, when a newer arm supersedes it, or once it passes the lifetime cap — never `while true` unbounded.
29
+ So it exits when the PR goes terminal, after `MUGGLE_PR_WATCH_MAX_FETCH_FAILURES` consecutive failed fetches (default 60, back-off between each — hours of outage tolerance), when a newer arm supersedes it, or once it passes the lifetime cap — never `while true` unbounded.
28
30
  4. **On event.** Polling never enters the session: the cadence lives entirely in the detached monitor loop (step 3), and the only thing the session ever receives is the monitor's one event line. That line wakes the session: the wake turn runs the tick (step 1) with `--wake=<event>` — the flag that tells routing's live-watcher gate this poll was prompted, not idle curiosity ([`SKILL.md`](SKILL.md#routing)) — and whatever cycle the tick produces runs **inline in the owning session** — never in a subagent. The session's full history is part of the cycle's context (decisions, review nuance, owner phrasing); a subagent only knows its briefing plus disk/provider state, and anything the briefing omits is silently missing from the cycle. The token cost of inline cycles is accepted — context beats cost. The tick still 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 at the next session start.
29
31
 
30
32
  **After a cycle** — advance the watermark to the **handled wave's snapshot**, in the same dispatched context that ran the cycle. Capture the max review-id and comment-id **at the start of handling** — the ids the cycle actually read when it derived the wave, before any reply is posted — and advance the watermark to exactly those. **Never** re-derive it from a live-max fetched after the replies land: a reviewer comment that arrives during the handling window — between reading the wave and posting the replies — lands below that post-reply max and is marked seen without ever being read. Snapshotting at the start-of-handling read keeps such a comment above the watermark, so the next tick surfaces it. That advance is also what stops the loop from reporting its own reply; a cycle that skips it leaves the watermark stale, and the next event is an echo.
@@ -19,7 +19,7 @@ Any component moving means the block may have cleared — a new push (`head_sha`
19
19
  When an idle tick is a durable human-block and `last_seen.blocked` is not already set:
20
20
 
21
21
  1. Increment `last_seen.idle_tick_count`.
22
- 2. Write `last_seen.blocked = { reason, since: <now>, fingerprint }` (reuse the `latest_review_id` / `ci_digest` already fetched this tick).
22
+ 2. Write `last_seen.blocked = { reason, since: <now>, fingerprint }` (reuse the `latest_review_id` / `ci_digest` already fetched this tick). Mirror the fingerprint's `ci_digest` into the watch-watermark's `BLOCKED_CIDIGEST` ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)) — a whole-file rewrite of `watch-watermark.env` that leaves the other floors untouched. That non-empty value is what arms the monitor's blocked-resume probe, so a block resumes on any CI move ([`arm-watcher.md`](arm-watcher.md)), not just red — without it the probe stays dormant.
23
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
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
 
@@ -28,7 +28,7 @@ When an idle tick is a durable human-block and `last_seen.blocked` is not alread
28
28
  Every subsequent tick while `last_seen.blocked` is present: recompute the fingerprint and compare to `last_seen.blocked.fingerprint`.
29
29
 
30
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.
31
+ - **Changed** → clear `last_seen.blocked`, clear the watch-watermark's `BLOCKED_CIDIGEST` to empty (whole-file rewrite — disarms the monitor's blocked-resume probe now that the watch is unblocked), 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 (and re-arms `BLOCKED_CIDIGEST` afresh).
32
32
 
33
33
  ## Invariants
34
34
 
@@ -6,7 +6,9 @@ Deletes this slot's watcher cron, and nothing more — the caller owns whatever
6
6
 
7
7
  Two lookups, recorded-id first so the delete still works when `CronList` has gone blind to the cron (survived a session continue / compaction — see [`state-schemas.md`](state-schemas.md#cronjson)):
8
8
 
9
- 1. **By recorded id.** Read `cron.json` ([`state-schemas.md`](state-schemas.md#cronjson)). If `cron_id` is non-null, call `CronDelete` with it. This is the only handle that survives `CronList` blindness.
10
- 2. **By `CronList` match (fallback).** Call `CronList`, find the job whose command ends with `/muggle:muggle-pr-followup <slug> <n>` — the exact two-arg match for this slot's PR — and `CronDelete` its id. This catches a stale recorded id (a since-respawned cron whose new id no tick has recorded yet) and the case where `cron.json` is absent.
9
+ 1. **By recorded id.** Read `cron.json` ([`state-schemas.md`](state-schemas.md#cronjson)). If `cron_id` is non-null, call `CronDelete` with it. This is the only handle that survives `CronList` blindness. It **finds** a cron when the id names a live scheduled cron the delete removes; a `cron_id` that is null or already gone is a harmless no-op that finds nothing.
10
+ 2. **By `CronList` match (fallback).** Call `CronList`, find the job whose command ends with `/muggle:muggle-pr-followup <slug> <n>` — the exact two-arg match for this slot's PR — and `CronDelete` its id. This catches a stale recorded id (a since-respawned cron whose new id no tick has recorded yet) and the case where `cron.json` is absent. It **finds** a cron when the match surfaces a live job to delete; an empty `CronList` (already blind) or no matching job finds nothing.
11
11
 
12
- Both lookups are no-ops when nothing matches a manually-run tick, or a cron that already expired. Deleting an already-gone id is harmless. Do **not** delete `cron.json` itself here; a terminal slot keeps it as a record, and reconcile ([`reconcile.md`](reconcile.md)) may still read it to sweep a straggler.
12
+ **Reported result `found` or `not-found`.** cancel-cron reports **found** when either lookup located and cancelled a live cron: the cron was reachable in this runtime, so any stale fires that follow are just the finite queued-drain backlog emptying. It reports **not-found** when both lookups found nothing to cancel. A **not-found** that keeps recurring — the cron fires again yet no cancel can reach it is the orphan signal the stale-fire guard keys on ([`contract.md`](contract.md) Step 0). Callers that only tear down (finalize [`finalize.md`](finalize.md), the single-thread stops in [`contract.md`](contract.md) Steps 4–6) do not branch on the result; only Step 0's orphan gate consults it. Deleting an already-gone id is harmless either way.
13
+
14
+ Do **not** delete `cron.json` itself here; a terminal slot keeps it as a record, and reconcile ([`reconcile.md`](reconcile.md)) may still read it to sweep a straggler.
@@ -33,9 +33,15 @@ Every `increment`/`reset` this procedure applies to `last_seen.json`, and the `p
33
33
 
34
34
  **Stopped-slot absorb — before anything else, including the disk reads above.** If the kill file `~/.muggle-ai/muggle-do/polling.disabled` exists, or the slot dir is absent but `~/.muggle-ai/muggle-do/sessions/<slug>.stopped/` exists (the owner ran [`stop.md`](stop.md)), absorb: no fetch, no state write, no log line — output one line (`stopped: <slug> — absorbed`) and exit. This gate is what makes an **unreachable orphaned cron** harmless: a cron whose handle a compaction severed can neither be enumerated by `CronList` nor deleted by its recorded id, so it fires until its session dies — the absorb caps each fire at one line of output and nothing else. Never "fix" an orphan by deleting ids `CronList` does surface; those belong to other live watchers.
35
35
 
36
- 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.
36
+ 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), which reports **found** (a reachable cron was cancelled) or **not-found** (neither lookup matched a live cron). Append a `stale-tick` line to `followup.log` that records the result — `stale-tick cancel=<found|not-found>` — and exit. Do not re-fetch or re-finalize.
37
37
 
38
- **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.
38
+ **Orphan escalation — when no cancel ever reaches the cron, yet stale fires persist.** The signal is *reachability*, not a fire count. Once any cancel of this slug's cron has reported **found** finalize's teardown cancel ([`finalize.md`](finalize.md) Step 4) or an earlier stale fire's defensive cancel the cron was deletable in its runtime, so the fires that follow are the finite queued-drain backlog emptying: never escalate. The orphan is the opposite: the cron lives in a runtime neither the recorded id nor `CronList` can reach (a session continue / compaction severed every handle), so **every** cancel reports **not-found** and the fires keep arriving until the 7-day expiry. Escalate only when all three hold:
39
+
40
+ - this fire's defensive cancel reported **not-found**, **and**
41
+ - a prior `stale-tick` line for this slot already exists in `followup.log` — this is not the first stale fire, so it is not a lone drain, a manually-run tick, or a just-expired cron, **and**
42
+ - no `cancel=found` has ever been recorded for this slot (neither finalize's teardown line nor any earlier `stale-tick` line) — the cron was never reachable, so these fires are not a drained backlog.
43
+
44
+ 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.
39
45
 
40
46
  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.
41
47
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "skill_name": "muggle-pr-followup",
3
- "notes": "These evals test the TICK DECISION behavior plus the recovery/stop substrate. A real tick needs live GitHub state and the Cron tools, so each prompt hands the model a concrete slot + provider state and asks for the step-by-step plan it would execute; assertions check the plan takes the branch the contract mandates. Cases 0–10 cover per-tick dispatch/idle decisions; cases 11–17 cover cron-delivered ticks handing back to the token-free monitor, the owner stop switch, and the stopped-slot / kill-file absorb that makes an unreachable orphaned cron inert. Run via skill-creator's eval runner — CI does not execute these (ci.yml gates on vitest/check-skill-deps; skill-eval.yml runs gate scenarios sourced from muggle-ai-brain and the routing eval).",
3
+ "notes": "These evals test the TICK DECISION behavior plus the recovery/stop substrate. A real tick needs live GitHub state and the Cron tools, so each prompt hands the model a concrete slot + provider state and asks for the step-by-step plan it would execute; assertions check the plan takes the branch the contract mandates. Cases 0–10 cover per-tick dispatch/idle decisions; cases 11–17 cover cron-delivered ticks handing back to the token-free monitor, the owner stop switch, and the stopped-slot / kill-file absorb that makes an unreachable orphaned cron inert; cases 8 and 18–19 cover the monitor's own wake signals (settled-red CI, branch-behind/conflicting, and blocked-resume on any CI-digest move). Run via skill-creator's eval runner — CI does not execute these (ci.yml gates on vitest/check-skill-deps; skill-eval.yml runs gate scenarios sourced from muggle-ai-brain and the routing eval).",
4
4
  "evals": [
5
5
  {
6
6
  "id": 0,
@@ -88,10 +88,12 @@
88
88
  {
89
89
  "id": 7,
90
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.",
91
+ "prompt": "Tick muggle-ai-works-pr507 507. prs.json records state merged and result.md exists. The watcher's session was continued, so neither the recorded cron id nor CronList can reach this slug's cron. followup.log shows finalize recorded cancel=not-found and one earlier stale-tick line also recorded cancel=not-found — there is no cancel=found line anywhere for this slot and no stale-orphan-escalated line. This fire's defensive cancel per cancel-cron.md again reports not-found. Walk me through the plan for this tick.",
92
92
  "files": [],
93
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." },
94
+ { "name": "detects_orphaned_cron", "text": "Plan concludes the cron is orphaned because no cancel has ever reached it finalize's teardown cancel and every stale fire's defensive cancel all report not-found and the stale fires persist (a prior stale-tick line already exists). It reaches this from the reachability signal, not from counting stale fires to a threshold." },
95
+ { "name": "requires_persistence_not_a_lone_fire", "text": "Plan escalates because this is not the first stale fire (a prior stale-tick line exists); it would treat a single not-found fire as a possible manually-run tick or just-expired cron, not an orphan." },
96
+ { "name": "would_not_escalate_a_drained_backlog", "text": "Plan would NOT escalate had any cancel reported found — finalize's teardown cancel or an earlier stale fire's defensive cancel — because a reachable cron's later fires are a finite queued-drain backlog, not an orphan." },
95
97
  { "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
98
  { "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
99
  { "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." }
@@ -216,6 +218,31 @@
216
218
  { "name": "then_seeds_and_arms", "text": "Plan then seeds the slot and arms the watch normally — leaving the kill file in place would make every tick of the new watch absorb itself stillborn." },
217
219
  { "name": "only_bootstrap_clears_it", "text": "Plan notes only bootstrap (an explicit URL) clears the kill file; auto-track and reconcile never do." }
218
220
  ]
221
+ },
222
+ {
223
+ "id": 18,
224
+ "eval_name": "arm-monitor-wakes-on-branch-behind",
225
+ "prompt": "Arm the watch for muggle-ai-works-pr525 525 after a clean drain (no reviews, no unresolved threads, CI green, branch level with master at seed time). Describe the watch.sh monitor loop you would write and specifically what makes it wake the session when the base branch later advances and the branch falls behind.",
226
+ "files": [],
227
+ "assertions": [
228
+ { "name": "polls_branch_standing", "text": "The loop polls the branch's standing against its base each iteration (behind_by via the compare call and the conflict signal per branch-standing.md), not just reviews, threads, and CI." },
229
+ { "name": "wakes_on_behind_or_conflict", "text": "The loop wakes the session when behind_by > 0 or the branch conflicts with its base, so a silently-stale branch triggers a tick without waiting for an unrelated wake." },
230
+ { "name": "dedups_on_rebase_key_pair", "text": "The loop dedups the rebase wake on the rebase_key pair (head_sha..base_tip_sha), firing once per newly-due pair and re-arming when the head or base moves — not on a head-only key, which would wedge permanently once the base advances." },
231
+ { "name": "not_a_wake_when_unknown_and_level", "text": "The loop does NOT wake when behind_by == 0 and mergeable is UNKNOWN (conflict state still computing) — it waits, matching the tick's Step 5." },
232
+ { "name": "stays_a_dumb_pipe", "text": "The loop only signals a rebase is due and hands off to a tick; it does NOT apply the conflict_resolve_attempts budget, resolve conflicts, or dispatch the rebase itself — those belong to the tick and /muggle-do." }
233
+ ]
234
+ },
235
+ {
236
+ "id": 19,
237
+ "eval_name": "arm-monitor-wakes-on-cidigest-change-while-blocked",
238
+ "prompt": "Arm the watch for muggle-ai-works-pr526 526 while it is blocked pending a human (last_seen.blocked present, reason ci_escalated). Describe the watch.sh monitor loop and specifically what makes it resume the block when the head's CI later moves to green, or a rerun/external deploy check posts — i.e. a non-red CI change.",
239
+ "files": [],
240
+ "assertions": [
241
+ { "name": "blocked_probe_is_conditional", "text": "The loop runs the blocked-resume CI probe only while the watch is blocked — it keys off BLOCKED_CIDIGEST being non-empty, and the probe stays dormant when the watch is not blocked." },
242
+ { "name": "wakes_on_any_digest_move_not_just_red", "text": "While blocked, the loop wakes on ANY change to the head's CI digest away from BLOCKED_CIDIGEST — a green pass, a rerun, or an external deploy check — not only on a settled-red transition." },
243
+ { "name": "digest_matches_fingerprint_definition", "text": "The digest the loop computes is the same bucket-plus-sorted-name/conclusion signature the blocked fingerprint's ci_digest uses, so the loop and the tick's Step 2.5 resume gate agree by construction." },
244
+ { "name": "resume_is_the_tick_job", "text": "The loop only wakes on the digest move; clearing last_seen.blocked and re-evaluating belongs to the tick's Step 2.5 fingerprint gate, and the blocked-tick flag/resume path is what maintains BLOCKED_CIDIGEST — the loop never writes it." }
245
+ ]
219
246
  }
220
247
  ]
221
248
  }
@@ -26,4 +26,4 @@ Append the terminal line per [`output-templates/watcher-log.md`](output-template
26
26
 
27
27
  ### Step 4 — Unschedule the cron
28
28
 
29
- Cancel this slot's cron per [`cancel-cron.md`](cancel-cron.md). No-op when none matches — a manually-run tick, or a cron that already expired. Recurring `/loop` crons auto-expire after 7 days; that lapse is the gap [`reconcile.md`](reconcile.md) exists to catch.
29
+ Cancel this slot's cron per [`cancel-cron.md`](cancel-cron.md), which reports **found** or **not-found**. Record the result in `followup.log` — `finalize cancel=<found|not-found>` so a later stale fire's orphan gate ([`contract.md`](contract.md) Step 0) can tell a reachable cron whose queued fires merely drain (**found** here) from one it could never reach (**not-found** here, the orphan case). A **not-found** is otherwise benign — a manually-run tick, or a cron that already expired. Recurring `/loop` crons auto-expire after 7 days; that lapse is the gap [`reconcile.md`](reconcile.md) exists to catch.
@@ -176,8 +176,10 @@ The watch loop's comparison floor — plain `KEY=VALUE` lines, one file per slot
176
176
  - `COM` — highest thread-comment id already handled
177
177
  - `THREADS` — semicolon-joined ids of threads already known unresolved
178
178
  - `CIRED` — head SHA whose settled-red CI the drain already handled; empty when the checks are green, still pending, or unseen. 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)).
179
+ - `REBASED` — the `rebase_key` (`<head_sha>..<base_tip_sha>`) whose behind/conflicting state the drain already handled; empty when the branch is current with its base or unseen. 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) Step 5 dedups rebase dispatch on; the monitor only signals a due rebase, the tick applies the `conflict_resolve_attempts` budget.
180
+ - `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.
179
181
 
180
- Written whole-file by the orchestrating session — seeded at arm time to the **drain's own observed max** (the ids the drain read, snapshotted at that read) and advanced after every cycle to the **handled wave's snapshot** (the max review-id and comment-id the cycle read at the start of handling, captured before replies were posted). **Never** derived from a fresh fetch taken after the drain or after replies land: a later fetch includes ids that arrived in the handling window and were never read, and marking them seen swallows them ([`arm-watcher.md`](arm-watcher.md)). The advance also never moves any floor past an unresolved thread the cycle left unaddressed. 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.
182
+ Written whole-file by the orchestrating session — the review/comment/CI/rebase floors seeded at arm time (`REV`/`COM` to the **drain's own observed max**, `CIRED`/`REBASED` to what the drain handled) and advanced after every cycle to the **handled wave's snapshot** (the max review-id and comment-id the cycle read at the start of handling, captured before replies were posted); `BLOCKED_CIDIGEST` is maintained by the blocked-tick flag/resume path rather than the arm/cycle path. **Never** derived from a fresh fetch taken after the drain or after replies land: a later fetch includes ids that arrived in the handling window and were never read, and marking them seen swallows them ([`arm-watcher.md`](arm-watcher.md)). The advance also never moves any floor past an unresolved thread the cycle left unaddressed. 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.
181
183
 
182
184
  **Never `source` this file, and quote or extract values.** `THREADS` holds bare semicolons: sourced unquoted, the shell splits the line at the first `;` and silently drops every id after it — the loop then re-reports known threads as new. The watch loop must extract values verbatim (e.g. `grep '^THREADS=' | cut -d= -f2- | tr -d '"\r'`), tolerating quotes and CRLF; writers should quote the value anyway.
183
185
 
@@ -1,7 +1,7 @@
1
1
  {
2
- "release": "5.8.1",
3
- "buildId": "run-68-1",
4
- "commitSha": "5196f24a9e206da839b8605702a0a4f7c7c5e695",
5
- "buildTime": "2026-08-01T08:31:31Z",
2
+ "release": "5.9.0",
3
+ "buildId": "run-69-1",
4
+ "commitSha": "65137194ea9b71fb5eb97ae290869357a826b51d",
5
+ "buildTime": "2026-08-04T22:00:43Z",
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.8.1",
4
+ "version": "5.9.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",
@@ -47,14 +47,14 @@
47
47
  "eval:studio-gen": "tsx internal/studio-gen-eval/src/run.ts"
48
48
  },
49
49
  "muggleConfig": {
50
- "electronAppVersion": "1.6.11",
50
+ "electronAppVersion": "1.6.12",
51
51
  "downloadBaseUrl": "https://github.com/multiplex-ai/muggle-ai-works/releases/download",
52
52
  "runtimeTargetDefault": "production",
53
53
  "checksums": {
54
- "darwin-arm64": "97f7f5ba622dc3baf86457184bc44030c777d7dbac58b8c1c5418970662e7a07",
55
- "darwin-x64": "c8c2745282583144b34110ef45c4745e03c03c4ac2e020c820daa51124c3fc50",
56
- "linux-x64": "d85e14ed38cb8035b932aef26148934e3aec6e036b6bb80ff8927cbde901a935",
57
- "win32-x64": "6e38a5b017ee5756985e1507def6c5a92d357a4bbd4e4032b6e490ad05dd09a2"
54
+ "darwin-arm64": "d313b881b6f448b968c838a3b9bea4d8f226c60b703c377514d37f33d0dacd66",
55
+ "darwin-x64": "31f316bdcc97dbc764a9656fc3003a2640c613d6ce1f8ba1a73564987235f2f1",
56
+ "linux-x64": "1d06dfbf66ea97f55abfb6e3cd8a3edbea2c7c6bf75acbbd607039e7c6dae69b",
57
+ "win32-x64": "ff1150431afad2717e30d419d4ba15df79d20e10ae48a6293f71a723c7b3159a"
58
58
  }
59
59
  },
60
60
  "dependencies": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "muggle",
3
3
  "description": "Run real-browser end-to-end (E2E) acceptance tests on your web app from any AI coding agent. Generate test scripts from plain English, replay them on localhost, capture screenshots, and validate user flows like signup, checkout, and dashboards. Works across Claude Code, Cursor, Codex, and Windsurf.",
4
- "version": "5.8.1",
4
+ "version": "5.9.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.8.1",
5
+ "version": "5.9.0",
6
6
  "author": {
7
7
  "name": "Muggle AI",
8
8
  "email": "support@muggle-ai.com"
@@ -110,6 +110,12 @@
110
110
  "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-terminal-gate.sh\"",
111
111
  "async": false,
112
112
  "timeout": 10
113
+ },
114
+ {
115
+ "type": "command",
116
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-watch-gate.sh\"",
117
+ "async": false,
118
+ "timeout": 10
113
119
  }
114
120
  ]
115
121
  }
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+
4
+ # watcher-arm gate (Stop). When a PR was opened this session but no watcher was
5
+ # armed for it, block the turn end and point at the Stage-8 hand-off (or the
6
+ # MUGGLE_WATCH_SKIP escape hatch). Mirrors guardrail-e2e-gate.sh: synchronous
7
+ # (only a sync Stop hook can block the turn end), fires on EVERY turn end, and
8
+ # pre-filters in shell so Node spawns only when a PR was opened this session and
9
+ # no skip was recorded. The real owed-vs-armed decision (a sessions/*/ slot scan)
10
+ # runs in guardrails.mjs. On the overwhelming majority of turns no PR was opened,
11
+ # so the state file is absent or prsHandled is empty and we return {} in-shell,
12
+ # never paying Node cold-start. Degrades to {}.
13
+ payload="$(cat)"
14
+
15
+ raw_sid="$(printf '%s' "$payload" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')"
16
+ [ -n "$raw_sid" ] || raw_sid="unknown"
17
+ sid="$(printf '%s' "$raw_sid" | sed 's/[^A-Za-z0-9_-]/_/g')"
18
+
19
+ # Resolve the same home dir Node's os.homedir() uses. HOME is correct on
20
+ # macOS/Linux and on most Git Bash setups; fall back to converting USERPROFILE
21
+ # when HOME doesn't hold the state dir (some Windows shells point HOME elsewhere).
22
+ home="${HOME:-}"
23
+ if [ ! -d "$home/.muggle-ai" ] && command -v cygpath >/dev/null 2>&1 && [ -n "${USERPROFILE:-}" ]; then
24
+ home="$(cygpath -u "$USERPROFILE" 2>/dev/null || printf '%s' "$home")"
25
+ fi
26
+
27
+ # Empty array serializes as `"prsHandled": []` (one line); a non-empty array spans
28
+ # lines, so the empty match reliably tells them apart. Skip Node unless a PR was
29
+ # opened this session and no watcher skip was recorded.
30
+ state_file="$home/.muggle-ai/guardrails/$sid.json"
31
+ if [ ! -f "$state_file" ] \
32
+ || ! grep -q '"prsHandled"' "$state_file" \
33
+ || grep -q '"prsHandled": \[\]' "$state_file" \
34
+ || grep -q '"watchSkipped": true' "$state_file"; then
35
+ printf '{}'
36
+ exit 0
37
+ fi
38
+
39
+ root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
40
+ printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" watch-gate 2>/dev/null || printf '{}'
@@ -1,4 +1,4 @@
1
- import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
1
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'fs';
2
2
  import { isAbsolute, resolve, join } from 'path';
3
3
  import { homedir } from 'os';
4
4
 
@@ -45,6 +45,7 @@ var GH_PR_MERGED_LINE = /\b(?:Merged|Squashed and merged|Rebased and merged) pul
45
45
  var GH_PR_CLOSED_LINE = /\bClosed pull request [\w./-]*#(\d+)/;
46
46
  var PR_MONITOR_TERMINAL_LINE = /\bTERMINAL pr=(\d+): (MERGED|CLOSED)\b/;
47
47
  var MAX_PR_TERMINAL_BLOCKS = 3;
48
+ var MAX_WATCH_BLOCKS = 3;
48
49
  var MUGGLE_SKILL_EMIT_TOOL = /muggle-local-telemetry-skill-emit/i;
49
50
  var MUGGLE_TEST_SKILL_NAME = "muggle-test";
50
51
 
@@ -143,6 +144,72 @@ function e2eGateDecision(state, maxBlocks = MAX_E2E_BLOCKS) {
143
144
  if (blockCount >= maxBlocks) return { action: "release" /* Release */, blockCount };
144
145
  return { action: "block" /* Block */, blockCount: blockCount + 1 };
145
146
  }
147
+ var HEARTBEAT_FRESH_MS = 15 * 60 * 1e3;
148
+ var WATCH_SKIP_MARKER = /^\s*echo\s+["']?MUGGLE_WATCH_SKIP\b/;
149
+ function isWatchSkipMarker(cmd) {
150
+ return WATCH_SKIP_MARKER.test(cmd);
151
+ }
152
+ function applyWatchSkip(state, skipped) {
153
+ if (!skipped || state.watchSkipped === true) return state;
154
+ return { ...state, watchSkipped: true };
155
+ }
156
+ function slotHasArmedWatcher(slotDir) {
157
+ if (existsSync(join(slotDir, "result.md"))) return true;
158
+ const pidFile = join(slotDir, "watch.pid");
159
+ if (existsSync(pidFile)) {
160
+ const pid = Number.parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
161
+ if (Number.isInteger(pid) && pid > 0) {
162
+ try {
163
+ process.kill(pid, 0);
164
+ return true;
165
+ } catch (err) {
166
+ if (err.code === "EPERM") return true;
167
+ }
168
+ }
169
+ }
170
+ const beat = join(slotDir, "watch-heartbeat");
171
+ if (existsSync(beat)) {
172
+ try {
173
+ if (Date.now() - statSync(beat).mtimeMs < HEARTBEAT_FRESH_MS) return true;
174
+ } catch {
175
+ return false;
176
+ }
177
+ }
178
+ return false;
179
+ }
180
+ function findUnarmedHandledPrs(handledUrls, sessionsDirOverride) {
181
+ if (handledUrls.length === 0) return [];
182
+ const sessionsDir = join(homedir(), ".muggle-ai", "muggle-do", "sessions");
183
+ if (!existsSync(sessionsDir)) return [...handledUrls];
184
+ const watchedUrls = /* @__PURE__ */ new Set();
185
+ for (const slug of readdirSync(sessionsDir)) {
186
+ const slotDir = join(sessionsDir, slug);
187
+ const prsFile = join(slotDir, "prs.json");
188
+ if (!existsSync(prsFile)) continue;
189
+ let slotUrl;
190
+ try {
191
+ const parsed = JSON.parse(readFileSync(prsFile, "utf-8"));
192
+ const entry = Array.isArray(parsed) ? parsed[0] : parsed;
193
+ slotUrl = entry?.url;
194
+ } catch {
195
+ continue;
196
+ }
197
+ if (slotUrl && handledUrls.includes(slotUrl) && slotHasArmedWatcher(slotDir)) {
198
+ watchedUrls.add(slotUrl);
199
+ }
200
+ }
201
+ return handledUrls.filter((url) => !watchedUrls.has(url));
202
+ }
203
+ function watchGateDecision(state, owedUrls, maxBlocks = MAX_WATCH_BLOCKS) {
204
+ const blockCount = state.watchBlockCount ?? 0;
205
+ if (state.watchSkipped === true || owedUrls.length === 0) {
206
+ return { action: "none" /* None */, blockCount, owed: owedUrls };
207
+ }
208
+ if (blockCount >= maxBlocks) {
209
+ return { action: "release" /* Release */, blockCount, owed: owedUrls };
210
+ }
211
+ return { action: "block" /* Block */, blockCount: blockCount + 1, owed: owedUrls };
212
+ }
146
213
 
147
214
  // src/guardrails/detectBuildIntent.ts
148
215
  var BUILD = /\b(implement|build|add|create|write|fix|refactor|wire up|hook up|make (a|the|it)|change the)\b/i;
@@ -285,11 +352,12 @@ function terminalGate() {
285
352
  function recordTests() {
286
353
  const cmd = input.tool_input?.command ?? "";
287
354
  const state = readState(sessionId);
288
- const next = applyRecordedRun(state, {
355
+ const recorded = applyRecordedRun(state, {
289
356
  unitTestPassed: isTestCommand(cmd) && testsPassed(input),
290
357
  e2eRan: isE2ERun(input),
291
358
  e2eSkipped: isE2ESkipMarker(cmd)
292
359
  });
360
+ const next = applyWatchSkip(recorded, isWatchSkipMarker(cmd));
293
361
  if (next !== state) writeState(next);
294
362
  return "{}";
295
363
  }
@@ -302,6 +370,19 @@ function e2eGate() {
302
370
  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>"\`.`;
303
371
  return blockStop(reason, host);
304
372
  }
373
+ function watchGate() {
374
+ const state = readState(sessionId);
375
+ const owed = findUnarmedHandledPrs(state.prsHandled);
376
+ const decision = watchGateDecision(state, owed);
377
+ if (decision.action === "none" /* None */ || decision.action === "release" /* Release */) {
378
+ return "{}";
379
+ }
380
+ state.watchBlockCount = decision.blockCount;
381
+ writeState(state);
382
+ const prList = decision.owed.join(", ");
383
+ const reason = decision.blockCount === 1 ? `Do not end the turn yet. A PR was opened this session but has no armed watcher: ${prList}. muggle-do Stage 8 seeds the watcher slot and arms one watcher per opened PR \u2014 arm it now with /muggle:muggle-pr-followup ${decision.owed[0]} (or reconcile). If this PR should NOT be watched (autoWatchPR=never, a manually-opened PR, one handed off elsewhere, or already merged/closed), tell the user why and run \`echo "MUGGLE_WATCH_SKIP: <reason>"\` \u2014 that records the skip and keeps this gate quiet for the rest of the session.` : `Watcher hand-off still owed for ${prList} (reminder ${decision.blockCount}/${MAX_WATCH_BLOCKS}): arm via /muggle:muggle-pr-followup, or record a legitimate skip via \`echo "MUGGLE_WATCH_SKIP: <reason>"\`.`;
384
+ return blockStop(reason, host);
385
+ }
305
386
  function reportGate() {
306
387
  const result = evaluateReportPost(input);
307
388
  if (!result.deny || !result.reason) return "{}";
@@ -323,6 +404,7 @@ var handlers = {
323
404
  "record-tests": recordTests,
324
405
  "e2e-gate": e2eGate,
325
406
  "terminal-gate": terminalGate,
407
+ "watch-gate": watchGate,
326
408
  "report-gate": reportGate,
327
409
  "build-router": buildRouter
328
410
  };
@@ -20,6 +20,23 @@
20
20
 
21
21
  MUGGLE_PR_WATCH_MAX_LIFETIME="${MUGGLE_PR_WATCH_MAX_LIFETIME:-21600}"
22
22
  MUGGLE_PR_WATCH_POLL_INTERVAL="${MUGGLE_PR_WATCH_POLL_INTERVAL:-60}"
23
+ # Consecutive failed fetches before a loop gives up. A watcher must ride through
24
+ # a GitHub / network outage — an observed drop lasted ~8 minutes — not die and
25
+ # leave the PR unwatched until the next session start. With the backoff below, 60
26
+ # spans hours; only a genuinely persistent unreachable slot (deleted repo,
27
+ # revoked auth) exhausts it.
28
+ MUGGLE_PR_WATCH_MAX_FETCH_FAILURES="${MUGGLE_PR_WATCH_MAX_FETCH_FAILURES:-60}"
29
+
30
+ # Seconds to sleep after `fails` consecutive failed fetches: the poll interval,
31
+ # then a linear back-off capped at 5 minutes so a sustained outage is retried
32
+ # calmly rather than hammered every 60s.
33
+ watcher_fetch_backoff() {
34
+ local fails="$1" base="${MUGGLE_PR_WATCH_POLL_INTERVAL}" step secs
35
+ step=$((fails * 30))
36
+ secs=$((base + step))
37
+ [ "$secs" -gt 300 ] && secs=300
38
+ echo "$secs"
39
+ }
23
40
 
24
41
  # True when watch.pid exists and names a PID other than this loop's — a newer arm
25
42
  # has taken ownership of the slot. Absent/empty watch.pid is not superseded: a
@@ -3,10 +3,10 @@
3
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
4
 
5
5
  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.
6
- 2. **Seed the watermark.** 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 monitor's first iteration surfaces it. Seed the CI floor (`CIRED`) from the same drain read: set it to the head SHA 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)) — that red is what the drain just handled — and empty otherwise, so an escalated red head the drain already saw does not re-fire on the loop's first iteration.
6
+ 2. **Seed the watermark.** 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 monitor's first iteration surfaces it. Seed the CI floor (`CIRED`) from the same drain read: set it to the head SHA 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)) — that red is what the drain just handled — and empty otherwise, so an escalated red head the drain already saw does not re-fire on the loop's first iteration. 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): set it to the current `rebase_key` (`<head_sha>..<base_tip_sha>`) when the drain found the branch already behind or conflicting — that staleness is what the drain just handled — and empty otherwise, so a branch the drain already rebased or escalated does not re-fire on the loop's first iteration. 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.
7
7
  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), a watcher already owns this slot — **skip arming, do not start a second**. This is what stops orphaned watchers from accumulating: the in-session monitor 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.
8
8
 
9
- Otherwise 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 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), or **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) — it prints one line and **keeps watching**, advancing its in-memory floor so each event fires the tick exactly once. The review and thread floors are monotonic ids; the CI 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. A head whose checks are still **pending** is never a wake: pending checks may yet go green, and the tick would idle on them (Step 6) regardless. Quiet iterations print nothing and cost nothing — no model tokens are spent while the watch is quiet.
9
+ Otherwise 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 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 and **keeps watching**, advancing its in-memory floor so each event fires the tick exactly once. 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.
10
10
 
11
11
  The loop is **self-terminating**, so it never becomes one of those orphans. It begins by sourcing the shipped guard library and claiming the slot, with the guard path resolved to an **absolute** path at arm time (from `${CLAUDE_PLUGIN_ROOT}/scripts/pr-watch-guards.sh`) so it still resolves after the arming session is gone:
12
12
 
@@ -17,6 +17,8 @@ How an orchestrating session starts the watch on one PR. Every arming point runs
17
17
  started=$(date +%s)
18
18
  ```
19
19
 
20
+ A failed fetch — a GitHub or network outage, which reaches the loop as an empty result and a `dial tcp … / error connecting to api.github.com` line — must not be mistaken for a terminal PR: the loop counts it toward `MUGGLE_PR_WATCH_MAX_FETCH_FAILURES` (60), sleeping `watcher_fetch_backoff "$fails"` between tries so a sustained outage is retried calmly, and appends the error to `<slot>/watch-fetch.log`. 60 backed-off failures span hours; only a genuinely persistent unreachable slot (deleted repo, revoked auth) exhausts the budget, and a successful fetch resets the counter. Terminal exit stays reserved for an observed `MERGED`/`CLOSED`.
21
+
20
22
  and the top of every iteration, before any `gh` call, checks the two guards from [`../../scripts/pr-watch-guards.sh`](../../scripts/pr-watch-guards.sh):
21
23
 
22
24
  ```sh
@@ -24,7 +26,7 @@ How an orchestrating session starts the watch on one PR. Every arming point runs
24
26
  watcher_lifetime_exceeded "$started" "$(date +%s)" && exit 0 # 6h cap → any orphan dies on its own; reconcile re-arms an open PR in a live session
25
27
  ```
26
28
 
27
- So it exits when the PR goes terminal, after five consecutive failed fetches, when a newer arm supersedes it, or once it passes the lifetime cap — never `while true` unbounded.
29
+ So it exits when the PR goes terminal, after `MUGGLE_PR_WATCH_MAX_FETCH_FAILURES` consecutive failed fetches (default 60, back-off between each — hours of outage tolerance), when a newer arm supersedes it, or once it passes the lifetime cap — never `while true` unbounded.
28
30
  4. **On event.** Polling never enters the session: the cadence lives entirely in the detached monitor loop (step 3), and the only thing the session ever receives is the monitor's one event line. That line wakes the session: the wake turn runs the tick (step 1) with `--wake=<event>` — the flag that tells routing's live-watcher gate this poll was prompted, not idle curiosity ([`SKILL.md`](SKILL.md#routing)) — and whatever cycle the tick produces runs **inline in the owning session** — never in a subagent. The session's full history is part of the cycle's context (decisions, review nuance, owner phrasing); a subagent only knows its briefing plus disk/provider state, and anything the briefing omits is silently missing from the cycle. The token cost of inline cycles is accepted — context beats cost. The tick still 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 at the next session start.
29
31
 
30
32
  **After a cycle** — advance the watermark to the **handled wave's snapshot**, in the same dispatched context that ran the cycle. Capture the max review-id and comment-id **at the start of handling** — the ids the cycle actually read when it derived the wave, before any reply is posted — and advance the watermark to exactly those. **Never** re-derive it from a live-max fetched after the replies land: a reviewer comment that arrives during the handling window — between reading the wave and posting the replies — lands below that post-reply max and is marked seen without ever being read. Snapshotting at the start-of-handling read keeps such a comment above the watermark, so the next tick surfaces it. That advance is also what stops the loop from reporting its own reply; a cycle that skips it leaves the watermark stale, and the next event is an echo.
@@ -19,7 +19,7 @@ Any component moving means the block may have cleared — a new push (`head_sha`
19
19
  When an idle tick is a durable human-block and `last_seen.blocked` is not already set:
20
20
 
21
21
  1. Increment `last_seen.idle_tick_count`.
22
- 2. Write `last_seen.blocked = { reason, since: <now>, fingerprint }` (reuse the `latest_review_id` / `ci_digest` already fetched this tick).
22
+ 2. Write `last_seen.blocked = { reason, since: <now>, fingerprint }` (reuse the `latest_review_id` / `ci_digest` already fetched this tick). Mirror the fingerprint's `ci_digest` into the watch-watermark's `BLOCKED_CIDIGEST` ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)) — a whole-file rewrite of `watch-watermark.env` that leaves the other floors untouched. That non-empty value is what arms the monitor's blocked-resume probe, so a block resumes on any CI move ([`arm-watcher.md`](arm-watcher.md)), not just red — without it the probe stays dormant.
23
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
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
 
@@ -28,7 +28,7 @@ When an idle tick is a durable human-block and `last_seen.blocked` is not alread
28
28
  Every subsequent tick while `last_seen.blocked` is present: recompute the fingerprint and compare to `last_seen.blocked.fingerprint`.
29
29
 
30
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.
31
+ - **Changed** → clear `last_seen.blocked`, clear the watch-watermark's `BLOCKED_CIDIGEST` to empty (whole-file rewrite — disarms the monitor's blocked-resume probe now that the watch is unblocked), 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 (and re-arms `BLOCKED_CIDIGEST` afresh).
32
32
 
33
33
  ## Invariants
34
34
 
@@ -6,7 +6,9 @@ Deletes this slot's watcher cron, and nothing more — the caller owns whatever
6
6
 
7
7
  Two lookups, recorded-id first so the delete still works when `CronList` has gone blind to the cron (survived a session continue / compaction — see [`state-schemas.md`](state-schemas.md#cronjson)):
8
8
 
9
- 1. **By recorded id.** Read `cron.json` ([`state-schemas.md`](state-schemas.md#cronjson)). If `cron_id` is non-null, call `CronDelete` with it. This is the only handle that survives `CronList` blindness.
10
- 2. **By `CronList` match (fallback).** Call `CronList`, find the job whose command ends with `/muggle:muggle-pr-followup <slug> <n>` — the exact two-arg match for this slot's PR — and `CronDelete` its id. This catches a stale recorded id (a since-respawned cron whose new id no tick has recorded yet) and the case where `cron.json` is absent.
9
+ 1. **By recorded id.** Read `cron.json` ([`state-schemas.md`](state-schemas.md#cronjson)). If `cron_id` is non-null, call `CronDelete` with it. This is the only handle that survives `CronList` blindness. It **finds** a cron when the id names a live scheduled cron the delete removes; a `cron_id` that is null or already gone is a harmless no-op that finds nothing.
10
+ 2. **By `CronList` match (fallback).** Call `CronList`, find the job whose command ends with `/muggle:muggle-pr-followup <slug> <n>` — the exact two-arg match for this slot's PR — and `CronDelete` its id. This catches a stale recorded id (a since-respawned cron whose new id no tick has recorded yet) and the case where `cron.json` is absent. It **finds** a cron when the match surfaces a live job to delete; an empty `CronList` (already blind) or no matching job finds nothing.
11
11
 
12
- Both lookups are no-ops when nothing matches a manually-run tick, or a cron that already expired. Deleting an already-gone id is harmless. Do **not** delete `cron.json` itself here; a terminal slot keeps it as a record, and reconcile ([`reconcile.md`](reconcile.md)) may still read it to sweep a straggler.
12
+ **Reported result `found` or `not-found`.** cancel-cron reports **found** when either lookup located and cancelled a live cron: the cron was reachable in this runtime, so any stale fires that follow are just the finite queued-drain backlog emptying. It reports **not-found** when both lookups found nothing to cancel. A **not-found** that keeps recurring — the cron fires again yet no cancel can reach it is the orphan signal the stale-fire guard keys on ([`contract.md`](contract.md) Step 0). Callers that only tear down (finalize [`finalize.md`](finalize.md), the single-thread stops in [`contract.md`](contract.md) Steps 4–6) do not branch on the result; only Step 0's orphan gate consults it. Deleting an already-gone id is harmless either way.
13
+
14
+ Do **not** delete `cron.json` itself here; a terminal slot keeps it as a record, and reconcile ([`reconcile.md`](reconcile.md)) may still read it to sweep a straggler.
@@ -33,9 +33,15 @@ Every `increment`/`reset` this procedure applies to `last_seen.json`, and the `p
33
33
 
34
34
  **Stopped-slot absorb — before anything else, including the disk reads above.** If the kill file `~/.muggle-ai/muggle-do/polling.disabled` exists, or the slot dir is absent but `~/.muggle-ai/muggle-do/sessions/<slug>.stopped/` exists (the owner ran [`stop.md`](stop.md)), absorb: no fetch, no state write, no log line — output one line (`stopped: <slug> — absorbed`) and exit. This gate is what makes an **unreachable orphaned cron** harmless: a cron whose handle a compaction severed can neither be enumerated by `CronList` nor deleted by its recorded id, so it fires until its session dies — the absorb caps each fire at one line of output and nothing else. Never "fix" an orphan by deleting ids `CronList` does surface; those belong to other live watchers.
35
35
 
36
- 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.
36
+ 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), which reports **found** (a reachable cron was cancelled) or **not-found** (neither lookup matched a live cron). Append a `stale-tick` line to `followup.log` that records the result — `stale-tick cancel=<found|not-found>` — and exit. Do not re-fetch or re-finalize.
37
37
 
38
- **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.
38
+ **Orphan escalation — when no cancel ever reaches the cron, yet stale fires persist.** The signal is *reachability*, not a fire count. Once any cancel of this slug's cron has reported **found** finalize's teardown cancel ([`finalize.md`](finalize.md) Step 4) or an earlier stale fire's defensive cancel the cron was deletable in its runtime, so the fires that follow are the finite queued-drain backlog emptying: never escalate. The orphan is the opposite: the cron lives in a runtime neither the recorded id nor `CronList` can reach (a session continue / compaction severed every handle), so **every** cancel reports **not-found** and the fires keep arriving until the 7-day expiry. Escalate only when all three hold:
39
+
40
+ - this fire's defensive cancel reported **not-found**, **and**
41
+ - a prior `stale-tick` line for this slot already exists in `followup.log` — this is not the first stale fire, so it is not a lone drain, a manually-run tick, or a just-expired cron, **and**
42
+ - no `cancel=found` has ever been recorded for this slot (neither finalize's teardown line nor any earlier `stale-tick` line) — the cron was never reachable, so these fires are not a drained backlog.
43
+
44
+ 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.
39
45
 
40
46
  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.
41
47
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "skill_name": "muggle-pr-followup",
3
- "notes": "These evals test the TICK DECISION behavior plus the recovery/stop substrate. A real tick needs live GitHub state and the Cron tools, so each prompt hands the model a concrete slot + provider state and asks for the step-by-step plan it would execute; assertions check the plan takes the branch the contract mandates. Cases 0–10 cover per-tick dispatch/idle decisions; cases 11–17 cover cron-delivered ticks handing back to the token-free monitor, the owner stop switch, and the stopped-slot / kill-file absorb that makes an unreachable orphaned cron inert. Run via skill-creator's eval runner — CI does not execute these (ci.yml gates on vitest/check-skill-deps; skill-eval.yml runs gate scenarios sourced from muggle-ai-brain and the routing eval).",
3
+ "notes": "These evals test the TICK DECISION behavior plus the recovery/stop substrate. A real tick needs live GitHub state and the Cron tools, so each prompt hands the model a concrete slot + provider state and asks for the step-by-step plan it would execute; assertions check the plan takes the branch the contract mandates. Cases 0–10 cover per-tick dispatch/idle decisions; cases 11–17 cover cron-delivered ticks handing back to the token-free monitor, the owner stop switch, and the stopped-slot / kill-file absorb that makes an unreachable orphaned cron inert; cases 8 and 18–19 cover the monitor's own wake signals (settled-red CI, branch-behind/conflicting, and blocked-resume on any CI-digest move). Run via skill-creator's eval runner — CI does not execute these (ci.yml gates on vitest/check-skill-deps; skill-eval.yml runs gate scenarios sourced from muggle-ai-brain and the routing eval).",
4
4
  "evals": [
5
5
  {
6
6
  "id": 0,
@@ -88,10 +88,12 @@
88
88
  {
89
89
  "id": 7,
90
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.",
91
+ "prompt": "Tick muggle-ai-works-pr507 507. prs.json records state merged and result.md exists. The watcher's session was continued, so neither the recorded cron id nor CronList can reach this slug's cron. followup.log shows finalize recorded cancel=not-found and one earlier stale-tick line also recorded cancel=not-found — there is no cancel=found line anywhere for this slot and no stale-orphan-escalated line. This fire's defensive cancel per cancel-cron.md again reports not-found. Walk me through the plan for this tick.",
92
92
  "files": [],
93
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." },
94
+ { "name": "detects_orphaned_cron", "text": "Plan concludes the cron is orphaned because no cancel has ever reached it finalize's teardown cancel and every stale fire's defensive cancel all report not-found and the stale fires persist (a prior stale-tick line already exists). It reaches this from the reachability signal, not from counting stale fires to a threshold." },
95
+ { "name": "requires_persistence_not_a_lone_fire", "text": "Plan escalates because this is not the first stale fire (a prior stale-tick line exists); it would treat a single not-found fire as a possible manually-run tick or just-expired cron, not an orphan." },
96
+ { "name": "would_not_escalate_a_drained_backlog", "text": "Plan would NOT escalate had any cancel reported found — finalize's teardown cancel or an earlier stale fire's defensive cancel — because a reachable cron's later fires are a finite queued-drain backlog, not an orphan." },
95
97
  { "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
98
  { "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
99
  { "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." }
@@ -216,6 +218,31 @@
216
218
  { "name": "then_seeds_and_arms", "text": "Plan then seeds the slot and arms the watch normally — leaving the kill file in place would make every tick of the new watch absorb itself stillborn." },
217
219
  { "name": "only_bootstrap_clears_it", "text": "Plan notes only bootstrap (an explicit URL) clears the kill file; auto-track and reconcile never do." }
218
220
  ]
221
+ },
222
+ {
223
+ "id": 18,
224
+ "eval_name": "arm-monitor-wakes-on-branch-behind",
225
+ "prompt": "Arm the watch for muggle-ai-works-pr525 525 after a clean drain (no reviews, no unresolved threads, CI green, branch level with master at seed time). Describe the watch.sh monitor loop you would write and specifically what makes it wake the session when the base branch later advances and the branch falls behind.",
226
+ "files": [],
227
+ "assertions": [
228
+ { "name": "polls_branch_standing", "text": "The loop polls the branch's standing against its base each iteration (behind_by via the compare call and the conflict signal per branch-standing.md), not just reviews, threads, and CI." },
229
+ { "name": "wakes_on_behind_or_conflict", "text": "The loop wakes the session when behind_by > 0 or the branch conflicts with its base, so a silently-stale branch triggers a tick without waiting for an unrelated wake." },
230
+ { "name": "dedups_on_rebase_key_pair", "text": "The loop dedups the rebase wake on the rebase_key pair (head_sha..base_tip_sha), firing once per newly-due pair and re-arming when the head or base moves — not on a head-only key, which would wedge permanently once the base advances." },
231
+ { "name": "not_a_wake_when_unknown_and_level", "text": "The loop does NOT wake when behind_by == 0 and mergeable is UNKNOWN (conflict state still computing) — it waits, matching the tick's Step 5." },
232
+ { "name": "stays_a_dumb_pipe", "text": "The loop only signals a rebase is due and hands off to a tick; it does NOT apply the conflict_resolve_attempts budget, resolve conflicts, or dispatch the rebase itself — those belong to the tick and /muggle-do." }
233
+ ]
234
+ },
235
+ {
236
+ "id": 19,
237
+ "eval_name": "arm-monitor-wakes-on-cidigest-change-while-blocked",
238
+ "prompt": "Arm the watch for muggle-ai-works-pr526 526 while it is blocked pending a human (last_seen.blocked present, reason ci_escalated). Describe the watch.sh monitor loop and specifically what makes it resume the block when the head's CI later moves to green, or a rerun/external deploy check posts — i.e. a non-red CI change.",
239
+ "files": [],
240
+ "assertions": [
241
+ { "name": "blocked_probe_is_conditional", "text": "The loop runs the blocked-resume CI probe only while the watch is blocked — it keys off BLOCKED_CIDIGEST being non-empty, and the probe stays dormant when the watch is not blocked." },
242
+ { "name": "wakes_on_any_digest_move_not_just_red", "text": "While blocked, the loop wakes on ANY change to the head's CI digest away from BLOCKED_CIDIGEST — a green pass, a rerun, or an external deploy check — not only on a settled-red transition." },
243
+ { "name": "digest_matches_fingerprint_definition", "text": "The digest the loop computes is the same bucket-plus-sorted-name/conclusion signature the blocked fingerprint's ci_digest uses, so the loop and the tick's Step 2.5 resume gate agree by construction." },
244
+ { "name": "resume_is_the_tick_job", "text": "The loop only wakes on the digest move; clearing last_seen.blocked and re-evaluating belongs to the tick's Step 2.5 fingerprint gate, and the blocked-tick flag/resume path is what maintains BLOCKED_CIDIGEST — the loop never writes it." }
245
+ ]
219
246
  }
220
247
  ]
221
248
  }
@@ -26,4 +26,4 @@ Append the terminal line per [`output-templates/watcher-log.md`](output-template
26
26
 
27
27
  ### Step 4 — Unschedule the cron
28
28
 
29
- Cancel this slot's cron per [`cancel-cron.md`](cancel-cron.md). No-op when none matches — a manually-run tick, or a cron that already expired. Recurring `/loop` crons auto-expire after 7 days; that lapse is the gap [`reconcile.md`](reconcile.md) exists to catch.
29
+ Cancel this slot's cron per [`cancel-cron.md`](cancel-cron.md), which reports **found** or **not-found**. Record the result in `followup.log` — `finalize cancel=<found|not-found>` so a later stale fire's orphan gate ([`contract.md`](contract.md) Step 0) can tell a reachable cron whose queued fires merely drain (**found** here) from one it could never reach (**not-found** here, the orphan case). A **not-found** is otherwise benign — a manually-run tick, or a cron that already expired. Recurring `/loop` crons auto-expire after 7 days; that lapse is the gap [`reconcile.md`](reconcile.md) exists to catch.
@@ -176,8 +176,10 @@ The watch loop's comparison floor — plain `KEY=VALUE` lines, one file per slot
176
176
  - `COM` — highest thread-comment id already handled
177
177
  - `THREADS` — semicolon-joined ids of threads already known unresolved
178
178
  - `CIRED` — head SHA whose settled-red CI the drain already handled; empty when the checks are green, still pending, or unseen. 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)).
179
+ - `REBASED` — the `rebase_key` (`<head_sha>..<base_tip_sha>`) whose behind/conflicting state the drain already handled; empty when the branch is current with its base or unseen. 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) Step 5 dedups rebase dispatch on; the monitor only signals a due rebase, the tick applies the `conflict_resolve_attempts` budget.
180
+ - `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.
179
181
 
180
- Written whole-file by the orchestrating session — seeded at arm time to the **drain's own observed max** (the ids the drain read, snapshotted at that read) and advanced after every cycle to the **handled wave's snapshot** (the max review-id and comment-id the cycle read at the start of handling, captured before replies were posted). **Never** derived from a fresh fetch taken after the drain or after replies land: a later fetch includes ids that arrived in the handling window and were never read, and marking them seen swallows them ([`arm-watcher.md`](arm-watcher.md)). The advance also never moves any floor past an unresolved thread the cycle left unaddressed. 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.
182
+ Written whole-file by the orchestrating session — the review/comment/CI/rebase floors seeded at arm time (`REV`/`COM` to the **drain's own observed max**, `CIRED`/`REBASED` to what the drain handled) and advanced after every cycle to the **handled wave's snapshot** (the max review-id and comment-id the cycle read at the start of handling, captured before replies were posted); `BLOCKED_CIDIGEST` is maintained by the blocked-tick flag/resume path rather than the arm/cycle path. **Never** derived from a fresh fetch taken after the drain or after replies land: a later fetch includes ids that arrived in the handling window and were never read, and marking them seen swallows them ([`arm-watcher.md`](arm-watcher.md)). The advance also never moves any floor past an unresolved thread the cycle left unaddressed. 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.
181
183
 
182
184
  **Never `source` this file, and quote or extract values.** `THREADS` holds bare semicolons: sourced unquoted, the shell splits the line at the first `;` and silently drops every id after it — the loop then re-reports known threads as new. The watch loop must extract values verbatim (e.g. `grep '^THREADS=' | cut -d= -f2- | tr -d '"\r'`), tolerating quotes and CRLF; writers should quote the value anyway.
183
185