@muggleai/works 5.6.0 → 5.8.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 (117) hide show
  1. package/dist/{chunk-LTULYOOW.js → chunk-5PYK7DWB.js} +15 -0
  2. package/dist/cli.js +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  5. package/dist/plugin/.cursor-plugin/plugin.json +1 -1
  6. package/dist/plugin/hooks/README.md +15 -3
  7. package/dist/plugin/hooks/hooks.json +41 -1
  8. package/dist/plugin/scripts/gc-state.sh +66 -0
  9. package/dist/plugin/scripts/guard-run-job-object.ps1 +256 -0
  10. package/dist/plugin/scripts/guard-run.mjs +327 -0
  11. package/dist/plugin/scripts/guardrail-offer-ran.sh +35 -0
  12. package/dist/plugin/scripts/guardrail-pr-terminal.sh +21 -0
  13. package/dist/plugin/scripts/guardrail-record-tests.sh +5 -4
  14. package/dist/plugin/scripts/guardrail-terminal-gate.sh +38 -0
  15. package/dist/plugin/scripts/guardrails.mjs +91 -3
  16. package/dist/plugin/scripts/pr-watch-guards.sh +46 -0
  17. package/dist/plugin/skills/_shared/pr-followup-helpers/loop-signature.md +3 -3
  18. package/dist/plugin/skills/_shared/vcs/CLAUDE.md +2 -1
  19. package/dist/plugin/skills/_shared/vcs/common/branch-standing.md +19 -0
  20. package/dist/plugin/skills/_shared/vcs/common/ci-rollup.md +16 -0
  21. package/dist/plugin/skills/_shared/vcs/common/push-to-branch.md +16 -0
  22. package/{plugin/skills/_shared/vcs/github → dist/plugin/skills/_shared/vcs/common}/verify-working-tree.md +4 -4
  23. package/dist/plugin/skills/_shared/vcs/github/pr-edit.md +2 -0
  24. package/dist/plugin/skills/_shared/vcs/github/reply-line-comment.md +2 -0
  25. package/dist/plugin/skills/_shared/vcs/github/signed-commits.md +28 -0
  26. package/dist/plugin/skills/_shared/vcs/github/top-level-comment.md +2 -0
  27. package/dist/plugin/skills/_shared/vcs/github/unresolved-threads.md +39 -39
  28. package/dist/plugin/skills/_shared/vcs/github.md +5 -2
  29. package/dist/plugin/skills/_shared/vcs/gitlab/loop-user-identity.md +1 -1
  30. package/dist/plugin/skills/_shared/vcs/gitlab/mr-create.md +1 -1
  31. package/dist/plugin/skills/_shared/vcs/gitlab/mr-discussions.md +1 -1
  32. package/dist/plugin/skills/_shared/vcs/gitlab/mr-metadata.md +2 -2
  33. package/dist/plugin/skills/_shared/vcs/gitlab/mr-pipeline.md +1 -1
  34. package/dist/plugin/skills/_shared/vcs/gitlab/reply-discussion.md +1 -1
  35. package/dist/plugin/skills/_shared/vcs/gitlab/resolve-discussion.md +1 -1
  36. package/dist/plugin/skills/_shared/vcs/gitlab/signed-commits.md +15 -0
  37. package/dist/plugin/skills/_shared/vcs/gitlab/unresolved-discussions.md +2 -2
  38. package/dist/plugin/skills/_shared/vcs/gitlab.md +6 -3
  39. package/dist/plugin/skills/_shared/vcs/post-signature.md +31 -0
  40. package/dist/plugin/skills/do/build.md +58 -56
  41. package/dist/plugin/skills/do/open-prs/forward.md +85 -84
  42. package/dist/plugin/skills/do/open-prs/update.md +3 -3
  43. package/dist/plugin/skills/do/per-comment-replies.md +2 -2
  44. package/dist/plugin/skills/do/resolve-conflicts.md +59 -58
  45. package/dist/plugin/skills/do/unit-tests.md +1 -1
  46. package/dist/plugin/skills/muggle-pr-followup/CLAUDE.md +1 -0
  47. package/dist/plugin/skills/muggle-pr-followup/SKILL.md +11 -4
  48. package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +29 -5
  49. package/dist/plugin/skills/muggle-pr-followup/bootstrap.md +3 -1
  50. package/dist/plugin/skills/muggle-pr-followup/contract.md +171 -158
  51. package/dist/plugin/skills/muggle-pr-followup/evals/evals.json +221 -101
  52. package/dist/plugin/skills/muggle-pr-followup/output-templates/inline-reply.md +2 -2
  53. package/dist/plugin/skills/muggle-pr-followup/output-templates/resolve-reminder.md +1 -1
  54. package/dist/plugin/skills/muggle-pr-followup/reconcile.md +9 -5
  55. package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +191 -175
  56. package/dist/plugin/skills/muggle-pr-followup/stop.md +31 -0
  57. package/dist/plugin/skills/muggle-pr-visual-walkthrough/SKILL.md +14 -5
  58. package/dist/plugin/skills/muggle-test-prepare/steps/start-services.md +2 -2
  59. package/dist/release-manifest.json +4 -4
  60. package/package.json +6 -6
  61. package/plugin/.claude-plugin/plugin.json +1 -1
  62. package/plugin/.cursor-plugin/plugin.json +1 -1
  63. package/plugin/hooks/README.md +15 -3
  64. package/plugin/hooks/hooks.json +41 -1
  65. package/plugin/scripts/gc-state.sh +66 -0
  66. package/plugin/scripts/guard-run-job-object.ps1 +256 -0
  67. package/plugin/scripts/guard-run.mjs +327 -0
  68. package/plugin/scripts/guardrail-offer-ran.sh +35 -0
  69. package/plugin/scripts/guardrail-pr-terminal.sh +21 -0
  70. package/plugin/scripts/guardrail-record-tests.sh +5 -4
  71. package/plugin/scripts/guardrail-terminal-gate.sh +38 -0
  72. package/plugin/scripts/guardrails.mjs +91 -3
  73. package/plugin/scripts/pr-watch-guards.sh +46 -0
  74. package/plugin/skills/_shared/pr-followup-helpers/loop-signature.md +3 -3
  75. package/plugin/skills/_shared/vcs/CLAUDE.md +2 -1
  76. package/plugin/skills/_shared/vcs/common/branch-standing.md +19 -0
  77. package/plugin/skills/_shared/vcs/common/ci-rollup.md +16 -0
  78. package/plugin/skills/_shared/vcs/common/push-to-branch.md +16 -0
  79. package/{dist/plugin/skills/_shared/vcs/github → plugin/skills/_shared/vcs/common}/verify-working-tree.md +4 -4
  80. package/plugin/skills/_shared/vcs/github/pr-edit.md +2 -0
  81. package/plugin/skills/_shared/vcs/github/reply-line-comment.md +2 -0
  82. package/plugin/skills/_shared/vcs/github/signed-commits.md +28 -0
  83. package/plugin/skills/_shared/vcs/github/top-level-comment.md +2 -0
  84. package/plugin/skills/_shared/vcs/github/unresolved-threads.md +39 -39
  85. package/plugin/skills/_shared/vcs/github.md +5 -2
  86. package/plugin/skills/_shared/vcs/gitlab/loop-user-identity.md +1 -1
  87. package/plugin/skills/_shared/vcs/gitlab/mr-create.md +1 -1
  88. package/plugin/skills/_shared/vcs/gitlab/mr-discussions.md +1 -1
  89. package/plugin/skills/_shared/vcs/gitlab/mr-metadata.md +2 -2
  90. package/plugin/skills/_shared/vcs/gitlab/mr-pipeline.md +1 -1
  91. package/plugin/skills/_shared/vcs/gitlab/reply-discussion.md +1 -1
  92. package/plugin/skills/_shared/vcs/gitlab/resolve-discussion.md +1 -1
  93. package/plugin/skills/_shared/vcs/gitlab/signed-commits.md +15 -0
  94. package/plugin/skills/_shared/vcs/gitlab/unresolved-discussions.md +2 -2
  95. package/plugin/skills/_shared/vcs/gitlab.md +6 -3
  96. package/plugin/skills/_shared/vcs/post-signature.md +31 -0
  97. package/plugin/skills/do/build.md +58 -56
  98. package/plugin/skills/do/open-prs/forward.md +85 -84
  99. package/plugin/skills/do/open-prs/update.md +3 -3
  100. package/plugin/skills/do/per-comment-replies.md +2 -2
  101. package/plugin/skills/do/resolve-conflicts.md +59 -58
  102. package/plugin/skills/do/unit-tests.md +1 -1
  103. package/plugin/skills/muggle-pr-followup/CLAUDE.md +1 -0
  104. package/plugin/skills/muggle-pr-followup/SKILL.md +11 -4
  105. package/plugin/skills/muggle-pr-followup/arm-watcher.md +29 -5
  106. package/plugin/skills/muggle-pr-followup/bootstrap.md +3 -1
  107. package/plugin/skills/muggle-pr-followup/contract.md +171 -158
  108. package/plugin/skills/muggle-pr-followup/evals/evals.json +221 -101
  109. package/plugin/skills/muggle-pr-followup/output-templates/inline-reply.md +2 -2
  110. package/plugin/skills/muggle-pr-followup/output-templates/resolve-reminder.md +1 -1
  111. package/plugin/skills/muggle-pr-followup/reconcile.md +9 -5
  112. package/plugin/skills/muggle-pr-followup/state-schemas.md +191 -175
  113. package/plugin/skills/muggle-pr-followup/stop.md +31 -0
  114. package/plugin/skills/muggle-pr-visual-walkthrough/SKILL.md +14 -5
  115. package/plugin/skills/muggle-test-prepare/steps/start-services.md +2 -2
  116. package/dist/plugin/skills/_shared/vcs/github/push-to-branch.md +0 -10
  117. package/plugin/skills/_shared/vcs/github/push-to-branch.md +0 -10
@@ -1,158 +1,171 @@
1
- # Watcher Per-Tick Contract
2
-
3
- The procedure for the **tick mode** of `muggle-pr-followup` — one polling iteration scoped to one PR. The watcher is a dumb pipe: it polls for actionable review threads, CI checks, and the branch's standing against its base, dispatches `/muggle-do` if there's unaddressed review feedback, fixable red CI, or a branch that's behind or conflicting with its base, and exits. It does not classify, fix, resolve, rebase, amend requirements, post replies, run cycles, or escalate.
4
-
5
- Routing into this mode is documented in [`SKILL.md`](SKILL.md#routing). The architectural rationale lives in the brain docs `architecture/2026-05-08-muggle-do-pr-comment-loop-design.md` (the overall loop) and `architecture/2026-06-06-pr-followup-thread-state-baseline-design.md` (the thread-state dispatch trigger).
6
-
7
- ## Turn preamble
8
-
9
- ```
10
- **muggle-pr-followup tick** — polling <repo>#<pr-number>.
11
- ```
12
-
13
- ## Input
14
-
15
- `$ARGUMENTS = <slug> <pr-number>` (or `<pr-number>` alone — slug inferred from on-disk state per [`SKILL.md`](SKILL.md#routing)).
16
-
17
- ## Inputs from disk
18
-
19
- Read these from `~/.muggle-ai/muggle-do/sessions/<slug>/`:
20
-
21
- - `prs.json` — see [`state-schemas.md`](state-schemas.md#prsjson). The watcher touches the single entry whose `number` matches the dispatched PR number.
22
- - `last_seen.json` — see [`state-schemas.md`](state-schemas.md#last_seenjson). Keyed by `"<owner>/<repo>#<n>"`.
23
-
24
- If either file is missing or the PR is not in `prs.json`, the tick is a no-op. Log an error line in `followup.log` and exit. The watcher must not be invoked in this state — if it happens, the slot is corrupt.
25
-
26
- ## Writing state
27
-
28
- Every `increment`/`reset` this procedure applies to `last_seen.json`, and the `prs.json` refresh in Step 1, is a **whole-file rewrite** (Read → change field → Write) per [`../_shared/session-state-writes.md`](../_shared/session-state-writes.md). **Never** patch session JSON with the Edit tool — an exact-string match against these files silently fails ("malformed edit") and drops the update, so the counter never advances.
29
-
30
- ## Procedure
31
-
32
- ### Step 0 — Stale-fire guard, then record this cron's id
33
-
34
- If `prs.json[0].state` on disk is already `merged` or `closed`, this slot was finalized by a prior tick and this is a stale (queued) fire — per-minute cron fires enqueued while the session was busy still drain after the cron is cancelled. Defensively cancel any lingering cron for this slug per [`cancel-cron.md`](cancel-cron.md) (no-op if none), append a `stale-tick` line to `followup.log`, and exit. Do not re-fetch or re-finalize.
35
-
36
- **Orphan escalation — on the third or later stale fire.** Count prior `stale-tick` lines for this slot in `followup.log` before appending this one. Three or more means both `cancel-cron.md` lookups have already run on earlier stale fires and the cron still fires — it is orphaned in a session runtime neither the recorded id nor `CronList` can reach, so it will keep firing until the 7-day expiry. Escalate to the owner **once**: if `followup.log` has no `stale-orphan-escalated` line, print one line — `orphaned cron: <slug> keeps firing after finalize; no in-session cancel can reach it — restarting the Claude session is the only clean clear` — and append a `stale-orphan-escalated` line so every later stale fire absorbs silently again. Never respond to an unreachable cron by guess-deleting ids `CronList` does surface: those belong to other live watchers, and deleting one silently kills a wanted loop.
37
-
38
- Otherwise, self-record this watcher's cron id per [`record-cron-id.md`](record-cron-id.md) before proceeding. Recording every tick — while `CronList` can still see the cron — is what keeps the id a valid `CronDelete` target after a session continue / compaction blinds `CronList` to it, so teardown ([`finalize.md`](finalize.md), [`reconcile.md`](reconcile.md)) can always kill the orphan.
39
-
40
- ### Step 1 — Refresh PR state
41
-
42
- Per [`../_shared/vcs/github/pr-metadata.md`](../_shared/vcs/github/pr-metadata.md). Update `prs.json[0].head_sha` and `prs.json[0].state` from the response; keep `mergeable` (conflict signal) for Step 5, and run the recipe's `compare` call to capture both `behind_by` (out-of-date signal) and `.base_commit.sha` — the base branch tip, Step 5's `base_tip_sha`.
43
-
44
- ### Step 2 — Termination check
45
-
46
- If `state` is `MERGED` or `CLOSED`:
47
-
48
- 1. Finalize the slot per [`finalize.md`](finalize.md) — mark terminal, write `result.md`, log + telemetry, unschedule this watcher's cron.
49
- 2. Hand off the terminal wrap-up as the last action of the turn — for both `MERGED` and `CLOSED`:
50
-
51
- ```
52
- /muggle-do post-merge cleanup slug=<slug> state=<merged|closed>
53
- ```
54
-
55
- `/muggle-do` owns the worktree/branch knowledge: it runs teardown only on `merged` (honoring the `autoCleanup` gate — `closed` is unmerged, so the branch and any worktree stay intact), then suggests the next step. This is a runtime dispatch, not a doc dependency on `/muggle-do` — see the one-way rule in [`../CLAUDE.md`](../CLAUDE.md).
56
- 3. Exit. The watcher has unscheduled itself; no future ticks fire for this PR.
57
-
58
-
59
- ### Step 2.5 — Blocked-tick gate
60
-
61
- Only when `last_seen.blocked` is present (the watcher is awaiting the owner on a durable human-block, flagged in Step 7). When absent, skip straight to Step 3.
62
-
63
- Run the resume gate per [`blocked-tick.md`](blocked-tick.md): recompute the fingerprint; while it holds, stay blocked **silently** — the single owner reminder went out when the block was flagged — and clear the block and fall through to Step 3 the moment it moves. The block never changes the poll; it only mutes dispatch until the state the user must act on moves.
64
-
65
- ### Step 3 — Compute the actionable set from live thread state
66
-
67
- The watcher's dispatch trigger is **derived from current provider state**, not a stored review-id cursor — see the [thread-state baseline design](../../../../muggle-ai-brain/architecture/2026-06-06-pr-followup-thread-state-baseline-design.md). Resolve the provider per [`../_shared/vcs/detect-vcs.md`](../_shared/vcs/detect-vcs.md), then:
68
-
69
- - **`github`** — two sources, unioned:
70
-
71
- **(a) Actionable threads.** Fetch unresolved review threads per [`../_shared/vcs/github/unresolved-threads.md`](../_shared/vcs/github/unresolved-threads.md). A thread is **actionable** when `isResolved == false` **and** `isOutdated == false` **and** its newest comment lacks the loop marker `<!-- muggle-do:bot -->` — classify by the marker, never `author.login` (see [`../_shared/pr-followup-helpers/loop-signature.md`](../_shared/pr-followup-helpers/loop-signature.md)). The marker rule makes echo intrinsic: once the loop has replied, the thread's newest comment is the loop's own, so the thread is no longer actionable — no cursor to advance, no self-recursion (see [`../_shared/pr-followup-helpers/echo-skip.md`](../_shared/pr-followup-helpers/echo-skip.md)).
72
-
73
- **(b) Actionable body-only reviews — GitHub only.** A body-only review — a submitted `CHANGES_REQUESTED`/`COMMENTED` review with no line comments — has no thread to derive state from, so it keeps a narrow watermark. GitLab has no review envelope (feedback is always a discussion note), so this sub-branch is GitHub-only and has no GitLab analogue. Fetch submitted reviews per [`../_shared/vcs/github/submitted-reviews.md`](../_shared/vcs/github/submitted-reviews.md); a body-only review is actionable when `id > last_seen.lastBodyReviewId` **and** `id ∉ last_seen.escalated_review_ids`.
74
-
75
- Collect the **owning review ids** for dispatch: for each actionable thread, the owning review of its newest comment (`pullRequestReview.databaseId` from the query); plus every actionable body-only review id. The dedup'd union is the dispatch list.
76
-
77
- - **`gitlab`** — single source. Fetch unresolved discussions per [`../_shared/vcs/gitlab/unresolved-discussions.md`](../_shared/vcs/gitlab/unresolved-discussions.md) (drop to [`../_shared/vcs/gitlab/mr-discussions.md`](../_shared/vcs/gitlab/mr-discussions.md) for the raw notes if a thread's classification needs them). A discussion is **actionable** when it is unresolved **and** its newest note lacks the loop marker `<!-- muggle-do:bot -->` — same marker classification, never `author.username`. There is no body-only watermark: discussion state is the sole authority. The dispatch list is the **discussion ids** of the actionable discussions.
78
-
79
- ### Step 4 — If the actionable set is non-empty → dispatch (reviews preempt CI)
80
-
81
- The watcher does **not** classify. Classification, batching, replying, escalation, and cycle execution all live in `/muggle-do`. The watcher hands over the dispatch ids from Step 3 (GitHub: owning review ids; GitLab: discussion ids) and exits — `/muggle-do`'s address-reviews re-derives the unresolved threads itself (its authority), so the watcher only needs to decide *that* there is work, not enumerate it exhaustively.
82
-
83
- 1. Reset `last_seen.idle_tick_count` to 0.
84
- 2. **Stop this watcher (single-thread).** Cancel its cron so no tick fires while the dev cycle runs, per [`cancel-cron.md`](cancel-cron.md). `/muggle-do` respawns the watcher when the cycle finishes — exactly one cron ever, and no tick overlaps a running cycle.
85
- 3. Dispatch `/muggle-do` with an *address-reviews* directive carrying:
86
- - PR URL (from `prs.json[0].url`)
87
- - Session slug (from the invocation arguments)
88
- - The dispatch ids from Step 3 (GitHub owning review ids / GitLab discussion ids), as a space-separated list
89
-
90
- Exact phrasing belongs to `/muggle-do`'s intent-routing. A reasonable shape is:
91
- ```
92
- /muggle-do address reviews <id1> <id2> ... on <pr-url> slug=<slug>
93
- ```
94
- 4. Append a dispatching line to `followup.log` per [`output-templates/watcher-log.md`](output-templates/watcher-log.md).
95
- 5. Emit a `tick` event with `actionable_threads: <count>`, `dispatched_review_ids: [<id>, ...]`.
96
- 6. Exit. **Reviews preempt CI** — when there is actionable feedback, this tick dispatches address-reviews and never polls CI. The watcher is now stopped; the dev cycle owns the PR and restarts the watcher when it finishes. (The watcher also self-unschedules in Step 2, terminal.)
97
-
98
- ### Step 5 — No actionable feedback → keep the branch rebased on its base
99
-
100
- A merge-ready branch is **current with its base** — neither conflicting nor behind. From the Step 1 metadata, the branch needs a rebase when either:
101
-
102
- - `mergeable == CONFLICTING` (corroborated by `mergeStateStatus == DIRTY`) — conflicts with the base, **or**
103
- - `behind_by > 0` — out of date with the base. Read this from the `compare` call (commit ancestry), **never** from `mergeStateStatus == BEHIND`: GitHub masks `BEHIND` behind `DIRTY`/`BLOCKED` and only surfaces it under "require branches up to date" protection, so a stale PR that is also awaiting review or has a red required check reports `BLOCKED` — and its staleness would go unseen. See [`../_shared/vcs/github/pr-metadata.md`](../_shared/vcs/github/pr-metadata.md#behind-by-out-of-date-detection). On `gitlab`, the same behind-by comes from the compare in [`../_shared/vcs/gitlab/mr-metadata.md`](../_shared/vcs/gitlab/mr-metadata.md#behind-by-out-of-date-detection) (commit ancestry, not `detailed_merge_status`); conflict is `detailed_merge_status` in `{broken_status, conflict}`.
104
-
105
- This trigger is **independent of approval and CI state**: an out-of-date branch is rebased whether or not it has been reviewed, approved, or has green checks. The watcher acts on staleness directly — it never waits for an approval to surface it.
106
-
107
- Rebase dedup is keyed on the **pair** `rebase_key = "<head_sha>..<base_tip_sha>"`, not on the head alone. Whether a branch conflicts is a function of both sides, so a head-only key wedges a PR permanently the first time the base moves: the head cannot change while nobody pushes, so one stale entry suppresses every genuinely new conflict that base movement introduces, forever. Take `base_tip_sha` from the Step 1 compare's `.base_commit.sha` (the base branch tip, which advances when the base does) — **never** `.merge_base_commit.sha`, which does not move when only the base advances and so would never re-arm.
108
-
109
- Entries written by an older watcher are bare head SHAs with no `..` — ignore them when reading `conflict_escalated_keys`, which re-arms any slot a head-only key had wedged.
110
-
111
- If a rebase is due **and** `conflict_resolve_attempts[rebase_key] < 2` **and** `rebase_key` ∉ `conflict_escalated_keys` → dispatch and exit:
112
-
113
- 1. Reset `last_seen.idle_tick_count` to 0.
114
- 2. **Stop this watcher (single-thread):** cancel its cron exactly as in Step 4 — `/muggle-do`'s rebase respawns it when the cycle is done.
115
- 3. Dispatch `/muggle-do` with a *rebase* directive (PR URL + slug; no review ids, no check names):
116
- ```
117
- /muggle-do rebase on <pr-url> slug=<slug>
118
- ```
119
- The executor rebases onto the base: a behind-only branch replays cleanly and force-pushes; a conflicting branch resolves behind the `autoResolveConflicts` gate. Both paths are `/muggle-do`'s — the watcher only decides *that* a rebase is due, never how.
120
- 4. Append a dispatching line to `followup.log`; emit a `tick` event with `rebase_needed: true`, `dispatched_rebase: true`.
121
- 5. Exit. The dev cycle owns the PR; its respawn restarts the watcher, whose next tick re-checks the branch against its base on the new head — the rebase is its own verify loop, bounded by the per-SHA attempt budget.
122
-
123
- Otherwise — `behind_by == 0` and not conflicting (`mergeable == UNKNOWN` is fine here: `behind_by` is exact while GitHub is still computing conflict state, so a stale branch still triggers), or budget spent (`conflict_resolve_attempts[rebase_key] >= 2` or `rebase_key` ∈ `conflict_escalated_keys`) → fall through to CI.
124
-
125
- ### Step 6 — No actionable feedback, branch current → poll CI for the head SHA
126
-
127
- Fetch the CI rollup for `prs.json[0].head_sha`, provider resolved as in Step 3 — `github` → the check-run rollup per [`../_shared/vcs/github/pr-checks.md`](../_shared/vcs/github/pr-checks.md); `gitlab` → the pipeline-job rollup per [`../_shared/vcs/gitlab/mr-pipeline.md`](../_shared/vcs/gitlab/mr-pipeline.md) (failed/running/success jobs fold into the same red/pending/green buckets). Then, on the bucket:
128
-
129
- - **Any check still pending** (`bucket == "pending"`) → idle (wait for checks to settle).
130
- - **All checks green / skipped, or no checks** → idle (green path).
131
- - **One or more checks red** (`bucket == "fail"`), **and** `ci_fix_attempts[head_sha] < 3`, **and** `head_sha` ∉ `ci_escalated_shas` → dispatch and exit:
132
- 1. Reset `last_seen.idle_tick_count` to 0.
133
- 2. **Stop this watcher (single-thread):** cancel its cron exactly as in Step 4 — `/muggle-do`'s fix-ci respawns it when the cycle is done.
134
- 3. Dispatch `/muggle-do` with a *fix-ci* directive carrying the PR URL, slug, and the red check names (no review ids):
135
- ```
136
- /muggle-do fix ci <check-1> <check-2> ... on <pr-url> slug=<slug>
137
- ```
138
- 4. Append a dispatching line to `followup.log`; emit a `tick` event with `checks_red: <count>`, `dispatched_ci_fix: true`.
139
- 5. Exit. The dev cycle owns the PR; its respawn restarts the watcher, whose next tick re-checks CI on the new head SHA — CI itself is the verify loop.
140
- - **One or more red, but `ci_fix_attempts[head_sha] >= 3` or `head_sha` ∈ `ci_escalated_shas`** → idle. The fix budget is spent; `/muggle-do`'s fix-ci stage already recorded the escalation. The watcher does not re-dispatch.
141
-
142
- ### Step 7 — Idle (remind when blocked pending a human)
143
-
144
- Any idle branch (Steps 4–6 that did not dispatch). First classify **why** this tick idled. It is **blocked pending a human** when the head is under a durable block that only the user can clear:
145
-
146
- - `rebase_key` ∈ `conflict_escalated_keys` — a rebase `/muggle-do` gave up on (a semantic conflict, or `autoResolveConflicts=never`), reason `conflict_escalated`. This block clears on its own when the base moves: the new `base_tip_sha` yields a key the set does not contain, and the branch re-arms for a fresh rebase attempt; or
147
- - `head_sha` ∈ `ci_escalated_shas` — CI the fix-ci stage gave up on, reason `ci_escalated`; or
148
- - `last_seen.escalated_review_ids` is non-empty with the actionable set empty — an ambiguous review awaiting the user's direction, reason `reviews_escalated`.
149
-
150
- Everything else that idles is **transient** — green and waiting for the next review, CI still pending, or `mergeable == UNKNOWN` — and must keep the responsive `1m` cadence; those turn a state on their own and the watcher should catch it promptly.
151
-
152
- **Transient idle** (no durable block): unchanged — increment `last_seen.idle_tick_count`, append an idle line to `followup.log` per [`output-templates/watcher-log.md`](output-templates/watcher-log.md), emit a `tick` event with `idle: true`, `blocked: false`, `actionable_threads: 0`, `dispatched_review_ids: []`, `rebase_needed: <bool>`, `dispatched_rebase: false`, `checks_red: <count or 0>`, `dispatched_ci_fix: false`. Exit. The next tick fires from the arming loop ([`arm-watcher.md`](arm-watcher.md)) — or, under a recovery cron, in 1 min via `/loop`.
153
-
154
- **Blocked pending a human** (a durable block, and `last_seen.blocked` not already set): enter the blocked path per [`blocked-tick.md`](blocked-tick.md) — flag `last_seen.blocked` and emit the one-line owner reminder, **once per block**. From the next tick on, the Step 2.5 gate carries the block silently. Exit.
155
-
156
- ## Output
157
-
158
- The watcher stays invisible to the **GitHub reviewer** — it never posts to the PR from a tick. To the **loop owner** in the session it emits: the turn preamble, the one-line blocked reminder while awaiting the owner (Steps 2.5 / 7), and (when a dispatch fires) the `/muggle-do` directive. Nothing else.
1
+ # Watcher Per-Tick Contract
2
+
3
+ The procedure for the **tick mode** of `muggle-pr-followup` — one polling iteration scoped to one PR. The watcher is a dumb pipe: it polls for actionable review threads, CI checks, and the branch's standing against its base, dispatches `/muggle-do` if there's unaddressed review feedback, fixable red CI, or a branch that's behind or conflicting with its base, and exits. It does not classify, fix, resolve, rebase, amend requirements, post replies, run cycles, or escalate.
4
+
5
+ Routing into this mode is documented in [`SKILL.md`](SKILL.md#routing). The architectural rationale lives in the brain docs `architecture/2026-05-08-muggle-do-pr-comment-loop-design.md` (the overall loop) and `architecture/2026-06-06-pr-followup-thread-state-baseline-design.md` (the thread-state dispatch trigger).
6
+
7
+ ## Turn preamble
8
+
9
+ ```
10
+ **muggle-pr-followup tick** — polling <repo>#<pr-number>.
11
+ ```
12
+
13
+ ## Input
14
+
15
+ `$ARGUMENTS = <slug> <pr-number> [--wake=<event>]` (or `<pr-number>` alone — slug inferred from on-disk state per [`SKILL.md`](SKILL.md#routing)). `--wake` is consumed by routing's live-watcher gate; the tick itself ignores it. An invocation that reaches this file has already passed that gate — a live watcher plus no `--wake` never gets here.
16
+
17
+ ## Inputs from disk
18
+
19
+ Read these from `~/.muggle-ai/muggle-do/sessions/<slug>/`:
20
+
21
+ - `prs.json` — see [`state-schemas.md`](state-schemas.md#prsjson). The watcher touches the single entry whose `number` matches the dispatched PR number.
22
+ - `last_seen.json` — see [`state-schemas.md`](state-schemas.md#last_seenjson). Keyed by `"<owner>/<repo>#<n>"`.
23
+
24
+ If either file is missing or the PR is not in `prs.json`, the tick is a no-op. Log an error line in `followup.log` and exit. The watcher must not be invoked in this state — if it happens, the slot is corrupt.
25
+
26
+ ## Writing state
27
+
28
+ Every `increment`/`reset` this procedure applies to `last_seen.json`, and the `prs.json` refresh in Step 1, is a **whole-file rewrite** (Read → change field → Write) per [`../_shared/session-state-writes.md`](../_shared/session-state-writes.md). **Never** patch session JSON with the Edit tool — an exact-string match against these files silently fails ("malformed edit") and drops the update, so the counter never advances.
29
+
30
+ ## Procedure
31
+
32
+ ### Step 0 — Stopped-slot absorb, stale-fire guard, then record this cron's id
33
+
34
+ **Stopped-slot absorb — before anything else, including the disk reads above.** If the kill file `~/.muggle-ai/muggle-do/polling.disabled` exists, or the slot dir is absent but `~/.muggle-ai/muggle-do/sessions/<slug>.stopped/` exists (the owner ran [`stop.md`](stop.md)), absorb: no fetch, no state write, no log line — output one line (`stopped: <slug> — absorbed`) and exit. This gate is what makes an **unreachable orphaned cron** harmless: a cron whose handle a compaction severed can neither be enumerated by `CronList` nor deleted by its recorded id, so it fires until its session dies — the absorb caps each fire at one line of output and nothing else. Never "fix" an orphan by deleting ids `CronList` does surface; those belong to other live watchers.
35
+
36
+ If `prs.json[0].state` on disk is already `merged` or `closed`, this slot was finalized by a prior tick and this is a stale (queued) fire — per-minute cron fires enqueued while the session was busy still drain after the cron is cancelled. Defensively cancel any lingering cron for this slug per [`cancel-cron.md`](cancel-cron.md) (no-op if none), append a `stale-tick` line to `followup.log`, and exit. Do not re-fetch or re-finalize.
37
+
38
+ **Orphan escalation — on the third or later stale fire.** Count prior `stale-tick` lines for this slot in `followup.log` before appending this one. Three or more means both `cancel-cron.md` lookups have already run on earlier stale fires and the cron still fires — it is orphaned in a session runtime neither the recorded id nor `CronList` can reach, so it will keep firing until the 7-day expiry. Escalate to the owner **once**: if `followup.log` has no `stale-orphan-escalated` line, print one line — `orphaned cron: <slug> keeps firing after finalize; no in-session cancel can reach it — restarting the Claude session is the only clean clear` — and append a `stale-orphan-escalated` line so every later stale fire absorbs silently again. Never respond to an unreachable cron by guess-deleting ids `CronList` does surface: those belong to other live watchers, and deleting one silently kills a wanted loop.
39
+
40
+ Otherwise, self-record this watcher's cron id per [`record-cron-id.md`](record-cron-id.md) before proceeding. Recording every tick — while `CronList` can still see the cron — is what keeps the id a valid `CronDelete` target after a session continue / compaction blinds `CronList` to it, so teardown ([`finalize.md`](finalize.md), [`reconcile.md`](reconcile.md)) can always kill the orphan.
41
+
42
+ ### Step 1 — Refresh PR state
43
+
44
+ Per [`../_shared/vcs/github/pr-metadata.md`](../_shared/vcs/github/pr-metadata.md). Update `prs.json[0].head_sha` and `prs.json[0].state` from the response; keep `mergeable` (conflict signal) for Step 5, and run the recipe's `compare` call to capture both `behind_by` (out-of-date signal) and `.base_commit.sha` — the base branch tip, Step 5's `base_tip_sha`.
45
+
46
+ ### Step 2 — Termination check
47
+
48
+ If `state` is `MERGED` or `CLOSED`:
49
+
50
+ 1. Finalize the slot per [`finalize.md`](finalize.md) — mark terminal, write `result.md`, log + telemetry, unschedule this watcher's cron.
51
+ 2. Hand off the terminal wrap-up as the last action of the turn — for both `MERGED` and `CLOSED`:
52
+
53
+ ```
54
+ /muggle-do post-merge cleanup slug=<slug> state=<merged|closed>
55
+ ```
56
+
57
+ `/muggle-do` owns the worktree/branch knowledge: it runs teardown only on `merged` (honoring the `autoCleanup` gate — `closed` is unmerged, so the branch and any worktree stay intact), then suggests the next step. This is a runtime dispatch, not a doc dependency on `/muggle-do` — see the one-way rule in [`../CLAUDE.md`](../CLAUDE.md).
58
+ 3. Exit. The watcher has unscheduled itself; no future ticks fire for this PR.
59
+
60
+
61
+ ### Step 2.5 — Blocked-tick gate
62
+
63
+ Only when `last_seen.blocked` is present (the watcher is awaiting the owner on a durable human-block, flagged in Step 7). When absent, skip straight to Step 3.
64
+
65
+ Run the resume gate per [`blocked-tick.md`](blocked-tick.md): recompute the fingerprint; while it holds, stay blocked **silently** — the single owner reminder went out when the block was flagged — and clear the block and fall through to Step 3 the moment it moves. The block never changes the poll; it only mutes dispatch until the state the user must act on moves. A held block still exits through Step 7.5 — a cron-delivered blocked tick must not stay on a model-turn cadence either.
66
+
67
+ ### Step 3 — Compute the actionable set from live thread state
68
+
69
+ The watcher's dispatch trigger is **derived from current provider state**, not a stored review-id cursor — see the [thread-state baseline design](../../../../muggle-ai-brain/architecture/2026-06-06-pr-followup-thread-state-baseline-design.md). Resolve the provider per [`../_shared/vcs/detect-vcs.md`](../_shared/vcs/detect-vcs.md), then:
70
+
71
+ - **`github`** — two sources, unioned:
72
+
73
+ **(a) Actionable threads.** Fetch unresolved review threads per [`../_shared/vcs/github/unresolved-threads.md`](../_shared/vcs/github/unresolved-threads.md). A thread is **actionable** when `isResolved == false` **and** its newest comment lacks the loop marker `<!-- muggle-do:bot -->` — classify by the marker, never `author.login` (see [`../_shared/pr-followup-helpers/loop-signature.md`](../_shared/pr-followup-helpers/loop-signature.md)). Outdatedness is **not** a gate: a thread whose anchored line has since moved (a rebase or a later push to the branch) still carries its unanswered question, and dropping it leaves that question unanswered forever — the failure this trigger exists to prevent. Whether the moved code already mooted the concern is a judgment for `/muggle-do` to make and reply to, not a reason for the watcher to skip. The marker rule makes echo intrinsic: once the loop has replied, the thread's newest comment is the loop's own, so the thread is no longer actionable regardless of outdatedness — no cursor to advance, no self-recursion (see [`../_shared/pr-followup-helpers/echo-skip.md`](../_shared/pr-followup-helpers/echo-skip.md)).
74
+
75
+ **(b) Actionable body-only reviews — GitHub only.** A body-only review — a submitted `CHANGES_REQUESTED`/`COMMENTED` review with no line comments — has no thread to derive state from, so it keeps a narrow watermark. GitLab has no review envelope (feedback is always a discussion note), so this sub-branch is GitHub-only and has no GitLab analogue. Fetch submitted reviews per [`../_shared/vcs/github/submitted-reviews.md`](../_shared/vcs/github/submitted-reviews.md); a body-only review is actionable when `id > last_seen.lastBodyReviewId` **and** `id ∉ last_seen.escalated_review_ids`.
76
+
77
+ Collect the **owning review ids** for dispatch: for each actionable thread, the owning review of its newest comment (`pullRequestReview.databaseId` from the query); plus every actionable body-only review id. The dedup'd union is the dispatch list.
78
+
79
+ - **`gitlab`** — single source. Fetch unresolved discussions per [`../_shared/vcs/gitlab/unresolved-discussions.md`](../_shared/vcs/gitlab/unresolved-discussions.md) (drop to [`../_shared/vcs/gitlab/mr-discussions.md`](../_shared/vcs/gitlab/mr-discussions.md) for the raw notes if a thread's classification needs them). A discussion is **actionable** when it is unresolved **and** its newest note lacks the loop marker `<!-- muggle-do:bot -->` — same marker classification, never `author.username`. There is no body-only watermark: discussion state is the sole authority. The dispatch list is the **discussion ids** of the actionable discussions.
80
+
81
+ ### Step 4 — If the actionable set is non-empty → dispatch (reviews preempt CI)
82
+
83
+ The watcher does **not** classify. Classification, batching, replying, escalation, and cycle execution all live in `/muggle-do`. The watcher hands over the dispatch ids from Step 3 (GitHub: owning review ids; GitLab: discussion ids) and exits — `/muggle-do`'s address-reviews re-derives the unresolved threads itself (its authority), so the watcher only needs to decide *that* there is work, not enumerate it exhaustively.
84
+
85
+ 1. Reset `last_seen.idle_tick_count` to 0.
86
+ 2. **Stop this watcher (single-thread).** Cancel its cron so no tick fires while the dev cycle runs, per [`cancel-cron.md`](cancel-cron.md). `/muggle-do` respawns the watcher when the cycle finishes — exactly one cron ever, and no tick overlaps a running cycle.
87
+ 3. Dispatch `/muggle-do` with an *address-reviews* directive carrying:
88
+ - PR URL (from `prs.json[0].url`)
89
+ - Session slug (from the invocation arguments)
90
+ - The dispatch ids from Step 3 (GitHub owning review ids / GitLab discussion ids), as a space-separated list
91
+
92
+ Exact phrasing belongs to `/muggle-do`'s intent-routing. A reasonable shape is:
93
+ ```
94
+ /muggle-do address reviews <id1> <id2> ... on <pr-url> slug=<slug>
95
+ ```
96
+ 4. Append a dispatching line to `followup.log` per [`output-templates/watcher-log.md`](output-templates/watcher-log.md).
97
+ 5. Emit a `tick` event with `actionable_threads: <count>`, `dispatched_review_ids: [<id>, ...]`.
98
+ 6. Exit. **Reviews preempt CI** — when there is actionable feedback, this tick dispatches address-reviews and never polls CI. The watcher is now stopped; the dev cycle owns the PR and restarts the watcher when it finishes. (The watcher also self-unschedules in Step 2, terminal.)
99
+
100
+ ### Step 5 — No actionable feedback → keep the branch rebased on its base
101
+
102
+ A merge-ready branch is **current with its base** — neither conflicting nor behind. From the Step 1 metadata, the branch needs a rebase when either:
103
+
104
+ - the branch **conflicts** with its base — the provider's conflict signal per [`../_shared/vcs/common/branch-standing.md`](../_shared/vcs/common/branch-standing.md), **or**
105
+ - `behind_by > 0` — out of date with the base. Read this from the `compare` call (commit ancestry), **never** from `mergeStateStatus == BEHIND`: GitHub masks `BEHIND` behind `DIRTY`/`BLOCKED` and only surfaces it under "require branches up to date" protection, so a stale PR that is also awaiting review or has a red required check reports `BLOCKED` — and its staleness would go unseen. The compare recipe for this — and each provider's conflict signal — is dispatched in [`../_shared/vcs/common/branch-standing.md`](../_shared/vcs/common/branch-standing.md).
106
+
107
+ This trigger is **independent of approval and CI state**: an out-of-date branch is rebased whether or not it has been reviewed, approved, or has green checks. The watcher acts on staleness directly — it never waits for an approval to surface it.
108
+
109
+ Rebase dedup is keyed on the **pair** `rebase_key = "<head_sha>..<base_tip_sha>"`, not on the head alone. Whether a branch conflicts is a function of both sides, so a head-only key wedges a PR permanently the first time the base moves: the head cannot change while nobody pushes, so one stale entry suppresses every genuinely new conflict that base movement introduces, forever. Take `base_tip_sha` from the Step 1 compare's `.base_commit.sha` (the base branch tip, which advances when the base does) — **never** `.merge_base_commit.sha`, which does not move when only the base advances and so would never re-arm.
110
+
111
+ Entries written by an older watcher are bare head SHAs with no `..` — ignore them when reading `conflict_escalated_keys`, which re-arms any slot a head-only key had wedged.
112
+
113
+ If a rebase is due **and** `conflict_resolve_attempts[rebase_key] < 2` **and** `rebase_key` ∉ `conflict_escalated_keys` → dispatch and exit:
114
+
115
+ 1. Reset `last_seen.idle_tick_count` to 0.
116
+ 2. **Stop this watcher (single-thread):** cancel its cron exactly as in Step 4 — `/muggle-do`'s rebase respawns it when the cycle is done.
117
+ 3. Dispatch `/muggle-do` with a *rebase* directive (PR URL + slug; no review ids, no check names):
118
+ ```
119
+ /muggle-do rebase on <pr-url> slug=<slug>
120
+ ```
121
+ The executor rebases onto the base: a behind-only branch replays cleanly and force-pushes; a conflicting branch resolves behind the `autoResolveConflicts` gate. Both paths are `/muggle-do`'s — the watcher only decides *that* a rebase is due, never how.
122
+ 4. Append a dispatching line to `followup.log`; emit a `tick` event with `rebase_needed: true`, `dispatched_rebase: true`.
123
+ 5. Exit. The dev cycle owns the PR; its respawn restarts the watcher, whose next tick re-checks the branch against its base on the new head — the rebase is its own verify loop, bounded by the per-SHA attempt budget.
124
+
125
+ Otherwise — `behind_by == 0` and not conflicting (`mergeable == UNKNOWN` is fine here: `behind_by` is exact while GitHub is still computing conflict state, so a stale branch still triggers), or budget spent (`conflict_resolve_attempts[rebase_key] >= 2` or `rebase_key` ∈ `conflict_escalated_keys`) → fall through to CI.
126
+
127
+ ### Step 6 — No actionable feedback, branch current → poll CI for the head SHA
128
+
129
+ Fetch the CI rollup for `prs.json[0].head_sha` per [`../_shared/vcs/common/ci-rollup.md`](../_shared/vcs/common/ci-rollup.md), provider resolved as in Step 3 (failed/running/success fold into the same red/pending/green buckets). Then, on the bucket:
130
+
131
+ - **Any check still pending** (`bucket == "pending"`) → idle (wait for checks to settle).
132
+ - **All checks green / skipped, or no checks** → idle (green path).
133
+ - **One or more checks red** (`bucket == "fail"`), **and** `ci_fix_attempts[head_sha] < 3`, **and** `head_sha` ∉ `ci_escalated_shas` → dispatch and exit:
134
+ 1. Reset `last_seen.idle_tick_count` to 0.
135
+ 2. **Stop this watcher (single-thread):** cancel its cron exactly as in Step 4 — `/muggle-do`'s fix-ci respawns it when the cycle is done.
136
+ 3. Dispatch `/muggle-do` with a *fix-ci* directive carrying the PR URL, slug, and the red check names (no review ids):
137
+ ```
138
+ /muggle-do fix ci <check-1> <check-2> ... on <pr-url> slug=<slug>
139
+ ```
140
+ 4. Append a dispatching line to `followup.log`; emit a `tick` event with `checks_red: <count>`, `dispatched_ci_fix: true`.
141
+ 5. Exit. The dev cycle owns the PR; its respawn restarts the watcher, whose next tick re-checks CI on the new head SHA — CI itself is the verify loop.
142
+ - **One or more red, but `ci_fix_attempts[head_sha] >= 3` or `head_sha` ∈ `ci_escalated_shas`** → idle. The fix budget is spent; `/muggle-do`'s fix-ci stage already recorded the escalation. The watcher does not re-dispatch.
143
+
144
+ ### Step 7 — Idle (remind when blocked pending a human)
145
+
146
+ Any idle branch (Steps 4–6 that did not dispatch). First classify **why** this tick idled. It is **blocked pending a human** when the head is under a durable block that only the user can clear:
147
+
148
+ - `rebase_key` ∈ `conflict_escalated_keys` — a rebase `/muggle-do` gave up on (a semantic conflict, or `autoResolveConflicts=never`), reason `conflict_escalated`. This block clears on its own when the base moves: the new `base_tip_sha` yields a key the set does not contain, and the branch re-arms for a fresh rebase attempt; or
149
+ - `head_sha` ∈ `ci_escalated_shas` — CI the fix-ci stage gave up on, reason `ci_escalated`; or
150
+ - `last_seen.escalated_review_ids` is non-empty with the actionable set empty — an ambiguous review awaiting the user's direction, reason `reviews_escalated`.
151
+
152
+ Everything else that idles is **transient** — green and waiting for the next review, CI still pending, or `mergeable == UNKNOWN` — and must keep the responsive `1m` cadence; those turn a state on their own and the watcher should catch it promptly.
153
+
154
+ **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)).
155
+
156
+ **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.
157
+
158
+ ### Step 7.5 — Hand a cron-delivered tick back to the monitor
159
+
160
+ 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)).
161
+
162
+ Check the slot's `watch-heartbeat` mtime — a live monitor touches it every iteration:
163
+
164
+ - **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`.
165
+ - **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.
166
+
167
+ 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.
168
+
169
+ ## Output
170
+
171
+ The watcher stays invisible to the **GitHub reviewer** — it never posts to the PR from a tick. To the **loop owner** in the session it emits: the turn preamble, the one-line blocked reminder while awaiting the owner (Steps 2.5 / 7), and (when a dispatch fires) the `/muggle-do` directive. Nothing else.