@muggleai/works 5.27.0-staging.246 → 5.27.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 (56) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/{chunk-PT5EGMBA.js → chunk-B5BWBZZ5.js} +2 -5
  3. package/dist/cli.js +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  6. package/dist/plugin/.codex-mcp.json +1 -1
  7. package/dist/plugin/.codex-plugin/plugin.json +1 -1
  8. package/dist/plugin/.cursor-plugin/plugin.json +1 -1
  9. package/dist/plugin/.mcp.json +1 -1
  10. package/dist/plugin/hooks/README.md +2 -2
  11. package/dist/plugin/scripts/pr-watch-arm.sh +27 -10
  12. package/dist/plugin/scripts/pr-watch-floors.sh +62 -0
  13. package/dist/plugin/scripts/pr-watch-guards.sh +11 -11
  14. package/dist/plugin/scripts/pr-watch-loop.sh +63 -50
  15. package/dist/plugin/scripts/reconcile-stale-watchers.sh +7 -30
  16. package/dist/plugin/skills/_shared/host-tool-equivalents.md +1 -1
  17. package/dist/plugin/skills/do/next-step.md +1 -1
  18. package/dist/plugin/skills/do/open-prs/forward.md +1 -1
  19. package/dist/plugin/skills/muggle-pr-followup/SKILL.md +5 -5
  20. package/dist/plugin/skills/muggle-pr-followup/adopt.md +2 -2
  21. package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +21 -16
  22. package/dist/plugin/skills/muggle-pr-followup/blocked-tick.md +3 -3
  23. package/dist/plugin/skills/muggle-pr-followup/bootstrap.md +1 -1
  24. package/dist/plugin/skills/muggle-pr-followup/contract.md +9 -7
  25. package/dist/plugin/skills/muggle-pr-followup/evals/evals.json +16 -16
  26. package/dist/plugin/skills/muggle-pr-followup/reconcile.md +4 -4
  27. package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +17 -6
  28. package/dist/plugin/skills/muggle-pr-followup/stop.md +3 -3
  29. package/dist/plugin/skills/muggle-preferences/preference-gates/watcherLifetime.md +2 -2
  30. package/dist/release-manifest.json +3 -3
  31. package/package.json +2 -2
  32. package/plugin/.claude-plugin/plugin.json +1 -1
  33. package/plugin/.codex-mcp.json +1 -1
  34. package/plugin/.codex-plugin/plugin.json +1 -1
  35. package/plugin/.cursor-plugin/plugin.json +1 -1
  36. package/plugin/.mcp.json +1 -1
  37. package/plugin/hooks/README.md +2 -2
  38. package/plugin/scripts/pr-watch-arm.sh +27 -10
  39. package/plugin/scripts/pr-watch-floors.sh +62 -0
  40. package/plugin/scripts/pr-watch-guards.sh +11 -11
  41. package/plugin/scripts/pr-watch-loop.sh +63 -50
  42. package/plugin/scripts/reconcile-stale-watchers.sh +7 -30
  43. package/plugin/skills/_shared/host-tool-equivalents.md +1 -1
  44. package/plugin/skills/do/next-step.md +1 -1
  45. package/plugin/skills/do/open-prs/forward.md +1 -1
  46. package/plugin/skills/muggle-pr-followup/SKILL.md +5 -5
  47. package/plugin/skills/muggle-pr-followup/adopt.md +2 -2
  48. package/plugin/skills/muggle-pr-followup/arm-watcher.md +21 -16
  49. package/plugin/skills/muggle-pr-followup/blocked-tick.md +3 -3
  50. package/plugin/skills/muggle-pr-followup/bootstrap.md +1 -1
  51. package/plugin/skills/muggle-pr-followup/contract.md +9 -7
  52. package/plugin/skills/muggle-pr-followup/evals/evals.json +16 -16
  53. package/plugin/skills/muggle-pr-followup/reconcile.md +4 -4
  54. package/plugin/skills/muggle-pr-followup/state-schemas.md +17 -6
  55. package/plugin/skills/muggle-pr-followup/stop.md +3 -3
  56. package/plugin/skills/muggle-preferences/preference-gates/watcherLifetime.md +2 -2
@@ -10,49 +10,54 @@ bash "<abs>/scripts/pr-watch-arm.sh" --slot "<slot>" --repo "<owner>/<repo>" --p
10
10
 
11
11
  The same argument that forbids authoring a per-slot `watch.sh` applies to arming itself. Performed from prose, the middle step — writing `watch-watermark.env` — is the one that gets dropped, and dropping it is silent: the loop gates every wake condition on that file, so it polls nothing, reports nothing and never reaches its terminal check, while still holding its PID lease and touching its heartbeat. Eleven such watchers once ran for hours across one session, missed nine merges and a red CI run between them, and every health signal said they were fine. Running one command cannot half-happen.
12
12
 
13
- Read the `DRAIN` lines it prints: they are the outstanding work as of the arm. Review ids and threads the drain read are floored, so the monitor will *not* tell you about those again. A stale branch or a red head is floored only when `last_seen.json` records a rebase or fix-ci cycle already attempted for that key or head ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)); otherwise the drain line says `not floored: dispatch …` and the loop's first poll reports it as an event. A PR that spent its catch-up rebase budget is the exception: its drain line says the rebase is `held for the owner`, and nothing is dispatched — the tick would refuse it — until the owner raises [`maxCatchUpRebases`](../muggle-preferences/preference-gates/maxCatchUpRebases.md). Re-arming is never a way to set work aside — it neither handles nor silences anything. Steps 1-3 below are what the script does, and why.
13
+ The `DRAIN` lines it prints, in the watch's output, are the outstanding work as of the arm. Review ids and threads the drain read are floored, so the loop will *not* tell you about those again. A stale branch or a red head is floored only when `last_seen.json` records a rebase or fix-ci cycle already attempted for that key or head ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)); otherwise the drain line says `not floored: dispatch …` and the loop's first poll reports it as an event. A PR that spent its catch-up rebase budget is the exception: its drain line says the rebase is `held for the owner`, and nothing is dispatched — the tick would refuse it — until the owner raises [`maxCatchUpRebases`](../muggle-preferences/preference-gates/maxCatchUpRebases.md). Re-arming is never a way to set work aside — it neither handles nor silences anything. Steps 1-3 below are what the script does, and why.
14
14
 
15
15
  1. **Drain.** Run one tick per [`contract.md`](contract.md). It acts on everything already outstanding — actionable threads (`gitlab`: discussions), body-only reviews past the watermark (GitHub-only — GitLab has no review envelope), a stale branch, red CI — and finalizes a terminal PR. If the tick dispatched a cycle, stop here: the cycle's exit path settles the watch when it finishes.
16
- 2. **Seed the watermark.** *(`pr-watch-arm.sh` does this; the reasoning is kept because the floors are subtle and wrong ones are quiet.)* Resolve the provider once per [`../_shared/vcs/detect-vcs.md`](../_shared/vcs/detect-vcs.md) — every fetch in this sequence uses that provider's recipes. Write the slot's watch watermark ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)) to the ids the **drain itself read** — the max review-id and comment-id observed at the drain's own fetch (Step 1), snapshotted at that read. Never let the loop capture its own baseline — the arming session writes it; and **never** from a fresh fetch taken after the drain, which would include a comment that arrived after the drain read the wave and mark it seen unread. Seeded to the drain's floor, anything landing after that read stays above the watermark and the monitor's first iteration surfaces it. Seed the CI floor (`CIRED`) from the same drain read: set it to the head SHA only when the checks have **already settled red** at that read (no check pending, one or more in the `fail` bucket per [`../_shared/vcs/common/ci-rollup.md`](../_shared/vcs/common/ci-rollup.md)) **and** `last_seen.json` records a fix-ci attempt for that head — a cycle already ran for that red, so its escalation or retry is not news — and empty otherwise. Seed the rebase floor (`REBASED`) the same way, from the drain's branch-standing read per [`../_shared/vcs/common/branch-standing.md`](../_shared/vcs/common/branch-standing.md): the current `rebase_key` (`<head_sha>..<base_tip_sha>`) only when the drain found the branch behind or conflicting **and** a rebase attempt is recorded for that key, empty otherwise. An escalation entry alone never floors anything: one with no attempt behind it records a decision nobody acted on, and flooring it is how a re-arming session once kept a stale branch and a red head silent for a day ([`state-schemas.md`](state-schemas.md#watch-watermarkenv) names the fields). Seed the blocked-CI floor (`BLOCKED_CIDIGEST`) to the blocked fingerprint's `ci_digest` when arming while `last_seen.blocked` is already set, and empty otherwise — empty is the not-blocked state, in which the loop's blocked-resume probe stays dormant. Seed the rebase hold (`REBASE_HELD`) to `1` when `last_seen.json`'s `catch_up_rebases` has reached `maxCatchUpRebases` — the same check the tick's verdict blocks on — and empty otherwise: while it is set the loop raises no rebase wake on any base move. The script reads all four facts from `last_seen.json` through [`../../scripts/pr-watch-verdict.mjs`](../../scripts/pr-watch-verdict.mjs) `arm-floors`, and records what it found due in `watch-due.env` ([`state-schemas.md`](state-schemas.md#watch-dueenv)).
17
- 3. **Dedup, then watch.** First read `<slot>/watch.pid` ([`state-schemas.md`](state-schemas.md#watchpid)): if it names a live process (`kill -0 "$pid"` succeeds) **and `owner.json` records this session**, a watcher already owns this slot — **skip arming, do not start a second**. This is what stops orphaned watchers from accumulating: the in-session 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.
16
+ 2. **Seed the watermark.** *(`pr-watch-arm.sh` does this; the reasoning is kept because the floors are subtle and wrong ones are quiet.)* Resolve the provider once per [`../_shared/vcs/detect-vcs.md`](../_shared/vcs/detect-vcs.md) — every fetch in this sequence uses that provider's recipes. Write the slot's watch watermark ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)) to the ids the **drain itself read** — the max review-id and comment-id observed at the drain's own fetch (Step 1), snapshotted at that read. Never let the loop capture its own baseline — the arming session writes it; and **never** from a fresh fetch taken after the drain, which would include a comment that arrived after the drain read the wave and mark it seen unread. Seeded to the drain's floor, anything landing after that read stays above the watermark and the loop's first iteration surfaces it. Seed the CI floor (`CIRED`) from the same drain read: set it to the head SHA only when the checks have **already settled red** at that read (no check pending, one or more in the `fail` bucket per [`../_shared/vcs/common/ci-rollup.md`](../_shared/vcs/common/ci-rollup.md)) **and** `last_seen.json` records a fix-ci attempt for that head — a cycle already ran for that red, so its escalation or retry is not news — and empty otherwise. Seed the rebase floor (`REBASED`) the same way, from the drain's branch-standing read per [`../_shared/vcs/common/branch-standing.md`](../_shared/vcs/common/branch-standing.md): the current `rebase_key` (`<head_sha>..<base_tip_sha>`) only when the drain found the branch behind or conflicting **and** a rebase attempt is recorded for that key, empty otherwise. An escalation entry alone never floors anything: one with no attempt behind it records a decision nobody acted on, and flooring it is how a re-arming session once kept a stale branch and a red head silent for a day ([`state-schemas.md`](state-schemas.md#watch-watermarkenv) names the fields). Seed the blocked-CI floor (`BLOCKED_CIDIGEST`) to the blocked fingerprint's `ci_digest` when arming while `last_seen.blocked` is already set, and empty otherwise — empty is the not-blocked state, in which the loop's blocked-resume probe stays dormant. Seed the rebase hold (`REBASE_HELD`) to `1` when `last_seen.json`'s `catch_up_rebases` has reached `maxCatchUpRebases` — the same check the tick's verdict blocks on — and empty otherwise: while it is set the loop raises no rebase wake on any base move. The script reads all four facts from `last_seen.json` through [`../../scripts/pr-watch-verdict.mjs`](../../scripts/pr-watch-verdict.mjs) `arm-floors`, and records what it found due in `watch-due.env` ([`state-schemas.md`](state-schemas.md#watch-dueenv)).
17
+ 3. **Dedup, then watch.** First read `<slot>/watch.pid` ([`state-schemas.md`](state-schemas.md#watchpid)): if it names a live process (`kill -0 "$pid"` succeeds) **and `owner.json` records this session**, a watcher already owns this slot — **skip arming, do not start a second**. This is what stops orphaned watchers from accumulating: the in-session task dying does not stop the OS loop it launched (on Windows a detached Git Bash loop keeps running and polling `gh` forever after the session ends), so checking a live task list is not enough — the PID lease is.
18
18
 
19
- **A live lease owned by a different session does not count as owned** — check it with `watcher_lease_is_foreign` from [`../../scripts/pr-watch-guards.sh`](../../scripts/pr-watch-guards.sh). A live PID proves the loop is running, never that anything is listening: an orphan left by a dead session keeps polling and keeps touching its heartbeat while its stdout is that session's closed monitor pipe, so every liveness signal reads healthy and the events reach nobody. Skipping on the PID alone let such a corpse block this session from arming the PR for the whole lifetime cap — seven days, since the default moved off six hours. When the guard reports a foreign lease, kill that PID, delete `watch.pid`, and arm fresh; note it in the slot's `followup.log` so the reclaim is visible.
19
+ **A live lease owned by a different session does not count as owned** — check it with `watcher_lease_is_foreign` from [`../../scripts/pr-watch-guards.sh`](../../scripts/pr-watch-guards.sh). A live PID proves the loop is running, never that anything is listening: an orphan left by a dead session keeps polling and keeps touching its heartbeat while its stdout is that session's closed pipe, so every liveness signal reads healthy and the events reach nobody. Skipping on the PID alone let such a corpse block this session from arming the PR for the whole lifetime cap — seven days, since the default moved off six hours. When the guard reports a foreign lease, kill that PID, delete `watch.pid`, and arm fresh; note it in the slot's `followup.log` so the reclaim is visible.
20
20
 
21
- This is narrow on purpose. It reclaims only a lease held on behalf of *another* session, which is decidable from `owner.json` alone. A loop orphaned by its **own** session's monitor still reads as owned, and nothing on disk distinguishes it — so the signal comes from the reader instead. **When this arm answers the slot's own `ROLLOVER` line or its monitor's expiry notice, skip this dedup and arm.** Either notice means the previous loop is gone or no longer heard: a rolled-over loop has already exited, and one that outlived an expired monitor is writing into a closed pipe. The new loop writes its PID to `watch.pid`, and `watcher_superseded` retires any survivor at its next iteration. Treat an armed watcher as evidence a poller exists, never as proof a review will be seen.
21
+ This is narrow on purpose. It reclaims only a lease held on behalf of *another* session, which is decidable from `owner.json` alone. A loop orphaned by its **own** session still reads as owned, and nothing on disk distinguishes it — so the signal comes from the reader instead. **When this arm answers the slot's own loop ending — its `EXPIRED` or `ROLLOVER` line, or a monitor's expiry notice — skip this dedup and arm.** Each means the previous loop is gone or no longer heard. The new loop writes its PID to `watch.pid`, and `watcher_superseded` retires any survivor at its next iteration. Treat an armed watcher as evidence a poller exists, never as proof a review will be seen.
22
22
 
23
23
  Otherwise **claim the slot for this session** before starting anything: write `owner.json` ([`state-schemas.md`](state-schemas.md#ownerjson)) with `session_id` from `$CLAUDE_CODE_SESSION_ID` and `claimed_at` now. Arming is what establishes ownership, so every arming point records it here rather than each caller remembering to. If `$CLAUDE_CODE_SESSION_ID` is unset, write no `owner.json` — an unidentifiable owner is worse than none, since [`reconcile.md`](reconcile.md) would read a bogus id as some other session's claim and could never recover the slot.
24
24
 
25
25
  The same claim **registers the slot with the muggle watch service**: `pr-watch-arm.sh` writes [`watch-registration.json`](state-schemas.md#watch-registrationjson) beside `owner.json`. The service is a per-user scheduled job that runs outside every session. When a registered slot's loop is gone while its owner session is down, it resumes that owner in the background (`claude --resume <owner> --bg`), and the owner's own reconcile re-arms the watch. A slot armed any other way is registered with `muggle watch register --slot <slot>`. `muggle watch unregister --slot <slot>` keeps a slot session-scoped. The service still reports on it, but never resumes its owner.
26
26
 
27
- Then start the watch as a **persistent background monitor** in the orchestrating session, with the script path resolved to an absolute path at arm time (from `${CLAUDE_PLUGIN_ROOT}/scripts/`) so it still resolves after the arming session is gone. `pr-watch-arm.sh` performs Steps 1-2 and then execs the loop, so the monitor's command is the arm script:
27
+ Then start the watch as a **background shell command** in the orchestrating session — the `Bash` tool with `run_in_background: true`, never `Monitor` — with the script path resolved to an absolute path at arm time (from `${CLAUDE_PLUGIN_ROOT}/scripts/`) so it still resolves after the arming session is gone. `pr-watch-arm.sh` performs Steps 1-2 and then execs the loop, so the command is the arm script:
28
28
 
29
29
  ```sh
30
30
  bash "<abs>/scripts/pr-watch-arm.sh" --slot "<slot>" --repo "<owner>/<repo>" --pr <n> --base <base-branch>
31
31
  ```
32
32
 
33
- Give the monitor the tool's maximum timeout, 1800000 ms. The loop ends itself one poll inside that, at `MUGGLE_PR_WATCH_MONITOR_WINDOW` (1740s), because the tool stops reading a background task at its timeout without stopping the process — a loop left running past it would poll into a closed pipe.
33
+ **A quiet PR costs the session nothing.** A background command has no time cap and wakes the session only when it exits, and the loop exits only when there is something to hear: the first poll that finds work, the PR going terminal, persistent fetch failure, or the lifetime cap. A `Monitor` is the wrong tool: it stops reading after 30 minutes, so a loop under it must hand the watch back for a fresh arm every half hour, and every hand-back and every line it prints is a model turn re-reading the whole session — about two per PR per half hour, whether or not anything changed.
34
34
 
35
35
  Pass `--no-exec` to seed a slot without starting a loop; it is otherwise the same sequence.
36
36
 
37
37
  **Never author a per-slot `watch.sh`.** The loop ships as [`../../scripts/pr-watch-loop.sh`](../../scripts/pr-watch-loop.sh), with its wake conditions in [`../../scripts/pr-watch-events.sh`](../../scripts/pr-watch-events.sh) and its state projection in [`../../scripts/pr-watch-state.jq`](../../scripts/pr-watch-state.jq); arming runs it and passes arguments. Writing the loop from this prose was how it drifted — each arm produced an independent derivation, and a derivation that quietly dropped a wake still ran, still heartbeat, still logged, and simply never fired for the signal it lost. Two slots on one machine ended up without the behind-base wake, which left their PRs unmergeable under watchers that looked healthy. The prose below says *why* each wake exists; the shipped files are the only definition of *what* fires. A slot holding a legacy generated `watch.sh` keeps it until re-armed, at which point the supersede guard retires the old loop.
38
38
 
39
- The label is `PR #<n> — <title>`. Label and command both matter: some task surfaces show one, some the other, and a slot-bearing command keeps the watch identifiable everywhere a raw script blob would not. One monitor per PR at a time: it is the watch's visible handle, showing as a running task while the PR is polled, and replaced by a fresh one each time the loop rolls over. 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.
39
+ The task's description is `PR #<n> — <title>`. Description and command both matter: some task surfaces show one, some the other, and a slot-bearing command keeps the watch identifiable everywhere a raw script blob would not. One background task per PR at a time: it is the watch's visible handle, showing as a running task while the PR is polled, and replaced by a fresh one after each report. Its loop checks about every 60 seconds, re-reading the watermark and touching the slot's `watch-heartbeat` file each iteration — the liveness beacon that tells [`reconcile.md`](reconcile.md) a quiet watch is still alive; on a newer submitted review, a newer thread comment (`gitlab`: a newer discussion note), a thread newly unresolved (`gitlab`: discussion), **the head SHA's checks settling red** (no check pending and one or more in the `fail` bucket per [`../_shared/vcs/common/ci-rollup.md`](../_shared/vcs/common/ci-rollup.md)), **the branch falling behind or conflicting with its base** (`behind_by > 0` or the conflict signal per [`../_shared/vcs/common/branch-standing.md`](../_shared/vcs/common/branch-standing.md)), or — **only while the watch is blocked** (`BLOCKED_CIDIGEST` non-empty) — **the head's CI digest changing in any way** (not just to red) — it prints one line per finding and **exits**, recording what it reported in `watch-reported.env` ([`state-schemas.md`](state-schemas.md#watch-reportedenv)) so no later loop reports the same occurrence again. The review and thread floors are monotonic ids; the other three are not. The CI-red floor is the **head SHA**, because the check rollup is non-monotonic — it flips green↔red and resets on every push — so recording the red head SHA fires CI once per red head, and a later push re-arms it on the new SHA. The rebase floor (`REBASED`) is the **`rebase_key`** — `<head_sha>..<base_tip_sha>` — because staleness is a function of both sides: keying on the pair fires once per newly-due pair and re-arms when either the head or the base moves, where a head-only key would wedge permanently the first time the base advances (the head cannot change while nobody pushes). A head whose checks are still **pending** is never a red wake, and a branch with `behind_by == 0` and `mergeable == UNKNOWN` is never a rebase wake: pending checks may yet go green and conflict state is still computing, and the tick would idle on either (Steps 5–6) regardless. The blocked-CI signal is different in kind — a **resume** probe, live only while the watch is blocked: it wakes on any move of the head's CI digest (the same bucket-plus-sorted-name/conclusion signature the blocked fingerprint records — [`blocked-tick.md`](blocked-tick.md)) away from `BLOCKED_CIDIGEST`, so a block waiting on a green pass, a rerun, or an external deploy check resumes as promptly as one waiting on red. Quiet iterations print nothing and cost nothing — no model tokens are spent while the watch is quiet.
40
40
 
41
41
  Each poll also rewrites the slot's `watch-due.env` whenever the due work changes — the rebase key found due and the head whose checks settled red, or an empty file when neither — so the session's turn-end gate can see work the watch reported without a provider call ([`state-schemas.md`](state-schemas.md#watch-dueenv)).
42
42
 
43
- The loop is **self-terminating**, so it never becomes one of those orphans. It claims the slot's `watch.pid`, then checks the guards from [`../../scripts/pr-watch-guards.sh`](../../scripts/pr-watch-guards.sh) at the top of every iteration before any `gh` call: `watcher_superseded` steps it down when a newer arm takes the slot; `watcher_window_elapsed` ends it one poll inside its monitor's timeout, printing a `ROLLOVER` line so the owning session re-arms; and `watcher_lifetime_exceeded` retires an orphan nothing supersedes. Every wait, a fetch backoff included, is clamped to the window, so no sleep carries the loop past its monitor. A failed fetch is never mistaken for a terminal PR — it counts toward `MUGGLE_PR_WATCH_MAX_FETCH_FAILURES` (60) with `watcher_fetch_backoff` between tries, so hours of outage are ridden out and the error lands in `<slot>/watch-fetch.log`. Terminal exit stays reserved for an observed `MERGED`/`CLOSED`.
43
+ The loop is **self-terminating**, so it never becomes one of those orphans. It claims the slot's `watch.pid`, then checks the guards from [`../../scripts/pr-watch-guards.sh`](../../scripts/pr-watch-guards.sh) at the top of every iteration before any `gh` call: `watcher_superseded` steps it down silently when a newer arm takes the slot, and `watcher_lifetime_exceeded` retires it with an `EXPIRED` line so a still-open PR's owner re-arms it. `watcher_window_elapsed` acts only on a loop given a `MUGGLE_PR_WATCH_MONITOR_WINDOW`, for running under a `Monitor`: it ends the loop with a `ROLLOVER` line before the monitor stops reading, and every wait is clamped to that window. A failed fetch is never mistaken for a terminal PR — it counts toward `MUGGLE_PR_WATCH_MAX_FETCH_FAILURES` (60) with `watcher_fetch_backoff` between tries, so hours of outage are ridden out and the error lands in `<slot>/watch-fetch.log`. Terminal exit stays reserved for an observed `MERGED`/`CLOSED`.
44
44
 
45
- The lifetime cap comes from the [`watcherLifetime`](../muggle-preferences/preference-gates/watcherLifetime.md) preference — `7d` by default, `1d`, or `never` for unbounded. The loop cannot read preferences, so resolve the value at arm time, convert it to seconds, and export `MUGGLE_PR_WATCH_MAX_LIFETIME` into the monitor's environment; an already-set value wins and is never overwritten. `never` exports 0, which the guard reads as unbounded. Under a monitor the window ends each loop long before either cap, so it is the window, not this value, that retires a loop once its session is gone — within about 29 minutes. The lifetime bounds a loop run with the window disabled (`MUGGLE_PR_WATCH_MONITOR_WINDOW=0`).
45
+ The lifetime cap comes from the [`watcherLifetime`](../muggle-preferences/preference-gates/watcherLifetime.md) preference — `7d` by default, `1d`, or `never` for unbounded. The loop cannot read preferences, so resolve the value at arm time, convert it to seconds, and export `MUGGLE_PR_WATCH_MAX_LIFETIME` into the command's environment; an already-set value wins and is never overwritten. `never` exports 0, which the guard reads as unbounded. A quiet PR's loop runs until this cap, so it also sets how often such a PR costs its owner a turn: once per lifetime, to re-arm. It is also what retires an orphan — a loop that outlived its session, which on Windows a detached Git Bash loop can — that nothing supersedes.
46
46
 
47
- So it exits when the PR goes terminal, after `MUGGLE_PR_WATCH_MAX_FETCH_FAILURES` consecutive failed fetches, when a newer arm supersedes it, when its monitor window elapses, or once it passes the lifetime cap — never `while true` unbounded.
48
- 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 start of the session that owns it.
47
+ So it exits on its first report, when the PR goes terminal, after `MUGGLE_PR_WATCH_MAX_FETCH_FAILURES` consecutive failed fetches, when a newer arm supersedes it, or once it passes the lifetime cap — never `while true` unbounded.
48
+ 4. **On exit.** Polling never enters the session: the cadence lives entirely in the detached loop (step 3), and the session hears it only when the loop exits. Read the tail of the task's output — its last lines say why it ended:
49
49
 
50
- A `ROLLOVER` line is not an event to tick on: re-run [`pr-watch-arm.sh`](../../scripts/pr-watch-arm.sh) for the slot as a new monitor, without Step 3's dedup. Its `DRAIN` lines cover anything that landed between the old loop's last poll and the new one's first, and work still unattempted wakes the new loop again — a rollover is not a reprieve. Answer a monitor expiry notice for the slot the same way — it should not occur, since the window closes first, but if it does the loop is unheard and must be replaced.
50
+ - `EVENT` lines — run the tick (step 1) with `--wake=<event>`. A tick that dispatches a cycle leaves re-arming to the cycle's settle; a tick that idles re-arms per [`contract.md`](contract.md) Step 7.5.
51
+ - `TERMINAL` — run the tick, which finalizes the slot.
52
+ - `WATCH-FAIL` — surface the reason; [`reconcile.md`](reconcile.md) re-arms the slot at the next start of the session that owns it.
53
+ - `EXPIRED` (or `ROLLOVER`, from a loop given a monitor window) — not an event to tick on: re-arm per Step 3, without its dedup. The new arm's `DRAIN` read covers anything that landed between the old loop's last poll and the new one's first.
54
+ - No line — a newer arm superseded the loop, and that arm's loop owns the slot. Nothing to do.
51
55
 
56
+ The wake turn runs the tick 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 loop exits on its own.
52
57
  **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.
53
58
 
54
- **Scan before advancing — a hard invariant.** Before writing the advanced watermark, confirm every unresolved review thread in the handled wave has been addressed: a reply posted, or an explicit deferral recorded. If any unresolved thread is still unaddressed, do **not** advance past it — leave its newest-comment id above the `COM` floor and its thread id out of the known-unresolved `THREADS` set, so the monitor's next iteration wakes on it and the next tick re-surfaces it. The watermark only ever moves over threads that were actually handled.
59
+ **Scan before advancing — a hard invariant.** Before writing the advanced watermark, confirm every unresolved review thread in the handled wave has been addressed: a reply posted, or an explicit deferral recorded. If any unresolved thread is still unaddressed, do **not** advance past it — leave its newest-comment id above the `COM` floor and its thread id out of the known-unresolved `THREADS` set, so the loop's next iteration wakes on it and the next tick re-surfaces it. The watermark only ever moves over threads that were actually handled.
55
60
 
56
- Arm a fresh monitor only when none is alive — judged by the `<slot>/watch.pid` lease (a live PID means a watcher already owns the slot), not by the in-session task list, since an orphaned OS loop outlives the session that launched it; the settle path never duplicates a running watch. The one exception is answering the slot's own `ROLLOVER` or monitor-expiry notice (Step 3).
61
+ Arm a fresh loop only when none is alive — judged by the `<slot>/watch.pid` lease (a live PID means a watcher already owns the slot), not by the in-session task list, since an orphaned OS loop outlives the session that launched it; the settle path never duplicates a running watch. The one exception is answering the slot's own loop ending (Step 3).
57
62
 
58
- Drain-then-watch is the invariant: the tick handles everything up to the watermark with the real semantics — marker rule, watermark, attempt budgets — that the watch deliberately lacks, so the monitor only needs to see what arrives after it.
63
+ Drain-then-watch is the invariant: the tick handles everything up to the watermark with the real semantics — marker rule, watermark, attempt budgets — that the watch deliberately lacks, so the loop only needs to see what arrives after it.
@@ -4,7 +4,7 @@ The watcher's conditional path for a PR **blocked pending a human** — a durabl
4
4
 
5
5
  A block is entered only on a `block-*` verdict from the Step 6 verdict script. An escalation entry no cycle attempted is not a block — the verdict dispatches the work instead — and neither is the cost of running it.
6
6
 
7
- **Governing rule — one reminder per block, and the watch never stops.** The tick that flags the block reminds the owner once; every later blocked tick is silent. The watch keeps standing — the monitor stays visible, and ticks still run at their normal cadence (historically `1m`) whenever a wake or recovery cron fires — with fingerprint-based auto-resume clearing the block the moment external state moves. No repeat nagging, no cadence swap, no separate `reminded` flag: `blocked` present means the reminder has already been sent.
7
+ **Governing rule — one reminder per block, and the watch never stops.** The tick that flags the block reminds the owner once; every later blocked tick is silent. The watch keeps standing — the loop stays visible, and ticks still run at their normal cadence (historically `1m`) whenever a wake or recovery cron fires — with fingerprint-based auto-resume clearing the block the moment external state moves. No repeat nagging, no cadence swap, no separate `reminded` flag: `blocked` present means the reminder has already been sent.
8
8
 
9
9
  ## The fingerprint
10
10
 
@@ -21,7 +21,7 @@ Any component moving means the block may have cleared — a new push (`head_sha`
21
21
  When an idle tick is a durable human-block and `last_seen.blocked` is not already set:
22
22
 
23
23
  1. Increment `last_seen.idle_tick_count`.
24
- 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.
24
+ 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 loop'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.
25
25
  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.
26
26
  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.
27
27
 
@@ -32,7 +32,7 @@ Every subsequent tick while `last_seen.blocked` is present: recompute the finger
32
32
  - **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.
33
33
  `reason: "rebase_budget_exhausted"` is the one exception to the gate: treat it as **Unchanged** whatever the fingerprint says. The budget was spent by the base advancing, so resuming on a fingerprint move would hand the PR straight back into the unbounded rebase loop the cap exists to end. It clears only when the owner raises [`maxCatchUpRebases`](../muggle-preferences/preference-gates/maxCatchUpRebases.md) or the PR goes terminal.
34
34
 
35
- - **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).
35
+ - **Changed** → clear `last_seen.blocked`, clear the watch-watermark's `BLOCKED_CIDIGEST` to empty (whole-file rewrite — disarms the loop'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).
36
36
 
37
37
  ## Invariants
38
38
 
@@ -85,7 +85,7 @@ Create `iterations/` subdir (empty) for future caller use.
85
85
 
86
86
  ### Step 8 — Arm the watch
87
87
 
88
- Arm per [`arm-watcher.md`](arm-watcher.md) as the last action of the turn: one tick drains anything already actionable — this is the first tick Step 6 promises — then a persistent, labeled monitor keeps watch — visible until the PR terminates. The cron path stays as the recovery substrate ([`reconcile.md`](reconcile.md)), so `cron.json` is still seeded in Step 7.
88
+ Arm per [`arm-watcher.md`](arm-watcher.md) as the last action of the turn: one tick drains anything already actionable — this is the first tick Step 6 promises — then a background watch loop keeps watch — visible until the PR terminates. The cron path stays as the recovery substrate ([`reconcile.md`](reconcile.md)), so `cron.json` is still seeded in Step 7.
89
89
 
90
90
  ### Step 9 — Print the success summary
91
91
 
@@ -178,20 +178,22 @@ Any tick that did not dispatch. The Step 6 verdict says **why** it idled. It is
178
178
 
179
179
  `idle` is **transient** — green and waiting for the next review, CI still pending, or `mergeable == UNKNOWN` — and must keep the responsive `1m` cadence; those turn a state on their own and the watcher should catch it promptly.
180
180
 
181
- **Transient idle** (no durable block): unchanged — increment `last_seen.idle_tick_count`, append an idle line to `followup.log` per [`output-templates/watcher-log.md`](output-templates/watcher-log.md), emit a `tick` event with `idle: true`, `blocked: false`, `actionable_threads: 0`, `dispatched_review_ids: []`, `rebase_needed: <bool>`, `dispatched_rebase: false`, `checks_red: <count or 0>`, `dispatched_ci_fix: false`. Exit through Step 7.5. The next tick fires from the monitor ([`arm-watcher.md`](arm-watcher.md)).
181
+ **Transient idle** (no durable block): unchanged — increment `last_seen.idle_tick_count`, append an idle line to `followup.log` per [`output-templates/watcher-log.md`](output-templates/watcher-log.md), emit a `tick` event with `idle: true`, `blocked: false`, `actionable_threads: 0`, `dispatched_review_ids: []`, `rebase_needed: <bool>`, `dispatched_rebase: false`, `checks_red: <count or 0>`, `dispatched_ci_fix: false`. Exit through Step 7.5. The next tick fires from the loop that step leaves running ([`arm-watcher.md`](arm-watcher.md)).
182
182
 
183
183
  **Blocked pending a human** (a durable block, and `last_seen.blocked` not already set): enter the blocked path per [`blocked-tick.md`](blocked-tick.md) — flag `last_seen.blocked` and emit the one-line owner reminder, **once per block**. From the next tick on, the Step 2.5 gate carries the block silently. Exit through Step 7.5.
184
184
 
185
- ### Step 7.5 — Hand a cron-delivered tick back to the monitor
185
+ ### Step 7.5 — Leave one loop polling
186
186
 
187
- Runs on every tick that idles — both Step 7 branches and a Step 2.5 held block — and only when the tick was **delivered by a recovery cron** (`/loop` fired it). A monitor wake skips this step (the monitor already owns the cadence). The cron's job was to deliver *this* tick, never to become the poller: every cron fire is a full model turn, while the monitor polls token-free at the same `1m` cadence ([`arm-watcher.md`](arm-watcher.md)).
187
+ Runs on every tick that idles — both Step 7 branches and a Step 2.5 held block. A loop exits on every report, so after a wake nothing polls the PR until this step arms one; a dispatching tick skips it, because the cycle's settle re-arms when the cycle ends.
188
188
 
189
- Check the slot's `watch-heartbeat` mtime — a live monitor touches it every iteration:
189
+ Check the slot's `watch.pid` lease ([`state-schemas.md`](state-schemas.md#watchpid)):
190
190
 
191
- - **Stale or missing (older than 3 minutes)** — the monitor is dead and this cron has become the primary poller. This tick already served as the drain, so finish the arming sequence per [`arm-watcher.md`](arm-watcher.md): seed the watermark to the ids this tick itself read — its own observed max, never a fresh post-tick fetch (a later fetch would swallow a comment that arrived after the tick read the wave) — and start the persistent monitor. Then cancel this cron per [`cancel-cron.md`](cancel-cron.md) and append a `re-armed (monitor restored)` line to `followup.log`.
192
- - **Fresh** — a monitor already owns the cadence and this cron is a duplicate poller. Cancel the cron per [`cancel-cron.md`](cancel-cron.md); nothing to arm.
191
+ - **Dead or missing** — arm per [`arm-watcher.md`](arm-watcher.md) and append a `re-armed` line to `followup.log`. The arm carries the slot's review, comment and thread floors forward, so anything that landed after this tick's read still reaches the session.
192
+ - **Live** — a loop already owns the cadence (this tick was not its wake). Nothing to arm.
193
193
 
194
- Either way exactly one poller remains — the monitor — and at most one model turn was spent. Without this step a recovery cron keeps firing a model-turn tick every minute until its 7-day expiry, burning tokens on unchanged PRs the whole time.
194
+ When a **recovery cron** delivered the tick (`/loop` fired it), also cancel the cron per [`cancel-cron.md`](cancel-cron.md). Its job was to deliver *this* tick, never to become the poller: every cron fire is a full model turn, while the loop polls token-free at the same `1m` cadence.
195
+
196
+ Either way exactly one poller remains — the loop — and this tick was the only model turn spent.
195
197
 
196
198
  ## Output
197
199
 
@@ -137,37 +137,37 @@
137
137
  },
138
138
  {
139
139
  "id": 11,
140
- "eval_name": "cron-delivered-idle-with-live-monitor-cancels-duplicate",
141
- "prompt": "Tick muggle-ai-works-pr520 520 — this fire was delivered by a /loop recovery cron, not a monitor wake. The slot is open, watch-heartbeat was touched 40 seconds ago, there are no unresolved review threads, CI is green, and the branch is level with master. Walk me through the plan for this tick.",
140
+ "eval_name": "cron-delivered-idle-with-live-loop-cancels-duplicate",
141
+ "prompt": "Tick muggle-ai-works-pr520 520 — this fire was delivered by a /loop recovery cron, not a loop's wake. The slot is open, watch.pid names a live loop process, there are no unresolved review threads, CI is green, and the branch is level with master. Walk me through the plan for this tick.",
142
142
  "files": [],
143
143
  "assertions": [
144
- { "name": "recognises_cron_delivered_fire", "text": "Plan recognises this tick was delivered by a /loop recovery cron and therefore runs the hand-back-to-monitor step — a monitor wake would skip it." },
145
- { "name": "reads_heartbeat_as_live_monitor", "text": "Plan reads watch-heartbeat mtime, sees it is fresh (within 3 minutes), and concludes a live monitor already owns the 1m cadence." },
144
+ { "name": "recognises_cron_delivered_fire", "text": "Plan recognises this tick was delivered by a /loop recovery cron, so besides leaving one loop polling it must cancel the cron." },
145
+ { "name": "reads_lease_as_live_loop", "text": "Plan checks the slot's watch.pid lease, finds a live loop, and concludes it already owns the 1m cadence." },
146
146
  { "name": "cancels_duplicate_cron", "text": "Plan cancels this cron per cancel-cron.md (recorded-id first) because it is a duplicate poller — there is nothing to arm." },
147
- { "name": "never_recreates_cron_or_double_arms", "text": "Plan does NOT CronCreate a recurring cron and does NOT start a second monitor over the live one; exactly one poller (the monitor) remains." }
147
+ { "name": "never_recreates_cron_or_double_arms", "text": "Plan does NOT CronCreate a recurring cron and does NOT start a second loop over the live one; exactly one poller (the loop) remains." }
148
148
  ]
149
149
  },
150
150
  {
151
151
  "id": 12,
152
- "eval_name": "cron-delivered-idle-with-dead-monitor-rearms-monitor",
153
- "prompt": "Tick muggle-ai-works-pr521 521 — this fire was delivered by a /loop recovery cron. The slot is open, watch-heartbeat is missing (the last monitor died), there are no unresolved review threads, CI is green, and the branch is level with master. Walk me through the plan for this tick.",
152
+ "eval_name": "cron-delivered-idle-with-dead-loop-rearms-loop",
153
+ "prompt": "Tick muggle-ai-works-pr521 521 — this fire was delivered by a /loop recovery cron. The slot is open, watch.pid is missing (the last loop is gone), there are no unresolved review threads, CI is green, and the branch is level with master. Walk me through the plan for this tick.",
154
154
  "files": [],
155
155
  "assertions": [
156
- { "name": "detects_dead_monitor_from_beacon", "text": "Plan reads watch-heartbeat, finds it missing or older than 3 minutes, and concludes the monitor is dead and this cron has become the primary poller." },
157
- { "name": "finishes_arming_a_monitor", "text": "Plan finishes the arm-watcher sequence — seed the watermark to the ids this tick itself read (its own observed max, not a fresh post-tick fetch), start the persistent labeled monitor — using this tick as the drain." },
158
- { "name": "cancels_cron_after_arming", "text": "Plan then cancels this cron per cancel-cron.md and appends a re-armed (monitor restored) line to followup.log, so the 1m cadence lives in the token-free monitor rather than a per-minute model turn." },
156
+ { "name": "detects_dead_loop_from_lease", "text": "Plan checks the slot's watch.pid lease, finds it missing or naming a dead process, and concludes nothing is polling the PR." },
157
+ { "name": "rearms_a_background_loop", "text": "Plan re-arms per arm-watcher.md, running pr-watch-arm.sh as a background shell command (Bash run_in_background), never a Monitor, and appends a re-armed line to followup.log." },
158
+ { "name": "cancels_cron_after_arming", "text": "Plan also cancels this cron per cancel-cron.md, so the 1m cadence lives in the token-free loop rather than a per-minute model turn." },
159
159
  { "name": "never_rearms_with_a_cron", "text": "Plan does NOT restore the poller by creating a recurring /loop cron; the cron is only ever a one-shot recovery delivery." }
160
160
  ]
161
161
  },
162
162
  {
163
163
  "id": 13,
164
- "eval_name": "reconcile-rearms-silent-open-watcher-with-monitor",
164
+ "eval_name": "reconcile-rearms-silent-open-watcher-with-loop",
165
165
  "prompt": "Reconcile the slots. muggle-ai-works-pr522 522 is open (no result.md), but its watch-heartbeat is 40 minutes stale and the newest followup.log line is 40 minutes old — the watcher stopped silently (a crashed cycle that never respawned). The global kill file is absent. Walk me through the plan for this slot.",
166
166
  "files": [],
167
167
  "assertions": [
168
168
  { "name": "classifies_silent_watcher_from_freshest_beacon", "text": "Plan judges the watcher dead because the freshest beacon (watch-heartbeat mtime or newest followup.log line) is older than the staleness window." },
169
- { "name": "rearms_via_arm_watcher_monitor", "text": "Plan re-arms per arm-watcher.md — drain tick, watermark seed, persistent monitor — restoring a token-free poller." },
170
- { "name": "never_rearms_with_cron", "text": "Plan does NOT re-arm by creating a recurring /loop cron; the 1m cadence belongs in the monitor and a cron fire would be a full model turn." },
169
+ { "name": "rearms_via_arm_watcher_loop", "text": "Plan re-arms per arm-watcher.md — drain tick, watermark seed, a background loop (Bash run_in_background, never a Monitor) — restoring a token-free poller." },
170
+ { "name": "never_rearms_with_cron", "text": "Plan does NOT re-arm by creating a recurring /loop cron; the 1m cadence belongs in the loop and a cron fire would be a full model turn." },
171
171
  { "name": "would_skip_under_killfile", "text": "Plan notes it would skip re-arming entirely if polling.disabled existed — here it is absent, so it proceeds." }
172
172
  ]
173
173
  },
@@ -284,13 +284,13 @@
284
284
  {
285
285
  "id": 23,
286
286
  "eval_name": "arm-runs-the-shipped-loop-never-authors-one",
287
- "prompt": "Arm the watch for muggle-ai-works-pr600 600 after a clean drain. The slot has no watch.pid. Walk me through exactly how you start the monitor, including the command you run.",
287
+ "prompt": "Arm the watch for muggle-ai-works-pr600 600 after a clean drain. The slot has no watch.pid. Walk me through exactly how you start the watch, including the command you run and the tool you run it with.",
288
288
  "files": [],
289
289
  "assertions": [
290
290
  { "name": "runs_the_shipped_loop", "text": "Plan starts the shipped scripts/pr-watch-loop.sh, passing --slot, --repo and --pr, with the script path resolved to an absolute path under CLAUDE_PLUGIN_ROOT." },
291
291
  { "name": "does_not_author_a_watch_sh", "text": "Plan does NOT write, generate, or hand-author a per-slot watch.sh, and does NOT copy one from another slot." },
292
- { "name": "persistent_labeled_monitor", "text": "Plan starts it as a persistent background monitor labeled 'PR #<n> — <title>', one per PR." },
293
- { "name": "claims_owner_first", "text": "Plan writes owner.json with this session's $CLAUDE_CODE_SESSION_ID before starting the monitor." }
292
+ { "name": "background_shell_not_monitor", "text": "Plan starts it as a background shell command (the Bash tool with run_in_background) described 'PR #<n> — <title>', one per PR, and NOT as a Monitor, whose 30-minute cap would cost the session a model turn per re-arm." },
293
+ { "name": "claims_owner_first", "text": "Plan writes owner.json with this session's $CLAUDE_CODE_SESSION_ID before starting the loop." }
294
294
  ]
295
295
  },
296
296
  {
@@ -16,7 +16,7 @@ Two invariants hold on every trigger, however the sweep was reached: it never se
16
16
 
17
17
  ## Watching is session-scoped
18
18
 
19
- A watcher lives and dies with its session: monitors and `/loop` crons are both session-bound, so a session that ends or hits its usage limit takes every watch down with it. No poller outlives its session to act on a PR. This is deliberate: a review is addressed only inside a session that carries the context to address it, never by a headless process that would reply without that context.
19
+ A watcher lives and dies with its session: watch loops and `/loop` crons are both session-bound, so a session that ends or hits its usage limit takes every watch down with it. No poller outlives its session to act on a PR. This is deliberate: a review is addressed only inside a session that carries the context to address it, never by a headless process that would reply without that context.
20
20
 
21
21
  What does run out of session is the muggle watch service (`muggle watch`, a per-user scheduled job). It addresses nothing on any PR. For a slot registered in [`watch-registration.json`](state-schemas.md#watch-registrationjson) whose loop is gone while its owner is down, or idle in the background between turns, it resumes the **owning** session in the background, stopping an idle one first. That session's reconcile, this procedure, re-arms the watch with its own context. Restarting an idle owner onto a newly installed build goes the same way — stop, then resume on reconcile — so the watchers come back with it. A slot whose PR went terminal while neither its loop nor its owner is running it finalizes itself, as Step 3 would, leaving the post-merge cleanup unrun. Everything else it finds (a dead watcher under an owner mid-turn, waiting on its user, or open in a terminal; red CI; a stale branch) it only reports.
22
22
 
@@ -71,11 +71,11 @@ The recovery net for a **dropped respawn**: a `/muggle-do` cycle cancels the wat
71
71
 
72
72
  Consider only the **owned** candidates still `open` after Step 3. A foreign slot is skipped here outright, before any beacon is read — its staleness is not evidence this session should take it, and counting it here is what let a fresh session inherit every watcher on the machine. Collect the foreign ones for Step 4 instead.
73
73
 
74
- For each owned candidate, first check the slot's `watch.pid` ([`state-schemas.md`](state-schemas.md#watchpid)): if it names a live process (`kill -0 "$pid"`), a monitor loop already owns the slot — **leave it, do not re-arm**. Arming a recovery cron on top of a live monitor is exactly the duplicate poller this sweep must avoid, and the PID lease is a direct signal a stale beacon is not. Only when no live watcher holds the lease, fall back to the liveness beacons: the `watch-heartbeat` file's mtime (a live monitor touches it every iteration, even when quiet — [`arm-watcher.md`](arm-watcher.md)) and the newest **tick line** in `followup.log` — a line whose timestamp is followed by `tick` or `stale-tick` (a live `1m` recovery cron logs a tick every fire; fall back to `cron.json.recorded_at` if both are absent). Non-tick lines are **not** beacons: arming announcements (`armed …` / `re-armed …`), cycle notes, and error lines record activity by a session that may already be dead — logging is not polling, and counting them masks a dead watcher for the whole window. If the **freshest beacon is older than 15 minutes**, the poller is gone → re-arm:
74
+ For each owned candidate, first check the slot's `watch.pid` ([`state-schemas.md`](state-schemas.md#watchpid)): if it names a live process (`kill -0 "$pid"`), a watch loop already owns the slot — **leave it, do not re-arm**. Arming a recovery cron on top of a live monitor is exactly the duplicate poller this sweep must avoid, and the PID lease is a direct signal a stale beacon is not. Only when no live watcher holds the lease, fall back to the liveness beacons: the `watch-heartbeat` file's mtime (a live loop touches it every iteration, even when quiet — [`arm-watcher.md`](arm-watcher.md)) and the newest **tick line** in `followup.log` — a line whose timestamp is followed by `tick` or `stale-tick` (a live `1m` recovery cron logs a tick every fire; fall back to `cron.json.recorded_at` if both are absent). Non-tick lines are **not** beacons: arming announcements (`armed …` / `re-armed …`), cycle notes, and error lines record activity by a session that may already be dead — logging is not polling, and counting them masks a dead watcher for the whole window. If the **freshest beacon is older than 15 minutes**, the poller is gone → re-arm:
75
75
 
76
- - Re-arm per [`arm-watcher.md`](arm-watcher.md) — drain tick, watermark seed, persistent monitor. Append a `re-armed (silent watcher)` line to the slot's `followup.log`. **Never re-arm with a recurring cron**: every cron fire is a full model turn, and the `1m` cadence already lives token-free in the monitor loop. A cron's only legitimate job is delivering a single recovery tick, and [`contract.md`](contract.md) Step 7.5 converts even that back to a monitor. Skip re-arming entirely while the global kill file `~/.muggle-ai/muggle-do/polling.disabled` exists ([`stop.md`](stop.md)).
76
+ - Re-arm per [`arm-watcher.md`](arm-watcher.md) — drain tick, watermark seed, background loop. Append a `re-armed (silent watcher)` line to the slot's `followup.log`. **Never re-arm with a recurring cron**: every cron fire is a full model turn, and the `1m` cadence already lives token-free in the watch loop. A cron's only legitimate job is delivering a single recovery tick, and [`contract.md`](contract.md) Step 7.5 converts even that back to a loop. Skip re-arming entirely while the global kill file `~/.muggle-ai/muggle-do/polling.disabled` exists ([`stop.md`](stop.md)).
77
77
 
78
- A fresh beacon (within the window) means the poller is alive — a quiet monitor still touching its heartbeat, or a cron `CronList` has gone blind to (which hands itself back to a monitor on its next tick — [`contract.md`](contract.md) Step 7.5) — so this step leaves it untouched; re-arming can never double an already-live poller. This recovers only a slot that was **already being watched**; a PR that never had a watcher is seeded by [`auto-track.md`](auto-track.md) / bootstrap, not here.
78
+ A fresh beacon (within the window) means the poller is alive — a quiet loop still touching its heartbeat, or a cron `CronList` has gone blind to (which hands itself back to a loop on its next tick — [`contract.md`](contract.md) Step 7.5) — so this step leaves it untouched; re-arming can never double an already-live poller. This recovers only a slot that was **already being watched**; a PR that never had a watcher is seeded by [`auto-track.md`](auto-track.md) / bootstrap, not here.
79
79
 
80
80
  ### Step 4 — Report
81
81
 
@@ -59,7 +59,7 @@ The slot's opt-in to recovery by the muggle watch service (`muggle watch`, a per
59
59
  }
60
60
  ```
61
61
 
62
- Recovery requires all of: the slot is registered, the PR is open on GitHub, no loop process carries the slot's `--slot` path, the heartbeat is older than the rollover hand-off, `polling.disabled` is absent, and the `owner.json` session either has no running process or is a background session idle between turns. The service then resumes that session in the background from the project dir its transcript records, starting it on `/muggle:muggle-pr-followup reconcile`. An idle running owner is stopped first, and only after a throwaway background launch confirms Claude will resume it in that workspace. Reconcile re-arms only the slots the session owns, so recovery never moves a PR to another session. One resume per owner covers every slot it owns. The service caps resumes per pass and per owner, then falls back to a desktop alert. A resume whose turn ended on the usage-limit notice doesn't count toward the per-owner cap.
62
+ Recovery requires all of: the slot is registered, the PR is open on GitHub, no loop process carries the slot's `--slot` path, the heartbeat is older than the re-arm hand-off, `polling.disabled` is absent, and the `owner.json` session either has no running process or is a background session idle between turns. The service then resumes that session in the background from the project dir its transcript records, starting it on `/muggle:muggle-pr-followup reconcile`. An idle running owner is stopped first, and only after a throwaway background launch confirms Claude will resume it in that workspace. Reconcile re-arms only the slots the session owns, so recovery never moves a PR to another session. One resume per owner covers every slot it owns. The service caps resumes per pass and per owner, then falls back to a desktop alert. A resume whose turn ended on the usage-limit notice doesn't count toward the per-owner cap.
63
63
 
64
64
  **Absent ⇒ observed only.** The service still reports on the slot's watcher, CI and branch standing, but never resumes its owner. Slots armed before this file existed stay that way until their next arm. The file lives in the slot, so a `.stopped` rename or a `result.md` retires it with the slot.
65
65
 
@@ -208,7 +208,7 @@ Written exactly once when the PR's watcher exits terminally (PR merged or closed
208
208
 
209
209
  ## `watch-heartbeat`
210
210
 
211
- Touched (mtime refreshed) by the watch loop every iteration — content irrelevant. The slot's liveness beacon: a quiet monitor writes no log lines, so mtime is the only proof it is still polling. Read by [`reconcile.md`](reconcile.md) Step 3.6; a beacon older than 15 minutes means the poller is dead.
211
+ Touched (mtime refreshed) by the watch loop every iteration — content irrelevant. The slot's liveness beacon: a quiet loop writes no log lines, so mtime is the only proof it is still polling. Read by [`reconcile.md`](reconcile.md) Step 3.6; a beacon older than 15 minutes means the poller is dead.
212
212
 
213
213
  ## `watch-watermark.env`
214
214
 
@@ -218,11 +218,11 @@ The watch loop's comparison floor — plain `KEY=VALUE` lines, one file per slot
218
218
  - `COM` — highest thread-comment id already handled
219
219
  - `THREADS` — semicolon-joined ids of threads already known unresolved
220
220
  - `CIRED` — head SHA whose settled-red CI a fix-ci cycle already attempted (`ci_fix_attempts[sha] >= 1`); empty when the checks are green, still pending, unseen, or red with no attempt — the last so the loop reports it. The CI floor is a SHA rather than a monotonic id because the check rollup flips green↔red and resets on each push — keying on the head SHA fires the loop once per red head and re-arms on the next push ([`arm-watcher.md`](arm-watcher.md)).
221
- - `REBASED` — the `rebase_key` (`<head_sha>..<base_tip_sha>`) whose behind/conflicting state a rebase cycle already attempted (`conflict_resolve_attempts[key] >= 1`); empty when the branch is current with its base, unseen, or stale with no attempt. Keyed on the pair, not a monotonic id, because staleness depends on both sides — the loop fires once per newly-due pair and re-arms when the head or the base moves. Same key [`contract.md`](contract.md) Steps 5–6 dedup rebase dispatch on; the monitor only signals a due rebase, the tick's verdict applies the budgets.
221
+ - `REBASED` — the `rebase_key` (`<head_sha>..<base_tip_sha>`) whose behind/conflicting state a rebase cycle already attempted (`conflict_resolve_attempts[key] >= 1`); empty when the branch is current with its base, unseen, or stale with no attempt. Keyed on the pair, not a monotonic id, because staleness depends on both sides — the loop fires once per newly-due pair and re-arms when the head or the base moves. Same key [`contract.md`](contract.md) Steps 5–6 dedup rebase dispatch on; the loop only signals a due rebase, the tick's verdict applies the budgets.
222
222
  - `BLOCKED_CIDIGEST` — the head's `ci_digest` at the moment a block was flagged (the same signature `blocked.fingerprint.ci_digest` carries); empty whenever the watch is not blocked. Non-empty arms the loop's blocked-resume probe: while set, the loop wakes on any move of the live digest away from this value, so a block resumes on a green pass, a rerun, or an external deploy check — not only on red. Written by [`blocked-tick.md`](blocked-tick.md) when a block is flagged and cleared to empty when it resumes.
223
- - `REBASE_HELD` — `1` when the PR has spent its catch-up rebase budget (`catch_up_rebases` has reached [`maxCatchUpRebases`](../muggle-preferences/preference-gates/maxCatchUpRebases.md)), empty otherwise. While set, the loop raises no rebase wake and records no rebase as due however far the base moves: the tick's verdict refuses every rebase until the owner raises the cap, so a wake would only ask for work it turns down. Every arm recomputes it, so a raised cap lifts the hold at the next rollover or respawn.
223
+ - `REBASE_HELD` — `1` when the PR has spent its catch-up rebase budget (`catch_up_rebases` has reached [`maxCatchUpRebases`](../muggle-preferences/preference-gates/maxCatchUpRebases.md)), empty otherwise. While set, the loop raises no rebase wake and records no rebase as due however far the base moves: the tick's verdict refuses every rebase until the owner raises the cap, so a wake would only ask for work it turns down. Every arm recomputes it, so a raised cap lifts the hold at the next re-arm.
224
224
 
225
- 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 the red head and stale key a cycle already attempted, `REBASE_HELD` from the rebase budget) 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 set and cleared by the blocked-tick flag/resume path, and every arm re-seeds it from `last_seen.blocked.fingerprint.ci_digest` so a rollover or respawn keeps the resume probe live. **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.
225
+ Written whole-file by the orchestrating session — the review/comment/CI/rebase floors seeded at arm time (`REV`/`COM`/`THREADS` to the **drain's own observed max** on a first arm, and carried forward from this file on a re-arm, dropping threads since resolved, so a review that landed after the session's last read stays above them; `CIRED`/`REBASED` to the red head and stale key a cycle already attempted, `REBASE_HELD` from the rebase budget) 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 set and cleared by the blocked-tick flag/resume path, and every arm re-seeds it from `last_seen.blocked.fingerprint.ci_digest` so a re-arm keeps the resume probe live. **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.
226
226
 
227
227
  **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.
228
228
 
@@ -236,6 +236,17 @@ The work the watch last saw due on its PR, for the owning session's turn-end gat
236
236
 
237
237
  The file is **empty** when nothing is due. Written by [`../../scripts/pr-watch-arm.sh`](../../scripts/pr-watch-arm.sh) from the drain's read and rewritten by the loop whenever what is due changes; the session never writes it. The follow-through Stop gate ([`../../hooks/README.md`](../../hooks/README.md)) blocks the turn end while a non-empty record in a slot this session owns names work with no matching attempt in `last_seen.json`: a `REBASE_DUE` key with no `conflict_resolve_attempts` entry (under `autoRebase=always`, budget left), or a `CI_RED_HEAD` with no `ci_fix_attempts` entry. A later tick's `prs.json` naming another head supersedes the record.
238
238
 
239
+ ## `watch-reported.env`
240
+
241
+ What the slot's loops already reported to the session — `KEY="value"` lines like the watermark's, written only by the loop, and only on the poll that ends it with a report:
242
+
243
+ - `REV` / `COM` — the newest review and comment ids that poll saw; each one at or below them was reported or already floored.
244
+ - `THREADS` — the threads unresolved at that poll.
245
+ - `CIRED` / `REBASED` — the red head and the rebase key a loop reported.
246
+ - `BLOCKED_CIDIGEST` / `BLOCKED_FOR` — the CI digest a blocked-resume wake reported, and the watermark's `BLOCKED_CIDIGEST` it was reported under. It quiets only that block, so the same digest can resume a later one.
247
+
248
+ A loop fires only on what sits above both this file and [`watch-watermark.env`](#watch-watermarkenv). Every report ends a loop, so no floor can live in its memory: without this file, anything the session saw and chose not to act on — a comment needing no reply, a red check under `HOLD WATCH` — would end every fresh loop on its first poll. Read it as the watermark is read, never by `source`.
249
+
239
250
  ## `watch.pid`
240
251
 
241
252
  A single line: the process id of the watch loop that currently owns this slot. Written once by the loop itself on start (`echo "$$" > watch.pid`); read by two parties, never rewritten in place:
@@ -243,4 +254,4 @@ A single line: the process id of the watch loop that currently owns this slot. W
243
254
  - **arm-watcher's pre-arm dedup** ([`arm-watcher.md`](arm-watcher.md) Step 3) reads it and, if the PID is live (`kill -0`), skips arming — one live watcher per slot.
244
255
  - **the loop's own supersede guard** (`watcher_superseded`, [`../../scripts/pr-watch-guards.sh`](../../scripts/pr-watch-guards.sh)) compares it to `$$` each iteration and exits when they differ — a newer arm that overwrote the file steps the old loop down.
245
256
 
246
- This is the lease that keeps orphaned watchers from accumulating across sessions: the in-session monitor task dying does not stop the detached OS loop it launched (notably on Windows), so slot ownership is tracked by a durable PID on disk, not by the live task list. It is not seeded, migrated, or cleaned up — a stale PID (loop already dead) simply fails the `kill -0` liveness check and the next arm proceeds.
257
+ This is the lease that keeps orphaned watchers from accumulating across sessions: the in-session task dying does not stop the detached OS loop it launched (notably on Windows), so slot ownership is tracked by a durable PID on disk, not by the live task list. It is not seeded, migrated, or cleaned up — a stale PID (loop already dead) simply fails the `kill -0` liveness check and the next arm proceeds.
@@ -1,6 +1,6 @@
1
1
  # Stop Procedure
2
2
 
3
- The procedure for the **stop mode** of `muggle-pr-followup` — the owner's kill switch. One command tears down every polling substrate a slot has (monitor, cron) and leaves the slot in a state where anything that survives teardown — an orphaned cron in an unreachable scheduler, a queued fire still draining — arrives as an inert one-line absorb ([`contract.md`](contract.md) Step 0). Routing is in [`SKILL.md`](SKILL.md#routing).
3
+ The procedure for the **stop mode** of `muggle-pr-followup` — the owner's kill switch. One command tears down every polling substrate a slot has (loop, cron) and leaves the slot in a state where anything that survives teardown — an orphaned cron in an unreachable scheduler, a queued fire still draining — arrives as an inert one-line absorb ([`contract.md`](contract.md) Step 0). Routing is in [`SKILL.md`](SKILL.md#routing).
4
4
 
5
5
  Stopping is not finalizing: the PR stays open on the provider, and no `result.md` is written. A stopped slot can be resumed later by renaming `<slug>.stopped` back to `<slug>` and re-arming per [`arm-watcher.md`](arm-watcher.md).
6
6
 
@@ -12,7 +12,7 @@ Stopping is not finalizing: the PR stays open on the provider, and no `result.md
12
12
 
13
13
  Run per slot in scope (every dir under `~/.muggle-ai/muggle-do/sessions/` with a `prs.json` and no `result.md`, or just the named one):
14
14
 
15
- 1. **Stop the monitor.** `TaskStop` any running monitor whose command references this slot's `watch.sh`. On Windows also kill surviving grandchildren: `TaskStop` reaps the wrapper but the inner `bash <slot>/watch.sh` process can outlive it and keep touching `watch-heartbeat` — enumerate processes whose command line contains `<slug>/watch.sh` and kill them. A survivor here also blocks the Step 3 rename (open handles inside the dir).
15
+ 1. **Stop the loop.** `TaskStop` the background task whose command references this slot — `pr-watch-arm.sh --slot <slot>`, or a legacy `<slot>/watch.sh`. On Windows also kill surviving grandchildren: `TaskStop` reaps the wrapper but the inner loop process can outlive it and keep touching `watch-heartbeat` — enumerate processes whose command line carries this slot's `--slot` path (or a legacy `<slug>/watch.sh`) and kill them. A survivor here also blocks the Step 3 rename (open handles inside the dir).
16
16
  2. **Cancel the cron.** Per [`cancel-cron.md`](cancel-cron.md) — recorded-id first, `CronList` match as fallback. A cron neither can reach is orphaned; it stays inert via the absorb and dies with its session.
17
17
  3. **Mark the slot stopped.** Rename `~/.muggle-ai/muggle-do/sessions/<slug>/` → `<slug>.stopped/`. This single rename is the load-bearing act: the tick absorb keys on it, [`reconcile.md`](reconcile.md) skips `*.stopped` dirs, and the muggle watch service ignores a stopped slot's [`watch-registration.json`](state-schemas.md#watch-registrationjson), so no recovery path of any kind re-arms the slot. To keep a watch running but stop the service from resuming its owner, use `muggle watch unregister --slot <slot>` instead.
18
18
 
@@ -22,7 +22,7 @@ Then, for a no-slug (stop-everything) invocation only:
22
22
 
23
23
  ## Output
24
24
 
25
- One line per slot (`stopped: <slug> — monitor <killed|none>, cron <cancelled|orphaned|none>`), plus `polling disabled globally` when the kill file was written. Report an orphaned cron honestly: it may keep firing one-line absorbs until its host session ends — that is the harness's floor, not a teardown failure.
25
+ One line per slot (`stopped: <slug> — loop <killed|none>, cron <cancelled|orphaned|none>`), plus `polling disabled globally` when the kill file was written. Report an orphaned cron honestly: it may keep firing one-line absorbs until its host session ends — that is the harness's floor, not a teardown failure.
26
26
 
27
27
  ## Invariants
28
28
 
@@ -14,9 +14,9 @@ How long a `muggle-pr-followup` watch loop polls before retiring itself.
14
14
 
15
15
  A watch loop is a detached process. On Windows it survives the session that launched it, so an abandoned loop keeps polling the provider indefinitely. `watcher_superseded` retires one only when a *newer* arm claims the same slot, which never happens if nothing re-arms, so a time-based bound is needed.
16
16
 
17
- Under a monitor that bound is `MUGGLE_PR_WATCH_MONITOR_WINDOW`, not this value. Arming runs the loop under a monitor, and the loop ends itself after the window (1740s) so it never outlives the monitor reading its output; an abandoned loop therefore retires within about 29 minutes, well before `1d` or `7d`. This lifetime bounds a loop run with the window disabled.
17
+ Arming runs the loop as a background shell, which has no time cap, so this value is the bound. It also sets how often a quiet PR costs its owning session a turn: the loop announces the end of its lifetime with an `EXPIRED` line, and the session re-arms it.
18
18
 
19
- **`never` removes this bound.** Under a monitor the window still retires the loop; a loop run outside one then polls until the machine restarts or someone kills it.
19
+ **`never` removes this bound.** An abandoned loop then polls until the machine restarts or someone kills it.
20
20
 
21
21
  ## Applying it
22
22