@muggleai/works 5.27.0-staging.246 → 5.27.0-staging.247

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/{chunk-PT5EGMBA.js → chunk-B5BWBZZ5.js} +2 -5
  3. package/dist/cli.js +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  6. package/dist/plugin/.codex-mcp.json +1 -1
  7. package/dist/plugin/.codex-plugin/plugin.json +1 -1
  8. package/dist/plugin/.cursor-plugin/plugin.json +1 -1
  9. package/dist/plugin/.mcp.json +1 -1
  10. package/dist/plugin/hooks/README.md +2 -2
  11. package/dist/plugin/scripts/pr-watch-arm.sh +27 -10
  12. package/dist/plugin/scripts/pr-watch-floors.sh +62 -0
  13. package/dist/plugin/scripts/pr-watch-guards.sh +11 -11
  14. package/dist/plugin/scripts/pr-watch-loop.sh +63 -50
  15. package/dist/plugin/scripts/reconcile-stale-watchers.sh +7 -30
  16. package/dist/plugin/skills/_shared/host-tool-equivalents.md +1 -1
  17. package/dist/plugin/skills/do/next-step.md +1 -1
  18. package/dist/plugin/skills/do/open-prs/forward.md +1 -1
  19. package/dist/plugin/skills/muggle-pr-followup/SKILL.md +5 -5
  20. package/dist/plugin/skills/muggle-pr-followup/adopt.md +2 -2
  21. package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +21 -16
  22. package/dist/plugin/skills/muggle-pr-followup/blocked-tick.md +3 -3
  23. package/dist/plugin/skills/muggle-pr-followup/bootstrap.md +1 -1
  24. package/dist/plugin/skills/muggle-pr-followup/contract.md +9 -7
  25. package/dist/plugin/skills/muggle-pr-followup/evals/evals.json +16 -16
  26. package/dist/plugin/skills/muggle-pr-followup/reconcile.md +4 -4
  27. package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +17 -6
  28. package/dist/plugin/skills/muggle-pr-followup/stop.md +3 -3
  29. package/dist/plugin/skills/muggle-preferences/preference-gates/watcherLifetime.md +2 -2
  30. package/dist/release-manifest.json +3 -3
  31. package/package.json +1 -1
  32. package/plugin/.claude-plugin/plugin.json +1 -1
  33. package/plugin/.codex-mcp.json +1 -1
  34. package/plugin/.codex-plugin/plugin.json +1 -1
  35. package/plugin/.cursor-plugin/plugin.json +1 -1
  36. package/plugin/.mcp.json +1 -1
  37. package/plugin/hooks/README.md +2 -2
  38. package/plugin/scripts/pr-watch-arm.sh +27 -10
  39. package/plugin/scripts/pr-watch-floors.sh +62 -0
  40. package/plugin/scripts/pr-watch-guards.sh +11 -11
  41. package/plugin/scripts/pr-watch-loop.sh +63 -50
  42. package/plugin/scripts/reconcile-stale-watchers.sh +7 -30
  43. package/plugin/skills/_shared/host-tool-equivalents.md +1 -1
  44. package/plugin/skills/do/next-step.md +1 -1
  45. package/plugin/skills/do/open-prs/forward.md +1 -1
  46. package/plugin/skills/muggle-pr-followup/SKILL.md +5 -5
  47. package/plugin/skills/muggle-pr-followup/adopt.md +2 -2
  48. package/plugin/skills/muggle-pr-followup/arm-watcher.md +21 -16
  49. package/plugin/skills/muggle-pr-followup/blocked-tick.md +3 -3
  50. package/plugin/skills/muggle-pr-followup/bootstrap.md +1 -1
  51. package/plugin/skills/muggle-pr-followup/contract.md +9 -7
  52. package/plugin/skills/muggle-pr-followup/evals/evals.json +16 -16
  53. package/plugin/skills/muggle-pr-followup/reconcile.md +4 -4
  54. package/plugin/skills/muggle-pr-followup/state-schemas.md +17 -6
  55. package/plugin/skills/muggle-pr-followup/stop.md +3 -3
  56. package/plugin/skills/muggle-preferences/preference-gates/watcherLifetime.md +2 -2
@@ -9,7 +9,7 @@
9
9
  "name": "muggleai",
10
10
  "source": "./plugin",
11
11
  "description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
12
- "version": "5.27.0-staging.246"
12
+ "version": "5.27.0-staging.247"
13
13
  }
14
14
  ]
15
15
  }
@@ -2384,7 +2384,6 @@ var WATCH_STATUS_FILE = "status.json";
2384
2384
  var WATCH_STATE_FILE = "state.json";
2385
2385
  var WATCH_EVENTS_FILE = "events.jsonl";
2386
2386
  var WATCH_EVENTS_ROTATE_BYTES = 5 * 1024 * 1024;
2387
- var MONITOR_STDOUT_CAP_MS = 30 * 6e4;
2388
2387
  var WATCHER_REARM_GRACE_MS = 5 * 6e4;
2389
2388
  var CODEX_ACTIVE_WINDOW_MS = 10 * 6e4;
2390
2389
  var CODEX_ROLLOUT_DAY_DIRS_SCANNED = 7;
@@ -4990,8 +4989,7 @@ function resolveWatcherLiveness(loop, heartbeatAgeMs, isOwnerSessionActive, isPr
4990
4989
  return isHeartbeatWithin(WATCH_HEARTBEAT_STALE_MS) ? "polling" /* Polling */ : "dead" /* Dead */;
4991
4990
  }
4992
4991
  if (loop) {
4993
- const hasOutlivedMonitor = (loop.ageMs ?? 0) > MONITOR_STDOUT_CAP_MS;
4994
- return hasOutlivedMonitor || isOwnerSessionActive === false ? "deaf" /* Deaf */ : "polling" /* Polling */;
4992
+ return isOwnerSessionActive === false ? "deaf" /* Deaf */ : "polling" /* Polling */;
4995
4993
  }
4996
4994
  return isHeartbeatWithin(WATCHER_REARM_GRACE_MS) ? "rearming" /* Rearming */ : "dead" /* Dead */;
4997
4995
  }
@@ -5058,12 +5056,11 @@ function watcherFinding(assessment, subject) {
5058
5056
  );
5059
5057
  }
5060
5058
  if (assessment.watcher === "deaf" /* Deaf */) {
5061
- const reason = assessment.isOwnerSessionActive === false ? `its owner session ${shortSessionId(assessment.ownerSessionId ?? "")} has ended` : `it has run ${formatDuration(assessment.loopAgeMs ?? 0)}, past the 30-minute Monitor cap`;
5062
5059
  return finding(
5063
5060
  "WATCHER_DEAF" /* WatcherDeaf */,
5064
5061
  [assessment.pr.url, assessment.loopPid],
5065
5062
  subject,
5066
- `watch loop pid ${assessment.loopPid} reports to no session: ${reason}`
5063
+ `watch loop pid ${assessment.loopPid} reports to no session: its owner session ${shortSessionId(assessment.ownerSessionId ?? "")} has ended`
5067
5064
  );
5068
5065
  }
5069
5066
  return void 0;
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { runCli } from './chunk-PT5EGMBA.js';
2
+ import { runCli } from './chunk-B5BWBZZ5.js';
3
3
  import './chunk-6BAB7IVG.js';
4
4
 
5
5
  // src/cli/main.ts
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-PT5EGMBA.js';
1
+ export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-B5BWBZZ5.js';
2
2
  export { createChildLogger, e2e_exports as e2e, getConfig, getLocalQaTools, getLogger, getQaTools, local_exports as localQa, mcp_exports as mcp, e2e_exports as qa, src_exports as shared } from './chunk-6BAB7IVG.js';
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "muggle",
3
3
  "description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
4
- "version": "5.27.0-staging.246",
4
+ "version": "5.27.0-staging.247",
5
5
  "author": {
6
6
  "name": "Muggle AI",
7
7
  "email": "support@muggle-ai.com"
@@ -4,7 +4,7 @@
4
4
  "command": "npx",
5
5
  "args": [
6
6
  "-y",
7
- "@muggleai/works@5.27.0-staging.246",
7
+ "@muggleai/works@5.27.0-staging.247",
8
8
  "serve"
9
9
  ],
10
10
  "env": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muggle",
3
- "version": "5.27.0-staging.246",
3
+ "version": "5.27.0-staging.247",
4
4
  "description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
5
5
  "author": {
6
6
  "name": "Muggle AI",
@@ -2,7 +2,7 @@
2
2
  "name": "muggle",
3
3
  "displayName": "Muggle AI",
4
4
  "description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
5
- "version": "5.27.0-staging.246",
5
+ "version": "5.27.0-staging.247",
6
6
  "author": {
7
7
  "name": "Muggle AI",
8
8
  "email": "support@muggle-ai.com"
@@ -4,7 +4,7 @@
4
4
  "command": "npx",
5
5
  "args": [
6
6
  "-y",
7
- "@muggleai/works@5.27.0-staging.246",
7
+ "@muggleai/works@5.27.0-staging.247",
8
8
  "serve"
9
9
  ]
10
10
  }
@@ -77,9 +77,9 @@ Codex runs a plugin's hooks only once the user trusts them: it asks at startup (
77
77
 
78
78
  `SessionStart` (`scripts/reconcile-stale-watchers.sh`) — a standalone advisory, not part of the `guardrails.mjs` decision tree above.
79
79
 
80
- `muggle-pr-followup` watchers are session-only (a monitor or `/loop` cron); they die with their session, leaving open PRs with no live poller. This is by design — a review is addressed only inside a session that carries the context to address it. The skill's [`reconcile`](../skills/muggle-pr-followup/reconcile.md) procedure recovers them at the next session start — finalizes slots whose PR went terminal, sweeps orphan crons, re-arms silently-stopped open watchers — but re-arming needs Claude tools a shell hook can't call. So this hook nudges rather than acts.
80
+ `muggle-pr-followup` watchers are session-only (a background watch loop or `/loop` cron); they die with their session, leaving open PRs with no live poller. This is by design — a review is addressed only inside a session that carries the context to address it. The skill's [`reconcile`](../skills/muggle-pr-followup/reconcile.md) procedure recovers them at the next session start — finalizes slots whose PR went terminal, sweeps orphan crons, re-arms silently-stopped open watchers — but re-arming needs Claude tools a shell hook can't call. So this hook nudges rather than acts.
81
81
 
82
- The nudge counts only slots **this session owns**, and reports the rest as orphans it will not act on — ownership and the recovery rules it gates are defined in [`reconcile`](../skills/muggle-pr-followup/reconcile.md). Nothing owned and nothing orphaned → it emits nothing. A pure directory scan (no `gh`, no writes), so it's cheap enough for every session start.
82
+ The nudge counts only slots **this session owns** — ownership and the recovery rules it gates are defined in [`reconcile`](../skills/muggle-pr-followup/reconcile.md). A session that owns none hears nothing: the hook speaks into every session start, and slots owned elsewhere are no work of this one's — `muggle watch status` reports them. A pure directory scan (no `gh`, no writes), so it's cheap enough for every session start.
83
83
 
84
84
  ## Session-start upkeep
85
85
 
@@ -8,9 +8,10 @@
8
8
  # and never reaching its terminal check. Every health signal said fine. Doing
9
9
  # the three steps here, in one command, is what makes that unskippable.
10
10
  #
11
- # The floors are taken from the drain's own read rather than from a fresh fetch
12
- # afterwards: anything that lands between the two would otherwise be marked seen
13
- # without ever being reported.
11
+ # A first arm takes its floors from the drain's own read rather than from a fresh
12
+ # fetch afterwards: anything that lands between the two would otherwise be marked
13
+ # seen without ever being reported. A re-arm keeps the review, comment and thread
14
+ # floors the session already holds, for the same reason.
14
15
  #
15
16
  # A stale branch or a red head is floored only when the slot proves a cycle ran
16
17
  # for it. Flooring whatever was outstanding at arm time let a session that never
@@ -38,7 +39,7 @@ if [ -z "$slot" ] || [ -z "$repo" ] || [ -z "$pr_number" ] || [ -z "$base_branch
38
39
  exit 2
39
40
  fi
40
41
 
41
- for lib in pr-watch-guards.sh pr-watch-events.sh pr-watch-fetch.sh pr-watch-due.sh; do
42
+ for lib in pr-watch-guards.sh pr-watch-events.sh pr-watch-fetch.sh pr-watch-due.sh pr-watch-floors.sh; do
42
43
  if [ ! -f "${script_dir}/${lib}" ]; then
43
44
  echo "pr-watch-arm: missing ${lib} beside this script" >&2
44
45
  exit 2
@@ -135,9 +136,10 @@ recorded_rebase_due_key="$rebase_due_key"
135
136
  thread_count=0
136
137
  [ -n "$unresolved_threads" ] && thread_count=$(printf '%s' "$unresolved_threads" | tr ';' '\n' | grep -c .)
137
138
 
138
- # Printed, not swallowed: these are the things the arming session owes action on
139
- # before the monitor takes over. Unattempted work is left unfloored, so the
140
- # loop's first pass reports it again as an event.
139
+ # A record of the PR as armed, read from the watch's output when its loop ends.
140
+ # Printing wakes nobody: the session hears the watch only when the loop exits.
141
+ # Unattempted work is left unfloored, so the loop's first poll reports it and
142
+ # exits — that report is the wake.
141
143
  echo "DRAIN pr=$pr_number state=$pr_state head=${head_sha:0:8}"
142
144
  echo "DRAIN unresolved-threads=$thread_count latest-review=${latest_review:-0} latest-comment=${latest_comment:-0}"
143
145
  echo "DRAIN checks pending=${pending_checks:-0} failed=${failed_checks:-0} behind-base=${behind_count:-unknown} mergeable=${mergeable:-UNKNOWN}"
@@ -158,10 +160,25 @@ if [ -n "$rebase_due_key" ]; then
158
160
  fi
159
161
  fi
160
162
 
163
+ # A re-arm carries the review, comment and thread floors forward. The session
164
+ # advanced them past what it handled, so anything newer — a review landing
165
+ # between its last tick's read and this one — is above them, and the loop's
166
+ # first poll reports it. Seeding them from this read would floor that review
167
+ # unseen. Only a first arm, with nothing handled yet, takes them from its read.
168
+ review_floor="${latest_review:-0}"
169
+ comment_floor="${latest_comment:-0}"
170
+ threads_floor="$unresolved_threads"
171
+ if [ -f "${slot}/watch-watermark.env" ]; then
172
+ review_floor="$(watch_read_slot_value "$slot" watch-watermark.env REV)"
173
+ comment_floor="$(watch_read_slot_value "$slot" watch-watermark.env COM)"
174
+ threads_floor="$(watch_threads_still_unresolved \
175
+ "$(watch_read_slot_value "$slot" watch-watermark.env THREADS)" "$unresolved_threads")"
176
+ fi
177
+
161
178
  {
162
- printf 'REV=%s\n' "${latest_review:-0}"
163
- printf 'COM=%s\n' "${latest_comment:-0}"
164
- printf 'THREADS="%s"\n' "$unresolved_threads"
179
+ printf 'REV=%s\n' "${review_floor:-0}"
180
+ printf 'COM=%s\n' "${comment_floor:-0}"
181
+ printf 'THREADS="%s"\n' "$threads_floor"
165
182
  printf 'CIRED="%s"\n' "$ci_red_floor"
166
183
  printf 'REBASED="%s"\n' "$rebase_floor"
167
184
  printf 'BLOCKED_CIDIGEST="%s"\n' "$blocked_digest_floor"
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # The floors a watch compares each poll against, as they persist between loops.
4
+ # Sourced by pr-watch-arm.sh and pr-watch-loop.sh.
5
+ #
6
+ # A loop exits on every report, because the session that owns it hears it only
7
+ # when it exits. So no floor can live in a loop's memory: whatever stops one
8
+ # occurrence firing twice has to be on disk for the next loop to read. Two files
9
+ # hold them — watch-watermark.env, which the session advances past what it
10
+ # handled, and watch-reported.env, which the loop advances past what it told
11
+ # the session — and a poll fires only on what is above both.
12
+
13
+ MUGGLE_PR_WATCH_REPORTED_FILE="watch-reported.env"
14
+
15
+ # Prints the value of `key` in a slot file of `KEY="value"` lines, quotes
16
+ # stripped, the last match winning; nothing when the file or key is absent.
17
+ # Read with builtins, never sourced: THREADS holds bare semicolons, which a
18
+ # sourced unquoted line would split, and a CI digest carries check names an
19
+ # author controls.
20
+ watch_read_slot_value() {
21
+ local slot="$1" file_name="$2" key="$3" line value=""
22
+ [ -f "${slot}/${file_name}" ] || return 0
23
+ while IFS= read -r line || [ -n "$line" ]; do
24
+ line="${line%$'\r'}"
25
+ case "$line" in
26
+ "${key}="*)
27
+ value="${line#"${key}"=}"
28
+ value="${value%\"}"
29
+ value="${value#\"}"
30
+ ;;
31
+ esac
32
+ done < "${slot}/${file_name}"
33
+ printf '%s' "$value"
34
+ }
35
+
36
+ # Writes <slot>/watch-reported.env: the newest review and comment ids and the
37
+ # unresolved threads a loop's last poll saw — every one of them either reported
38
+ # or already floored — and the red head, rebase key and blocked digest it
39
+ # reported, with the block (`blocked_for`, that block's watermark digest) the
40
+ # digest was reported under.
41
+ watch_record_reported() {
42
+ local slot="$1" review="$2" comment="$3" threads="$4" ci_red="$5" rebase="$6" blocked_digest="$7" blocked_for="$8"
43
+ printf 'REV=%s\nCOM=%s\nTHREADS="%s"\nCIRED="%s"\nREBASED="%s"\nBLOCKED_CIDIGEST="%s"\nBLOCKED_FOR="%s"\n' \
44
+ "$review" "$comment" "$threads" "$ci_red" "$rebase" "$blocked_digest" "$blocked_for" \
45
+ > "${slot}/${MUGGLE_PR_WATCH_REPORTED_FILE}"
46
+ }
47
+
48
+ # Prints the threads of `known` (semicolon-joined) that are still in
49
+ # `unresolved`, semicolon-joined. A thread resolved since it was floored drops
50
+ # out, so its unresolving again is news.
51
+ watch_threads_still_unresolved() {
52
+ local known="$1" unresolved="$2" thread_id kept=""
53
+ local -a thread_ids
54
+ IFS=';' read -ra thread_ids <<< "$known"
55
+ for thread_id in "${thread_ids[@]}"; do
56
+ [ -n "$thread_id" ] || continue
57
+ case ";${unresolved};" in
58
+ *";${thread_id};"*) kept="${kept:+${kept};}${thread_id}" ;;
59
+ esac
60
+ done
61
+ printf '%s' "$kept"
62
+ }
@@ -13,26 +13,26 @@
13
13
  # watcher_superseded — <slot>/watch.pid holds the PID of the watcher that
14
14
  # owns the slot. A loop whose PID no longer matches has
15
15
  # been replaced by a newer arm and must exit.
16
- # watcher_window_elapsed — a loop exits once it has run for its monitor
17
- # window, before the Monitor that owns its stdout ends.
18
- # It announces the exit so the owning session re-arms.
16
+ # watcher_window_elapsed — a loop run under a Monitor exits once it has run for
17
+ # its window, before the Monitor that owns its stdout
18
+ # ends. It announces the exit so the owning session
19
+ # re-arms. Off unless MUGGLE_PR_WATCH_MONITOR_WINDOW is set.
19
20
  # watcher_lifetime_exceeded — a loop exits after MUGGLE_PR_WATCH_MAX_LIFETIME
20
21
  # regardless, so an orphan nothing supersedes still dies
21
22
  # on its own; reconcile re-arms an open PR inside a live
22
23
  # session.
23
24
 
24
25
  # Seconds a watch loop may live. 0 means unbounded — the `never` setting of the
25
- # watcherLifetime preference. Under a Monitor the window below ends a loop long
26
- # before this does; this bounds a loop run with the window disabled.
26
+ # watcherLifetime preference.
27
27
  MUGGLE_PR_WATCH_MAX_LIFETIME="${MUGGLE_PR_WATCH_MAX_LIFETIME:-604800}"
28
28
  MUGGLE_PR_WATCH_POLL_INTERVAL="${MUGGLE_PR_WATCH_POLL_INTERVAL:-60}"
29
29
  # Seconds one loop process runs before handing the watch back for a fresh arm.
30
- # The Monitor tool ends a background task after 1800s but does not stop the
31
- # process it launched, so a loop that outlives its monitor polls into a closed
32
- # pipe: every event it prints reaches nobody while its lease and heartbeat read
33
- # healthy. 1740 closes the window one poll interval inside that cap. 0 means
34
- # unbounded, for a loop run outside a Monitor.
35
- MUGGLE_PR_WATCH_MONITOR_WINDOW="${MUGGLE_PR_WATCH_MONITOR_WINDOW:-1740}"
30
+ # Only a loop run under a Monitor needs one: the Monitor tool stops reading after
31
+ # 1800s without stopping the process, so such a loop must end itself first, and
32
+ # every hand-back costs the session a model turn. The watch runs as a background
33
+ # shell, which has no cap and wakes the session only when the loop exits, so the
34
+ # default is 0, unbounded.
35
+ MUGGLE_PR_WATCH_MONITOR_WINDOW="${MUGGLE_PR_WATCH_MONITOR_WINDOW:-0}"
36
36
  # Consecutive failed fetches before a loop gives up. A watcher must ride through
37
37
  # a GitHub / network outage — an observed drop lasted ~8 minutes — not die and
38
38
  # leave the PR unwatched until its owning session next starts. With the backoff, 60
@@ -1,7 +1,12 @@
1
1
  #!/usr/bin/env bash
2
2
 
3
3
  # The muggle-pr-followup watch loop. One process per PR slot: polls provider
4
- # state, prints one line per new event, and exits when the PR goes terminal.
4
+ # state silently, and exits as soon as a poll finds something — printing one line
5
+ # per new event — or the PR goes terminal.
6
+ #
7
+ # It runs as a background shell, which wakes the owning session only when the
8
+ # process exits. So a quiet PR costs the session nothing, and each report costs
9
+ # one turn, after which the session handles it and arms a fresh loop.
5
10
  #
6
11
  # Ships as a file so arming runs it rather than rewrites it. A loop re-derived
7
12
  # from prose on every arm drifts — the behind-base wake went missing from half
@@ -39,7 +44,7 @@ fi
39
44
  # gone. Guards missing means the plugin moved or upgraded underneath this loop —
40
45
  # a newer version's watcher owns the slot now, so step down rather than run on
41
46
  # without the supersede check.
42
- for lib in pr-watch-guards.sh pr-watch-events.sh pr-watch-fetch.sh pr-watch-due.sh; do
47
+ for lib in pr-watch-guards.sh pr-watch-events.sh pr-watch-fetch.sh pr-watch-due.sh pr-watch-floors.sh; do
43
48
  [ -f "${script_dir}/${lib}" ] || exit 0
44
49
  # shellcheck source=/dev/null
45
50
  . "${script_dir}/${lib}"
@@ -60,16 +65,6 @@ unseeded=0
60
65
  # look at the PR rather than handing back a watch that never polled.
61
66
  has_iterated=""
62
67
 
63
- # In-memory floors, above the on-disk watermark. The watermark is advanced by
64
- # the session after it handles a wave; these stop the loop re-reporting an event
65
- # in the window before that write lands.
66
- floor_review=0
67
- floor_comment=0
68
- floor_threads=";"
69
- floor_ci_red=""
70
- floor_rebase=""
71
- floor_blocked_digest=""
72
-
73
68
  # What watch-due.env last recorded, so the file is rewritten only when the due
74
69
  # work changes rather than every poll.
75
70
  due_rebase_key=""
@@ -81,19 +76,28 @@ pinned_token="$(gh auth token 2>/dev/null)"
81
76
  [ -n "$pinned_token" ] && export GH_TOKEN="$pinned_token"
82
77
 
83
78
 
84
- read_watermark_value() {
85
- local key="$1" line value=""
86
- while IFS= read -r line || [ -n "$line" ]; do
87
- line="${line%$'\r'}"
88
- case "$line" in
89
- "${key}="*)
90
- value="${line#"${key}"=}"
91
- value="${value%\"}"
92
- value="${value#\"}"
93
- ;;
94
- esac
95
- done < "${slot}/watch-watermark.env"
96
- printf '%s' "$value"
79
+ # What earlier loops already reported. Without these, work the session saw and
80
+ # chose not to act on — a HOLD WATCH, a comment needing no reply — would end
81
+ # every fresh loop on its first poll. A blocked-resume floor holds only for the
82
+ # block it was reported under (BLOCKED_FOR), so the same digest can resume a
83
+ # later block.
84
+ read_reported() {
85
+ watch_read_slot_value "$slot" "$MUGGLE_PR_WATCH_REPORTED_FILE" "$1"
86
+ }
87
+ reported_review=$(read_reported REV)
88
+ reported_comment=$(read_reported COM)
89
+ reported_threads=$(read_reported THREADS)
90
+ floor_ci_red=$(read_reported CIRED)
91
+ floor_rebase=$(read_reported REBASED)
92
+ floor_blocked_digest=$(read_reported BLOCKED_CIDIGEST)
93
+ floor_blocked_for=$(read_reported BLOCKED_FOR)
94
+
95
+ read_watermark() {
96
+ watch_read_slot_value "$slot" watch-watermark.env "$1"
97
+ }
98
+
99
+ higher_id() {
100
+ if [ "${1:-0}" -gt "${2:-0}" ] 2>/dev/null; then printf '%s' "${1:-0}"; else printf '%s' "${2:-0}"; fi
97
101
  }
98
102
 
99
103
  # Every wait goes through here so that none, however long, can carry the loop
@@ -104,7 +108,10 @@ pause() {
104
108
 
105
109
  while :; do
106
110
  watcher_superseded "$slot" "$$" && exit 0
107
- watcher_lifetime_exceeded "$started" "$(date +%s)" && exit 0
111
+ if watcher_lifetime_exceeded "$started" "$(date +%s)"; then
112
+ echo "EXPIRED pr=$pr_number watch lifetime of ${MUGGLE_PR_WATCH_MAX_LIFETIME}s reached — re-arm to keep watching"
113
+ exit 0
114
+ fi
108
115
  # Exit while the Monitor that owns this stdout is still reading, and say so:
109
116
  # the line is the owning session's cue to re-arm, and ending here is what
110
117
  # frees the lease that re-arm needs. A loop that ran on past its monitor would
@@ -136,13 +143,13 @@ while :; do
136
143
  fi
137
144
  unseeded=0
138
145
 
139
- watermark_review=$(read_watermark_value REV)
140
- watermark_comment=$(read_watermark_value COM)
141
- watermark_threads=$(read_watermark_value THREADS)
142
- watermark_ci_red=$(read_watermark_value CIRED)
143
- watermark_rebase=$(read_watermark_value REBASED)
144
- watermark_blocked_digest=$(read_watermark_value BLOCKED_CIDIGEST)
145
- watermark_rebase_held=$(read_watermark_value REBASE_HELD)
146
+ watermark_review=$(read_watermark REV)
147
+ watermark_comment=$(read_watermark COM)
148
+ watermark_threads=$(read_watermark THREADS)
149
+ watermark_ci_red=$(read_watermark CIRED)
150
+ watermark_rebase=$(read_watermark REBASED)
151
+ watermark_blocked_digest=$(read_watermark BLOCKED_CIDIGEST)
152
+ watermark_rebase_held=$(read_watermark REBASE_HELD)
146
153
 
147
154
  state_line=$(watch_fetch_state "$repo" "$pr_number" "$slot" "$state_projection")
148
155
  # One quick retry before counting a strike: a single flaky call should not
@@ -187,29 +194,25 @@ while :; do
187
194
  continue
188
195
  fi
189
196
  fails=0
197
+ has_reported=""
190
198
 
191
- if watch_wake_review "$pr_number" "$latest_review" \
192
- "$(( watermark_review > floor_review ? watermark_review : floor_review ))"; then
193
- floor_review="$latest_review"
194
- fi
195
-
196
- if watch_wake_comment "$pr_number" "$latest_comment" \
197
- "$(( watermark_comment > floor_comment ? watermark_comment : floor_comment ))"; then
198
- floor_comment="$latest_comment"
199
- fi
199
+ watch_wake_review "$pr_number" "$latest_review" "$(higher_id "$watermark_review" "$reported_review")" && has_reported=1
200
+ watch_wake_comment "$pr_number" "$latest_comment" "$(higher_id "$watermark_comment" "$reported_comment")" && has_reported=1
200
201
 
201
202
  if [ -n "$unresolved_threads" ]; then
202
203
  IFS=';' read -ra thread_ids <<< "$unresolved_threads"
203
204
  for thread_id in "${thread_ids[@]}"; do
204
- if watch_wake_thread "$pr_number" "$thread_id" "${watermark_threads};${floor_threads}"; then
205
- floor_threads="${floor_threads}${thread_id};"
206
- fi
205
+ watch_wake_thread "$pr_number" "$thread_id" "${watermark_threads};${reported_threads}" && has_reported=1
207
206
  done
208
207
  fi
209
208
 
210
- if watch_wake_ci_red "$pr_number" "$pending_checks" "$failed_checks" "$head_sha" \
211
- "${floor_ci_red:-$watermark_ci_red}"; then
209
+ # Either floor quiets the head: the watermark's when a fix already ran for
210
+ # it, the reported one when a loop already told the session it went red.
211
+ ci_red_floor="$watermark_ci_red"
212
+ [ "$floor_ci_red" = "$head_sha" ] && ci_red_floor="$floor_ci_red"
213
+ if watch_wake_ci_red "$pr_number" "$pending_checks" "$failed_checks" "$head_sha" "$ci_red_floor"; then
212
214
  floor_ci_red="$head_sha"
215
+ has_reported=1
213
216
  fi
214
217
 
215
218
  rebase_key="${head_sha}..${base_tip_sha}"
@@ -227,6 +230,7 @@ while :; do
227
230
  if watch_wake_rebase "$pr_number" "$mergeable" "$behind_count" "$rebase_key" ""; then
228
231
  floor_rebase="$rebase_key"
229
232
  due_rebase_key="$rebase_key"
233
+ has_reported=1
230
234
  fi
231
235
  fi
232
236
 
@@ -239,12 +243,21 @@ while :; do
239
243
  fi
240
244
 
241
245
  # The tick owns clearing the block, and it may not run for a while. Holding
242
- # the emitted digest stops the loop repeating itself every iteration in the
243
- # meantime — the repeat nagging the design rules out — while a further move
244
- # still wakes it.
246
+ # the reported digest stops each fresh loop repeating it in the meantime — the
247
+ # repeat nagging the design rules out — while a further move still wakes it.
248
+ [ "$floor_blocked_for" = "$watermark_blocked_digest" ] || floor_blocked_digest=""
245
249
  if [ "$ci_digest" != "$floor_blocked_digest" ] \
246
250
  && watch_wake_blocked_resume "$pr_number" "$ci_digest" "$watermark_blocked_digest"; then
247
251
  floor_blocked_digest="$ci_digest"
252
+ floor_blocked_for="$watermark_blocked_digest"
253
+ has_reported=1
254
+ fi
255
+
256
+ if [ -n "$has_reported" ]; then
257
+ watch_record_reported "$slot" "$(higher_id "$latest_review" "$reported_review")" \
258
+ "$(higher_id "$latest_comment" "$reported_comment")" "$unresolved_threads" \
259
+ "$floor_ci_red" "$floor_rebase" "$floor_blocked_digest" "$floor_blocked_for"
260
+ exit 0
248
261
  fi
249
262
 
250
263
  pause "$MUGGLE_PR_WATCH_POLL_INTERVAL"
@@ -9,10 +9,11 @@ set -euo pipefail
9
9
  # The nudge is owner-scoped. Each slot records the session that armed it in
10
10
  # owner.json; only slots owned by THIS session are a recovery the agent should
11
11
  # run, because re-arming a stranger's PR would hand review work to a session with
12
- # no context for it. Slots owned by another session (or none) are reported as
13
- # orphans with the adopt command, never as work to pick up. A pure directory scan
14
- # — no gh, no writes, bash builtins only — so it stays cheap enough for every
15
- # session start however many slots have piled up.
12
+ # no context for it. A session that owns none hears nothing: the hook speaks into
13
+ # every session start, and slots owned elsewhere are no work of this one's —
14
+ # `muggle watch status` reports them. A pure directory scan — no gh, no writes,
15
+ # bash builtins only — so it stays cheap enough for every session start however
16
+ # many slots have piled up.
16
17
 
17
18
  hook_dir="${BASH_SOURCE[0]%/*}"
18
19
  [ "$hook_dir" = "${BASH_SOURCE[0]}" ] && hook_dir=.
@@ -56,8 +57,6 @@ fi
56
57
  # stopped watchers back as work.
57
58
  sessions_dir="${HOME}/.muggle-ai/muggle-do/sessions"
58
59
  owned_count=0
59
- orphan_count=0
60
- first_orphan_slug=""
61
60
 
62
61
  if [ -d "$sessions_dir" ]; then
63
62
  for slot in "$sessions_dir"/*/; do
@@ -76,20 +75,11 @@ if [ -d "$sessions_dir" ]; then
76
75
  # (legacy slot) is likewise foreign — never adopted by default.
77
76
  if [ -n "$current_session_id" ] && [ "$slot_owner" = "$current_session_id" ]; then
78
77
  owned_count=$((owned_count + 1))
79
- else
80
- orphan_count=$((orphan_count + 1))
81
- if [ -z "$first_orphan_slug" ]; then
82
- first_orphan_slug="${slot%/}"
83
- first_orphan_slug="${first_orphan_slug##*/}"
84
- fi
85
78
  fi
86
79
  done
87
80
  fi
88
81
 
89
- # Clean state: nothing of this session's to recover and no orphans → stay silent.
90
- if [ "$owned_count" -eq 0 ] && [ "$orphan_count" -eq 0 ]; then
91
- exit 0
92
- fi
82
+ [ "$owned_count" -gt 0 ] || exit 0
93
83
 
94
84
  if [ "$owned_count" -eq 1 ]; then
95
85
  owned_word="watcher"
@@ -97,20 +87,7 @@ else
97
87
  owned_word="watchers"
98
88
  fi
99
89
 
100
- if [ "$owned_count" -gt 0 ]; then
101
- context="muggle-pr-followup: ${owned_count} open ${owned_word} owned by this session may have lost its poller. Run \`/muggle:muggle-pr-followup reconcile\` to finalize any whose PR went terminal and re-arm the silently-stopped ones. Reconcile is idempotent and re-arms only watchers this session armed itself."
102
- else
103
- context="muggle-pr-followup: no watchers belong to this session — nothing to re-arm."
104
- fi
105
-
106
- if [ "$orphan_count" -gt 0 ]; then
107
- if [ "$orphan_count" -eq 1 ]; then
108
- orphan_word="slot is"
109
- else
110
- orphan_word="slots are"
111
- fi
112
- context="${context} ${orphan_count} open ${orphan_word} owned by other sessions; reconcile will finalize them if their PR already merged or closed, but will never re-arm them. Adopt one deliberately with \`/muggle:muggle-pr-followup adopt <slug>\` (e.g. ${first_orphan_slug}) only if the user asks — do not offer or adopt on your own."
113
- fi
90
+ context="muggle-pr-followup: ${owned_count} open ${owned_word} owned by this session may have lost its poller. Run \`/muggle:muggle-pr-followup reconcile\` to finalize any whose PR went terminal and re-arm the silently-stopped ones. Reconcile is idempotent and re-arms only watchers this session armed itself."
114
91
 
115
92
  escaped_context=$(escape_for_json "$context")
116
93
 
@@ -11,7 +11,7 @@ These skills are written against Claude Code's tools. In another agent host —
11
11
  | `$CLAUDE_CODE_SESSION_ID` | This session's id | `$CODEX_SESSION_ID` — the same id the hooks receive as `session_id`. |
12
12
  | Dispatch a plugin agent (`agents/*.md`, subagent type `muggle:<agent>`) | The Agent tool | `spawn_agent` with that agent file's instructions as the task, when multi-agent is enabled; otherwise run the agent file inline, as each skill already allows for a host with no subagents. |
13
13
  | `TodoWrite` | The session task list | `update_plan` |
14
- | `Monitor`, `/loop`, `CronCreate`/`CronDelete` (the PR watcher's background watch) | Wake the session when the watch reports an event | No equivalent: nothing in Codex wakes a session from a background process. Run the drain tick, seed the slot without a loop (`pr-watch-arm.sh … --no-exec`), and tell the user that following the PR up means invoking `$muggle:muggle-pr-followup` again — or scheduling it with `codex exec`. Never leave a loop polling that no session will hear. |
14
+ | A background `Bash` command (`run_in_background`), `/loop`, `CronCreate`/`CronDelete` (the PR watcher's background watch) | Wake the session when the watch reports an event | No equivalent: nothing in Codex wakes a session from a background process. Run the drain tick, seed the slot without a loop (`pr-watch-arm.sh … --no-exec`), and tell the user that following the PR up means invoking `$muggle:muggle-pr-followup` again — or scheduling it with `codex exec`. Never leave a loop polling that no session will hear. |
15
15
  | `model:` in a skill's frontmatter | Runs the skill on that model | Ignored — the session's model runs every skill. |
16
16
 
17
17
  ## Codex's sandbox
@@ -55,4 +55,4 @@ At most four options, in this order:
55
55
 
56
56
  A new task goes through the selector's free-text answer, which starts a fresh `/muggle-do` forward run.
57
57
 
58
- **Text while a watch runs.** When the status block holds an `open` or `blocked` line, print the selector as text — one numbered option per line — and end the turn on the question; the user's next message is the pick. A pending `AskUserQuestion` holds the turn, and every watcher this chat runs with it: a rollover goes unanswered and the watcher stays dead until the user replies. With no such line, ask with one `AskUserQuestion`. The adopt selector under **Other chats' work** follows the same rule.
58
+ **Text while a watch runs.** When the status block holds an `open` or `blocked` line, print the selector as text — one numbered option per line — and end the turn on the question; the user's next message is the pick. A pending `AskUserQuestion` holds the turn, and every watcher this chat runs with it: a loop that ends on a report goes unanswered and the watcher stays dead until the user replies. With no such line, ask with one `AskUserQuestion`. The adopt selector under **Other chats' work** follows the same rule.
@@ -86,7 +86,7 @@ If `prs.json` is empty, **do not arm** — record the reason in `result.md` and
86
86
  **Skipped:** repo → reason (when `autoCreatePR` short-circuited)
87
87
  **Overflow comments posted:** repo → PR #
88
88
  **Stage 7.5:** `green` | `waived — <reason>` | `iteration-cap`
89
- **Stage 8:** `Watching <N> PR(s) — one monitor per PR, armed per arm-watcher.md` | `No PRs to watch — stage 8 not armed`
89
+ **Stage 8:** `Watching <N> PR(s) — one watch loop per PR, armed per arm-watcher.md` | `No PRs to watch — stage 8 not armed`
90
90
  **Errors:** repo → message
91
91
 
92
92
  ## Post-merge cleanup
@@ -16,13 +16,13 @@ A watcher that babysits one open PR toward **merge-ready** — review threads ad
16
16
 
17
17
  **Per-PR isolation.** One watcher per PR. Multi-PR work runs N independent watchers.
18
18
 
19
- **Arming.** Bootstrap, auto-track, and the executor's post-cycle respawn all arm the watch the same way — one drain tick, then one persistent labeled monitor per PR — visible as a running task for as long as the PR is polled, gone at terminal ([`arm-watcher.md`](arm-watcher.md)). Crons only ever deliver a recovery tick, never the ongoing cadence: a cron-delivered tick re-arms the monitor and cancels its own cron ([`contract.md`](contract.md) Step 7.5), because every cron fire is a model turn while the monitor polls token-free.
19
+ **Arming.** Bootstrap, auto-track, and the executor's post-cycle respawn all arm the watch the same way — one drain tick, then one background watch loop per PR — visible as a running task for as long as the PR is polled, gone at terminal ([`arm-watcher.md`](arm-watcher.md)). The loop polls token-free and reaches the session only by exiting, on the poll that finds something to act on, so a quiet PR costs no turns; the tick that answers it re-arms the loop ([`contract.md`](contract.md) Step 7.5). Crons only ever deliver a recovery tick, never the ongoing cadence: a cron-delivered tick re-arms the loop and cancels its own cron, because every cron fire is a model turn.
20
20
 
21
21
  **One reminder when blocked pending a human.** When a PR can't progress without the user — an escalated rebase/CI budget spent, or an ambiguous review awaiting direction — the tick that flags the block emits **one** one-line reminder: the pending act plus a reference back to the decision context ([`contract.md`](contract.md) Steps 2.5, 7). After that the watch stays visible and silent — no repeat nagging — and the block clears the instant a wake finds a push, review, or CI/deploy state moved.
22
22
 
23
- **Cron lifecycle.** Each tick records its `/loop` cron id to `cron.json` while `CronList` can still see it ([`record-cron-id.md`](record-cron-id.md)), so teardown can delete the cron by id after a session continue / compaction blinds `CronList` to it. Reconcile ([`reconcile.md`](reconcile.md)) sweeps crons whose PR is terminal or whose slot is gone, and re-arms an open slot whose watcher stopped silently — monitor-first, never with a recurring cron.
23
+ **Cron lifecycle.** Each tick records its `/loop` cron id to `cron.json` while `CronList` can still see it ([`record-cron-id.md`](record-cron-id.md)), so teardown can delete the cron by id after a session continue / compaction blinds `CronList` to it. Reconcile ([`reconcile.md`](reconcile.md)) sweeps crons whose PR is terminal or whose slot is gone, and re-arms an open slot whose watcher stopped silently — loop-first, never with a recurring cron.
24
24
 
25
- **Session death.** Monitors and crons are both session-bound — a session that ends or hits its usage limit takes every watch with it. That is by design: nothing polls out of session, because a review is addressed only inside a session that carries the context to address it, never by a headless process replying context-blind.
25
+ **Session death.** Watch loops and crons are both session-bound — a session that ends or hits its usage limit takes every watch with it. That is by design: nothing polls out of session, because a review is addressed only inside a session that carries the context to address it, never by a headless process replying context-blind.
26
26
 
27
27
  **A watcher belongs to the session that armed it.** Each slot records its owning session in `owner.json` ([`state-schemas.md`](state-schemas.md#ownerjson)), and recovery is owner-scoped: [`reconcile`](reconcile.md#triggers) re-arms a dead watcher only inside the session that armed it, and reports the rest as orphans it declined to touch. Starting a session therefore picks up nothing new — the same context argument that rules out a headless daemon rules out inheriting a stranger's PR, which would hand review work to a session that never saw the design. A PR whose owning session is gone stays unwatched until the user adopts it by name ([`adopt.md`](adopt.md)).
28
28
 
@@ -34,10 +34,10 @@ The skill recognizes its mode by inspecting `$ARGUMENTS` and falling back to on-
34
34
  | :---- | :------------ | :--- |
35
35
  | First arg matches `https?://github\.com/[^/]+/[^/]+/pull/\d+` | — | **bootstrap** → [`bootstrap.md`](bootstrap.md) |
36
36
  | `<slug> <pr-number>` | session dir for `<slug>` exists; slot's `watch.pid` is dead, or `--wake` passed | **tick** → [`contract.md`](contract.md) |
37
- | `<slug> <pr-number>` | session dir for `<slug>` exists; `watch.pid` names a live process and no `--wake` | **watch-status** — report the slot from on-disk state (`prs.json` state + head, newest `followup.log` tick line, monitor liveness, watermark floors). **Zero provider calls:** a live monitor owns the cadence, and the session never polls unwoken. |
37
+ | `<slug> <pr-number>` | session dir for `<slug>` exists; `watch.pid` names a live process and no `--wake` | **watch-status** — report the slot from on-disk state (`prs.json` state + head, newest `followup.log` tick line, loop liveness, watermark floors). **Zero provider calls:** a live loop owns the cadence, and the session never polls unwoken. |
38
38
  | `<slug> <pr-number>` | session dir missing, `<slug>.stopped` exists (or the global kill file `~/.muggle-ai/muggle-do/polling.disabled`) | **absorb** — one line, nothing else ([`contract.md`](contract.md) Step 0) |
39
39
  | `<slug> <pr-number>` | session dir missing | **error:** "no session at `<path>`; pass a PR URL to start one" |
40
- | `stop` (optional `<slug>`) | — | **stop** → [`stop.md`](stop.md) — tear down monitor + cron, mark slot(s) `.stopped`; no slug stops everything and writes the kill file |
40
+ | `stop` (optional `<slug>`) | — | **stop** → [`stop.md`](stop.md) — tear down loop + cron, mark slot(s) `.stopped`; no slug stops everything and writes the kill file |
41
41
  | `<pr-number>` alone | exactly one existing session contains it | **tick** or **watch-status** for that PR, by the same `watch.pid` gate |
42
42
  | `<pr-number>` alone | zero or multiple matches | **error:** ambiguous; list candidates and exit |
43
43
  | empty | — | **auto-track** → [`auto-track.md`](auto-track.md) |