@muggleai/works 5.14.0-staging.96 → 5.14.0-staging.98

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.
@@ -190,6 +190,12 @@
190
190
  "async": false,
191
191
  "timeout": 10
192
192
  },
193
+ {
194
+ "type": "command",
195
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-build-followthrough-gate.sh\"",
196
+ "async": false,
197
+ "timeout": 10
198
+ },
193
199
  {
194
200
  "type": "command",
195
201
  "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-walkthrough-gate.sh\"",
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+
4
+ # build-followthrough gate (Stop). When the front-door router took a
5
+ # build/implement/fix prompt this session but no PR was ever opened, block the
6
+ # turn end and point at /muggle-do (or the MUGGLE_BUILD_SKIP escape hatch). The
7
+ # router's offer is advisory and a session that finds the root cause can still
8
+ # end without shipping it — the fix then lives only in a transcript that dies
9
+ # with the session, and no other gate catches it: the watcher gate only fires on
10
+ # a PR that already exists.
11
+ #
12
+ # Mirrors guardrail-watch-gate.sh: synchronous (only a sync Stop hook can block
13
+ # the turn end), fires on EVERY turn end, and pre-filters in shell so Node spawns
14
+ # only when a build request was routed and no PR was handled. On the
15
+ # overwhelming majority of turns no build intent was detected, so the state file
16
+ # is absent or the flag is unset and we return {} in-shell, never paying Node
17
+ # cold-start. Degrades to {}.
18
+ payload="$(cat)"
19
+
20
+ raw_sid="$(printf '%s' "$payload" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')"
21
+ [ -n "$raw_sid" ] || raw_sid="unknown"
22
+ sid="$(printf '%s' "$raw_sid" | sed 's/[^A-Za-z0-9_-]/_/g')"
23
+
24
+ # Resolve the same home dir Node's os.homedir() uses. HOME is correct on
25
+ # macOS/Linux and on most Git Bash setups; fall back to converting USERPROFILE
26
+ # when HOME doesn't hold the state dir (some Windows shells point HOME elsewhere).
27
+ home="${HOME:-}"
28
+ if [ ! -d "$home/.muggle-ai" ] && command -v cygpath >/dev/null 2>&1 && [ -n "${USERPROFILE:-}" ]; then
29
+ home="$(cygpath -u "$USERPROFILE" 2>/dev/null || printf '%s' "$home")"
30
+ fi
31
+
32
+ # A non-empty prsHandled array spans lines, so the empty match reliably says no
33
+ # PR was opened. Skip Node unless a build request was routed and nothing shipped.
34
+ state_file="$home/.muggle-ai/guardrails/$sid.json"
35
+ if [ ! -f "$state_file" ] \
36
+ || ! grep -q '"buildIntentRouted": true' "$state_file" \
37
+ || ! grep -q '"prsHandled": \[\]' "$state_file" \
38
+ || grep -q '"buildSkipped": true' "$state_file"; then
39
+ printf '{}'
40
+ exit 0
41
+ fi
42
+
43
+ root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
44
+ printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" build-followthrough-gate 2>/dev/null || printf '{}'
@@ -13,6 +13,7 @@ var GH_PR_REOPENED_LINE = /\bReopened pull request [\w./-]*#(\d+)/;
13
13
  var PR_MONITOR_TERMINAL_LINE = /\bTERMINAL pr=(\d+): (MERGED|CLOSED)\b/;
14
14
  var MAX_PR_TERMINAL_BLOCKS = 3;
15
15
  var MAX_WATCH_BLOCKS = 3;
16
+ var MAX_BUILD_BLOCKS = 3;
16
17
  var MAX_WALKTHROUGH_BLOCKS = 3;
17
18
  var GH_LOOKUP_TIMEOUT_MS = 1e4;
18
19
  var MUGGLE_SKILL_EMIT_TOOL = /muggle-local-telemetry-skill-emit/i;
@@ -583,6 +584,25 @@ function watchGateDecision(state, untrackedPrUrls, maxBlocks = MAX_WATCH_BLOCKS)
583
584
  untracked: untrackedPrUrls
584
585
  };
585
586
  }
587
+
588
+ // src/guardrails/buildFollowthrough.ts
589
+ var BUILD_SKIP_MARKER = /^\s*echo\s+["']?MUGGLE_BUILD_SKIP\b/;
590
+ function isBuildSkipMarker(cmd) {
591
+ return BUILD_SKIP_MARKER.test(cmd);
592
+ }
593
+ function applyBuildSkip(state, skipped) {
594
+ if (!skipped || state.buildSkipped === true) return state;
595
+ return { ...state, buildSkipped: true };
596
+ }
597
+ function buildFollowthroughDecision(state, maxBlocks = MAX_BUILD_BLOCKS) {
598
+ const blockCount = state.buildBlockCount ?? 0;
599
+ const owed = state.buildIntentRouted === true && state.buildSkipped !== true && state.prsHandled.length === 0;
600
+ if (!owed) return { action: "none" /* None */, blockCount };
601
+ if (blockCount >= maxBlocks) {
602
+ return { action: "release" /* Release */, blockCount };
603
+ }
604
+ return { action: "block" /* Block */, blockCount: blockCount + 1 };
605
+ }
586
606
  var REPORT_SENTINEL = "muggle-pr-section";
587
607
  var PR_PROSE_CMD = /\bgh\s+pr\s+(comment|create|edit)\b/;
588
608
  var GH_API_CMD = /\bgh\s+api\b/;
@@ -1080,7 +1100,8 @@ function recordTests() {
1080
1100
  e2eSkipped: isE2ESkipMarker(cmd)
1081
1101
  });
1082
1102
  const withWatchSkip = applyWatchSkip(recorded, isWatchSkipMarker(cmd));
1083
- const withWalkthroughPost = applyWalkthroughPosted(withWatchSkip, detectWalkthroughPost(input));
1103
+ const withBuildSkip = applyBuildSkip(withWatchSkip, isBuildSkipMarker(cmd));
1104
+ const withWalkthroughPost = applyWalkthroughPosted(withBuildSkip, detectWalkthroughPost(input));
1084
1105
  const withWalkthroughSkip = applyWalkthroughSkip(withWalkthroughPost, isWalkthroughSkipMarker(cmd));
1085
1106
  const failedRunId = detectFailedRunId(input);
1086
1107
  const next = failedRunId ? applyFailedRun(withWalkthroughSkip, failedRunId) : withWalkthroughSkip;
@@ -1179,6 +1200,16 @@ function watchGate() {
1179
1200
  const reason = decision.blockCount === 1 ? `Do not end the turn yet. A PR was opened this session but no muggle-do session slot tracks it: ${prList}. Seed the slot and hand off per muggle-do Stage 8 \u2014 /muggle:muggle-pr-followup ${decision.untracked[0]} does both. Seeding is what matters: once a slot exists, reconcile arms it at the next session start and finalizes it when the PR goes terminal, so an unarmed slot is fine but no slot means nothing ever picks this PR up. If it genuinely should not be tracked (autoWatchPR=never, handed off elsewhere), tell the user why and run \`echo "MUGGLE_WATCH_SKIP: <reason>"\` \u2014 that records the skip and keeps this gate quiet for the rest of the session.` : `PR hand-off still owed for ${prList} (reminder ${decision.blockCount}/${MAX_WATCH_BLOCKS}): seed a slot via /muggle:muggle-pr-followup, or record a legitimate skip via \`echo "MUGGLE_WATCH_SKIP: <reason>"\`.`;
1180
1201
  return blockStop(reason, host);
1181
1202
  }
1203
+ function buildFollowthroughGate() {
1204
+ const state = readState(sessionId);
1205
+ const decision = buildFollowthroughDecision(state);
1206
+ if (decision.action === "release" /* Release */) return releaseGate("buildSkipped");
1207
+ if (decision.action === "none" /* None */) return "{}";
1208
+ state.buildBlockCount = decision.blockCount;
1209
+ writeState(state);
1210
+ const reason = decision.blockCount === 1 ? `Do not end the turn yet. This session took a build/implement/fix request but no PR was opened. A root cause written into the transcript ships nothing \u2014 once the session ends it is gone, and the watcher gate never fires because it only looks at PRs that exist. Carry the work to a PR via /muggle-do, which runs requirements \u2192 build \u2192 impact \u2192 unit tests \u2192 E2E \u2192 PR \u2192 watcher. If no PR is owed here (the user changed their mind, the fix landed in another repo, the answer was advice rather than a change), tell the user why and run \`echo "MUGGLE_BUILD_SKIP: <reason>"\` \u2014 that records the skip and keeps this gate quiet for the rest of the session.` : `Build request still unanswered \u2014 no PR opened (reminder ${decision.blockCount}/${MAX_BUILD_BLOCKS}): carry it to a PR via /muggle-do, or record a legitimate skip via \`echo "MUGGLE_BUILD_SKIP: <reason>"\`.`;
1211
+ return blockStop(reason, host);
1212
+ }
1182
1213
  function walkthroughGate() {
1183
1214
  const state = readState(sessionId);
1184
1215
  if (state.e2eRun !== true || state.walkthroughPosted === true || state.walkthroughSkipped === true) {
@@ -1302,6 +1333,7 @@ var handlers = {
1302
1333
  "e2e-gate": e2eGate,
1303
1334
  "terminal-gate": terminalGate,
1304
1335
  "watch-gate": watchGate,
1336
+ "build-followthrough-gate": buildFollowthroughGate,
1305
1337
  "walkthrough-gate": walkthroughGate,
1306
1338
  "record-comment-replies": recordCommentReplies,
1307
1339
  "comment-reply-gate": commentReplyGate,
@@ -85,3 +85,33 @@ watcher_pid_alive() {
85
85
  [ -n "$pid" ] || return 1
86
86
  kill -0 "$pid" 2>/dev/null
87
87
  }
88
+
89
+ # True when the slot's lease is held by a live loop that no longer belongs to the
90
+ # session arming now — the deaf-orphan case.
91
+ #
92
+ # A live PID proves the loop is running; it never proves anything is listening.
93
+ # On Windows a detached loop outlives the session that launched it, keeps
94
+ # polling, and keeps touching its heartbeat, while its stdout is the dead
95
+ # session's monitor pipe. Every liveness signal reads healthy and the events
96
+ # reach nobody. Because arming skipped on a live PID alone, that corpse blocked
97
+ # a live session from arming the PR for the whole lifetime cap — seven days
98
+ # since the default moved off six hours.
99
+ #
100
+ # Deliberately narrow. It answers only "is this lease held on behalf of some
101
+ # other session", which is decidable from state already on disk. It says nothing
102
+ # about a loop orphaned by its own session's monitor dying, where the owner still
103
+ # matches; that one needs a signal from the reader, which no file here carries.
104
+ #
105
+ # Conservative on every unknown — no watch.pid, a dead PID, no owner.json, or no
106
+ # current session id all answer false, so a slot is never reclaimed on a guess.
107
+ watcher_lease_is_foreign() {
108
+ local slot="$1" current_session="$2" owner_pid owner_session
109
+ [ -n "$current_session" ] || return 1
110
+ [ -f "${slot}/watch.pid" ] || return 1
111
+ owner_pid=$(tr -d ' \r\n' < "${slot}/watch.pid" 2>/dev/null)
112
+ watcher_pid_alive "$owner_pid" || return 1
113
+ [ -f "${slot}/owner.json" ] || return 1
114
+ owner_session=$(sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${slot}/owner.json" | head -1)
115
+ [ -n "$owner_session" ] || return 1
116
+ [ "$owner_session" != "$current_session" ]
117
+ }
@@ -14,7 +14,11 @@ Read the `DRAIN` lines it prints: they are the outstanding work the monitor will
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
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 monitor's first iteration surfaces it. Seed the CI floor (`CIRED`) from the same drain read: set it to the head SHA when the checks have **already settled red** at that read (no check pending, one or more in the `fail` bucket per [`../_shared/vcs/common/ci-rollup.md`](../_shared/vcs/common/ci-rollup.md)) — that red is what the drain just handled — and empty otherwise, so an escalated red head the drain already saw does not re-fire on the loop's first iteration. Seed the rebase floor (`REBASED`) the same way, from the drain's branch-standing read per [`../_shared/vcs/common/branch-standing.md`](../_shared/vcs/common/branch-standing.md): set it to the current `rebase_key` (`<head_sha>..<base_tip_sha>`) when the drain found the branch already behind or conflicting — that staleness is what the drain just handled — and empty otherwise, so a branch the drain already rebased or escalated does not re-fire on the loop's first iteration. Seed the blocked-CI floor (`BLOCKED_CIDIGEST`) to the blocked fingerprint's `ci_digest` when arming while `last_seen.blocked` is already set, and empty otherwise — empty is the not-blocked state, in which the loop's blocked-resume probe stays dormant.
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), a watcher already owns this slot — **skip arming, do not start a second**. This is what stops orphaned watchers from accumulating: the in-session monitor dying does not stop the OS loop it launched (on Windows a detached Git Bash loop keeps running and polling `gh` forever after the session ends), so checking a live task list is not enough — the PID lease is.
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 monitor dying does not stop the OS loop it launched (on Windows a detached Git Bash loop keeps running and polling `gh` forever after the session ends), so checking a live task list is not enough — the PID lease is.
18
+
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 monitor 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.
20
+
21
+ This is narrow on purpose. It reclaims only a lease held on behalf of *another* session, which is decidable from `owner.json` alone. A loop orphaned by its **own** session's monitor dying still reads as owned, and nothing on disk distinguishes it — that needs a signal from the reader, which no slot file carries. Treat an armed watcher as evidence a poller exists, never as proof a review will be seen.
18
22
 
19
23
  Otherwise **claim the slot for this session** before starting anything: write `owner.json` ([`state-schemas.md`](state-schemas.md#ownerjson)) with `session_id` from `$CLAUDE_CODE_SESSION_ID` and `claimed_at` now. Arming is what establishes ownership, so every arming point records it here rather than each caller remembering to. If `$CLAUDE_CODE_SESSION_ID` is unset, write no `owner.json` — an unidentifiable owner is worse than none, since [`reconcile.md`](reconcile.md) would read a bogus id as some other session's claim and could never recover the slot.
20
24
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "release": "5.13.0",
3
- "buildId": "run-96-1",
4
- "commitSha": "3a0a6a70d012faf3cc36cf1ac486fd5650c21da7",
5
- "buildTime": "2026-09-05T00:42:20Z",
3
+ "buildId": "run-98-1",
4
+ "commitSha": "e54fb7c620d882db7a9333d6f2e252fbd6c6c543",
5
+ "buildTime": "2026-09-05T01:07:04Z",
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.14.0-staging.96",
4
+ "version": "5.14.0-staging.98",
5
5
  "description": "Ship quality products with AI-powered E2E acceptance testing that validates your web app like a real user — from Claude Code and Cursor to PR.",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -190,6 +190,12 @@
190
190
  "async": false,
191
191
  "timeout": 10
192
192
  },
193
+ {
194
+ "type": "command",
195
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-build-followthrough-gate.sh\"",
196
+ "async": false,
197
+ "timeout": 10
198
+ },
193
199
  {
194
200
  "type": "command",
195
201
  "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-walkthrough-gate.sh\"",
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+
4
+ # build-followthrough gate (Stop). When the front-door router took a
5
+ # build/implement/fix prompt this session but no PR was ever opened, block the
6
+ # turn end and point at /muggle-do (or the MUGGLE_BUILD_SKIP escape hatch). The
7
+ # router's offer is advisory and a session that finds the root cause can still
8
+ # end without shipping it — the fix then lives only in a transcript that dies
9
+ # with the session, and no other gate catches it: the watcher gate only fires on
10
+ # a PR that already exists.
11
+ #
12
+ # Mirrors guardrail-watch-gate.sh: synchronous (only a sync Stop hook can block
13
+ # the turn end), fires on EVERY turn end, and pre-filters in shell so Node spawns
14
+ # only when a build request was routed and no PR was handled. On the
15
+ # overwhelming majority of turns no build intent was detected, so the state file
16
+ # is absent or the flag is unset and we return {} in-shell, never paying Node
17
+ # cold-start. Degrades to {}.
18
+ payload="$(cat)"
19
+
20
+ raw_sid="$(printf '%s' "$payload" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')"
21
+ [ -n "$raw_sid" ] || raw_sid="unknown"
22
+ sid="$(printf '%s' "$raw_sid" | sed 's/[^A-Za-z0-9_-]/_/g')"
23
+
24
+ # Resolve the same home dir Node's os.homedir() uses. HOME is correct on
25
+ # macOS/Linux and on most Git Bash setups; fall back to converting USERPROFILE
26
+ # when HOME doesn't hold the state dir (some Windows shells point HOME elsewhere).
27
+ home="${HOME:-}"
28
+ if [ ! -d "$home/.muggle-ai" ] && command -v cygpath >/dev/null 2>&1 && [ -n "${USERPROFILE:-}" ]; then
29
+ home="$(cygpath -u "$USERPROFILE" 2>/dev/null || printf '%s' "$home")"
30
+ fi
31
+
32
+ # A non-empty prsHandled array spans lines, so the empty match reliably says no
33
+ # PR was opened. Skip Node unless a build request was routed and nothing shipped.
34
+ state_file="$home/.muggle-ai/guardrails/$sid.json"
35
+ if [ ! -f "$state_file" ] \
36
+ || ! grep -q '"buildIntentRouted": true' "$state_file" \
37
+ || ! grep -q '"prsHandled": \[\]' "$state_file" \
38
+ || grep -q '"buildSkipped": true' "$state_file"; then
39
+ printf '{}'
40
+ exit 0
41
+ fi
42
+
43
+ root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
44
+ printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" build-followthrough-gate 2>/dev/null || printf '{}'
@@ -13,6 +13,7 @@ var GH_PR_REOPENED_LINE = /\bReopened pull request [\w./-]*#(\d+)/;
13
13
  var PR_MONITOR_TERMINAL_LINE = /\bTERMINAL pr=(\d+): (MERGED|CLOSED)\b/;
14
14
  var MAX_PR_TERMINAL_BLOCKS = 3;
15
15
  var MAX_WATCH_BLOCKS = 3;
16
+ var MAX_BUILD_BLOCKS = 3;
16
17
  var MAX_WALKTHROUGH_BLOCKS = 3;
17
18
  var GH_LOOKUP_TIMEOUT_MS = 1e4;
18
19
  var MUGGLE_SKILL_EMIT_TOOL = /muggle-local-telemetry-skill-emit/i;
@@ -583,6 +584,25 @@ function watchGateDecision(state, untrackedPrUrls, maxBlocks = MAX_WATCH_BLOCKS)
583
584
  untracked: untrackedPrUrls
584
585
  };
585
586
  }
587
+
588
+ // src/guardrails/buildFollowthrough.ts
589
+ var BUILD_SKIP_MARKER = /^\s*echo\s+["']?MUGGLE_BUILD_SKIP\b/;
590
+ function isBuildSkipMarker(cmd) {
591
+ return BUILD_SKIP_MARKER.test(cmd);
592
+ }
593
+ function applyBuildSkip(state, skipped) {
594
+ if (!skipped || state.buildSkipped === true) return state;
595
+ return { ...state, buildSkipped: true };
596
+ }
597
+ function buildFollowthroughDecision(state, maxBlocks = MAX_BUILD_BLOCKS) {
598
+ const blockCount = state.buildBlockCount ?? 0;
599
+ const owed = state.buildIntentRouted === true && state.buildSkipped !== true && state.prsHandled.length === 0;
600
+ if (!owed) return { action: "none" /* None */, blockCount };
601
+ if (blockCount >= maxBlocks) {
602
+ return { action: "release" /* Release */, blockCount };
603
+ }
604
+ return { action: "block" /* Block */, blockCount: blockCount + 1 };
605
+ }
586
606
  var REPORT_SENTINEL = "muggle-pr-section";
587
607
  var PR_PROSE_CMD = /\bgh\s+pr\s+(comment|create|edit)\b/;
588
608
  var GH_API_CMD = /\bgh\s+api\b/;
@@ -1080,7 +1100,8 @@ function recordTests() {
1080
1100
  e2eSkipped: isE2ESkipMarker(cmd)
1081
1101
  });
1082
1102
  const withWatchSkip = applyWatchSkip(recorded, isWatchSkipMarker(cmd));
1083
- const withWalkthroughPost = applyWalkthroughPosted(withWatchSkip, detectWalkthroughPost(input));
1103
+ const withBuildSkip = applyBuildSkip(withWatchSkip, isBuildSkipMarker(cmd));
1104
+ const withWalkthroughPost = applyWalkthroughPosted(withBuildSkip, detectWalkthroughPost(input));
1084
1105
  const withWalkthroughSkip = applyWalkthroughSkip(withWalkthroughPost, isWalkthroughSkipMarker(cmd));
1085
1106
  const failedRunId = detectFailedRunId(input);
1086
1107
  const next = failedRunId ? applyFailedRun(withWalkthroughSkip, failedRunId) : withWalkthroughSkip;
@@ -1179,6 +1200,16 @@ function watchGate() {
1179
1200
  const reason = decision.blockCount === 1 ? `Do not end the turn yet. A PR was opened this session but no muggle-do session slot tracks it: ${prList}. Seed the slot and hand off per muggle-do Stage 8 \u2014 /muggle:muggle-pr-followup ${decision.untracked[0]} does both. Seeding is what matters: once a slot exists, reconcile arms it at the next session start and finalizes it when the PR goes terminal, so an unarmed slot is fine but no slot means nothing ever picks this PR up. If it genuinely should not be tracked (autoWatchPR=never, handed off elsewhere), tell the user why and run \`echo "MUGGLE_WATCH_SKIP: <reason>"\` \u2014 that records the skip and keeps this gate quiet for the rest of the session.` : `PR hand-off still owed for ${prList} (reminder ${decision.blockCount}/${MAX_WATCH_BLOCKS}): seed a slot via /muggle:muggle-pr-followup, or record a legitimate skip via \`echo "MUGGLE_WATCH_SKIP: <reason>"\`.`;
1180
1201
  return blockStop(reason, host);
1181
1202
  }
1203
+ function buildFollowthroughGate() {
1204
+ const state = readState(sessionId);
1205
+ const decision = buildFollowthroughDecision(state);
1206
+ if (decision.action === "release" /* Release */) return releaseGate("buildSkipped");
1207
+ if (decision.action === "none" /* None */) return "{}";
1208
+ state.buildBlockCount = decision.blockCount;
1209
+ writeState(state);
1210
+ const reason = decision.blockCount === 1 ? `Do not end the turn yet. This session took a build/implement/fix request but no PR was opened. A root cause written into the transcript ships nothing \u2014 once the session ends it is gone, and the watcher gate never fires because it only looks at PRs that exist. Carry the work to a PR via /muggle-do, which runs requirements \u2192 build \u2192 impact \u2192 unit tests \u2192 E2E \u2192 PR \u2192 watcher. If no PR is owed here (the user changed their mind, the fix landed in another repo, the answer was advice rather than a change), tell the user why and run \`echo "MUGGLE_BUILD_SKIP: <reason>"\` \u2014 that records the skip and keeps this gate quiet for the rest of the session.` : `Build request still unanswered \u2014 no PR opened (reminder ${decision.blockCount}/${MAX_BUILD_BLOCKS}): carry it to a PR via /muggle-do, or record a legitimate skip via \`echo "MUGGLE_BUILD_SKIP: <reason>"\`.`;
1211
+ return blockStop(reason, host);
1212
+ }
1182
1213
  function walkthroughGate() {
1183
1214
  const state = readState(sessionId);
1184
1215
  if (state.e2eRun !== true || state.walkthroughPosted === true || state.walkthroughSkipped === true) {
@@ -1302,6 +1333,7 @@ var handlers = {
1302
1333
  "e2e-gate": e2eGate,
1303
1334
  "terminal-gate": terminalGate,
1304
1335
  "watch-gate": watchGate,
1336
+ "build-followthrough-gate": buildFollowthroughGate,
1305
1337
  "walkthrough-gate": walkthroughGate,
1306
1338
  "record-comment-replies": recordCommentReplies,
1307
1339
  "comment-reply-gate": commentReplyGate,
@@ -85,3 +85,33 @@ watcher_pid_alive() {
85
85
  [ -n "$pid" ] || return 1
86
86
  kill -0 "$pid" 2>/dev/null
87
87
  }
88
+
89
+ # True when the slot's lease is held by a live loop that no longer belongs to the
90
+ # session arming now — the deaf-orphan case.
91
+ #
92
+ # A live PID proves the loop is running; it never proves anything is listening.
93
+ # On Windows a detached loop outlives the session that launched it, keeps
94
+ # polling, and keeps touching its heartbeat, while its stdout is the dead
95
+ # session's monitor pipe. Every liveness signal reads healthy and the events
96
+ # reach nobody. Because arming skipped on a live PID alone, that corpse blocked
97
+ # a live session from arming the PR for the whole lifetime cap — seven days
98
+ # since the default moved off six hours.
99
+ #
100
+ # Deliberately narrow. It answers only "is this lease held on behalf of some
101
+ # other session", which is decidable from state already on disk. It says nothing
102
+ # about a loop orphaned by its own session's monitor dying, where the owner still
103
+ # matches; that one needs a signal from the reader, which no file here carries.
104
+ #
105
+ # Conservative on every unknown — no watch.pid, a dead PID, no owner.json, or no
106
+ # current session id all answer false, so a slot is never reclaimed on a guess.
107
+ watcher_lease_is_foreign() {
108
+ local slot="$1" current_session="$2" owner_pid owner_session
109
+ [ -n "$current_session" ] || return 1
110
+ [ -f "${slot}/watch.pid" ] || return 1
111
+ owner_pid=$(tr -d ' \r\n' < "${slot}/watch.pid" 2>/dev/null)
112
+ watcher_pid_alive "$owner_pid" || return 1
113
+ [ -f "${slot}/owner.json" ] || return 1
114
+ owner_session=$(sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${slot}/owner.json" | head -1)
115
+ [ -n "$owner_session" ] || return 1
116
+ [ "$owner_session" != "$current_session" ]
117
+ }
@@ -14,7 +14,11 @@ Read the `DRAIN` lines it prints: they are the outstanding work the monitor will
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
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 monitor's first iteration surfaces it. Seed the CI floor (`CIRED`) from the same drain read: set it to the head SHA when the checks have **already settled red** at that read (no check pending, one or more in the `fail` bucket per [`../_shared/vcs/common/ci-rollup.md`](../_shared/vcs/common/ci-rollup.md)) — that red is what the drain just handled — and empty otherwise, so an escalated red head the drain already saw does not re-fire on the loop's first iteration. Seed the rebase floor (`REBASED`) the same way, from the drain's branch-standing read per [`../_shared/vcs/common/branch-standing.md`](../_shared/vcs/common/branch-standing.md): set it to the current `rebase_key` (`<head_sha>..<base_tip_sha>`) when the drain found the branch already behind or conflicting — that staleness is what the drain just handled — and empty otherwise, so a branch the drain already rebased or escalated does not re-fire on the loop's first iteration. Seed the blocked-CI floor (`BLOCKED_CIDIGEST`) to the blocked fingerprint's `ci_digest` when arming while `last_seen.blocked` is already set, and empty otherwise — empty is the not-blocked state, in which the loop's blocked-resume probe stays dormant.
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), a watcher already owns this slot — **skip arming, do not start a second**. This is what stops orphaned watchers from accumulating: the in-session monitor dying does not stop the OS loop it launched (on Windows a detached Git Bash loop keeps running and polling `gh` forever after the session ends), so checking a live task list is not enough — the PID lease is.
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 monitor dying does not stop the OS loop it launched (on Windows a detached Git Bash loop keeps running and polling `gh` forever after the session ends), so checking a live task list is not enough — the PID lease is.
18
+
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 monitor 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.
20
+
21
+ This is narrow on purpose. It reclaims only a lease held on behalf of *another* session, which is decidable from `owner.json` alone. A loop orphaned by its **own** session's monitor dying still reads as owned, and nothing on disk distinguishes it — that needs a signal from the reader, which no slot file carries. Treat an armed watcher as evidence a poller exists, never as proof a review will be seen.
18
22
 
19
23
  Otherwise **claim the slot for this session** before starting anything: write `owner.json` ([`state-schemas.md`](state-schemas.md#ownerjson)) with `session_id` from `$CLAUDE_CODE_SESSION_ID` and `claimed_at` now. Arming is what establishes ownership, so every arming point records it here rather than each caller remembering to. If `$CLAUDE_CODE_SESSION_ID` is unset, write no `owner.json` — an unidentifiable owner is worse than none, since [`reconcile.md`](reconcile.md) would read a bogus id as some other session's claim and could never recover the slot.
20
24