@muggleai/works 5.9.0 → 5.11.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 (171) hide show
  1. package/README.md +8 -0
  2. package/dist/{chunk-LVUJ4E62.js → chunk-MSPMXBO5.js} +638 -468
  3. package/dist/{chunk-5PYK7DWB.js → chunk-WUMAJOBT.js} +17 -6
  4. package/dist/cli.js +2 -2
  5. package/dist/index.js +2 -2
  6. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  7. package/dist/plugin/.cursor-plugin/plugin.json +1 -1
  8. package/dist/plugin/README.md +19 -13
  9. package/dist/plugin/agents/test-prepare-runner.md +4 -1
  10. package/dist/plugin/agents/visual-walkthrough-builder.md +12 -5
  11. package/dist/plugin/commands/mtest.md +1 -1
  12. package/dist/plugin/hooks/README.md +15 -3
  13. package/dist/plugin/hooks/hooks.json +79 -0
  14. package/dist/plugin/scripts/ensure-electron-app.sh +62 -47
  15. package/dist/plugin/scripts/guardrail-classify-gate.sh +23 -0
  16. package/dist/plugin/scripts/guardrail-debug-path-gate.sh +41 -0
  17. package/dist/plugin/scripts/guardrail-pr-terminal.sh +6 -2
  18. package/dist/plugin/scripts/guardrail-record-stage-read.sh +21 -0
  19. package/dist/plugin/scripts/guardrail-record-stage-signals.sh +23 -0
  20. package/dist/plugin/scripts/guardrail-record-tests.sh +11 -4
  21. package/dist/plugin/scripts/guardrail-report-format.sh +9 -5
  22. package/dist/plugin/scripts/guardrail-skill-stages.sh +35 -0
  23. package/dist/plugin/scripts/guardrail-stage-gate.sh +43 -0
  24. package/dist/plugin/scripts/guardrail-walkthrough-gate.sh +41 -0
  25. package/dist/plugin/scripts/guardrails.mjs +474 -80
  26. package/dist/plugin/scripts/pr-watch-events.sh +116 -0
  27. package/dist/plugin/scripts/pr-watch-guards.sh +8 -2
  28. package/dist/plugin/scripts/pr-watch-loop.sh +254 -0
  29. package/dist/plugin/scripts/reconcile-stale-watchers.sh +66 -18
  30. package/dist/plugin/scripts/sign-body.sh +88 -0
  31. package/dist/plugin/skills/CLAUDE.md +16 -0
  32. package/dist/plugin/skills/_shared/post-merge-cleanup.md +83 -3
  33. package/dist/plugin/skills/_shared/pr-followup-helpers/loop-signature.md +6 -8
  34. package/dist/plugin/skills/_shared/pr-followup-helpers/reply-routing.md +10 -18
  35. package/dist/plugin/skills/_shared/resolve-e2e-validation-context.md +1 -0
  36. package/dist/plugin/skills/_shared/vcs/CLAUDE.md +1 -0
  37. package/dist/plugin/skills/_shared/vcs/github/pr-edit.md +3 -2
  38. package/dist/plugin/skills/_shared/vcs/github/reply-line-comment.md +3 -2
  39. package/dist/plugin/skills/_shared/vcs/github/top-level-comment.md +3 -2
  40. package/dist/plugin/skills/_shared/vcs/gitlab/mr-create.md +4 -1
  41. package/dist/plugin/skills/_shared/vcs/gitlab/mr-edit.md +4 -1
  42. package/dist/plugin/skills/_shared/vcs/gitlab/mr-note.md +4 -1
  43. package/dist/plugin/skills/_shared/vcs/gitlab/reply-discussion.md +4 -1
  44. package/dist/plugin/skills/_shared/vcs/post-signature.md +23 -12
  45. package/dist/plugin/skills/do/open-prs/forward.md +2 -2
  46. package/dist/plugin/skills/do/per-comment-replies.md +2 -15
  47. package/dist/plugin/skills/muggle/SKILL.md +1 -1
  48. package/dist/plugin/skills/muggle-browser-task/SKILL.md +1 -1
  49. package/dist/plugin/skills/muggle-pr-followup/CLAUDE.md +2 -1
  50. package/dist/plugin/skills/muggle-pr-followup/SKILL.md +6 -3
  51. package/dist/plugin/skills/muggle-pr-followup/adopt.md +60 -0
  52. package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +10 -14
  53. package/dist/plugin/skills/muggle-pr-followup/auto-track.md +2 -0
  54. package/dist/plugin/skills/muggle-pr-followup/evals/evals.json +63 -1
  55. package/dist/plugin/skills/muggle-pr-followup/output-templates/help.md +11 -1
  56. package/dist/plugin/skills/muggle-pr-followup/output-templates/inline-reply.md +1 -7
  57. package/dist/plugin/skills/muggle-pr-followup/output-templates/resolve-reminder.md +1 -4
  58. package/dist/plugin/skills/muggle-pr-followup/reconcile.md +32 -6
  59. package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +18 -0
  60. package/dist/plugin/skills/muggle-preferences/SKILL.md +1 -1
  61. package/dist/plugin/skills/muggle-preferences/evals/evals.json +45 -0
  62. package/dist/plugin/skills/muggle-preferences/ops/change-one.md +1 -1
  63. package/dist/plugin/skills/muggle-preferences/ops/configure.md +4 -5
  64. package/dist/plugin/skills/muggle-preferences/ops/list.md +1 -1
  65. package/dist/plugin/skills/muggle-preferences/ops/set.md +3 -4
  66. package/dist/plugin/skills/muggle-preferences/preference-gates/README.md +3 -3
  67. package/dist/plugin/skills/muggle-preferences/preference-gates/autoSelectLocalHost.md +2 -2
  68. package/dist/plugin/skills/muggle-preferences/preference-gates/autoSelectProject.md +1 -1
  69. package/dist/plugin/skills/muggle-preferences/preference-gates/watcherLifetime.md +23 -0
  70. package/dist/plugin/skills/muggle-test/SKILL.md +23 -3
  71. package/dist/plugin/skills/muggle-test/execute-local.md +2 -0
  72. package/dist/plugin/skills/muggle-test-feature-local/SKILL.md +6 -1
  73. package/dist/plugin/skills/muggle-test-import/SKILL.md +1 -1
  74. package/dist/plugin/skills/muggle-test-prepare/SKILL.md +28 -15
  75. package/dist/plugin/skills/muggle-test-prepare/steps/check-running.md +1 -1
  76. package/dist/plugin/skills/muggle-test-prepare/steps/confirm-recipe.md +56 -0
  77. package/dist/plugin/skills/muggle-test-prepare/steps/derive-service-graph.md +51 -0
  78. package/dist/plugin/skills/muggle-test-prepare/steps/e2e-instructions.md +111 -0
  79. package/dist/plugin/skills/muggle-test-prepare/steps/identify-services.md +4 -2
  80. package/dist/plugin/skills/muggle-test-prepare/steps/readiness-report.md +12 -5
  81. package/dist/plugin/skills/muggle-test-prepare/steps/record-resolution.md +32 -0
  82. package/dist/plugin/skills/muggle-test-prepare/steps/replay-or-learn.md +20 -0
  83. package/dist/plugin/skills/muggle-test-prepare/steps/reuse-plan.md +9 -6
  84. package/dist/plugin/skills/muggle-test-prepare/steps/smoke-test.md +5 -1
  85. package/dist/plugin/skills/muggle-test-prepare/steps/start-services.md +3 -1
  86. package/dist/plugin/skills/muggle-test-regenerate-missing/SKILL.md +1 -1
  87. package/dist/release-manifest.json +4 -4
  88. package/dist/src-B5OTAZKM.js +1 -0
  89. package/package.json +7 -6
  90. package/plugin/.claude-plugin/plugin.json +1 -1
  91. package/plugin/.cursor-plugin/plugin.json +1 -1
  92. package/plugin/README.md +19 -13
  93. package/plugin/agents/test-prepare-runner.md +4 -1
  94. package/plugin/agents/visual-walkthrough-builder.md +12 -5
  95. package/plugin/commands/mtest.md +1 -1
  96. package/plugin/hooks/README.md +15 -3
  97. package/plugin/hooks/hooks.json +79 -0
  98. package/plugin/scripts/ensure-electron-app.sh +62 -47
  99. package/plugin/scripts/guardrail-classify-gate.sh +23 -0
  100. package/plugin/scripts/guardrail-debug-path-gate.sh +41 -0
  101. package/plugin/scripts/guardrail-pr-terminal.sh +6 -2
  102. package/plugin/scripts/guardrail-record-stage-read.sh +21 -0
  103. package/plugin/scripts/guardrail-record-stage-signals.sh +23 -0
  104. package/plugin/scripts/guardrail-record-tests.sh +11 -4
  105. package/plugin/scripts/guardrail-report-format.sh +9 -5
  106. package/plugin/scripts/guardrail-skill-stages.sh +35 -0
  107. package/plugin/scripts/guardrail-stage-gate.sh +43 -0
  108. package/plugin/scripts/guardrail-walkthrough-gate.sh +41 -0
  109. package/plugin/scripts/guardrails.mjs +474 -80
  110. package/plugin/scripts/pr-watch-events.sh +116 -0
  111. package/plugin/scripts/pr-watch-guards.sh +8 -2
  112. package/plugin/scripts/pr-watch-loop.sh +254 -0
  113. package/plugin/scripts/reconcile-stale-watchers.sh +66 -18
  114. package/plugin/scripts/sign-body.sh +88 -0
  115. package/plugin/skills/CLAUDE.md +16 -0
  116. package/plugin/skills/_shared/post-merge-cleanup.md +83 -3
  117. package/plugin/skills/_shared/pr-followup-helpers/loop-signature.md +6 -8
  118. package/plugin/skills/_shared/pr-followup-helpers/reply-routing.md +10 -18
  119. package/plugin/skills/_shared/resolve-e2e-validation-context.md +1 -0
  120. package/plugin/skills/_shared/vcs/CLAUDE.md +1 -0
  121. package/plugin/skills/_shared/vcs/github/pr-edit.md +3 -2
  122. package/plugin/skills/_shared/vcs/github/reply-line-comment.md +3 -2
  123. package/plugin/skills/_shared/vcs/github/top-level-comment.md +3 -2
  124. package/plugin/skills/_shared/vcs/gitlab/mr-create.md +4 -1
  125. package/plugin/skills/_shared/vcs/gitlab/mr-edit.md +4 -1
  126. package/plugin/skills/_shared/vcs/gitlab/mr-note.md +4 -1
  127. package/plugin/skills/_shared/vcs/gitlab/reply-discussion.md +4 -1
  128. package/plugin/skills/_shared/vcs/post-signature.md +23 -12
  129. package/plugin/skills/do/open-prs/forward.md +2 -2
  130. package/plugin/skills/do/per-comment-replies.md +2 -15
  131. package/plugin/skills/muggle/SKILL.md +1 -1
  132. package/plugin/skills/muggle-browser-task/SKILL.md +1 -1
  133. package/plugin/skills/muggle-pr-followup/CLAUDE.md +2 -1
  134. package/plugin/skills/muggle-pr-followup/SKILL.md +6 -3
  135. package/plugin/skills/muggle-pr-followup/adopt.md +60 -0
  136. package/plugin/skills/muggle-pr-followup/arm-watcher.md +10 -14
  137. package/plugin/skills/muggle-pr-followup/auto-track.md +2 -0
  138. package/plugin/skills/muggle-pr-followup/evals/evals.json +63 -1
  139. package/plugin/skills/muggle-pr-followup/output-templates/help.md +11 -1
  140. package/plugin/skills/muggle-pr-followup/output-templates/inline-reply.md +1 -7
  141. package/plugin/skills/muggle-pr-followup/output-templates/resolve-reminder.md +1 -4
  142. package/plugin/skills/muggle-pr-followup/reconcile.md +32 -6
  143. package/plugin/skills/muggle-pr-followup/state-schemas.md +18 -0
  144. package/plugin/skills/muggle-preferences/SKILL.md +1 -1
  145. package/plugin/skills/muggle-preferences/evals/evals.json +45 -0
  146. package/plugin/skills/muggle-preferences/ops/change-one.md +1 -1
  147. package/plugin/skills/muggle-preferences/ops/configure.md +4 -5
  148. package/plugin/skills/muggle-preferences/ops/list.md +1 -1
  149. package/plugin/skills/muggle-preferences/ops/set.md +3 -4
  150. package/plugin/skills/muggle-preferences/preference-gates/README.md +3 -3
  151. package/plugin/skills/muggle-preferences/preference-gates/autoSelectLocalHost.md +2 -2
  152. package/plugin/skills/muggle-preferences/preference-gates/autoSelectProject.md +1 -1
  153. package/plugin/skills/muggle-preferences/preference-gates/watcherLifetime.md +23 -0
  154. package/plugin/skills/muggle-test/SKILL.md +23 -3
  155. package/plugin/skills/muggle-test/execute-local.md +2 -0
  156. package/plugin/skills/muggle-test-feature-local/SKILL.md +6 -1
  157. package/plugin/skills/muggle-test-import/SKILL.md +1 -1
  158. package/plugin/skills/muggle-test-prepare/SKILL.md +28 -15
  159. package/plugin/skills/muggle-test-prepare/steps/check-running.md +1 -1
  160. package/plugin/skills/muggle-test-prepare/steps/confirm-recipe.md +56 -0
  161. package/plugin/skills/muggle-test-prepare/steps/derive-service-graph.md +51 -0
  162. package/plugin/skills/muggle-test-prepare/steps/e2e-instructions.md +111 -0
  163. package/plugin/skills/muggle-test-prepare/steps/identify-services.md +4 -2
  164. package/plugin/skills/muggle-test-prepare/steps/readiness-report.md +12 -5
  165. package/plugin/skills/muggle-test-prepare/steps/record-resolution.md +32 -0
  166. package/plugin/skills/muggle-test-prepare/steps/replay-or-learn.md +20 -0
  167. package/plugin/skills/muggle-test-prepare/steps/reuse-plan.md +9 -6
  168. package/plugin/skills/muggle-test-prepare/steps/smoke-test.md +5 -1
  169. package/plugin/skills/muggle-test-prepare/steps/start-services.md +3 -1
  170. package/plugin/skills/muggle-test-regenerate-missing/SKILL.md +1 -1
  171. package/dist/src-FDK2KQSZ.js +0 -1
@@ -6,28 +6,24 @@ How an orchestrating session starts the watch on one PR. Every arming point runs
6
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), **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.
9
+ 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.
10
10
 
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:
11
+ Then start the **shipped** loop 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:
12
12
 
13
13
  ```sh
14
- GUARDS="<abs>/scripts/pr-watch-guards.sh"
15
- [ -f "$GUARDS" ] && . "$GUARDS" || exit 0 # guards gone (plugin moved/upgraded) → a newer version's watcher owns this now
16
- echo "$$" > "<slot>/watch.pid"
17
- started=$(date +%s)
14
+ bash "<abs>/scripts/pr-watch-loop.sh" --slot "<slot>" --repo "<owner>/<repo>" --pr <n> --base <base-branch>
18
15
  ```
19
16
 
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` linemust 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`.
17
+ **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); 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.
21
18
 
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):
19
+ 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, 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.
23
20
 
24
- ```sh
25
- watcher_superseded "<slot>" "$$" && exit 0 # a newer arm took the slot's watch.pid → this loop steps down
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
27
- ```
21
+ 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, and `watcher_lifetime_exceeded` retires an orphan nothing supersedes. 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`.
22
+
23
+ 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 — leaving `watcher_superseded` as the only thing that can retire an orphan.
28
24
 
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.
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.
25
+ So it exits 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.
26
+ 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.
31
27
 
32
28
  **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.
33
29
 
@@ -20,6 +20,8 @@ Auto-track discovers the PRs you pushed or opened during this Claude Code sessio
20
20
 
21
21
  Run [`reconcile.md`](reconcile.md) first. A no-arg invocation is the natural moment to finalize any slot whose PR merged or closed while its watcher was down — an expired `/loop` cron or an ended session leaves termination un-run (see reconcile's rationale). Then continue discovering new PRs below.
22
22
 
23
+ **Reconcile here can only recover, never widen.** It re-arms watchers this session already owns and finalizes terminal slots; a slot owned by another session is reported and left alone. That boundary matters most at this call site: auto-track is invoked with no arguments, so the user named no PR, and a sweep that adopted every open slot on disk would turn "track what I pushed" into "watch everything on this machine". Whatever the sweep reports as orphaned is **not** a candidate for Step 1 — this session did not push those.
24
+
23
25
  ### Step 1 — Discover candidate PRs from session context
24
26
 
25
27
  A PR counts as **pushed this session** if, earlier in this conversation, you:
@@ -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; 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).",
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); cases 20–22 cover session ownership — reconcile re-arms only slots the running session armed, fails closed when it cannot identify itself, and a dead session's slot changes hands only through an explicit single-slug adopt. 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,
@@ -243,6 +243,68 @@
243
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
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
245
  ]
246
+ },
247
+ {
248
+ "id": 20,
249
+ "eval_name": "reconcile-never-rearms-another-sessions-slot",
250
+ "prompt": "Run reconcile. $CLAUDE_CODE_SESSION_ID is session-AAA. On disk: muggle-ai-works-pr600 is open, owner.json says session-AAA, heartbeat 3 hours old. muggle-ai-ui-pr601 is open, owner.json says session-BBB, heartbeat 3 hours old. muggle-ai-brain-pr602 is open with no owner.json at all, heartbeat 3 hours old. muggle-ai-works-pr603 has owner.json session-BBB and its PR is now MERGED. Walk me through the plan for this sweep.",
251
+ "files": [],
252
+ "assertions": [
253
+ { "name": "partitions_by_owner_session_id", "text": "Plan splits the slots by owner.json session_id against $CLAUDE_CODE_SESSION_ID before deciding anything — pr600 owned, pr601/pr602/pr603 foreign." },
254
+ { "name": "rearms_only_the_owned_slot", "text": "Plan re-arms ONLY muggle-ai-works-pr600. It does not re-arm pr601 or pr602 despite their stale heartbeats." },
255
+ { "name": "missing_owner_is_foreign_not_adoptable", "text": "Plan treats muggle-ai-brain-pr602's absent owner.json as foreign and never re-arms or backfills an owner for it." },
256
+ { "name": "finalizes_terminal_regardless_of_owner", "text": "Plan finalizes muggle-ai-works-pr603 even though session-BBB owns it, because finalizing needs no context — it writes result.md and cancels the cron." },
257
+ { "name": "reports_orphans_without_offering", "text": "Plan reports pr601 and pr602 as orphaned with the adopt command, and does NOT offer to adopt them, prompt about them, or pick one to take." }
258
+ ]
259
+ },
260
+ {
261
+ "id": 21,
262
+ "eval_name": "reconcile-fails-closed-without-a-session-id",
263
+ "prompt": "Run reconcile in an environment where $CLAUDE_CODE_SESSION_ID is unset. Three open slots exist, all with stale heartbeats: two carry owner.json with different session ids, one carries none. Walk me through the plan.",
264
+ "files": [],
265
+ "assertions": [
266
+ { "name": "treats_every_slot_as_foreign", "text": "Plan treats ALL three slots as foreign because the running session cannot identify itself — it does not match the unset id against anything." },
267
+ { "name": "rearms_nothing", "text": "Plan re-arms zero watchers." },
268
+ { "name": "still_finalizes_and_reports", "text": "Plan still refreshes live state, finalizes any slot whose PR went terminal, and reports the open ones as orphans — it degrades to finalize-and-report rather than aborting." }
269
+ ]
270
+ },
271
+ {
272
+ "id": 22,
273
+ "eval_name": "adopt-is-explicit-and-single-slot",
274
+ "prompt": "The user runs: /muggle:muggle-pr-followup adopt muggle-ai-ui-pr601. That slot is open, its owner.json names a different session, and its PR is still open on GitHub. Walk me through the plan.",
275
+ "files": [],
276
+ "assertions": [
277
+ { "name": "claims_owner_before_arming", "text": "Plan overwrites owner.json with this session's $CLAUDE_CODE_SESSION_ID and a fresh claimed_at BEFORE arming the monitor." },
278
+ { "name": "warns_about_the_context_gap", "text": "Plan states that this session lacks the original session's context for the PR, so cycles work from the PR and diff alone." },
279
+ { "name": "arms_with_a_drain_tick", "text": "Plan arms per arm-watcher — a drain tick first, then the persistent monitor — rather than resuming from the previous owner's watermark alone." },
280
+ { "name": "adopts_only_the_named_slot", "text": "Plan adopts exactly muggle-ai-ui-pr601 and does not sweep, list-and-take, or adopt any other orphaned slot in the same run." },
281
+ { "name": "refuses_a_stopped_slot", "text": "Plan would refuse the adoption outright if the slot were named <slug>.stopped, since stop is the owner's kill switch and adoption is not a way around it." }
282
+ ]
283
+ },
284
+ {
285
+ "id": 23,
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.",
288
+ "files": [],
289
+ "assertions": [
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
+ { "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." }
294
+ ]
295
+ },
296
+ {
297
+ "id": 24,
298
+ "eval_name": "monitor-wakes-on-branch-merely-behind-base",
299
+ "prompt": "A watch is running on muggle-ai-works-pr383 383. Its base branch advances by two commits. The PR reports mergeable=MERGEABLE (no conflict) and mergeStateStatus=BLOCKED because a review is still required. The base requires branches to be up to date before merging. Should the watch loop wake, and how does it decide?",
300
+ "files": [],
301
+ "assertions": [
302
+ { "name": "wakes_on_behind_not_only_conflict", "text": "The loop DOES wake. A branch that is merely behind reports MERGEABLE, so the conflict signal alone never fires for it — behind_by > 0 is its own wake condition." },
303
+ { "name": "does_not_rely_on_mergestatestatus", "text": "The decision does NOT come from mergeStateStatus: it is a single enum with precedence, and BLOCKED (review required) masks BEHIND on a PR still awaiting approval." },
304
+ { "name": "measures_behind_via_compare", "text": "Behind-ness is measured with a compare call (behind_by), not inferred from the PR's mergeable/mergeStateStatus fields." },
305
+ { "name": "keys_on_the_head_base_pair", "text": "The floor is the rebase_key <head_sha>..<base_tip_sha>, so the wake fires once per newly-due pair and re-arms when the base advances again under an unchanged head." },
306
+ { "name": "condition_lives_in_shipped_file", "text": "The wake condition is defined in the shipped scripts/pr-watch-events.sh (watch_wake_rebase), not re-derived per arm." }
307
+ ]
246
308
  }
247
309
  ]
248
310
  }
@@ -5,14 +5,24 @@ For `/muggle:muggle-pr-followup help` or `?` (no args runs **auto-track** instea
5
5
  ```
6
6
  muggle-pr-followup — watcher loop for PR review follow-ups
7
7
 
8
- Active loops:
8
+ Active loops (this session):
9
9
  <slug> → <owner>/<repo>#<n> (<N> cycles)
10
10
  ...
11
11
  (or "no active loops")
12
12
 
13
+ Owned by other sessions (not watched here):
14
+ <slug> → <owner>/<repo>#<n>
15
+ ...
16
+ (omit this block entirely when there are none)
17
+
13
18
  Usage:
14
19
  /muggle:muggle-pr-followup → auto-track every PR you pushed this session
15
20
  /muggle:muggle-pr-followup <pr-url> → bootstrap a new loop
16
21
  /muggle:muggle-pr-followup <slug> <pr-number> → run one tick (called by /loop)
17
22
  /muggle:muggle-pr-followup <pr-number> → run one tick (slug inferred from on-disk state)
23
+ /muggle:muggle-pr-followup reconcile → finalize terminal slots, re-arm your own dead watchers
24
+ /muggle:muggle-pr-followup adopt [<slug>] → take over a slot from a dead session (no slug lists them)
25
+ /muggle:muggle-pr-followup stop [<slug>] → tear down a watcher (no slug stops everything)
18
26
  ```
27
+
28
+ Split the two blocks by `owner.json` against `$CLAUDE_CODE_SESSION_ID` ([`../state-schemas.md`](../state-schemas.md#ownerjson)). Listing a foreign slot under "active loops" would claim this session is watching a PR nothing here polls.
@@ -4,12 +4,9 @@ Posted via `gh api .../comments/<comment-id>/replies` per cycle, one per line co
4
4
 
5
5
  ```
6
6
  Addressed in <short-sha>: <one-line summary of the change made for THIS comment>.
7
-
8
- <!-- muggle-do:bot -->
9
- 🤖 _Posted by `/muggle-do` · [Muggle Works](https://github.com/multiplex-ai/muggle-ai-works)_
10
7
  ```
11
8
 
12
- `<short-sha>` is the first 7 chars of the new SHA; the body must contain that substring so the resolve-reminder stage knows *which push* addressed the thread. The trailing signature blockdefined in [`../../_shared/pr-followup-helpers/loop-signature.md`](../../_shared/pr-followup-helpers/loop-signature.md) is mandatory; its `<!-- muggle-do:bot -->` marker is what identifies the comment as loop-authored.
9
+ `<short-sha>` is the first 7 chars of the new SHA; the body must contain that substring so the resolve-reminder stage knows *which push* addressed the thread. Write the content onlythe posting recipe signs it with `--mode loop` ([`../../_shared/vcs/post-signature.md`](../../_shared/vcs/post-signature.md)), appending the `<!-- muggle-do:bot -->` marker that identifies the comment as loop-authored.
13
10
 
14
11
  ## Top-level fallback (review-body-only)
15
12
 
@@ -17,7 +14,4 @@ When an actionable review has a non-empty body but zero line comments, GitHub ha
17
14
 
18
15
  ```
19
16
  Re: review #<review_id> — addressed in <short-sha>: <one-line summary>.
20
-
21
- <!-- muggle-do:bot -->
22
- 🤖 _Posted by `/muggle-do` · [Muggle Works](https://github.com/multiplex-ai/muggle-ai-works)_
23
17
  ```
@@ -7,9 +7,6 @@ These threads are addressed and still open — mark them resolved if satisfied,
7
7
  - #<thread-id-1>
8
8
  - #<thread-id-2>
9
9
  - ...
10
-
11
- <!-- muggle-do:bot -->
12
- 🤖 _Posted by `/muggle-do` · [Muggle Works](https://github.com/multiplex-ai/muggle-ai-works)_
13
10
  ```
14
11
 
15
- If no such thread exists, no comment is posted (silent). The trailing signature block ([`loop-signature.md`](../../_shared/pr-followup-helpers/loop-signature.md)) keeps the loop from later mistaking its own reminder for a human comment.
12
+ If no such thread exists, no comment is posted (silent). Sign with `--mode loop` ([`../../_shared/vcs/post-signature.md`](../../_shared/vcs/post-signature.md)) — the marker it appends keeps the loop from later mistaking its own reminder for a human comment.
@@ -12,11 +12,15 @@ Three ways in, all running the same procedure:
12
12
  - **Auto-track** — the top of a no-arg invocation ([`auto-track.md`](auto-track.md)).
13
13
  - **Session start** — the `reconcile-stale-watchers.sh` hook ([`../../hooks/README.md`](../../hooks/README.md)) runs this sweep, catching a watcher that died with its session (end, or 7-day `/loop` cron expiry) before its PR's merge was observed.
14
14
 
15
- Recover-don't-seed holds on every trigger: a session-start run still never seeds a first watcher (see Invariants).
15
+ Two invariants hold on every trigger, however the sweep was reached: it never seeds a first watcher, and it never re-arms a slot this session does not own (see Invariants). A no-arg auto-track and a session-start nudge are the two paths that fire without the user naming a PR, so they are exactly the ones that must not widen what the session watches.
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. Nothing polls out of session — there is no detached daemon. 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. The recovery point is the **next session start**, where the `reconcile-stale-watchers.sh` hook nudges this sweep and Step 3.6 re-arms every open slot whose watcher is dead. The cost is that a review landing while no session runs waits until the next one starts — the accepted trade for never acting context-blind.
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. Nothing polls out of session — there is no detached daemon. 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
+
21
+ **Ownership is session-scoped too, and that is what bounds recovery.** Each slot records the session that armed it in [`owner.json`](state-schemas.md#ownerjson). Step 3.6 re-arms only slots the running session owns — watchers it armed itself, whose poller died under it. A slot owned by another session, or by none, is **never** re-armed here however dead its poller looks. The same argument that forbids a headless daemon forbids a fresh session inheriting a stranger's PR: it would be picking up review work it has no context for, on a branch it never built, and every push it made would be as context-blind as the daemon the design already rejected. Adoption exists precisely so that step is a deliberate act by the owner ([`adopt.md`](adopt.md)) rather than a side effect of starting a session.
22
+
23
+ The cost is real and accepted: a PR whose owning session is gone stops being watched and stays unwatched until someone adopts it. Reconcile makes that visible instead of silent — Step 4 lists every orphan it declined to touch — and it still finalizes an orphan whose PR went terminal, which needs no context at all.
20
24
 
21
25
  ## Input
22
26
 
@@ -24,17 +28,28 @@ A watcher lives and dies with its session: monitors and `/loop` crons are both s
24
28
 
25
29
  ## Procedure
26
30
 
27
- ### Step 1 — Enumerate slots
31
+ ### Step 1 — Enumerate and partition slots
28
32
 
29
33
  List `~/.muggle-ai/muggle-do/sessions/*/` dirs that contain a `prs.json`. Skip any that already have a `result.md` — those are finalized — and any whose dir name ends in `.stopped` — the owner killed those per [`stop.md`](stop.md), and no recovery path may revive them (a `.stopped` dir still holds a `prs.json`, so this name check is the only thing standing between the owner's stop and a resurrected watcher). Scope to a single `<slug>` if the arg gave one.
30
34
 
35
+ Read `$CLAUDE_CODE_SESSION_ID` once, then split what remains on each slot's [`owner.json`](state-schemas.md#ownerjson):
36
+
37
+ - **Owned** — its `session_id` equals this session's. Eligible for every step below, re-arm included.
38
+ - **Foreign** — its `session_id` differs, or the file is absent (a legacy slot, or one armed before ownership was recorded). Eligible for finalize (Step 3) and the cron sweep (Step 3.5) only. Step 3.6 never re-arms a foreign slot, and no step may write one's `owner.json` — a sweep that claimed what it touched would adopt the whole disk on first run.
39
+
40
+ If `$CLAUDE_CODE_SESSION_ID` is unset, treat **every** slot as foreign. A session that cannot identify itself owns nothing, so the sweep degrades to finalize-and-report rather than guessing.
41
+
31
42
  ### Step 2 — Refresh live state
32
43
 
33
44
  For each candidate, fetch the PR per [`../_shared/vcs/github/pr-metadata.md`](../_shared/vcs/github/pr-metadata.md) using `prs.json[0].url`. A `gh` failure on one slot (deleted repo, missing auth) → log it to that slot's `followup.log` and skip; never abort the whole sweep.
34
45
 
35
46
  ### Step 3 — Finalize the terminal ones
36
47
 
37
- For each candidate whose live `state` is `MERGED` or `CLOSED`, run [`finalize.md`](finalize.md) which unschedules the cron recorded-id-first per [`cancel-cron.md`](cancel-cron.md), killing it even when `CronList` has gone blind. `finalize.md` dispatches nothing, so a backfilled merge gets no post-merge cleanup — its branch is typically long gone, and the `autoCleanup` gate governs if the user runs cleanup later. Slots still `open` are carried to Step 3.6, which re-arms any whose watcher went silent.
48
+ For each candidate whose live `state` is `MERGED` or `CLOSED` — **owned or foreign alike** — run [`finalize.md`](finalize.md), which unschedules the cron recorded-id-first per [`cancel-cron.md`](cancel-cron.md), killing it even when `CronList` has gone blind. `finalize.md` dispatches nothing, so a backfilled merge gets no post-merge cleanup — its branch is typically long gone, and the `autoCleanup` gate governs if the user runs cleanup later.
49
+
50
+ Finalizing is deliberately ownership-free: it writes a `result.md` for a PR that already reached its end state on the provider and stops a dead cron. It resumes no watch, pushes nothing, and reads no review — so none of the context argument that gates re-arming applies, and leaving foreign terminal slots un-finalized would grow the orphan list forever with PRs that merged months ago.
51
+
52
+ Slots still `open` are carried to Step 3.6, which re-arms the **owned** ones whose watcher went silent.
38
53
 
39
54
  ### Step 3.5 — Sweep orphaned crons
40
55
 
@@ -48,11 +63,13 @@ Call `CronList`. For every job whose command ends with `/muggle:muggle-pr-follow
48
63
 
49
64
  This reaches only crons `CronList` still enumerates. A cron that both survived a compaction (invisible to `CronList`) **and** lost its slot (recorded id gone with it) is beyond either mechanism — see the residual note below.
50
65
 
51
- ### Step 3.6 — Re-arm a silently-stopped open watcher
66
+ ### Step 3.6 — Re-arm a silently-stopped watcher this session owns
52
67
 
53
68
  The recovery net for a **dropped respawn**: a `/muggle-do` cycle cancels the watcher's cron when it dispatches ([`contract.md`](contract.md) Steps 4 / 5 / 5b) and is responsible for respawning it when the cycle ends, but a cycle that crashes or errors out before it respawns can leave an open slot with no poller and no next tick — the watch stops silently. This step re-arms it.
54
69
 
55
- For each candidate still `open` after Step 3, 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:
70
+ 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.
71
+
72
+ 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:
56
73
 
57
74
  - 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)).
58
75
 
@@ -62,9 +79,18 @@ A fresh beacon (within the window) means the poller is alive — a quiet monitor
62
79
 
63
80
  One line: slots scanned, finalized (with final state each), left open, re-armed (silently-stopped watchers recovered), and orphan crons swept. Silent only when zero slots exist and nothing was swept.
64
81
 
82
+ When any **foreign** open slot remains, add one line naming them and how to take one back:
83
+
84
+ ```
85
+ orphaned (other sessions): <slug> → <owner>/<repo>#<n>, … — adopt with /muggle:muggle-pr-followup adopt <slug>
86
+ ```
87
+
88
+ List them once, as fact. Do **not** offer to adopt, pick a likely candidate, or adopt one because it looks important or recently active — the whole point of the gate is that taking a stranger's PR is the user's call, and a sweep that nudges toward adoption every session start relitigates that call until someone says yes.
89
+
65
90
  ## Invariants
66
91
 
67
92
  - **Idempotent.** A slot with `result.md` is never re-finalized; a still-ticking open slot is never re-armed; once everything terminal is swept and every open watcher is live, re-running is a no-op.
68
93
  - **Recover, don't seed.** Reconcile finalizes terminal slots, sweeps orphan crons, and re-arms an open slot whose watcher went silent (a dropped respawn — Step 3.6). It never arms a PR that was never watched — seeding a first watcher is [`auto-track.md`](auto-track.md)'s / bootstrap's job.
94
+ - **Never adopts.** No path through this sweep re-arms a slot another session owns, or writes `owner.json` for a slot it did not already own. Running reconcile in a brand-new session re-arms exactly nothing, and running it a hundred times never widens what that session watches. Ownership changes only through [`adopt.md`](adopt.md), on the user's explicit instruction.
69
95
  - **Per-slot isolation.** One slot's `gh` failure never blocks finalizing the others.
70
96
  - **Residual orphan.** A cron that survived a compaction (blind to `CronList`) whose slot was also deleted has no on-disk id left to `CronDelete` and no `CronList` entry to match — only a session restart clears it. Recording the id durably while the slot lives ([`record-cron-id.md`](record-cron-id.md)) shrinks this window to slots removed before their first tick.
@@ -30,6 +30,24 @@ A list of one entry. (Historical: the file is an array for forward-compat with t
30
30
  - `state` is the **observed** state from the last `gh pr view`. The watcher refreshes it each tick.
31
31
  - Terminal states (`merged`, `closed`) are sticky — once set, the watcher writes `result.md` and exits without rescheduling.
32
32
 
33
+ ## `owner.json`
34
+
35
+ The Claude Code session that owns this slot's watch. Written when a session arms the slot ([`arm-watcher.md`](arm-watcher.md)) or deliberately takes it over ([`adopt.md`](adopt.md)); read by [`reconcile.md`](reconcile.md) to decide whether the running session may re-arm a dead watcher.
36
+
37
+ ```json
38
+ {
39
+ "session_id": "<claude-code-session-id>",
40
+ "claimed_at": "<ISO-8601>"
41
+ }
42
+ ```
43
+
44
+ - `session_id`: `$CLAUDE_CODE_SESSION_ID` as the arming session observes it. It survives compaction and resume, so a session that continues its own work keeps its watchers; a genuinely new session gets a new id and therefore owns nothing it did not arm.
45
+ - `claimed_at`: when the current owner took the slot. Rewritten on every claim, so an adopted slot records the adopting session rather than the original one.
46
+
47
+ **Absent ⇒ unowned.** Slots written before this file existed carry no owner, and every recovery path reads that as *not mine*: an unowned slot is still finalized when its PR goes terminal, but never re-armed. There is no backfill — inferring an owner would guess at exactly the thing this file exists to record.
48
+
49
+ Distinct from [`watch.pid`](#watchpid), and the two are not interchangeable. `watch.pid` leases the slot to one OS **process** so two loops never poll it at once; `owner.json` leases it to one **session** so a foreign session never revives a watch it has no context for. A dead PID marks a watcher to recover; a foreign `session_id` marks one to leave alone. Reconcile consults both, in that order.
50
+
33
51
  ## `cron.json`
34
52
 
35
53
  A durable, on-disk handle to this slot's watcher cron. Its whole reason to exist: `CronList` goes **blind to crons that outlive a session continue / compaction** (the watcher's `/loop` cron survives, but the tool can no longer enumerate it), so a teardown that can only find crons through `CronList` can never delete the orphan — it re-fires until the 7-day `/loop` expiry. A cron id recorded to disk **while the cron was still visible** stays a valid `CronDelete` target afterward. See [`record-cron-id.md`](record-cron-id.md) (who writes it) and [`cancel-cron.md`](cancel-cron.md) (who deletes by it).
@@ -29,4 +29,4 @@ Pick the operation, then read its op file for the procedure.
29
29
  - **Current values**: session-context line `Muggle Test Preferences key=value …`. Default `ask`.
30
30
  - **Per-key files**: `preference-gates/<key>.md`. Key list = `ls preference-gates/*.md` minus `README.md`.
31
31
  - **Allowed values**: `always`/`never`/`ask` (or `local`/`remote`/`ask` for `defaultExecutionMode`).
32
- - **Scope**: `global` default; `project` if user says "for this project" / "just this repo" (pass `cwd`).
32
+ - **Scope**: preferences are user-level. Every write lands in `~/.muggle-ai/preferences.json` and applies to every repo. If the user asks for a per-project setting, say it isn't supported and confirm before setting it everywhere.
@@ -0,0 +1,45 @@
1
+ {
2
+ "skill_name": "muggle-preferences",
3
+ "notes": "These evals test the PLAN behavior after per-project preferences were removed. Preferences resolve from defaults overlaid by ~/.muggle-ai/preferences.json only; muggle-local-preferences-set rejects a `scope` argument. The prompts ask for a step-by-step plan rather than execution, since writing preferences mutates the user's home directory. Assertions check that the plan never offers a per-project scope, never passes `scope`, and confirms before applying a setting the user framed as project-specific.",
4
+ "evals": [
5
+ {
6
+ "id": 0,
7
+ "eval_name": "per-project-request-must-not-be-honoured-silently",
8
+ "prompt": "For this repo only, I want the Electron browser to actually show up when tests run — it's a UI-heavy project and I like watching it. Leave my other projects alone. Walk me through what you'd do.",
9
+ "files": [],
10
+ "assertions": [
11
+ { "name": "states_preferences_are_user_level", "text": "Plan states that preferences are user-level and apply to every repo, so a per-repo setting is not supported." },
12
+ { "name": "does_not_offer_project_scope", "text": "Plan does NOT offer a 'this project only' option, and does not claim a project-scoped preferences file can be written." },
13
+ { "name": "no_scope_argument", "text": "Plan does not pass a `scope` argument to muggle-local-preferences-set; it calls the tool with key and value only." },
14
+ { "name": "confirms_before_global_write", "text": "Plan explicitly confirms with the user before writing, because the user asked for one repo and the write will affect all of them." },
15
+ { "name": "names_the_correct_key", "text": "Plan identifies showElectronBrowser as the key to set." },
16
+ { "name": "does_not_invent_a_workaround", "text": "Plan does not invent a per-repo mechanism (env var, local override file, wrapper script) to simulate project scope." }
17
+ ]
18
+ },
19
+ {
20
+ "id": 1,
21
+ "eval_name": "list-shows-user-level-resolution",
22
+ "prompt": "show me my muggle settings and where they're stored",
23
+ "files": [],
24
+ "assertions": [
25
+ { "name": "resolution_is_two_layer", "text": "Plan describes resolution as defaults overlaid by ~/.muggle-ai/preferences.json, with no third per-project layer." },
26
+ { "name": "names_the_global_path", "text": "Plan names ~/.muggle-ai/preferences.json as the storage location." },
27
+ { "name": "no_project_file_mentioned", "text": "Plan does not tell the user that a preferences file inside the repo is read or honoured." },
28
+ { "name": "reads_from_session_context_or_tool", "text": "Plan reads current values from the session-context preferences line or the preferences tool rather than guessing." }
29
+ ]
30
+ },
31
+ {
32
+ "id": 2,
33
+ "eval_name": "stale-in-repo-file-explained",
34
+ "prompt": "I've got a .muggle-ai/preferences.json checked into my repo with autoLogin set to never, but muggle keeps logging me in automatically. Why isn't it being picked up?",
35
+ "files": [],
36
+ "assertions": [
37
+ { "name": "explains_file_is_not_read", "text": "Plan explains that a preferences file inside the project is no longer read — preferences are user-level." },
38
+ { "name": "points_at_global_file", "text": "Plan points the user at ~/.muggle-ai/preferences.json as the file that actually takes effect." },
39
+ { "name": "offers_to_reapply_globally", "text": "Plan offers to re-apply the affected key globally, noting it will then apply to every repo." },
40
+ { "name": "does_not_delete_user_file", "text": "Plan does not delete or rewrite the user's in-repo preferences file without asking." },
41
+ { "name": "no_scope_argument", "text": "Plan does not attempt to pass a `scope` argument to make the project file work." }
42
+ ]
43
+ }
44
+ ]
45
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  1. Verify `preference-gates/<key>.md` exists. If not, list `preference-gates/*.md` and ask.
4
4
  2. Read it. Run **Picker 1** with the options defined in the gate file, plus an additional `Ask me each time` (sub: `Prompt me at decision time.`) → `ask`.
5
- 3. `muggle-local-preferences-set` with the mapped value, `scope: "global"`.
5
+ 3. `muggle-local-preferences-set` with the mapped value.
6
6
  4. Confirm: `Set <key> to <value>.`
7
7
 
8
8
  Skip Picker 2 — user explicitly asked to change.
@@ -33,18 +33,17 @@ For each option: label = key name, description = first paragraph of `preference-
33
33
  - `multiSelect: true`, `header: "Branch hygiene"` — `autoUseWorktree`, `autoRebase`, `autoCleanup`
34
34
  - `multiSelect: false`, `header: "E2E acceptance"` — `autoE2ETest`. Options: `Always run Stage 6 at the end` (`always` — default), `Ask each cycle` (`ask`). No `never` option.
35
35
  - `multiSelect: false`, `header: "Default mode"` — `defaultExecutionMode`. Options: `Local — run on my computer` (`local`), `Remote — run in the Muggle Test cloud` (`remote`), `Ask each time` (don't change).
36
- - `multiSelect: false`, `header: "Scope"` — final scope question. Options: `Global (all repos)` (~/.muggle-ai/), `This project only` (.muggle-ai/ in repo).
37
36
 
38
- `AskUserQuestion` accepts up to 4 questions per call — split into two calls if needed (categories first, scope second).
37
+ `AskUserQuestion` accepts up to 4 questions per call — split across calls.
39
38
 
40
39
  ## Step 4 — apply selections
41
40
 
42
- For each toggled key (multi-select questions): `muggle-local-preferences-set` with `value: "always"`. For `defaultExecutionMode`: only set if user picked Local/Remote (skip "Ask each time"). Pass `scope` from the scope question; pass `cwd` when scope is `project`.
41
+ For each toggled key (multi-select questions): `muggle-local-preferences-set` with `value: "always"`. For `defaultExecutionMode`: only set if user picked Local/Remote (skip "Ask each time").
43
42
 
44
43
  ## Step 5 — `never` follow-up
45
44
 
46
- Ask: `Want any of these set to "never" (auto-skip without asking)? Name them, e.g. "never on showElectronBrowser", or say "no".`. For named keys, call `muggle-local-preferences-set` with `value: "never"`, same scope.
45
+ Ask: `Want any of these set to "never" (auto-skip without asking)? Name them, e.g. "never on showElectronBrowser", or say "no".`. For named keys, call `muggle-local-preferences-set` with `value: "never"`.
47
46
 
48
47
  ## Step 6 — confirm
49
48
 
50
- One-liner summary: `Set autoLogin=always, openTestResultsAfterRun=always (global).`
49
+ One-liner summary: `Set autoLogin=always, openTestResultsAfterRun=always.`
@@ -5,5 +5,5 @@
5
5
  3. Footer:
6
6
  ```
7
7
  Values: always · ask · never (defaultExecutionMode: local/remote/ask)
8
- Scope: global (~/.muggle-ai/) or project (.muggle-ai/ in repo root)
8
+ Stored: ~/.muggle-ai/preferences.json applies to every repo
9
9
  ```
@@ -1,10 +1,9 @@
1
1
  # Set — direct (key + value)
2
2
 
3
- Trigger: user names both key and value (e.g. "set autoLogin to always", "make showElectronBrowser never for this project").
3
+ Trigger: user names both key and value (e.g. "set autoLogin to always", "make showElectronBrowser never").
4
4
 
5
5
  1. Parse `key` and `value`.
6
6
  2. Verify `preference-gates/<key>.md` exists. If not, list `preference-gates/*.md` and ask.
7
7
  3. Validate `value` per Shared context.
8
- 4. Resolve scope per Shared context.
9
- 5. `muggle-local-preferences-set`.
10
- 6. Confirm: `Set {key} to {value} ({scope}).`
8
+ 4. `muggle-local-preferences-set`.
9
+ 5. Confirm: `Set {key} to {value}.`
@@ -11,8 +11,8 @@ which uses `local` / `remote` / `ask`).
11
11
  ## Resolution
12
12
 
13
13
  `SessionStart` injects a `Muggle Test Preferences` line (`key=value` pairs) from
14
- `~/.muggle-ai/preferences.json` (global) overlaid by
15
- `<repo>/.muggle-ai/preferences.json` (project). Absent → treat as `ask`.
14
+ `~/.muggle-ai/preferences.json`. Preferences are user-level — there is no
15
+ per-project layer. Absent → treat as `ask`.
16
16
 
17
17
  ## Gate behavior
18
18
 
@@ -48,7 +48,7 @@ Header `Remember this choice?`. Question: `"Always <restate Picker 1 choice in
48
48
  plain language> from now on, without asking?"`. Never put the raw key or
49
49
  `=` in the user-visible text.
50
50
 
51
- - `Yes, always` (sub: `You can change this later in muggle preferences.`) → `muggle-local-preferences-set` with the value Picker 1 mapped to, `scope: "global"`.
51
+ - `Yes, always` (sub: `You can change this later in muggle preferences.`) → `muggle-local-preferences-set` with the value Picker 1 mapped to.
52
52
  - `Just this once` (sub: `I'll ask again next time.`) → don't save.
53
53
 
54
54
  A few keys override this template (their per-key file says how).
@@ -2,7 +2,7 @@
2
2
 
3
3
  Reuse the saved local dev server URL for this repo, or pick one each run. Substitute `{lastHost}` (the URL used in the previous run for this repo — omit the option entirely when no cache exists) and `{suggestedHost}` (auto-detected from running ports, e.g. `http://localhost:3000`).
4
4
 
5
- Cache lives at `<cwd>/.muggle-ai/last-host.json`. The calling skill **always** updates the cache after the user picks/types a URL — independent of Picker 2 — so `Use {lastHost}` reflects the most recent run.
5
+ Cache lives in `~/.muggle-ai/last-host.json`, in the entry keyed on `<cwd>`. The calling skill **always** updates the cache after the user picks/types a URL — independent of Picker 2 — so `Use {lastHost}` reflects the most recent run.
6
6
 
7
7
  **Picker 1** — header `Local server`, question `"Which local URL should the test target?"`
8
8
  - `Use {lastHost}` — `From your last run in this repo.` → reuse cached URL. *Skip this option when no cache exists.*
@@ -11,7 +11,7 @@ Cache lives at `<cwd>/.muggle-ai/last-host.json`. The calling skill **always** u
11
11
 
12
12
  **Picker 2 — overrides shared template.** Fires after the user picks a URL.
13
13
  - Header `Remember this URL?`, question `"Always use {chosenHost} for this repo from now on, without asking?"`
14
- - `Yes, always` (sub: `You can change this later in muggle preferences.`) → `muggle-local-preferences-set` (`autoSelectLocalHost=always`, global). The cache is already up to date.
14
+ - `Yes, always` (sub: `You can change this later in muggle preferences.`) → `muggle-local-preferences-set` (`autoSelectLocalHost=always`). The cache is already up to date.
15
15
  - `Just this once` (sub: `I'll ask again next time.`) → don't save the preference. The cache still updates.
16
16
 
17
17
  **Silent action**
@@ -8,7 +8,7 @@ tail options like "Show full list" / "Create new project" are skill-defined).
8
8
  **Picker 2 — overrides shared template.** Fires only after picking an
9
9
  *existing* project; skip if user picked "Create new project".
10
10
  - Header `Reuse this project next time?`, question `"Always reuse {projectName} for this repo from now on, without asking?"`
11
- - `Yes, always` (sub: `You can change this later in muggle preferences.`) → call BOTH `muggle-local-preferences-set` (`autoSelectProject=always`, global) AND `muggle-local-last-project-set` (`cwd`, `projectId`, `projectUrl`, `projectName`).
11
+ - `Yes, always` (sub: `You can change this later in muggle preferences.`) → call BOTH `muggle-local-preferences-set` (`autoSelectProject=always`) AND `muggle-local-last-project-set` (`cwd`, `projectId`, `projectUrl`, `projectName`).
12
12
  - `Just this once` (sub: `I'll ask again next time.`) → don't save.
13
13
 
14
14
  **Silent action**
@@ -0,0 +1,23 @@
1
+ # `watcherLifetime`
2
+
3
+ How long a `muggle-pr-followup` watch loop polls before retiring itself.
4
+
5
+ **Not gated.** This is a configuration value — no Picker 1, no silent footer. Nothing prompts; the saved value is read when a watch is armed. The `muggle-preferences` skill exposes it through Configure and Set so users can change it.
6
+
7
+ | Value | Lifetime |
8
+ |:------|:---------|
9
+ | `1d` | 86400s |
10
+ | `7d` | 604800s — default |
11
+ | `never` | Unbounded |
12
+
13
+ ## Why a bound exists
14
+
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. This cap is the only **time-based** reaper for such a loop — `watcher_superseded` retires one only when a *newer* arm claims the same slot, which never happens if nothing re-arms.
16
+
17
+ **`never` removes that reaper.** It is a legitimate choice, since it ends the re-arm cycle entirely, but an orphaned loop then polls until the machine restarts or someone kills it.
18
+
19
+ ## Applying it
20
+
21
+ The loop is plain `sh` and cannot read preferences. Resolve this value at arm time, convert it to seconds, and export `MUGGLE_PR_WATCH_MAX_LIFETIME` into the loop's environment. `never` exports `0`, which the guard library reads as unbounded.
22
+
23
+ An already-set `MUGGLE_PR_WATCH_MAX_LIFETIME` wins and is never overwritten, so an operator can pin any value without changing the preference.