@muggleai/works 5.5.0 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  2. package/dist/plugin/.cursor-plugin/plugin.json +1 -1
  3. package/dist/plugin/hooks/README.md +9 -3
  4. package/dist/plugin/hooks/hooks.json +6 -0
  5. package/dist/plugin/scripts/guardrail-record-tests.sh +5 -4
  6. package/dist/plugin/scripts/guardrails.mjs +11 -3
  7. package/dist/plugin/scripts/reconcile-stale-watchers.sh +59 -0
  8. package/dist/plugin/skills/_shared/post-merge-cleanup.md +2 -3
  9. package/dist/plugin/skills/_shared/session-state-writes.md +1 -1
  10. package/dist/plugin/skills/_shared/telemetry-events/muggle-do-cycle.md +1 -1
  11. package/dist/plugin/skills/do/e2e-acceptance.md +2 -0
  12. package/dist/plugin/skills/do/resolve-conflicts.md +5 -5
  13. package/dist/plugin/skills/do/respawn-watcher.md +4 -14
  14. package/dist/plugin/skills/muggle-pr-followup/CLAUDE.md +3 -2
  15. package/dist/plugin/skills/muggle-pr-followup/SKILL.md +5 -1
  16. package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +12 -0
  17. package/dist/plugin/skills/muggle-pr-followup/auto-track.md +5 -11
  18. package/dist/plugin/skills/muggle-pr-followup/blocked-tick.md +10 -10
  19. package/dist/plugin/skills/muggle-pr-followup/bootstrap.md +4 -8
  20. package/dist/plugin/skills/muggle-pr-followup/contract.md +13 -7
  21. package/dist/plugin/skills/muggle-pr-followup/evals/evals.json +101 -0
  22. package/dist/plugin/skills/muggle-pr-followup/output-templates/bootstrap.md +2 -2
  23. package/dist/plugin/skills/muggle-pr-followup/reconcile.md +10 -0
  24. package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +19 -5
  25. package/dist/release-manifest.json +4 -4
  26. package/package.json +6 -6
  27. package/plugin/.claude-plugin/plugin.json +1 -1
  28. package/plugin/.cursor-plugin/plugin.json +1 -1
  29. package/plugin/hooks/README.md +9 -3
  30. package/plugin/hooks/hooks.json +6 -0
  31. package/plugin/scripts/guardrail-record-tests.sh +5 -4
  32. package/plugin/scripts/guardrails.mjs +11 -3
  33. package/plugin/scripts/reconcile-stale-watchers.sh +59 -0
  34. package/plugin/skills/_shared/post-merge-cleanup.md +2 -3
  35. package/plugin/skills/_shared/session-state-writes.md +1 -1
  36. package/plugin/skills/_shared/telemetry-events/muggle-do-cycle.md +1 -1
  37. package/plugin/skills/do/e2e-acceptance.md +2 -0
  38. package/plugin/skills/do/resolve-conflicts.md +5 -5
  39. package/plugin/skills/do/respawn-watcher.md +4 -14
  40. package/plugin/skills/muggle-pr-followup/CLAUDE.md +3 -2
  41. package/plugin/skills/muggle-pr-followup/SKILL.md +5 -1
  42. package/plugin/skills/muggle-pr-followup/arm-watcher.md +12 -0
  43. package/plugin/skills/muggle-pr-followup/auto-track.md +5 -11
  44. package/plugin/skills/muggle-pr-followup/blocked-tick.md +10 -10
  45. package/plugin/skills/muggle-pr-followup/bootstrap.md +4 -8
  46. package/plugin/skills/muggle-pr-followup/contract.md +13 -7
  47. package/plugin/skills/muggle-pr-followup/evals/evals.json +101 -0
  48. package/plugin/skills/muggle-pr-followup/output-templates/bootstrap.md +2 -2
  49. package/plugin/skills/muggle-pr-followup/reconcile.md +10 -0
  50. package/plugin/skills/muggle-pr-followup/state-schemas.md +19 -5
@@ -0,0 +1,12 @@
1
+ # Arming the Watch
2
+
3
+ How an orchestrating session starts the watch on one PR. Every arming point runs this same sequence: [`bootstrap.md`](bootstrap.md) Step 8, [`auto-track.md`](auto-track.md) Step 6, and the executor's post-cycle settle.
4
+
5
+ 1. **Drain.** Run one tick per [`contract.md`](contract.md). It acts on everything already outstanding — actionable threads, body-only reviews past the watermark, a stale branch, red CI — and finalizes a terminal PR. If the tick dispatched a cycle, stop here: the cycle's exit path settles the watch when it finishes.
6
+ 2. **Seed the watermark.** Write the slot's watch watermark ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)) from a fetch taken after the drain completes. Never let the loop capture its own baseline — anything landing between drain and launch would be swallowed.
7
+ 3. **Watch.** Write the loop as `watch.sh` in the slot and start it as a **persistent background monitor** in the orchestrating session — the command is the one-line script path, and the label is `PR #<n> — <title>`. Both matter: some task surfaces show the label, others show the command, and a slot-path command keeps the watch identifiable everywhere a raw script blob would not. One monitor per PR, alive from arm to terminal: it is the watch's visible handle, showing as a running task the entire time the PR is polled. Its loop checks about every 60 seconds, re-reading the watermark each iteration; on a newer submitted review, a newer thread comment, or a thread newly unresolved it prints one line and **keeps watching** — advancing its in-memory floor so each event prints exactly once. It exits only when the PR goes terminal or after five consecutive failed fetches. Quiet iterations print nothing and cost nothing — no model tokens are spent while the watch is quiet.
8
+ 4. **On event.** The monitor's line wakes the session: run the tick (step 1), which derives everything from live provider state; a terminal PR finalizes there while the monitor exits on its own. Persistent fetch failure → surface the reason; [`reconcile.md`](reconcile.md) re-arms the slot on its next sweep.
9
+
10
+ **After a cycle** — advance the watermark file from a fetch taken after the replies land; that write is what stops the loop from reporting the loop's own reply. A cycle that skips the advance leaves the watermark stale, and the next event will be an echo. Arm a fresh monitor only when none is alive (session restart, killed task); the settle path never duplicates a running watch.
11
+
12
+ Drain-then-watch is the invariant: the tick handles everything up to the watermark with the real semantics — marker rule, watermark, attempt budgets — that the watch deliberately lacks, so the monitor only needs to see what arrives after it.
@@ -67,22 +67,16 @@ Already tracking (skipped):
67
67
  ...
68
68
  (or "none")
69
69
 
70
- Dispatching:
71
- /loop 1m /muggle:muggle-pr-followup <slug> <n>
70
+ Arming:
71
+ <owner>/<repo>#<n>
72
72
  ...
73
73
  ```
74
74
 
75
- Print the summary **before** the `/loop` dispatches so it stays visible.
75
+ Print the summary **before** arming the watchers so it stays visible.
76
76
 
77
- ### Step 6 — Dispatch the watchers
77
+ ### Step 6 — Arm the watchers
78
78
 
79
- As the last action of the turn, emit one `/loop` line per **newly tracked** PR (not the skipped ones):
80
-
81
- ```
82
- /loop 1m /muggle:muggle-pr-followup <slug> <n>
83
- ```
84
-
85
- Each registers an independent cron — the N-independent-watchers model from [`SKILL.md`](SKILL.md).
79
+ Arm one watch per **newly tracked** PR (not the skipped ones), per [`arm-watcher.md`](arm-watcher.md). Each watch is independent — one drain tick and one labeled monitor per PR.
86
80
 
87
81
  ### Step 7 — Emit telemetry
88
82
 
@@ -1,8 +1,8 @@
1
1
  # Blocked-tick procedure
2
2
 
3
- The watcher's conditional path for a PR **blocked pending a human** — a durable block only the user can clear (an escalated rebase or CI budget spent, or an ambiguous review awaiting direction). Entered from [`contract.md`](contract.md) Step 7 (flag), then driven each subsequent tick by Step 2.5 (remind-or-resume). None of this runs on a normal tick: when `last_seen.blocked` is absent, the watcher skips straight through.
3
+ The watcher's conditional path for a PR **blocked pending a human** — a durable block only the user can clear (an escalated rebase or CI budget spent, or an ambiguous review awaiting direction). Entered from [`contract.md`](contract.md) Step 7 (flag), then driven on each subsequent tick by Step 2.5 (resume gate). None of this runs on a normal tick: when `last_seen.blocked` is absent, the watcher skips straight through.
4
4
 
5
- **Governing rule — remind at the normal `1m` cadence, never stop.** The poll stays at `1m` whether or not the PR is blocked — a `1m` tick is cheap (one fingerprint check, one line out) and keeps the owner nudged and an external unblock caught within a minute. The `blocked` state changes no cadence; its only job is (a) the reason-specific one-line reminder each tick and (b) fingerprint-based auto-resume. No cadence swap, no separate `reminded` flag — while `blocked` is set, the watcher reminds every tick by definition.
5
+ **Governing rule — one reminder per block, and the watch never stops.** The tick that flags the block reminds the owner once; every later blocked tick is silent. The watch keeps standing — the monitor stays visible, and ticks still run at their normal cadence (historically `1m`) whenever a wake or recovery cron fires — with fingerprint-based auto-resume clearing the block the moment external state moves. No repeat nagging, no cadence swap, no separate `reminded` flag: `blocked` present means the reminder has already been sent.
6
6
 
7
7
  ## The fingerprint
8
8
 
@@ -20,19 +20,19 @@ When an idle tick is a durable human-block and `last_seen.blocked` is not alread
20
20
 
21
21
  1. Increment `last_seen.idle_tick_count`.
22
22
  2. Write `last_seen.blocked = { reason, since: <now>, fingerprint }` (reuse the `latest_review_id` / `ci_digest` already fetched this tick).
23
- 3. **Remind the owner** — emit the one-line reminder per [`output-templates/blocked-reminder.md`](output-templates/blocked-reminder.md): the pending act plus a reference back to the decision context.
24
- 4. Append a `blocked reason=<reason>` line to `followup.log` per [`output-templates/watcher-log.md`](output-templates/watcher-log.md); emit a `tick` event with `idle: true`, `blocked: true`, and the same other fields as a transient idle. Exit. The `1m` cron is unchanged — no swap.
23
+ 3. **Remind the owner** — emit the one-line reminder per [`output-templates/blocked-reminder.md`](output-templates/blocked-reminder.md): the pending act plus a reference back to the decision context. This is the block's **only** reminder.
24
+ 4. Append a `blocked reason=<reason>` line to `followup.log` per [`output-templates/watcher-log.md`](output-templates/watcher-log.md); emit a `tick` event with `idle: true`, `blocked: true`, and the same other fields as a transient idle. Exit.
25
25
 
26
26
  ## Remind or resume (the Step 2.5 gate)
27
27
 
28
28
  Every subsequent tick while `last_seen.blocked` is present: recompute the fingerprint and compare to `last_seen.blocked.fingerprint`.
29
29
 
30
- - **Unchanged** → still blocked. Re-emit the one-line reminder per [`output-templates/blocked-reminder.md`](output-templates/blocked-reminder.md). Increment `last_seen.idle_tick_count`, append a `blocked reason=<reason>` line to `followup.log`, emit a `tick` event with `idle: true`, `blocked: true`. Exit. The `1m` cron is unchanged.
31
- - **Changed** → clear `last_seen.blocked` and **fall through to [`contract.md`](contract.md) Step 3** to re-evaluate against the moved state this same tick. The cron is already `1m`, so no swap is needed: if Step 3–6 dispatches, that cancels the `1m` cron and `/muggle-do` respawns `1m` (normal single-thread); if it idles transient, the `1m` cron is already correct; if it idles back into the block, Step 7 re-flags.
30
+ - **Unchanged** → still blocked. Stay **silent** — the reminder went out when the block was flagged. Increment `last_seen.idle_tick_count`, append a `blocked reason=<reason>` line to `followup.log`, emit a `tick` event with `idle: true`, `blocked: true`. Exit.
31
+ - **Changed** → clear `last_seen.blocked` and **fall through to [`contract.md`](contract.md) Step 3** to re-evaluate against the moved state this same tick: a dispatch hands the PR to the cycle (its exit settles the watch); a transient idle changes nothing; idling back into a block re-flags per Step 7 — a new block, which sends its own single reminder.
32
32
 
33
33
  ## Invariants
34
34
 
35
- - Cadence is `1m` whether blocked or active — the block never changes the poll interval. There is no cadence swap, so a blocked slot is never left cron-less by one.
36
- - The poll never stops — a blocked tick keeps firing and reminding; only a terminal PR or an explicit teardown removes the cron.
37
- - Every blocked tick emits exactly one owner reminder (implied by `blocked: true`, no separate flag) and no PR-side post.
38
- - The block clears the instant any fingerprint component moves; an external unblock is caught within one `1m` tick.
35
+ - One reminder per block — sent when flagged, never repeated while the same block holds. A re-flag after a resume is a new block and sends its own single reminder.
36
+ - The watch never stops — a blocked PR stays visibly watched at the normal `1m` cadence; only a terminal PR or an explicit teardown ends it.
37
+ - The block clears the instant any fingerprint component moves, caught at the next wake or tick.
38
+ - The blocked path never posts to the PR.
@@ -73,7 +73,7 @@ Write under `~/.muggle-ai/muggle-do/sessions/<slug>/`:
73
73
 
74
74
  **`last_seen.json`** — see [`state-schemas.md`](state-schemas.md#last_seenjson). One key (`"<owner>/<repo>#<n>"`), `lastBodyReviewId` from Step 6, `last_pushed_sha: null`, `idle_tick_count: 0`, `cycles_completed: 0`, `escalated_review_ids: []`, `pushed_shas: []`. Omit `blocked` — the watcher starts unblocked.
75
75
 
76
- **`cron.json`** — see [`state-schemas.md`](state-schemas.md#cronjson). `cron_id: null` (Step 8 dispatches `/loop` as the last action, so the id isn't observable yet — the first tick self-records it per [`record-cron-id.md`](record-cron-id.md)), `command: "/muggle:muggle-pr-followup <slug> <n>"`, `interval: "1m"`, `recorded_at: <now>`.
76
+ **`cron.json`** — see [`state-schemas.md`](state-schemas.md#cronjson). `cron_id: null` (bootstrap arms no cron; a tick running under one recorded by [`reconcile.md`](reconcile.md) self-records its id per [`record-cron-id.md`](record-cron-id.md)), `command: "/muggle:muggle-pr-followup <slug> <n>"`, `interval: "1m"`, `recorded_at: <now>`.
77
77
 
78
78
  **`state.md`** — see [`state-schemas.md`](state-schemas.md#statemd). `Bootstrapped from URL: yes`. Cache the loop-user login. If Step 6.5 resolved a validation context, append the `## Pre-flight answers` block with its fields, per [`../_shared/resolve-e2e-validation-context.md`](../_shared/resolve-e2e-validation-context.md#persisted-fields). If it seeded poll-only, write **no** such block — a missing block is a clean E2E skip.
79
79
 
@@ -81,17 +81,13 @@ Do **not** write `cycle.json` or `requirements.md` — those files are no longer
81
81
 
82
82
  Create `iterations/` subdir (empty) for future caller use.
83
83
 
84
- ### Step 8 — Dispatch the first watcher
84
+ ### Step 8 — Arm the watch
85
85
 
86
- The last action of this turn:
87
-
88
- ```
89
- /loop 1m /muggle:muggle-pr-followup <slug> <n>
90
- ```
86
+ Arm per [`arm-watcher.md`](arm-watcher.md) as the last action of the turn: one tick drains anything already actionable — this is the first tick Step 6 promises — then a persistent, labeled monitor keeps watch — visible until the PR terminates. The cron path stays as the recovery substrate ([`reconcile.md`](reconcile.md)), so `cron.json` is still seeded in Step 7.
91
87
 
92
88
  ### Step 9 — Print the success summary
93
89
 
94
- Use the success-summary template from [`output-templates/bootstrap.md`](output-templates/bootstrap.md). Print it **before** the `/loop` dispatch so it's visible.
90
+ Use the success-summary template from [`output-templates/bootstrap.md`](output-templates/bootstrap.md). Print it **before** arming the watch so it's visible.
95
91
 
96
92
  ### Step 10 — Emit telemetry
97
93
 
@@ -33,11 +33,13 @@ Every `increment`/`reset` this procedure applies to `last_seen.json`, and the `p
33
33
 
34
34
  If `prs.json[0].state` on disk is already `merged` or `closed`, this slot was finalized by a prior tick and this is a stale (queued) fire — per-minute cron fires enqueued while the session was busy still drain after the cron is cancelled. Defensively cancel any lingering cron for this slug per [`cancel-cron.md`](cancel-cron.md) (no-op if none), append a `stale-tick` line to `followup.log`, and exit. Do not re-fetch or re-finalize.
35
35
 
36
+ **Orphan escalation — on the third or later stale fire.** Count prior `stale-tick` lines for this slot in `followup.log` before appending this one. Three or more means both `cancel-cron.md` lookups have already run on earlier stale fires and the cron still fires — it is orphaned in a session runtime neither the recorded id nor `CronList` can reach, so it will keep firing until the 7-day expiry. Escalate to the owner **once**: if `followup.log` has no `stale-orphan-escalated` line, print one line — `orphaned cron: <slug> keeps firing after finalize; no in-session cancel can reach it — restarting the Claude session is the only clean clear` — and append a `stale-orphan-escalated` line so every later stale fire absorbs silently again. Never respond to an unreachable cron by guess-deleting ids `CronList` does surface: those belong to other live watchers, and deleting one silently kills a wanted loop.
37
+
36
38
  Otherwise, self-record this watcher's cron id per [`record-cron-id.md`](record-cron-id.md) before proceeding. Recording every tick — while `CronList` can still see the cron — is what keeps the id a valid `CronDelete` target after a session continue / compaction blinds `CronList` to it, so teardown ([`finalize.md`](finalize.md), [`reconcile.md`](reconcile.md)) can always kill the orphan.
37
39
 
38
40
  ### Step 1 — Refresh PR state
39
41
 
40
- 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 `behind_by` (out-of-date signal) for Step 5.
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`.
41
43
 
42
44
  ### Step 2 — Termination check
43
45
 
@@ -58,7 +60,7 @@ If `state` is `MERGED` or `CLOSED`:
58
60
 
59
61
  Only when `last_seen.blocked` is present (the watcher is awaiting the owner on a durable human-block, flagged in Step 7). When absent, skip straight to Step 3.
60
62
 
61
- Run the remind-or-resume gate per [`blocked-tick.md`](blocked-tick.md): recompute the fingerprint, re-emit the one-line owner reminder and stay blocked while it holds, or clear the block and fall through to Step 3 the moment it moves. While blocked the watcher keeps the normal `1m` cadence and reminds the owner each tick — the block reminds rather than backs off, and never changes the poll interval.
63
+ Run the resume gate per [`blocked-tick.md`](blocked-tick.md): recompute the fingerprint; while it holds, stay blocked **silently** — the single owner reminder went out when the block was flagged — and clear the block and fall through to Step 3 the moment it moves. The block never changes the poll; it only mutes dispatch until the state the user must act on moves.
62
64
 
63
65
  ### Step 3 — Compute the actionable set from live thread state
64
66
 
@@ -102,7 +104,11 @@ A merge-ready branch is **current with its base** — neither conflicting nor be
102
104
 
103
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.
104
106
 
105
- If a rebase is due **and** `conflict_resolve_attempts[head_sha] < 2` **and** `head_sha` ∉ `conflict_escalated_shas` → dispatch and exit:
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:
106
112
 
107
113
  1. Reset `last_seen.idle_tick_count` to 0.
108
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.
@@ -114,7 +120,7 @@ If a rebase is due **and** `conflict_resolve_attempts[head_sha] < 2` **and** `he
114
120
  4. Append a dispatching line to `followup.log`; emit a `tick` event with `rebase_needed: true`, `dispatched_rebase: true`.
115
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.
116
122
 
117
- 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[head_sha] >= 2` or `head_sha` ∈ `conflict_escalated_shas`) → fall through to CI.
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.
118
124
 
119
125
  ### Step 6 — No actionable feedback, branch current → poll CI for the head SHA
120
126
 
@@ -137,15 +143,15 @@ Fetch the CI rollup for `prs.json[0].head_sha`, provider resolved as in Step 3
137
143
 
138
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:
139
145
 
140
- - `head_sha` ∈ `conflict_escalated_shas` — a rebase `/muggle-do` gave up on (a semantic conflict, or `autoResolveConflicts=never`), reason `conflict_escalated`; or
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
141
147
  - `head_sha` ∈ `ci_escalated_shas` — CI the fix-ci stage gave up on, reason `ci_escalated`; or
142
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`.
143
149
 
144
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.
145
151
 
146
- **Transient idle** (no durable block): unchanged — increment `last_seen.idle_tick_count`, append an idle line to `followup.log` per [`output-templates/watcher-log.md`](output-templates/watcher-log.md), emit a `tick` event with `idle: true`, `blocked: false`, `actionable_threads: 0`, `dispatched_review_ids: []`, `rebase_needed: <bool>`, `dispatched_rebase: false`, `checks_red: <count or 0>`, `dispatched_ci_fix: false`. Exit. The next tick fires in 1 min via `/loop`.
152
+ **Transient idle** (no durable block): unchanged — increment `last_seen.idle_tick_count`, append an idle line to `followup.log` per [`output-templates/watcher-log.md`](output-templates/watcher-log.md), emit a `tick` event with `idle: true`, `blocked: false`, `actionable_threads: 0`, `dispatched_review_ids: []`, `rebase_needed: <bool>`, `dispatched_rebase: false`, `checks_red: <count or 0>`, `dispatched_ci_fix: false`. Exit. The next tick fires from the arming loop ([`arm-watcher.md`](arm-watcher.md)) — or, under a recovery cron, in 1 min via `/loop`.
147
153
 
148
- **Blocked pending a human** (a durable block, and `last_seen.blocked` not already set): enter the blocked path per [`blocked-tick.md`](blocked-tick.md) — flag `last_seen.blocked` and emit the one-line owner reminder (the watcher reminds each tick at the normal `1m` cadence rather than backing off). From the next tick on, the Step 2.5 gate carries the block. Exit.
154
+ **Blocked pending a human** (a durable block, and `last_seen.blocked` not already set): enter the blocked path per [`blocked-tick.md`](blocked-tick.md) — flag `last_seen.blocked` and emit the one-line owner reminder, **once per block**. From the next tick on, the Step 2.5 gate carries the block silently. Exit.
149
155
 
150
156
  ## Output
151
157
 
@@ -0,0 +1,101 @@
1
+ {
2
+ "skill_name": "muggle-pr-followup",
3
+ "notes": "These evals test the TICK DECISION behavior. 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. 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
+ "evals": [
5
+ {
6
+ "id": 0,
7
+ "eval_name": "actionable-threads-dispatch-address-reviews",
8
+ "prompt": "Tick muggle-ai-works-pr500 500. The slot is open. The PR has two unresolved, not-outdated review threads whose newest comments are from a human (no muggle-do:bot marker), CI is fully green, and the branch is level with master. Walk me through the plan for this tick.",
9
+ "files": [],
10
+ "assertions": [
11
+ { "name": "computes_actionable_from_live_thread_state", "text": "Plan derives the actionable set from current unresolved-thread state, not from a stored review-id cursor." },
12
+ { "name": "classifies_by_loop_marker", "text": "Plan decides a thread is actionable by the absence of the muggle-do:bot marker on the newest comment, never by author login." },
13
+ { "name": "cancels_own_cron_before_dispatch", "text": "Plan cancels this watcher's own cron before dispatching, so no tick overlaps the running cycle." },
14
+ { "name": "dispatches_address_reviews", "text": "Plan dispatches /muggle-do with an address-reviews directive carrying the PR URL, slug, and owning review ids, then exits." },
15
+ { "name": "stays_a_dumb_pipe", "text": "Plan does NOT classify reviews, post replies, resolve threads, or escalate — those belong to /muggle-do." }
16
+ ]
17
+ },
18
+ {
19
+ "id": 1,
20
+ "eval_name": "reviews-preempt-ci",
21
+ "prompt": "Tick muggle-ai-works-pr501 501. The slot is open. There is one unresolved human review thread AND two red required checks on the head SHA. Walk me through the plan for this tick.",
22
+ "files": [],
23
+ "assertions": [
24
+ { "name": "dispatches_address_reviews_only", "text": "Plan dispatches address-reviews for the review thread." },
25
+ { "name": "does_not_poll_or_dispatch_ci", "text": "Plan does NOT dispatch fix-ci on this tick — actionable feedback preempts the CI branch entirely." },
26
+ { "name": "single_dispatch_then_exit", "text": "Plan performs exactly one dispatch and exits rather than handling both concerns in one tick." }
27
+ ]
28
+ },
29
+ {
30
+ "id": 2,
31
+ "eval_name": "behind-branch-dispatches-rebase",
32
+ "prompt": "Tick muggle-ai-works-pr502 502. The slot is open, there are no unresolved review threads, CI is green. GitHub reports mergeStateStatus BLOCKED and mergeable MERGEABLE, and the compare call shows behind_by 4. Walk me through the plan for this tick.",
33
+ "files": [],
34
+ "assertions": [
35
+ { "name": "reads_staleness_from_compare", "text": "Plan determines the branch is out of date from behind_by on the compare call (commit ancestry)." },
36
+ { "name": "does_not_trust_mergeStateStatus", "text": "Plan does NOT conclude the branch is current just because mergeStateStatus reads BLOCKED rather than BEHIND." },
37
+ { "name": "dispatches_rebase", "text": "Plan dispatches /muggle-do with a rebase directive (PR URL + slug, no review ids, no check names)." },
38
+ { "name": "keys_dedup_on_head_and_base_pair", "text": "Plan keys the rebase attempt/escalation lookup on the head..base_tip pair, not the head SHA alone." }
39
+ ]
40
+ },
41
+ {
42
+ "id": 3,
43
+ "eval_name": "red-ci-within-budget-dispatches-fix-ci",
44
+ "prompt": "Tick muggle-ai-works-pr503 503. The slot is open, no unresolved review threads, branch is level with its base. Two checks are red on the head SHA. ci_fix_attempts for that SHA is 1 and the SHA is not in ci_escalated_shas. Walk me through the plan for this tick.",
45
+ "files": [],
46
+ "assertions": [
47
+ { "name": "checks_attempt_budget", "text": "Plan verifies ci_fix_attempts for the head SHA is under the cap of 3 before dispatching." },
48
+ { "name": "checks_escalated_set", "text": "Plan verifies the head SHA is not in ci_escalated_shas." },
49
+ { "name": "dispatches_fix_ci_with_check_names", "text": "Plan dispatches /muggle-do with a fix-ci directive carrying the red check names." },
50
+ { "name": "cancels_own_cron_before_dispatch", "text": "Plan cancels this watcher's cron before dispatching." }
51
+ ]
52
+ },
53
+ {
54
+ "id": 4,
55
+ "eval_name": "red-ci-over-budget-idles",
56
+ "prompt": "Tick muggle-ai-works-pr504 504. The slot is open, no unresolved review threads, branch is level with its base. Three checks are red on the head SHA, ci_fix_attempts for that SHA is 3, and the SHA is in ci_escalated_shas. Walk me through the plan for this tick.",
57
+ "files": [],
58
+ "assertions": [
59
+ { "name": "does_not_dispatch", "text": "Plan does NOT dispatch fix-ci — the budget is spent and the SHA is already escalated." },
60
+ { "name": "treats_as_durable_block", "text": "Plan classifies this idle as blocked pending a human (reason ci_escalated), not as a transient idle." },
61
+ { "name": "reminds_owner_at_1m", "text": "Plan emits a one-line owner reminder and keeps the normal 1m cadence — it does not back off, slow down, or stop polling." },
62
+ { "name": "no_pr_side_post", "text": "Plan does NOT post anything to the PR for a blocked tick; the reminder goes to the loop owner in-session." }
63
+ ]
64
+ },
65
+ {
66
+ "id": 5,
67
+ "eval_name": "merged-pr-finalizes-without-respawn",
68
+ "prompt": "Tick muggle-ai-works-pr505 505. Refreshing the PR shows state MERGED. The slot on disk still says open. Walk me through the plan for this tick.",
69
+ "files": [],
70
+ "assertions": [
71
+ { "name": "finalizes_the_slot", "text": "Plan finalizes the slot: marks it terminal, writes result.md, logs, and unschedules this watcher's cron." },
72
+ { "name": "does_not_respawn", "text": "Plan does NOT respawn or re-arm a watcher — a terminal PR needs none." },
73
+ { "name": "hands_off_terminal_wrapup", "text": "Plan hands the terminal wrap-up to /muggle-do post-merge cleanup as the turn's last action." },
74
+ { "name": "unschedules_recorded_id_first", "text": "Plan cancels the cron by the id recorded in cron.json first, falling back to a CronList match — so teardown works even after CronList has gone blind." }
75
+ ]
76
+ },
77
+ {
78
+ "id": 6,
79
+ "eval_name": "stale-queued-fire-does-not-refinalize",
80
+ "prompt": "Tick muggle-ai-works-pr506 506. Reading the slot, prs.json already records state merged and result.md exists — this fire was queued before the cron was cancelled. Walk me through the plan for this tick.",
81
+ "files": [],
82
+ "assertions": [
83
+ { "name": "detects_stale_fire", "text": "Plan recognises this as a stale queued fire from the on-disk terminal state before fetching anything." },
84
+ { "name": "does_not_refetch_or_refinalize", "text": "Plan does NOT re-fetch the PR, re-write result.md, or re-run the terminal handoff." },
85
+ { "name": "defensively_cancels_and_logs", "text": "Plan defensively cancels any lingering cron for the slug and appends a stale-tick line to followup.log, then exits." }
86
+ ]
87
+ },
88
+ {
89
+ "id": 7,
90
+ "eval_name": "repeated-stale-fires-escalate-orphan-once",
91
+ "prompt": "Tick muggle-ai-works-pr507 507. prs.json records state merged and result.md exists. followup.log already holds four stale-tick lines for this slot and no stale-orphan-escalated line. Walk me through the plan for this tick.",
92
+ "files": [],
93
+ "assertions": [
94
+ { "name": "detects_orphaned_cron", "text": "Plan concludes the cron is orphaned — repeated stale fires after finalize mean both cancel-cron lookups (recorded id, CronList match) keep missing it — rather than treating this as a normal queued-fire drain." },
95
+ { "name": "escalates_to_owner_once", "text": "Plan surfaces a one-line owner notice that only a session restart clears the orphan, and appends a stale-orphan-escalated marker line so the notice never repeats." },
96
+ { "name": "later_fires_absorb_silently", "text": "Plan treats any stale fire after the marker exists as a silent absorb — log stale-tick and exit, no repeated notice." },
97
+ { "name": "never_guess_deletes_other_ids", "text": "Plan does NOT delete cron ids that CronList surfaces for other slugs — those belong to other live watchers." }
98
+ ]
99
+ }
100
+ ]
101
+ }
@@ -1,13 +1,13 @@
1
1
  # Bootstrap output templates
2
2
 
3
- ## Success summary (printed just before dispatch)
3
+ ## Success summary (printed just before arming)
4
4
 
5
5
  ```
6
6
  Bootstrapped PR follow-up for <owner>/<repo>#<n>
7
7
  Slug: <slug>
8
8
  Baseline: thread-state (unresolved threads picked up live); lastBodyReviewId=0 | =<id> (forward-only)
9
9
  Working tree: <toplevel>
10
- Dispatching: /loop 1m /muggle:muggle-pr-followup <slug> <n>
10
+ Arming: <owner>/<repo>#<n>
11
11
  ```
12
12
 
13
13
  ## Aborts
@@ -4,6 +4,16 @@ The procedure for the **reconcile mode** of `muggle-pr-followup` — a sweep tha
4
4
 
5
5
  Termination is otherwise tick-driven ([`contract.md`](contract.md) Step 2): a slot finalizes only when a tick fires and observes `MERGED` / `CLOSED`. If the tick stream stops first — the recurring `/loop` cron auto-expires after 7 days, the session ends, or the machine is off when the PR merges — no tick catches the transition, and the slot is left un-finalized: no `result.md`, no post-merge cleanup, and a surviving cron would keep polling a dead PR. Reconcile is the catch-up.
6
6
 
7
+ ## Triggers
8
+
9
+ Three ways in, all running the same procedure:
10
+
11
+ - **Manual** — `/muggle:muggle-pr-followup reconcile` (or `sweep`).
12
+ - **Auto-track** — the top of a no-arg invocation ([`auto-track.md`](auto-track.md)).
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
+
15
+ Recover-don't-seed holds on every trigger: a session-start run still never seeds a first watcher (see Invariants).
16
+
7
17
  ## Input
8
18
 
9
19
  `$ARGUMENTS` is `reconcile` (or `sweep`), optionally followed by a `<slug>` to scope the sweep to one slot.
@@ -62,8 +62,8 @@ Keyed by `"<owner>/<repo>#<n>"`. One key per PR in the slot.
62
62
  "pushed_shas": ["<sha>", ...],
63
63
  "ci_fix_attempts": { "<sha>": <int> },
64
64
  "ci_escalated_shas": ["<sha>", ...],
65
- "conflict_resolve_attempts": { "<sha>": <int> },
66
- "conflict_escalated_shas": ["<sha>", ...],
65
+ "conflict_resolve_attempts": { "<head-sha>..<base-tip-sha>": <int> },
66
+ "conflict_escalated_keys": ["<head-sha>..<base-tip-sha>", ...],
67
67
  "blocked": {
68
68
  "reason": "conflict_escalated" | "ci_escalated" | "reviews_escalated",
69
69
  "since": "<ISO-8601>",
@@ -85,10 +85,14 @@ Keyed by `"<owner>/<repo>#<n>"`. One key per PR in the slot.
85
85
  - `pushed_shas`: every SHA `/muggle-do` has pushed for this PR. Append-only. Used by the resolve-reminder stage to recognize threads addressed by the loop.
86
86
  - `ci_fix_attempts`: per-SHA count of fix-ci cycles `/muggle-do` has run. The watcher stops dispatching fix-ci for a SHA once its count reaches 3. Keyed by head SHA.
87
87
  - `ci_escalated_shas`: head SHAs whose CI the fix-ci stage gave up on (attempts exhausted or only out-of-scope checks). The watcher excludes these from CI dispatch so a hopeless SHA is never re-fixed.
88
- - `conflict_resolve_attempts`: per-SHA count of rebase cycles `/muggle-do` has run for this SHA (behind-only or conflicting — both rebase onto the base). The watcher stops dispatching a rebase for a SHA once its count reaches 2. Keyed by head SHA. A clean behind-only rebase produces a new SHA, so the cap only bites a SHA that keeps failing to rebase-and-verify.
89
- - `conflict_escalated_shas`: head SHAs whose rebase `/muggle-do` gave up on (attempts exhausted, or a conflict under `autoResolveConflicts=never`). The watcher excludes these from rebase dispatch so a hopeless SHA is never re-attempted.
88
+ - `conflict_resolve_attempts`: count of rebase cycles `/muggle-do` has run (behind-only or conflicting — both rebase onto the base). The watcher stops dispatching once a key's count reaches 2. Keyed by `rebase_key` — `"<head_sha>..<base_tip_sha>"`, the head paired with the base branch tip it was measured against.
89
+ - `conflict_escalated_keys`: `rebase_key`s whose rebase `/muggle-do` gave up on (attempts exhausted, or a conflict under `autoResolveConflicts=never`). The watcher excludes these from rebase dispatch so a hopeless pairing is never re-attempted.
90
+
91
+ Both are keyed on the pair, not the head alone, because whether a branch conflicts depends on both sides. Under a head-only key, a base that moves produces a genuinely new conflict against an unchanged head — and the stale entry suppresses it permanently, because nothing can change the head while the branch sits blocked. Pairing re-arms the budget whenever either side moves. Legacy entries written before this change are bare SHAs with no `..`; readers ignore them, which un-wedges any slot they had blocked.
92
+
93
+ Unlike these, `ci_fix_attempts` / `ci_escalated_shas` stay keyed on the head SHA alone — a CI result is a function of the head only, so base movement must not re-arm them.
90
94
  - `blocked`: present only while the watcher is **awaiting the owner** on a PR that cannot progress without a human ([`contract.md`](contract.md) Step 7). Absent ⇒ the watcher is in its normal dispatch flow. When present, the watcher **keeps the normal `1m` cadence** and each tick is a reminder-or-resume check ([`contract.md`](contract.md) Step 2.5): it re-emits a one-line reminder to the owner, recomputes the `fingerprint`, and clears the block the moment any component moves. Its value is the reason-specific reminder plus fingerprint auto-resume.
91
- - `reason`: which durable block is being awaited — `conflict_escalated` (`head_sha` ∈ `conflict_escalated_shas`), `ci_escalated` (`head_sha` ∈ `ci_escalated_shas`), or `reviews_escalated` (a review sits in `escalated_review_ids` awaiting the user, actionable set empty). Selects the reminder wording; the resume decision is fingerprint-driven, not reason-driven.
95
+ - `reason`: which durable block is being awaited — `conflict_escalated` (`rebase_key` ∈ `conflict_escalated_keys`), `ci_escalated` (`head_sha` ∈ `ci_escalated_shas`), or `reviews_escalated` (a review sits in `escalated_review_ids` awaiting the user, actionable set empty). Selects the reminder wording; the resume decision is fingerprint-driven, not reason-driven.
92
96
  - `since`: when the block was first flagged — lets the reminder state how long the owner has been the blocker.
93
97
  - `fingerprint`: the external state the block is waiting on. `head_sha` moves on a new push (which also clears the per-SHA escalation sets, keyed by SHA); `latest_review_id` is `max(id)` over submitted reviews and moves when a reviewer submits anything new; `ci_digest` is a stable digest of the head SHA's CI rollup (bucket + each check's name/conclusion, sorted) and moves when a check flips, a rerun lands, or an external check such as a staging deploy posts. Any change clears the block and resumes evaluation.
94
98
 
@@ -159,3 +163,13 @@ Written exactly once when the PR's watcher exits terminally (PR merged or closed
159
163
  ## Not in the slot
160
164
 
161
165
  `cycle.json` and `requirements.md` are not seeded or read. `/muggle-do` reads reviews off GitHub each invocation.
166
+
167
+ ## `watch-watermark.env`
168
+
169
+ The watch loop's comparison floor — plain `KEY=VALUE` lines, one file per slot:
170
+
171
+ - `REV` — highest submitted-review id already handled
172
+ - `COM` — highest thread-comment id already handled
173
+ - `THREADS` — semicolon-joined ids of threads already known unresolved
174
+
175
+ Written whole-file by the orchestrating session — seeded at arm time from a post-drain fetch, advanced after every cycle from a post-replies fetch. Read by the watch loop each iteration; the loop never writes it. A stale watermark makes the next reported event the loop's own reply ([`arm-watcher.md`](arm-watcher.md)).