@muggleai/works 5.28.0 → 5.29.0-staging.256

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 (53) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/dist/{chunk-N3G4AX6M.js → chunk-XEKNDWXD.js} +7 -4
  4. package/dist/cli.js +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  7. package/dist/plugin/.codex-mcp.json +1 -1
  8. package/dist/plugin/.codex-plugin/plugin.json +1 -1
  9. package/dist/plugin/.cursor-plugin/plugin.json +1 -1
  10. package/dist/plugin/.mcp.json +1 -1
  11. package/dist/plugin/hooks/README.md +3 -3
  12. package/dist/plugin/scripts/guardrail-dispatch.sh +6 -0
  13. package/dist/plugin/scripts/guardrail-e2e-gate.sh +24 -0
  14. package/dist/plugin/scripts/guardrail-lib.sh +1 -0
  15. package/dist/plugin/scripts/guardrails.mjs +119 -45
  16. package/dist/plugin/scripts/pr-watch-arm.sh +14 -10
  17. package/dist/plugin/scripts/pr-watch-events.sh +14 -5
  18. package/dist/plugin/scripts/pr-watch-fetch.sh +1 -1
  19. package/dist/plugin/scripts/pr-watch-loop.sh +6 -5
  20. package/dist/plugin/scripts/pr-watch-state.jq +15 -5
  21. package/dist/plugin/scripts/pr-watch-verdict.mjs +22 -7
  22. package/dist/plugin/skills/_shared/ci-check-to-command.md +1 -1
  23. package/dist/plugin/skills/do/e2e-acceptance.md +1 -1
  24. package/dist/plugin/skills/do/fix-ci.md +9 -1
  25. package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +2 -2
  26. package/dist/plugin/skills/muggle-pr-followup/bootstrap.md +2 -0
  27. package/dist/plugin/skills/muggle-pr-followup/contract.md +2 -2
  28. package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +12 -12
  29. package/dist/release-manifest.json +3 -3
  30. package/package.json +2 -2
  31. package/plugin/.claude-plugin/plugin.json +1 -1
  32. package/plugin/.codex-mcp.json +1 -1
  33. package/plugin/.codex-plugin/plugin.json +1 -1
  34. package/plugin/.cursor-plugin/plugin.json +1 -1
  35. package/plugin/.mcp.json +1 -1
  36. package/plugin/hooks/README.md +3 -3
  37. package/plugin/scripts/guardrail-dispatch.sh +6 -0
  38. package/plugin/scripts/guardrail-e2e-gate.sh +24 -0
  39. package/plugin/scripts/guardrail-lib.sh +1 -0
  40. package/plugin/scripts/guardrails.mjs +119 -45
  41. package/plugin/scripts/pr-watch-arm.sh +14 -10
  42. package/plugin/scripts/pr-watch-events.sh +14 -5
  43. package/plugin/scripts/pr-watch-fetch.sh +1 -1
  44. package/plugin/scripts/pr-watch-loop.sh +6 -5
  45. package/plugin/scripts/pr-watch-state.jq +15 -5
  46. package/plugin/scripts/pr-watch-verdict.mjs +22 -7
  47. package/plugin/skills/_shared/ci-check-to-command.md +1 -1
  48. package/plugin/skills/do/e2e-acceptance.md +1 -1
  49. package/plugin/skills/do/fix-ci.md +9 -1
  50. package/plugin/skills/muggle-pr-followup/arm-watcher.md +2 -2
  51. package/plugin/skills/muggle-pr-followup/bootstrap.md +2 -0
  52. package/plugin/skills/muggle-pr-followup/contract.md +2 -2
  53. package/plugin/skills/muggle-pr-followup/state-schemas.md +12 -12
@@ -13,10 +13,12 @@
13
13
  # seen without ever being reported. A re-arm keeps the review, comment and thread
14
14
  # floors the session already holds, for the same reason.
15
15
  #
16
- # A stale branch or a red head is floored only when the slot proves a cycle ran
17
- # for it. Flooring whatever was outstanding at arm time let a session that never
18
- # rebased or fixed anything re-arm at every rollover and silence the loop each
19
- # time; the drain line said "handled" and nothing checked.
16
+ # A stale branch is floored only when the slot proves a rebase ran for it, and a
17
+ # red head only once fix-ci is finished with it — escalated, or out of attempts.
18
+ # Flooring whatever was outstanding at arm time let a session that never rebased
19
+ # or fixed anything re-arm at every rollover and silence the loop each time; the
20
+ # drain line said "handled" and nothing checked. A bare attempt count is no
21
+ # better proof for CI: writing it costs nothing and changes nothing on the PR.
20
22
  set -uo pipefail
21
23
 
22
24
  script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -77,6 +79,7 @@ latest_comment="${STATE_FIELDS[5]-}"
77
79
  unresolved_threads="${STATE_FIELDS[6]-}"
78
80
  pending_checks="${STATE_FIELDS[7]-}"
79
81
  failed_checks="${STATE_FIELDS[8]-}"
82
+ ci_red_key="$(watch_ci_red_key "$head_sha" "${STATE_FIELDS[10]-}")"
80
83
 
81
84
  if [ "$pr_state" = "MERGED" ] || [ "$pr_state" = "CLOSED" ]; then
82
85
  echo "TERMINAL pr=$pr_number state=$pr_state — nothing to arm"
@@ -92,7 +95,8 @@ rebase_due_key=""
92
95
  behind_count="$(watch_fetch_behind_count "$repo" "$base_branch" "$head_sha" "$slot")"
93
96
  watch_branch_needs_rebase "$mergeable" "$behind_count" && rebase_due_key="$rebase_key"
94
97
 
95
- # Whether a cycle already ran for this key and this head, and the blocked
98
+ # Whether a rebase already ran for this key, whether fix-ci is done with this
99
+ # head, and the blocked
96
100
  # fingerprint's digest. A slot re-armed while blocked keeps its resume probe
97
101
  # live: empty is the not-blocked state, so seeding it empty would leave the loop
98
102
  # asleep through the very CI move the block is waiting on.
@@ -105,14 +109,14 @@ watch_branch_needs_rebase "$mergeable" "$behind_count" && rebase_due_key="$rebas
105
109
  last_seen_file="${slot}/last_seen.json"
106
110
  [ -f "$last_seen_file" ] || last_seen_file=/dev/null
107
111
  rebase_attempted=0
108
- ci_attempted=0
112
+ ci_settled=0
109
113
  blocked_digest_floor=""
110
114
  rebase_budget_exhausted=0
111
115
  while IFS= read -r evidence_line; do
112
116
  evidence_line="${evidence_line%$'\r'}"
113
117
  case "$evidence_line" in
114
118
  REBASE_ATTEMPTED=*) rebase_attempted="${evidence_line#REBASE_ATTEMPTED=}" ;;
115
- CI_ATTEMPTED=*) ci_attempted="${evidence_line#CI_ATTEMPTED=}" ;;
119
+ CI_SETTLED=*) ci_settled="${evidence_line#CI_SETTLED=}" ;;
116
120
  BLOCKED_CIDIGEST=*) blocked_digest_floor="${evidence_line#BLOCKED_CIDIGEST=}" ;;
117
121
  REBASE_BUDGET_EXHAUSTED=*) rebase_budget_exhausted="${evidence_line#REBASE_BUDGET_EXHAUSTED=}" ;;
118
122
  esac
@@ -120,7 +124,7 @@ done < <(node "${script_dir}/pr-watch-verdict.mjs" arm-floors --pr-key "${repo}#
120
124
  --head "$head_sha" --rebase-key "$rebase_key" < "$last_seen_file" 2>/dev/null)
121
125
 
122
126
  ci_red_floor=""
123
- [ -n "$ci_red_head" ] && [ "$ci_attempted" = "1" ] && ci_red_floor="$ci_red_head"
127
+ [ -n "$ci_red_head" ] && [ "$ci_settled" = "1" ] && ci_red_floor="$ci_red_key"
124
128
  rebase_floor=""
125
129
  [ -n "$rebase_due_key" ] && [ "$rebase_attempted" = "1" ] && rebase_floor="$rebase_due_key"
126
130
 
@@ -145,9 +149,9 @@ echo "DRAIN unresolved-threads=$thread_count latest-review=${latest_review:-0} l
145
149
  echo "DRAIN checks pending=${pending_checks:-0} failed=${failed_checks:-0} behind-base=${behind_count:-unknown} mergeable=${mergeable:-UNKNOWN}"
146
150
  if [ -n "$ci_red_head" ]; then
147
151
  if [ -n "$ci_red_floor" ]; then
148
- echo "DRAIN ci red at arm time — a fix-ci cycle already ran for head ${head_sha:0:8}, floored"
152
+ echo "DRAIN ci red at arm time — fix-ci escalated or spent its attempts on head ${head_sha:0:8}, floored"
149
153
  else
150
- echo "DRAIN ci red at arm time — no fix-ci attempt recorded for head ${head_sha:0:8}, not floored: dispatch fix-ci"
154
+ echo "DRAIN ci red at arm time — fix-ci not finished with head ${head_sha:0:8}, not floored: dispatch fix-ci"
151
155
  fi
152
156
  fi
153
157
  if [ -n "$rebase_due_key" ]; then
@@ -80,15 +80,24 @@ watch_checks_settled_red() {
80
80
  [ "${failed_count:-0}" -gt 0 ] 2>/dev/null
81
81
  }
82
82
 
83
+ # The identity of one red verdict: the head plus the run ids of its failed
84
+ # checks (the state line's red-check-runs).
85
+ watch_ci_red_key() {
86
+ printf '%s@%s' "$1" "$2"
87
+ }
88
+
83
89
  # The head's checks having settled red (watch_checks_settled_red).
84
90
  #
85
- # The floor is the head SHA rather than a monotonic id because a check rollup is
86
- # not monotonic: it flips green to red and resets on every push. Keying on the
87
- # red head fires once per red head and re-arms on the next push.
91
+ # The floor is a red key (watch_ci_red_key) rather than a monotonic id because a
92
+ # check rollup is not monotonic: it flips green to red and resets on every push.
93
+ # The head alone is not enough: a fix-ci cycle that settles the walkthrough or
94
+ # reruns a check keeps the head, and when that new run comes back red too it is
95
+ # news the cycle is waiting on. A head-only floor swallowed it, so the re-run's
96
+ # verdict never reached anyone.
88
97
  watch_wake_ci_red() {
89
- local pr="$1" pending_count="$2" failed_count="$3" head_sha="$4" floor="$5"
98
+ local pr="$1" pending_count="$2" failed_count="$3" head_sha="$4" red_key="$5" floor="$6"
90
99
  [ -n "$head_sha" ] || return 1
91
- [ "$head_sha" != "$floor" ] || return 1
100
+ [ "$red_key" != "$floor" ] || return 1
92
101
  watch_checks_settled_red "$pending_count" "$failed_count" || return 1
93
102
  echo "EVENT pr=$pr checks settled red head=$head_sha"
94
103
  }
@@ -25,7 +25,7 @@ query($owner: String!, $name: String!, $number: Int!) {
25
25
  contexts(first: 100) {
26
26
  nodes {
27
27
  __typename
28
- ... on CheckRun { name status conclusion }
28
+ ... on CheckRun { databaseId name status conclusion }
29
29
  ... on StatusContext { context state }
30
30
  }
31
31
  }
@@ -178,6 +178,7 @@ while :; do
178
178
  pending_checks="${STATE_FIELDS[7]-}"
179
179
  failed_checks="${STATE_FIELDS[8]-}"
180
180
  ci_digest="${STATE_FIELDS[9]-}"
181
+ ci_red_key="$(watch_ci_red_key "$head_sha" "${STATE_FIELDS[10]-}")"
181
182
 
182
183
  if [ "$pr_state" = "MERGED" ] || [ "$pr_state" = "CLOSED" ]; then
183
184
  echo "TERMINAL pr=$pr_number state=$pr_state"
@@ -206,12 +207,12 @@ while :; do
206
207
  done
207
208
  fi
208
209
 
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.
210
+ # Either floor quiets the red verdict: the watermark's when fix-ci settled
211
+ # it, the reported one when a loop already told the session about it.
211
212
  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
214
- floor_ci_red="$head_sha"
213
+ [ "$floor_ci_red" = "$ci_red_key" ] && ci_red_floor="$floor_ci_red"
214
+ if watch_wake_ci_red "$pr_number" "$pending_checks" "$failed_checks" "$head_sha" "$ci_red_key" "$ci_red_floor"; then
215
+ floor_ci_red="$ci_red_key"
215
216
  has_reported=1
216
217
  fi
217
218
 
@@ -5,7 +5,14 @@
5
5
  #
6
6
  # Field order is the contract pr-watch-loop.sh reads positionally:
7
7
  # state, headRefOid, base-tip, mergeable, latest-review, latest-comment,
8
- # unresolved-thread-ids, pending-checks, failed-checks, check-digest
8
+ # unresolved-thread-ids, pending-checks, failed-checks, check-digest,
9
+ # red-check-runs
10
+ #
11
+ # red-check-runs names each failed check with its run id (`name#id`, sorted).
12
+ # A settle, a rerun or a comment-triggered re-check publishes a new run on the
13
+ # same head, so this is what tells a fresh red verdict from the one already
14
+ # reported. check-digest cannot carry the ids: it must equal the digest the
15
+ # blocked tick builds from `gh pr checks`, which has none.
9
16
  #
10
17
  # base-tip is where the base branch points now, the base half of the rebase key.
11
18
  # Not baseRefOid: GitHub moves that only when the head is pushed, so a key built
@@ -19,9 +26,11 @@ def loop_marked: ((.body // "") | contains("<!-- muggle-do:bot -->"));
19
26
  | (($pr.commits.nodes[0].commit.statusCheckRollup.contexts.nodes) // []) as $contexts
20
27
  | ($contexts | map(
21
28
  if .__typename == "CheckRun"
22
- then {name: .name, verdict: (if .status != "COMPLETED" then "PENDING" else (.conclusion // "NEUTRAL") end)}
23
- else {name: .context, verdict: (.state // "PENDING")}
29
+ then {name: .name, runId: (.databaseId // ""), verdict: (if .status != "COMPLETED" then "PENDING" else (.conclusion // "NEUTRAL") end)}
30
+ else {name: .context, runId: "", verdict: (.state // "PENDING")}
24
31
  end)) as $checks
32
+ | ($checks | map(select(.verdict == "FAILURE" or .verdict == "ERROR" or .verdict == "TIMED_OUT" or .verdict == "STARTUP_FAILURE")))
33
+ as $failedChecks
25
34
 
26
35
  # Every comment on a live thread that belongs to a submitted review. A PENDING
27
36
  # review is the reviewer's own draft and is not feedback yet.
@@ -53,6 +62,7 @@ def loop_marked: ((.body // "") | contains("<!-- muggle-do:bot -->"));
53
62
  ([$pr.reviewThreads.nodes[] | select(.isResolved == false) | select(.isOutdated == false)
54
63
  | select((.comments.nodes[0].pullRequestReview.state // "SUBMITTED") != "PENDING") | .id] | join(";")),
55
64
  ($checks | map(select(.verdict == "PENDING")) | length),
56
- ($checks | map(select(.verdict == "FAILURE" or .verdict == "ERROR" or .verdict == "TIMED_OUT" or .verdict == "STARTUP_FAILURE")) | length),
57
- ($checks | sort_by(.name) | map(.name + ":" + .verdict) | join(","))
65
+ ($failedChecks | length),
66
+ ($checks | sort_by(.name) | map(.name + ":" + .verdict) | join(",")),
67
+ ($failedChecks | map(.name + "#" + (.runId | tostring)) | sort | join(","))
58
68
  ] | @tsv
@@ -48,6 +48,12 @@ function isRebaseKeyEscalated(lastSeenEntry, rebaseKey) {
48
48
  function isCiHeadEscalated(lastSeenEntry, headSha) {
49
49
  return (lastSeenEntry.ci_escalated_shas ?? []).includes(headSha) && isCiHeadAttempted(lastSeenEntry, headSha);
50
50
  }
51
+ function isCiHeadBudgetSpent(lastSeenEntry, headSha) {
52
+ return ciFixAttemptsFor(lastSeenEntry, headSha) >= MAX_CI_FIX_ATTEMPTS_PER_HEAD;
53
+ }
54
+ function isCiHeadSettled(lastSeenEntry, headSha) {
55
+ return isCiHeadEscalated(lastSeenEntry, headSha) || isCiHeadBudgetSpent(lastSeenEntry, headSha);
56
+ }
51
57
  function isRebaseBudgetExhausted(lastSeenEntry, maxCatchUpRebases) {
52
58
  const catchUpRebases = lastSeenEntry.catch_up_rebases;
53
59
  return maxCatchUpRebases !== null && typeof catchUpRebases === "number" && catchUpRebases >= maxCatchUpRebases;
@@ -58,7 +64,7 @@ function resolveArmFloorEvidence(lastSeenEntry, headSha, rebaseKey, maxCatchUpRe
58
64
  const blockedCiDigest = lastSeenEntry.blocked?.fingerprint?.ci_digest;
59
65
  return {
60
66
  isRebaseKeyAttempted: rebaseKey !== "" && isRebaseKeyAttempted(lastSeenEntry, rebaseKey),
61
- isCiHeadAttempted: headSha !== "" && isCiHeadAttempted(lastSeenEntry, headSha),
67
+ isCiHeadSettled: headSha !== "" && isCiHeadSettled(lastSeenEntry, headSha),
62
68
  isRebaseBudgetExhausted: isRebaseBudgetExhausted(lastSeenEntry, maxCatchUpRebases),
63
69
  blockedCiDigest: typeof blockedCiDigest === "string" && !LINE_BREAK.test(blockedCiDigest) ? blockedCiDigest : ""
64
70
  };
@@ -104,7 +110,8 @@ function resolveWatchTickVerdict(lastSeenEntry, observation, preferences) {
104
110
  if (isCiHeadBudgetLeft && !isCiHeadEscalated(lastSeenEntry, headSha)) return "dispatch-fix-ci" /* DispatchFixCi */;
105
111
  }
106
112
  if (isRebaseKeyEscalated(lastSeenEntry, rebaseKey)) return "block-conflict-escalated" /* BlockConflictEscalated */;
107
- if (isCiHeadEscalated(lastSeenEntry, headSha)) return "block-ci-escalated" /* BlockCiEscalated */;
113
+ const isRedHeadOutOfAttempts = observation.ciBucket === "red" /* Red */ && isCiHeadBudgetSpent(lastSeenEntry, headSha);
114
+ if (isCiHeadEscalated(lastSeenEntry, headSha) || isRedHeadOutOfAttempts) return "block-ci-escalated" /* BlockCiEscalated */;
108
115
  if ((lastSeenEntry.escalated_review_ids ?? []).length > 0) return "block-reviews-escalated" /* BlockReviewsEscalated */;
109
116
  return "idle" /* Idle */;
110
117
  }
@@ -141,7 +148,7 @@ function runPrWatchVerdictCommand(commandLine, lastSeenJson, resolvePreferences)
141
148
  const evidence = resolveArmFloorEvidence(lastSeenEntry, headSha, rebaseKey, resolvePreferences().maxCatchUpRebases);
142
149
  return {
143
150
  stdout: `REBASE_ATTEMPTED=${evidence.isRebaseKeyAttempted ? 1 : 0}
144
- CI_ATTEMPTED=${evidence.isCiHeadAttempted ? 1 : 0}
151
+ CI_SETTLED=${evidence.isCiHeadSettled ? 1 : 0}
145
152
  BLOCKED_CIDIGEST=${evidence.blockedCiDigest}
146
153
  REBASE_BUDGET_EXHAUSTED=${evidence.isRebaseBudgetExhausted ? 1 : 0}
147
154
  `,
@@ -173,14 +180,22 @@ function parseMaxCatchUpRebases(configuredValue) {
173
180
  const spelled = typeof configuredValue === "number" ? String(configuredValue) : configuredValue;
174
181
  return typeof spelled === "string" && POSITIVE_INTEGER.test(spelled) ? Number(spelled) : void 0;
175
182
  }
183
+ var readUserPreferences = (homeDir) => asRecord(readJsonRecord(join(homeDir, ...USER_PREFERENCES_PATH_SEGMENTS)).preferences);
184
+ function readPreferenceLayers(homeDir, pluginRoot) {
185
+ return [readUserPreferences(homeDir), readJsonRecord(join(pluginRoot, ...PREFERENCE_DEFAULTS_PATH_SEGMENTS))];
186
+ }
187
+ function pickPreferenceValue(preferenceLayers, preferenceKey) {
188
+ const configuredValue = preferenceLayers.map((layer) => layer[preferenceKey]).find((layerValue) => layerValue != null);
189
+ return typeof configuredValue === "string" ? configuredValue : void 0;
190
+ }
176
191
  function resolveWatchPreferences(homeDir = homedir(), pluginRoot = resolvePluginRoot()) {
177
- const shippedDefaults = readJsonRecord(join(pluginRoot, ...PREFERENCE_DEFAULTS_PATH_SEGMENTS));
178
- const userPreferences = asRecord(readJsonRecord(join(homeDir, ...USER_PREFERENCES_PATH_SEGMENTS)).preferences);
179
- const autoRebase = userPreferences[AUTO_REBASE_PREFERENCE_KEY] ?? shippedDefaults[AUTO_REBASE_PREFERENCE_KEY];
192
+ const preferenceLayers = readPreferenceLayers(homeDir, pluginRoot);
193
+ const [userPreferences, shippedDefaults] = preferenceLayers;
194
+ const autoRebase = pickPreferenceValue(preferenceLayers, AUTO_REBASE_PREFERENCE_KEY);
180
195
  const userCap = parseMaxCatchUpRebases(userPreferences[MAX_CATCH_UP_REBASES_PREFERENCE_KEY]);
181
196
  const shippedCap = parseMaxCatchUpRebases(shippedDefaults[MAX_CATCH_UP_REBASES_PREFERENCE_KEY]);
182
197
  return {
183
- autoRebase: typeof autoRebase === "string" ? autoRebase : void 0,
198
+ autoRebase,
184
199
  maxCatchUpRebases: userCap !== void 0 ? userCap : shippedCap ?? null
185
200
  };
186
201
  }
@@ -7,6 +7,6 @@ Map a red CI check to the local command that reproduces and fixes it. Repo-agnos
7
7
  | lint / format | Run the repo's lint `--fix` / formatter; restage. |
8
8
  | typecheck | Run the typecheck script; read the errors; edit the types. |
9
9
  | unit / test | Run the suite; read the failures; fix the code or the test. |
10
- | `muggle-walkthrough` — the Muggle E2E walkthrough check | In scope. Its check-run summary names each failed or inconclusive case with the failing step and a link to the run; the walkthrough comment's hidden `muggle-e2e-result` record carries the same cases with their test case ids. Repair each, re-run the **whole** E2E suite, and settle the walkthrough comment with the new result — editing the comment re-runs the check. A summary saying the comment is still empty means no run was ever posted: run the suite and post it. |
10
+ | `muggle-walkthrough` — the Muggle E2E walkthrough check | In scope. Its check-run summary names each failed or inconclusive case with the failing step and a link to the run; the walkthrough comment's hidden `muggle-e2e-result` record carries the same cases with their test case ids. Repair each, re-run the **whole** E2E suite, and settle the walkthrough comment with the new result — editing the comment re-runs the check. A summary saying the comment is still empty means no run was ever posted: run the suite and post it, or — when the session holds no E2E plan or a `unit-only`/`skip` one — settle it with the verified skip code that holds. Deterministic: it reads the comment, so re-running it unchanged returns the same verdict. |
11
11
  | non-deterministic — an LLM-judged eval gate (e.g. `skill-routing-eval`, `skill-gate-eval`, `agent-gate-eval`) or another check whose verdict can flip with nothing changed | Re-run its failed jobs once per head, without a push: `gh run rerun <run-id> --failed` (GitLab: retry the failed job). The re-run counts as a fix attempt. Escalate only if it fails again on the same head. How long the check takes is never a reason to skip the re-run. |
12
12
  | out of scope — any other E2E-in-CI, build/deploy infra, or unknown | Do **not** attempt; record for escalation. |
@@ -57,7 +57,7 @@ You receive everything from `state.md` already — pre-flight resolved it:
57
57
 
58
58
  Read `state.md`.
59
59
 
60
- **No `## Pre-flight answers` block at all** → the session was seeded poll-only (e.g. by auto-track, [`../muggle-pr-followup/auto-track.md`](../muggle-pr-followup/auto-track.md)). Treat `Validation` as `skip`: emit a `SKIPPED` report citing `NO_PR` and exit cleanly. The watcher owns no E2E context by design; "no context" is a clean skip, not a failure.
60
+ **No `## Pre-flight answers` block at all** → the session was seeded poll-only (e.g. by auto-track, [`../muggle-pr-followup/auto-track.md`](../muggle-pr-followup/auto-track.md)). Treat `Validation` as `skip`: emit a `SKIPPED` report citing the code that holds — `NO_WEB_SURFACE` when the repo serves nothing to drive, `NOT_APPLICABLE` when no browser run can reach the change — and exit cleanly. Never `NO_PR`: this cycle is on a PR, so the guardrail refutes it and the walkthrough stays unsettled. When a browser run *could* reach the change, no code holds: report `SKIPPED` without declaring one and leave the walkthrough to the owner's E2E decision. The watcher owns no E2E context by design; "no context" is a clean skip, not a failure.
61
61
 
62
62
  Otherwise the block was seeded by pre-flight or bootstrap per [`../_shared/resolve-e2e-validation-context.md`](../_shared/resolve-e2e-validation-context.md) — read it the same way regardless of seeder. The persisted `Validation` field (`local-e2e`, `staging-replay`, `unit-only`, `skip`) picks execution vs early-exit below. In a forward run, [`autoE2ETest`](../muggle-preferences/preference-gates/autoE2ETest.md) `ask` was resolved by pre-flight Q13; in a watcher cycle there is no per-tick pre-flight, so `Validation` **is** the standing decision — don't re-resolve `ask`.
63
63
 
@@ -31,6 +31,8 @@ Check out the PR branch in the session's working tree (per `state.md`). Capture
31
31
 
32
32
  Then increment `last_seen.ci_fix_attempts[red_sha]` — before any fix, re-run or escalation, as a whole-file rewrite (Read → change field → Write) per [`../_shared/session-state-writes.md`](../_shared/session-state-writes.md), never the Edit tool. Every dispatched cycle is an attempt, including one that only re-runs a check or escalates without pushing. That count is the only record the watcher and its turn-end gate accept that this red head was acted on; an escalation written without it is ignored and fix-ci is dispatched again ([`../muggle-pr-followup/state-schemas.md`](../muggle-pr-followup/state-schemas.md#last_seenjson)).
33
33
 
34
+ The increment opens a cycle this turn carries through Step 5 or Step 6 — it never stands in for one. The single exception is a red `muggle-walkthrough` whose E2E run for **this** PR is already queued or running (its `state.md` shows `E2E dispatch: queued` or `running`): that run is the cycle, and its report settles the comment. Writing the count for a PR with no such run, or because another PR's run is in flight, clears the turn-end gate while the PR stays red; the watch never asks again until the head moves, and the PR can merge red.
35
+
34
36
  ### Step 2 — Map each failing check to a local command
35
37
 
36
38
  Per [`../_shared/ci-check-to-command.md`](../_shared/ci-check-to-command.md). Fix the in-scope checks in the working tree; record out-of-scope checks for escalation (Step 6). A red `muggle-walkthrough` check takes Step 2.5; a red non-deterministic check takes Step 2.6.
@@ -45,7 +47,13 @@ How long a check takes to run — a 45-minute eval included — is never a reaso
45
47
  4. **Order the settle after the push.** If Step 2 changed code, run Steps 3–4 below first, so the new head carries the fix. Then settle the walkthrough comment with the re-run's report via [`../muggle-pr-visual-walkthrough/SKILL.md`](../muggle-pr-visual-walkthrough/SKILL.md) Mode A; editing the comment re-runs the check against the head. With no code change (only retries and regenerations) there is nothing to push — the comment edit alone re-runs the check.
46
48
  5. **Wait for the fresh verdict** before Step 5's respawn: poll the check run until one completes later than the `completed_at` recorded in item 1, for up to 5 minutes. A watcher respawned sooner reads the stale red verdict and spends a fix attempt on a run already repaired. On timeout, respawn anyway — the per-SHA budget still bounds it.
47
49
 
48
- An empty-comment failure (no run ever posted) skips items 1–2: run the suite and settle the comment. Under a `unit-only` or `skip` validation in `state.md`, settle it with the verified skip code instead.
50
+ An empty-comment failure (no run ever posted) skips items 1–2. Pick the branch from `state.md`:
51
+
52
+ - **`local-e2e` or `staging-replay`** → run the suite and settle the comment with its report.
53
+ - **`unit-only`, `skip`, or no `## Pre-flight answers` block at all** (a poll-only session — auto-track, or bootstrap on a PR it found no testable surface for) → settle it with the skip code that holds per [`e2e-acceptance.md`](e2e-acceptance.md): `NO_WEB_SURFACE` when the repo serves nothing to drive, `NO_APP_LOGIC_CHANGE` (detail opening with the PR's merge-base commit) when every path the PR changed is docs, tests, lint or CI config, else `NOT_APPLICABLE` with the reason no browser run can reach the change. Run `echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"` from the PR's working tree. The write runs detached, so confirm it landed: run the foreground command the walkthrough gate's block names until it prints `{"settled":true}`, never a hand-written PATCH. Never `NO_PR`: the guardrail refutes it whenever the session handled a PR.
54
+ - **A poll-only PR whose change a browser run *can* reach** → no code holds, so do not cite one. Escalate per Step 6, naming the missing E2E decision; the owner chooses run, project or waive.
55
+
56
+ `muggle-walkthrough` is never a Step 2.6 check: it reads the comment, so re-running it without editing the comment returns the same verdict.
49
57
 
50
58
  ### Step 2.6 — Red non-deterministic check: re-run once per head
51
59
 
@@ -13,7 +13,7 @@ The same argument that forbids authoring a per-slot `watch.sh` applies to arming
13
13
  The `DRAIN` lines it prints, in the watch's output, are the outstanding work as of the arm. Review ids and threads the drain read are floored, so the loop will *not* tell you about those again. A stale branch or a red head is floored only when `last_seen.json` records a rebase or fix-ci cycle already attempted for that key or head ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)); otherwise the drain line says `not floored: dispatch …` and the loop's first poll reports it as an event. A PR that spent its catch-up rebase budget is the exception: its drain line says the rebase is `held for the owner`, and nothing is dispatched — the tick would refuse it — until the owner raises [`maxCatchUpRebases`](../muggle-preferences/preference-gates/maxCatchUpRebases.md). Re-arming is never a way to set work aside — it neither handles nor silences anything. Steps 1-3 below are what the script does, and why.
14
14
 
15
15
  1. **Drain.** Run one tick per [`contract.md`](contract.md). It acts on everything already outstanding — actionable threads (`gitlab`: discussions), body-only reviews past the watermark (GitHub-only — GitLab has no review envelope), a stale branch, red CI — and finalizes a terminal PR. If the tick dispatched a cycle, stop here: the cycle's exit path settles the watch when it finishes.
16
- 2. **Seed the watermark.** *(`pr-watch-arm.sh` does this; the reasoning is kept because the floors are subtle and wrong ones are quiet.)* Resolve the provider once per [`../_shared/vcs/detect-vcs.md`](../_shared/vcs/detect-vcs.md) — every fetch in this sequence uses that provider's recipes. Write the slot's watch watermark ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)) to the ids the **drain itself read** — the max review-id and comment-id observed at the drain's own fetch (Step 1), snapshotted at that read. Never let the loop capture its own baseline — the arming session writes it; and **never** from a fresh fetch taken after the drain, which would include a comment that arrived after the drain read the wave and mark it seen unread. Seeded to the drain's floor, anything landing after that read stays above the watermark and the loop's first iteration surfaces it. Seed the CI floor (`CIRED`) from the same drain read: set it to the head SHA only when the checks have **already settled red** at that read (no check pending, one or more in the `fail` bucket per [`../_shared/vcs/common/ci-rollup.md`](../_shared/vcs/common/ci-rollup.md)) **and** `last_seen.json` records a fix-ci attempt for that head — a cycle already ran for that red, so its escalation or retry is not news — and empty otherwise. Seed the rebase floor (`REBASED`) the same way, from the drain's branch-standing read per [`../_shared/vcs/common/branch-standing.md`](../_shared/vcs/common/branch-standing.md): the current `rebase_key` (`<head_sha>..<base_tip_sha>`) only when the drain found the branch behind or conflicting **and** a rebase attempt is recorded for that key, empty otherwise. An escalation entry alone never floors anything: one with no attempt behind it records a decision nobody acted on, and flooring it is how a re-arming session once kept a stale branch and a red head silent for a day ([`state-schemas.md`](state-schemas.md#watch-watermarkenv) names the fields). Seed the blocked-CI floor (`BLOCKED_CIDIGEST`) to the blocked fingerprint's `ci_digest` when arming while `last_seen.blocked` is already set, and empty otherwise — empty is the not-blocked state, in which the loop's blocked-resume probe stays dormant. Seed the rebase hold (`REBASE_HELD`) to `1` when `last_seen.json`'s `catch_up_rebases` has reached `maxCatchUpRebases` — the same check the tick's verdict blocks on — and empty otherwise: while it is set the loop raises no rebase wake on any base move. The script reads all four facts from `last_seen.json` through [`../../scripts/pr-watch-verdict.mjs`](../../scripts/pr-watch-verdict.mjs) `arm-floors`, and records what it found due in `watch-due.env` ([`state-schemas.md`](state-schemas.md#watch-dueenv)).
16
+ 2. **Seed the watermark.** *(`pr-watch-arm.sh` does this; the reasoning is kept because the floors are subtle and wrong ones are quiet.)* Resolve the provider once per [`../_shared/vcs/detect-vcs.md`](../_shared/vcs/detect-vcs.md) — every fetch in this sequence uses that provider's recipes. Write the slot's watch watermark ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)) to the ids the **drain itself read** — the max review-id and comment-id observed at the drain's own fetch (Step 1), snapshotted at that read. Never let the loop capture its own baseline — the arming session writes it; and **never** from a fresh fetch taken after the drain, which would include a comment that arrived after the drain read the wave and mark it seen unread. Seeded to the drain's floor, anything landing after that read stays above the watermark and the loop's first iteration surfaces it. Seed the CI floor (`CIRED`) from the same drain read: set it to the red key (`<head_sha>@<red-check-runs>`) only when the checks have **already settled red** at that read (no check pending, one or more in the `fail` bucket per [`../_shared/vcs/common/ci-rollup.md`](../_shared/vcs/common/ci-rollup.md)) **and** fix-ci is finished with that head — it escalated, or spent the tick's per-head budget — and empty otherwise. A lone attempt does not floor: the count is written before the fix runs, so a cycle that recorded it and changed nothing would otherwise leave the red silent. Seed the rebase floor (`REBASED`) the same way, from the drain's branch-standing read per [`../_shared/vcs/common/branch-standing.md`](../_shared/vcs/common/branch-standing.md): the current `rebase_key` (`<head_sha>..<base_tip_sha>`) only when the drain found the branch behind or conflicting **and** a rebase attempt is recorded for that key, empty otherwise. An escalation entry alone never floors anything: one with no attempt behind it records a decision nobody acted on, and flooring it is how a re-arming session once kept a stale branch and a red head silent for a day ([`state-schemas.md`](state-schemas.md#watch-watermarkenv) names the fields). Seed the blocked-CI floor (`BLOCKED_CIDIGEST`) to the blocked fingerprint's `ci_digest` when arming while `last_seen.blocked` is already set, and empty otherwise — empty is the not-blocked state, in which the loop's blocked-resume probe stays dormant. Seed the rebase hold (`REBASE_HELD`) to `1` when `last_seen.json`'s `catch_up_rebases` has reached `maxCatchUpRebases` — the same check the tick's verdict blocks on — and empty otherwise: while it is set the loop raises no rebase wake on any base move. The script reads all four facts from `last_seen.json` through [`../../scripts/pr-watch-verdict.mjs`](../../scripts/pr-watch-verdict.mjs) `arm-floors`, and records what it found due in `watch-due.env` ([`state-schemas.md`](state-schemas.md#watch-dueenv)).
17
17
  3. **Dedup, then watch.** First read `<slot>/watch.pid` ([`state-schemas.md`](state-schemas.md#watchpid)): if it names a live process (`kill -0 "$pid"` succeeds) **and `owner.json` records this session**, a watcher already owns this slot — **skip arming, do not start a second**. This is what stops orphaned watchers from accumulating: the in-session task dying does not stop the OS loop it launched (on Windows a detached Git Bash loop keeps running and polling `gh` forever after the session ends), so checking a live task list is not enough — the PID lease is.
18
18
 
19
19
  **A live lease owned by a different session does not count as owned** — check it with `watcher_lease_is_foreign` from [`../../scripts/pr-watch-guards.sh`](../../scripts/pr-watch-guards.sh). A live PID proves the loop is running, never that anything is listening: an orphan left by a dead session keeps polling and keeps touching its heartbeat while its stdout is that session's closed pipe, so every liveness signal reads healthy and the events reach nobody. Skipping on the PID alone let such a corpse block this session from arming the PR for the whole lifetime cap — seven days, since the default moved off six hours. When the guard reports a foreign lease, kill that PID, delete `watch.pid`, and arm fresh; note it in the slot's `followup.log` so the reclaim is visible.
@@ -36,7 +36,7 @@ The `DRAIN` lines it prints, in the watch's output, are the outstanding work as
36
36
 
37
37
  **Never author a per-slot `watch.sh`.** The loop ships as [`../../scripts/pr-watch-loop.sh`](../../scripts/pr-watch-loop.sh), with its wake conditions in [`../../scripts/pr-watch-events.sh`](../../scripts/pr-watch-events.sh) and its state projection in [`../../scripts/pr-watch-state.jq`](../../scripts/pr-watch-state.jq); arming runs it and passes arguments. Writing the loop from this prose was how it drifted — each arm produced an independent derivation, and a derivation that quietly dropped a wake still ran, still heartbeat, still logged, and simply never fired for the signal it lost. Two slots on one machine ended up without the behind-base wake, which left their PRs unmergeable under watchers that looked healthy. The prose below says *why* each wake exists; the shipped files are the only definition of *what* fires. A slot holding a legacy generated `watch.sh` keeps it until re-armed, at which point the supersede guard retires the old loop.
38
38
 
39
- The task's description is `PR #<n> — <title>`. Description and command both matter: some task surfaces show one, some the other, and a slot-bearing command keeps the watch identifiable everywhere a raw script blob would not. One background task per PR at a time: it is the watch's visible handle, showing as a running task while the PR is polled, and replaced by a fresh one after each report. Its loop checks about every 60 seconds, re-reading the watermark and touching the slot's `watch-heartbeat` file each iteration — the liveness beacon that tells [`reconcile.md`](reconcile.md) a quiet watch is still alive; on a newer submitted review, a newer thread comment (`gitlab`: a newer discussion note), a thread newly unresolved (`gitlab`: discussion), **the head SHA's checks settling red** (no check pending and one or more in the `fail` bucket per [`../_shared/vcs/common/ci-rollup.md`](../_shared/vcs/common/ci-rollup.md)), **the branch falling behind or conflicting with its base** (`behind_by > 0` or the conflict signal per [`../_shared/vcs/common/branch-standing.md`](../_shared/vcs/common/branch-standing.md)), or — **only while the watch is blocked** (`BLOCKED_CIDIGEST` non-empty) — **the head's CI digest changing in any way** (not just to red) — it prints one line per finding and **exits**, recording what it reported in `watch-reported.env` ([`state-schemas.md`](state-schemas.md#watch-reportedenv)) so no later loop reports the same occurrence again. The review and thread floors are monotonic ids; the other three are not. The CI-red floor is the **head SHA**, because the check rollup is non-monotonic — it flips green↔red and resets on every push — so recording the red head SHA fires CI once per red head, and a later push re-arms it on the new SHA. The rebase floor (`REBASED`) is the **`rebase_key`** — `<head_sha>..<base_tip_sha>` — because staleness is a function of both sides: keying on the pair fires once per newly-due pair and re-arms when either the head or the base moves, where a head-only key would wedge permanently the first time the base advances (the head cannot change while nobody pushes). A head whose checks are still **pending** is never a red wake, and a branch with `behind_by == 0` and `mergeable == UNKNOWN` is never a rebase wake: pending checks may yet go green and conflict state is still computing, and the tick would idle on either (Steps 5–6) regardless. The blocked-CI signal is different in kind — a **resume** probe, live only while the watch is blocked: it wakes on any move of the head's CI digest (the same bucket-plus-sorted-name/conclusion signature the blocked fingerprint records — [`blocked-tick.md`](blocked-tick.md)) away from `BLOCKED_CIDIGEST`, so a block waiting on a green pass, a rerun, or an external deploy check resumes as promptly as one waiting on red. Quiet iterations print nothing and cost nothing — no model tokens are spent while the watch is quiet.
39
+ The task's description is `PR #<n> — <title>`. Description and command both matter: some task surfaces show one, some the other, and a slot-bearing command keeps the watch identifiable everywhere a raw script blob would not. One background task per PR at a time: it is the watch's visible handle, showing as a running task while the PR is polled, and replaced by a fresh one after each report. Its loop checks about every 60 seconds, re-reading the watermark and touching the slot's `watch-heartbeat` file each iteration — the liveness beacon that tells [`reconcile.md`](reconcile.md) a quiet watch is still alive; on a newer submitted review, a newer thread comment (`gitlab`: a newer discussion note), a thread newly unresolved (`gitlab`: discussion), **the head SHA's checks settling red** (no check pending and one or more in the `fail` bucket per [`../_shared/vcs/common/ci-rollup.md`](../_shared/vcs/common/ci-rollup.md)), **the branch falling behind or conflicting with its base** (`behind_by > 0` or the conflict signal per [`../_shared/vcs/common/branch-standing.md`](../_shared/vcs/common/branch-standing.md)), or — **only while the watch is blocked** (`BLOCKED_CIDIGEST` non-empty) — **the head's CI digest changing in any way** (not just to red) — it prints one line per finding and **exits**, recording what it reported in `watch-reported.env` ([`state-schemas.md`](state-schemas.md#watch-reportedenv)) so no later loop reports the same occurrence again. The review and thread floors are monotonic ids; the other three are not. The CI-red floor is the **red key** — the head SHA plus each failed check's run id — because the check rollup is non-monotonic: it flips green↔red and resets on every push, so the key fires once per red verdict and a later push re-arms it on the new SHA. The run ids matter because a settled walkthrough comment or a rerun publishes a new run on the same head; when that run is red too, it is the verdict fix-ci is waiting on, and a head-only floor swallowed it. The rebase floor (`REBASED`) is the **`rebase_key`** — `<head_sha>..<base_tip_sha>` — because staleness is a function of both sides: keying on the pair fires once per newly-due pair and re-arms when either the head or the base moves, where a head-only key would wedge permanently the first time the base advances (the head cannot change while nobody pushes). A head whose checks are still **pending** is never a red wake, and a branch with `behind_by == 0` and `mergeable == UNKNOWN` is never a rebase wake: pending checks may yet go green and conflict state is still computing, and the tick would idle on either (Steps 5–6) regardless. The blocked-CI signal is different in kind — a **resume** probe, live only while the watch is blocked: it wakes on any move of the head's CI digest (the same bucket-plus-sorted-name/conclusion signature the blocked fingerprint records — [`blocked-tick.md`](blocked-tick.md)) away from `BLOCKED_CIDIGEST`, so a block waiting on a green pass, a rerun, or an external deploy check resumes as promptly as one waiting on red. Quiet iterations print nothing and cost nothing — no model tokens are spent while the watch is quiet.
40
40
 
41
41
  Each poll also rewrites the slot's `watch-due.env` whenever the due work changes — the rebase key found due and the head whose checks settled red, or an empty file when neither — so the session's turn-end gate can see work the watch reported without a provider call ([`state-schemas.md`](state-schemas.md#watch-dueenv)).
42
42
 
@@ -61,6 +61,8 @@ Line-comment threads need no seeding — the watcher derives them from live thre
61
61
 
62
62
  The only step that may prompt the user, and only when the PR has a testable surface. Run silent detection from [`../_shared/resolve-e2e-validation-context.md`](../_shared/resolve-e2e-validation-context.md) first. If it finds **no dev server / no testable surface** — a docs, config, skill, or library change, or a non-web repo — seed the watcher **poll-only**: no prompt, no `## Pre-flight answers` block, continue to Step 7 (same as [`auto-track`](auto-track.md)).
63
63
 
64
+ Detection just established why E2E does not apply, so record it on the PR before arming. If the PR's walkthrough comment is still the placeholder, run `echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"` from Step 3's working tree: `NO_WEB_SURFACE` when the repo serves nothing to drive, `NO_APP_LOGIC_CHANGE` (detail opening with the PR's merge-base commit) when every changed path is docs, tests, lint or CI config, else `NOT_APPLICABLE` naming why no browser run reaches the change. Never `NO_PR` — the guardrail refutes it on a PR. Then run the foreground command the walkthrough gate's block names until it prints `{"settled":true}`. Left unsettled, the walkthrough check sits red with nothing in this watch able to clear it, and the PR can merge that way.
65
+
64
66
  Otherwise resolve the context per that file: reuse an existing one (gated by `autoReuseValidationContext`), else one `AskUserQuestion` (strategy, local URL, backend, project, credentials, re-auth) with detected values as defaults; record Step 3's verified working tree as `Working tree`. The user may pick `skip`/`unit-only` or decline the prompt outright — either way, seed poll-only and continue. Never abort the watcher over validation.
65
67
 
66
68
  Capture any resolved fields for Step 7. Do **not** run E2E now — the first watcher tick that dispatches `/muggle-do` does that.
@@ -135,7 +135,7 @@ It prints one line, `VERDICT=<verdict>`. A non-zero exit is a broken install: re
135
135
 
136
136
  1. A rebase is due and `catch_up_rebases >= maxCatchUpRebases` (the cap from the user's preferences; the `rebase_budget_exhausted` flag is not consulted) → `block-rebase-budget-exhausted`.
137
137
  2. A rebase is due, `conflict_resolve_attempts[rebase_key] < 2`, and `rebase_key` ∉ `conflict_escalated_keys` → `dispatch-rebase`. With the per-key budget spent — `conflict_resolve_attempts[rebase_key] >= 2` or `rebase_key` ∈ `conflict_escalated_keys` — it falls through to CI.
138
- 3. CI is `red`, `ci_fix_attempts[head_sha] < 3`, and `head_sha` ∉ `ci_escalated_shas` → `dispatch-fix-ci`. Red with `ci_fix_attempts[head_sha] >= 3` or `head_sha` ∈ `ci_escalated_shas` dispatches nothing — the fix budget is spent and the watcher does not re-dispatch. A check still pending (`bucket == "pending"`) never dispatches: the tick idles until CI settles, and a green head idles too.
138
+ 3. CI is `red`, `ci_fix_attempts[head_sha] < 3`, and `head_sha` ∉ `ci_escalated_shas` → `dispatch-fix-ci`. Red with `ci_fix_attempts[head_sha] >= 3` or `head_sha` ∈ `ci_escalated_shas` dispatches nothing — the fix budget is spent and the watcher does not re-dispatch; it holds as `block-ci-escalated` (Step 4) even when fix-ci never wrote the escalation, because idling there leaves a red PR with nobody told. A check still pending (`bucket == "pending"`) never dispatches: the tick idles until CI settles, and a green head idles too.
139
139
  4. Otherwise `block-conflict-escalated`, `block-ci-escalated`, or `block-reviews-escalated` when the matching escalation stands, else `idle`.
140
140
 
141
141
  **An escalation counts only with an attempt behind it.** An entry in `conflict_escalated_keys` suppresses a rebase only when `conflict_resolve_attempts[rebase_key] >= 1`, and an entry in `ci_escalated_shas` suppresses fix-ci only when `ci_fix_attempts[head_sha] >= 1`: a cycle ran and gave up. An entry with no attempt is ignored, and the work dispatches. Those fields are the executor's record of what its cycles did — never write an escalation entry or an attempt counter by hand.
@@ -172,7 +172,7 @@ Act on the verdict:
172
172
  Any tick that did not dispatch. The Step 6 verdict says **why** it idled. It is **blocked pending a human** on a `block-*` verdict, a durable block only the user can clear:
173
173
 
174
174
  - `block-conflict-escalated` — a rebase `/muggle-do` attempted and 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;
175
- - `block-ci-escalated` — CI the fix-ci stage attempted and gave up on, reason `ci_escalated`;
175
+ - `block-ci-escalated` — CI the fix-ci stage gave up on, or a red head that spent all 3 fix-ci attempts, reason `ci_escalated`;
176
176
  - `block-reviews-escalated` — an ambiguous review in `last_seen.escalated_review_ids` awaiting the user's direction, with the actionable set empty, reason `reviews_escalated`;
177
177
  - `block-rebase-budget-exhausted` — reason `rebase_budget_exhausted` (Step 6).
178
178
 
@@ -217,7 +217,7 @@ The watch loop's comparison floor — plain `KEY=VALUE` lines, one file per slot
217
217
  - `REV` — highest submitted-review id already handled
218
218
  - `COM` — highest thread-comment id already handled
219
219
  - `THREADS` — semicolon-joined ids of threads already known unresolved
220
- - `CIRED` — head SHA whose settled-red CI a fix-ci cycle already attempted (`ci_fix_attempts[sha] >= 1`); empty when the checks are green, still pending, unseen, or red with no attempt — the last so the loop reports it. The CI floor is a SHA rather than a monotonic id because the check rollup flips green↔red and resets on each push — keying on the head SHA fires the loop once per red head and re-arms on the next push ([`arm-watcher.md`](arm-watcher.md)).
220
+ - `CIRED` — the red key `<head_sha>@<red-check-runs>` (each failed check's `name#run-id`, sorted — the last field of [`../../scripts/pr-watch-state.jq`](../../scripts/pr-watch-state.jq)) of a red verdict fix-ci is finished with: `head_sha` ∈ `ci_escalated_shas` with an attempt behind it, or `ci_fix_attempts[sha] >= 3`. Empty when the checks are green, still pending, unseen, or red with fix-ci not finished — the last so the loop reports it. A lone attempt never floors: the count is written before the fix runs, so it proves a cycle started, not that anything on the PR changed. The floor is a red key rather than a monotonic id because the rollup flips green↔red and resets on each push, and it carries run ids because a settled walkthrough comment or a rerun publishes a new run on the **same** head — a red verdict from that run is what fix-ci waits on, so it wakes the loop again ([`arm-watcher.md`](arm-watcher.md)).
221
221
  - `REBASED` — the `rebase_key` (`<head_sha>..<base_tip_sha>`) whose behind/conflicting state a rebase cycle already attempted (`conflict_resolve_attempts[key] >= 1`); empty when the branch is current with its base, unseen, or stale with no attempt. Keyed on the pair, not a monotonic id, because staleness depends on both sides — the loop fires once per newly-due pair and re-arms when the head or the base moves. Same key [`contract.md`](contract.md) Steps 5–6 dedup rebase dispatch on; the loop only signals a due rebase, the tick's verdict applies the budgets.
222
222
  - `BLOCKED_CIDIGEST` — the head's `ci_digest` at the moment a block was flagged (the same signature `blocked.fingerprint.ci_digest` carries); empty whenever the watch is not blocked. Non-empty arms the loop's blocked-resume probe: while set, the loop wakes on any move of the live digest away from this value, so a block resumes on a green pass, a rerun, or an external deploy check — not only on red. Written by [`blocked-tick.md`](blocked-tick.md) when a block is flagged and cleared to empty when it resumes.
223
223
  - `REBASE_HELD` — `1` when the PR has spent its catch-up rebase budget (`catch_up_rebases` has reached [`maxCatchUpRebases`](../muggle-preferences/preference-gates/maxCatchUpRebases.md)), empty otherwise. While set, the loop raises no rebase wake and records no rebase as due however far the base moves: the tick's verdict refuses every rebase until the owner raises the cap, so a wake would only ask for work it turns down. Every arm recomputes it, so a raised cap lifts the hold at the next re-arm.
@@ -236,17 +236,17 @@ The work the watch last saw due on its PR, for the owning session's turn-end gat
236
236
 
237
237
  The file is **empty** when nothing is due. Written by [`../../scripts/pr-watch-arm.sh`](../../scripts/pr-watch-arm.sh) from the drain's read and rewritten by the loop whenever what is due changes; the session never writes it. The follow-through Stop gate ([`../../hooks/README.md`](../../hooks/README.md)) blocks the turn end while a non-empty record in a slot this session owns names work with no matching attempt in `last_seen.json`: a `REBASE_DUE` key with no `conflict_resolve_attempts` entry (under `autoRebase=always`, budget left), or a `CI_RED_HEAD` with no `ci_fix_attempts` entry. A later tick's `prs.json` naming another head supersedes the record.
238
238
 
239
- ## `watch-reported.env`
240
-
241
- What the slot's loops already reported to the session — `KEY="value"` lines like the watermark's, written only by the loop, and only on the poll that ends it with a report:
242
-
243
- - `REV` / `COM` — the newest review and comment ids that poll saw; each one at or below them was reported or already floored.
244
- - `THREADS` — the threads unresolved at that poll.
245
- - `CIRED` / `REBASED` — the red head and the rebase key a loop reported.
246
- - `BLOCKED_CIDIGEST` / `BLOCKED_FOR` — the CI digest a blocked-resume wake reported, and the watermark's `BLOCKED_CIDIGEST` it was reported under. It quiets only that block, so the same digest can resume a later one.
247
-
248
- A loop fires only on what sits above both this file and [`watch-watermark.env`](#watch-watermarkenv). Every report ends a loop, so no floor can live in its memory: without this file, anything the session saw and chose not to act on — a comment needing no reply, a red check under `HOLD WATCH` — would end every fresh loop on its first poll. Read it as the watermark is read, never by `source`.
249
-
239
+ ## `watch-reported.env`
240
+
241
+ What the slot's loops already reported to the session — `KEY="value"` lines like the watermark's, written only by the loop, and only on the poll that ends it with a report:
242
+
243
+ - `REV` / `COM` — the newest review and comment ids that poll saw; each one at or below them was reported or already floored.
244
+ - `THREADS` — the threads unresolved at that poll.
245
+ - `CIRED` / `REBASED` — the red key and the rebase key a loop reported.
246
+ - `BLOCKED_CIDIGEST` / `BLOCKED_FOR` — the CI digest a blocked-resume wake reported, and the watermark's `BLOCKED_CIDIGEST` it was reported under. It quiets only that block, so the same digest can resume a later one.
247
+
248
+ A loop fires only on what sits above both this file and [`watch-watermark.env`](#watch-watermarkenv). Every report ends a loop, so no floor can live in its memory: without this file, anything the session saw and chose not to act on — a comment needing no reply, a red check under `HOLD WATCH` — would end every fresh loop on its first poll. Read it as the watermark is read, never by `source`.
249
+
250
250
  ## `watch.pid`
251
251
 
252
252
  A single line: the process id of the watch loop that currently owns this slot. Written once by the loop itself on start (`echo "$$" > watch.pid`); read by two parties, never rewritten in place:
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "release": "5.26.0",
3
- "buildId": "run-254-1",
4
- "commitSha": "827f3558e259bf1905d25d297bdb875478a1a9e0",
5
- "buildTime": "2026-10-30T07:03:59Z",
3
+ "buildId": "run-256-1",
4
+ "commitSha": "5838b08d25aebf0dda3aee280e619ba96c5799bc",
5
+ "buildTime": "2026-10-30T20:31:46Z",
6
6
  "serviceName": "muggle-ai-works-mcp"
7
7
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@muggleai/works",
3
3
  "mcpName": "io.github.multiplex-ai/muggle",
4
- "version": "5.28.0",
4
+ "version": "5.29.0-staging.256",
5
5
  "description": "13 years of Microsoft hyperscale engineering, packaged as a harness for your AI coding agent.",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -64,7 +64,7 @@
64
64
  "muggleConfig": {
65
65
  "electronAppVersion": "1.17.0",
66
66
  "downloadBaseUrl": "https://github.com/multiplex-ai/muggle-ai-works/releases/download",
67
- "runtimeTargetDefault": "production",
67
+ "runtimeTargetDefault": "staging",
68
68
  "checksumsByStream": {
69
69
  "production": {
70
70
  "win32-x64": "03687e6c511f407875354954cb1b0f8e95d4ad0602174d76388a52d1aa1554e4",
@@ -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.28.0",
4
+ "version": "5.29.0-staging.256",
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.28.0",
7
+ "@muggleai/works@5.29.0-staging.256",
8
8
  "serve"
9
9
  ],
10
10
  "env": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muggle",
3
- "version": "5.28.0",
3
+ "version": "5.29.0-staging.256",
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.28.0",
5
+ "version": "5.29.0-staging.256",
6
6
  "author": {
7
7
  "name": "Muggle AI",
8
8
  "email": "support@muggle-ai.com"
package/plugin/.mcp.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "command": "npx",
5
5
  "args": [
6
6
  "-y",
7
- "@muggleai/works@5.28.0",
7
+ "@muggleai/works@5.29.0-staging.256",
8
8
  "serve"
9
9
  ]
10
10
  }
@@ -47,14 +47,14 @@ Pre-filters are bash builtins only (`guardrail-lib.sh`), so an irrelevant event
47
47
  | `PostToolUse` (Bash) | `guardrail-record-comment-replies.sh` | record | an unresolved-thread fetch named review threads still awaiting an answer (classified by the `<!-- muggle-do:bot -->` marker, never the author login), a threaded reply the provider **confirmed** answered comments in one, or a `MUGGLE_REPLY_SKIP` marker deferred them | — | claim each thread and cover the comments answered, in the per-PR ledger beside the muggle-do slot |
48
48
  | `PreToolUse` (Bash, `if` a command names `gh`/`glab`) | `guardrail-report-format.sh` | **enforce** | a `gh pr comment\|create\|edit` body — or a `gh api … issues/comments/<id>` PATCH editing one — reads like an E2E report but lacks the `build-pr-section` sentinel | — | **deny** — render via `muggle build-pr-section` instead |
49
49
  | `PreToolUse` (muggle local execute/replay) | `guardrail-classify-gate.sh` | **enforce** | `muggle-test` is the skill in play and the target test case has no `pre-execution-classification` this session | — | **deny** — run Step 6f first (it calls `muggle-remote-test-script-list`, which is where the run learns the case has never passed) or record a legitimate skip via `MUGGLE_CLASSIFY_SKIP`. Scoped to `muggle-test`, so the single-target skills that legitimately skip classification are untouched; fails open when the test case can't be resolved |
50
- | `PreToolUse` (muggle local execute/replay) | `guardrail-walkthrough-rerun.sh` | record | a browser run is about to start in a working tree (the tool call's `cwd`) whose branch has a PR, and that PR's walkthrough comment already shows a run or a skip | — | a detached job puts a "new E2E run in progress" banner, with its UTC start time, on top of the walkthrough the run will replace. The comment underneath is untouched; the walkthrough check stays in progress until the run's own walkthrough replaces the whole body, banner included, and the walkthrough gate counts the PR as still owing that walkthrough. A reserved placeholder or a comment already bannered is left alone |
51
- | `Stop` | `guardrail-e2e-gate.sh` | **enforce** | unit tests passed this session, no E2E ran yet, and no skip was recorded | `autoE2ETest` | **block** the turn until E2E runs via `muggle-test` or a `MUGGLE_E2E_SKIP` marker records a legitimate skip (full message once, one-line reminders after; releases after 3 blocks). Quiet while a background acceptance-tester is in flight |
50
+ | `PreToolUse` (muggle local execute/replay) | `guardrail-walkthrough-rerun.sh` | record | a browser run is about to start in a working tree (the tool call's `cwd`) whose branch has a PR with a walkthrough comment | — | a detached job marks the comment with the run in progress and its UTC start time: a feasibility-check placeholder becomes "E2E running — waiting for the report"; a comment that already shows a run or a skip gets a "new E2E run in progress" banner on top, left untouched underneath. The walkthrough check stays in progress until the run's own walkthrough replaces the whole body, and the walkthrough gate counts the PR as still owing that walkthrough. A comment already marked is left alone |
51
+ | `Stop` | `guardrail-e2e-gate.sh` | **enforce** | unit tests passed this session, no E2E ran yet, and no skip was recorded | `autoE2ETest` | **block** the turn until E2E runs via `muggle-test` or a `MUGGLE_E2E_SKIP` marker records a legitimate skip (full message once, one-line reminders after; releases after 3 blocks, leaving the PR's walkthrough comment unsettled). Quiet while a background acceptance-tester is in flight |
52
52
  | `Stop` | `guardrail-terminal-gate.sh` | **enforce** | a PR went terminal this session and the next-options offer hasn't run since — an AskUserQuestion, or, while the session owns an open PR's watch slot, a closing message that asks the user | — | **block** the turn until the post-merge handoff runs (full message once, one-line reminders after; releases after 3 blocks; nothing but the offer resets the counter) |
53
53
  | `Stop` | `guardrail-watch-gate.sh` | **enforce** | a PR opened this session that is still open and has no live watch — no session slot for it is terminal, stopped by its owner, or carries a `watch-heartbeat` touched within 15 minutes; a slot that is only seeded does not count | `autoWatchPR` | **block** the turn until the watch is armed per `muggle-pr-followup`'s `arm-watcher.md` or a `MUGGLE_WATCH_SKIP` marker records a legitimate skip (releases after 3 blocks). A PR clears once the session sees it go terminal or a provider lookup reports it merged or closed — what lets a PR whose slot post-merge cleanup deleted through. The lookup runs in a detached scan queued at turn end; a PR it has not reached yet still blocks, and one it could not reach fails open |
54
54
  | `Stop` | `guardrail-watch-follow-through-gate.sh` | **enforce** | a `muggle-pr-followup` watch this session owns (the slot's `owner.json`) recorded due work in `watch-due.env` — a branch behind or conflicting with its base, or a head whose checks settled red — and `last_seen.json` records no attempt for it: no `conflict_resolve_attempts` entry for the rebase key (enforced under `autoRebase=always` while catch-up budget is left), no `ci_fix_attempts` entry for the red head. An escalation entry with no attempt behind it counts for nothing, and re-arming the watch changes nothing | `autoRebase` | **block** the turn, naming each PR and its `/muggle-do rebase` or `fix ci` dispatch. No release after N blocks: only a recorded attempt, a newer head in the slot's `prs.json`, or the user's literal `HOLD WATCH #<pr-number>` clears it. The hold must come from a turn the user typed — never a tool result, a loaded skill, a hook's message, a task notification, a compaction summary, or the agent's own text — and is kept for the session once read. Reads local files only, never a provider: the pre-filter is a builtins glob for a non-empty `watch-due.env` whose `owner.json` names the session, so Node starts only when a slot has due work |
55
55
  | `Stop` | `guardrail-walkthrough-gate.sh` | **enforce** | an E2E acceptance run happened this session and a PR in play — opened this session or on the working branch — carries no walkthrough in its body or comments, or only one under a "new E2E run in progress" banner; or an E2E skip was accepted and a PR it covers still shows the empty placeholder instead of the skip's record | `postPRVisualWalkthrough` | **block** the turn until the walkthrough is posted via `muggle-pr-visual-walkthrough` or a `MUGGLE_WALKTHROUGH_SKIP` marker records a legitimate skip. For a PR an accepted E2E skip covers, the block hands over the foreground `settle-walkthrough-skipped <pr> <session>` command, which prints `{"settled":true}` once the PR reads back the skip, and re-queues the detached write; a PR the skip does not cover is a different change and is told to run its own E2E or declare its own skip from its tree (releases after 3 blocks; an unreachable PR fails open to not-owed). A skip accepted before its PR existed is written straight into the PR's slot when it opens for the same repo and branch. The provider lookups run in a detached scan, queued when the gate arms, when a PR opens, and at each turn end; the gate decides on the last scan for the session's current PRs, begun after the last observed post, and stays open until one lands. Each PR is checked for its own walkthrough: a post to one PR settles only that one, and opening another re-arms the check. A PR whose own background run is in flight is set aside |
56
56
  | `Stop` | `guardrail-stage-gate.sh` | **enforce** | a skill invoked this session declared `mandatoryStages` and one of those files was never opened | — | **block** the turn until the stages are read or a `MUGGLE_STAGE_SKIP` marker records a legitimate skip (releases after 3 blocks) |
57
- | `Stop` | `guardrail-debug-path-gate.sh` | **enforce** | a local execution returned a non-passing run this session and nothing routed it through `_shared/debug-failed-run.md` | — | **block** the turn until the run is diagnosed (its `*-failure-classified` emit or feedback naming it) or a `MUGGLE_DEBUG_SKIP: <runId> <reason>` marker clears that run (releases after 3 blocks). Quiet while a background acceptance-tester is in flight — its failed runs land in this session's state and it debugs them itself |
57
+ | `Stop` | `guardrail-debug-path-gate.sh` | **enforce** | a local execution returned a non-passing run this session and nothing routed it through `_shared/debug-failed-run.md` | — | **block** the turn until the run is diagnosed (its `*-failure-classified` emit or feedback naming it) or a `MUGGLE_DEBUG_SKIP: <runId> <reason>` marker clears that run (releases after 3 blocks, leaving the PR's walkthrough comment unsettled). Quiet while a background acceptance-tester is in flight — its failed runs land in this session's state and it debugs them itself |
58
58
  | `Stop` | `guardrail-comment-reply-gate.sh` | **enforce** | this session claimed a review thread and left one of its comments uncovered | — | **block** the turn until each comment gets its threaded reply per `do/per-comment-replies.md` or a `MUGGLE_REPLY_SKIP: <comment-id> <reason>` marker defers it (releases after 3 blocks). The claim is the signal, not a push, so a round that answers a question with no code change is caught too; a thread another session claimed never blocks this one |
59
59
  | `Stop` | `guardrail-capability-claim.sh` | **enforce** | the closing turn tells the user a login- or code-gated flow can't be tested, reached, or verified | — | **block** once, citing `_shared/identity-and-inbox.md`: the managed profile's live inbox, provisioned number, stored TOTP secret, credentials, and CAPTCHA solver clear exactly that blocker. Reads the claim out of the transcript, since a Stop payload carries no message text. Per-sentence matching, and a sentence naming a voice call, an OAuth-only provider, or a deployment with no number provisioned never fires — those limits are real. Nudges once per session, then stays quiet, so a misread costs one turn |
60
60
  | `UserPromptSubmit` | `guardrail-build-router.sh` | advise | a build/implement/fix request (first one this session), or a direct `/muggle-do` / `/mdo` forward invocation | `autoRouteBuildToMuggleDo` | route the work through `muggle-do` (build delegated to superpowers); a direct invocation gets no advice. Either arms the build follow-through Stop gate |
@@ -23,8 +23,14 @@ guardrail_read_payload
23
23
  firing_subcommands=()
24
24
  for gate_script in "$@"; do
25
25
  GUARDRAIL_SUBCOMMAND=""
26
+ unset -f guardrail_shell_verdict
26
27
  . "$guardrail_script_dir/$gate_script" || continue
27
28
  if [[ -n $GUARDRAIL_SUBCOMMAND ]] && guardrail_wants; then
29
+ # A gate that can answer in-shell blocks the turn here, before Node's cold start can outlast the hook's cap;
30
+ # the gates after it run on the turn end that follows.
31
+ if declare -F guardrail_shell_verdict >/dev/null && guardrail_shell_verdict; then
32
+ exit 0
33
+ fi
28
34
  firing_subcommands+=("$GUARDRAIL_SUBCOMMAND")
29
35
  fi
30
36
  done