@muggleai/works 5.8.0 → 5.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  2. package/dist/plugin/.cursor-plugin/plugin.json +1 -1
  3. package/dist/plugin/agents/test-prepare-runner.md +47 -0
  4. package/dist/plugin/agents/visual-walkthrough-builder.md +58 -0
  5. package/dist/plugin/hooks/hooks.json +6 -0
  6. package/dist/plugin/scripts/guardrail-watch-gate.sh +40 -0
  7. package/dist/plugin/scripts/guardrails.mjs +84 -2
  8. package/dist/plugin/scripts/pr-watch-guards.sh +17 -0
  9. package/dist/plugin/skills/CLAUDE.md +39 -36
  10. package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +5 -3
  11. package/dist/plugin/skills/muggle-pr-followup/blocked-tick.md +2 -2
  12. package/dist/plugin/skills/muggle-pr-followup/cancel-cron.md +5 -3
  13. package/dist/plugin/skills/muggle-pr-followup/contract.md +8 -2
  14. package/dist/plugin/skills/muggle-pr-followup/evals/evals.json +30 -3
  15. package/dist/plugin/skills/muggle-pr-followup/finalize.md +1 -1
  16. package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +3 -1
  17. package/dist/plugin/skills/muggle-pr-visual-walkthrough/SKILL.md +41 -236
  18. package/dist/plugin/skills/muggle-test-prepare/SKILL.md +130 -129
  19. package/dist/release-manifest.json +4 -4
  20. package/package.json +7 -6
  21. package/plugin/.claude-plugin/plugin.json +1 -1
  22. package/plugin/.cursor-plugin/plugin.json +1 -1
  23. package/plugin/agents/test-prepare-runner.md +47 -0
  24. package/plugin/agents/visual-walkthrough-builder.md +58 -0
  25. package/plugin/hooks/hooks.json +6 -0
  26. package/plugin/scripts/guardrail-watch-gate.sh +40 -0
  27. package/plugin/scripts/guardrails.mjs +84 -2
  28. package/plugin/scripts/pr-watch-guards.sh +17 -0
  29. package/plugin/skills/CLAUDE.md +39 -36
  30. package/plugin/skills/muggle-pr-followup/arm-watcher.md +5 -3
  31. package/plugin/skills/muggle-pr-followup/blocked-tick.md +2 -2
  32. package/plugin/skills/muggle-pr-followup/cancel-cron.md +5 -3
  33. package/plugin/skills/muggle-pr-followup/contract.md +8 -2
  34. package/plugin/skills/muggle-pr-followup/evals/evals.json +30 -3
  35. package/plugin/skills/muggle-pr-followup/finalize.md +1 -1
  36. package/plugin/skills/muggle-pr-followup/state-schemas.md +3 -1
  37. package/plugin/skills/muggle-pr-visual-walkthrough/SKILL.md +41 -236
  38. package/plugin/skills/muggle-test-prepare/SKILL.md +130 -129
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "muggle",
3
3
  "description": "Run real-browser end-to-end (E2E) acceptance tests on your web app from any AI coding agent. Generate test scripts from plain English, replay them on localhost, capture screenshots, and validate user flows like signup, checkout, and dashboards. Works across Claude Code, Cursor, Codex, and Windsurf.",
4
- "version": "5.8.0",
4
+ "version": "5.9.0",
5
5
  "author": {
6
6
  "name": "Muggle AI",
7
7
  "email": "support@muggle-ai.com"
@@ -2,7 +2,7 @@
2
2
  "name": "muggle",
3
3
  "displayName": "Muggle AI",
4
4
  "description": "Ship quality products with AI-powered end-to-end (E2E) acceptance testing that validates your web app like a real user — from Claude Code and Cursor to PR.",
5
- "version": "5.8.0",
5
+ "version": "5.9.0",
6
6
  "author": {
7
7
  "name": "Muggle AI",
8
8
  "email": "support@muggle-ai.com"
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: test-prepare-runner
3
+ description: "Executes a fully-resolved Muggle Test prepare plan — detects what's listening, verifies env files, fresh-installs stale deps, starts approved services, smoke-tests, and returns the readiness table. Dispatched by the muggle-test-prepare skill after all user decisions are resolved; carries the opus pin so execution never runs below its reliability floor on a cheaper session model."
4
+ model: opus
5
+ ---
6
+
7
+ # Test Prepare Runner
8
+
9
+ You bring a local dev stack to verified readiness for E2E testing: detect what's already listening, verify env files, install stale dependencies, start approved services, smoke-test them, and report the readiness table. Every decision — which services, their directories, start commands and approvals, scope, exclusions, the dev-server URL — arrives resolved in the dispatch prompt from the muggle-test-prepare skill. You have no channel to the user: when the plan is missing a decision you need, return one `needs-input:` line naming it and stop — the dispatching skill resolves it (asking the user if needed) and re-dispatches.
10
+
11
+ ## Input contract
12
+
13
+ The dispatch prompt carries the resolved prepare plan:
14
+
15
+ - `services[]` — name, dir, start command, expected port, `external` flag, approval already granted.
16
+ - `testingScope` and `excludedServices[]` (with reasons).
17
+ - The recorded dev-server URL (from the `autoSelectLocalHost` resolution) — never invent or default a host/port; a framework default like `:3000` is not a fallback.
18
+ - Resolved gate values the stages read (`autoRebase` outcome already applied or explicitly skipped upstream).
19
+
20
+ ## Stages
21
+
22
+ Run these stage files from the skill, in order, exactly as written — they are the single source of truth for each stage's procedure:
23
+
24
+ 1. [`../skills/muggle-test-prepare/steps/check-running.md`](../skills/muggle-test-prepare/steps/check-running.md)
25
+ 2. [`../skills/muggle-test-prepare/steps/env-file.md`](../skills/muggle-test-prepare/steps/env-file.md)
26
+ 3. [`../skills/muggle-test-prepare/steps/start-commands.md`](../skills/muggle-test-prepare/steps/start-commands.md)
27
+ 4. [`../skills/muggle-test-prepare/steps/fresh-install.md`](../skills/muggle-test-prepare/steps/fresh-install.md)
28
+ 5. [`../skills/muggle-test-prepare/steps/start-services.md`](../skills/muggle-test-prepare/steps/start-services.md)
29
+ 6. [`../skills/muggle-test-prepare/steps/smoke-test.md`](../skills/muggle-test-prepare/steps/smoke-test.md)
30
+ 7. [`../skills/muggle-test-prepare/steps/readiness-report.md`](../skills/muggle-test-prepare/steps/readiness-report.md)
31
+
32
+ Where a stage file offers the user a choice, take the branch the plan resolved; where the plan doesn't cover it, return `needs-input:` — never guess, never start anything unapproved.
33
+
34
+ ## PID tracking
35
+
36
+ Track every launched process in `/tmp/muggle-test-prepare.json` exactly per the skill's schema (`session_started`, `testing_scope`, `excluded_services`, `services[]` with pid/port/log). Processes the user started independently stay `external: true` and are never killed. Prune dead PIDs silently.
37
+
38
+ ## Output contract
39
+
40
+ Return the readiness-report table verbatim as your report, prefixed by one line: `READY` (all services green), `DEGRADED: <which service, why>` (something is up but failed its smoke test after the clean-restart loop), or `needs-input: <decision>`. The dispatcher relays this to its caller — other skills gate on it, so a wrong `READY` is expensive; when in doubt between READY and DEGRADED, pick DEGRADED and say why. `needs-input:` is only for a decision the plan failed to resolve (a missing URL, an unapproved start, an unknown directory) — a service that stays broken after the loop is `DEGRADED` with the diagnosis, never `needs-input:`, even when no further automated fix exists. Repairing the app's own source code is out of scope entirely: a source-level bug surfaced by the smoke test is a `DEGRADED` diagnosis to report, not a decision to escalate.
41
+
42
+ ## Guardrails
43
+
44
+ - Privacy boundary as the skill defines it: file contents only inside directories the plan names; never traverse upward past one level.
45
+ - Port-listening is never enough — smoke-test (HTTP + body sniff + log tail) is mandatory before the report.
46
+ - Clean Restart is the first fix in the smoke-test loop; fresh-install notifies, doesn't ask.
47
+ - Never leave an orphan process untracked; never kill an `external` one.
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: visual-walkthrough-builder
3
+ description: "Renders the Muggle Test E2E visual walkthrough for a PR — assembles the E2eReport, runs `muggle build-pr-section`, and either posts to the PR (Mode A) or returns the rendered block to the dispatcher (Modes B/C). Dispatched by the muggle-pr-visual-walkthrough skill; carries its sonnet pin so the render runs on sonnet regardless of the session model."
4
+ model: sonnet
5
+ ---
6
+
7
+ # Visual Walkthrough Builder
8
+
9
+ You render and (in Mode A) post the Muggle Test E2E visual walkthrough. The dispatching skill has already resolved the mode, the PR, and user consent. You have no channel to the user: if an input you need is missing, return a single `needs-input:` line naming it and stop — the dispatching skill resolves it and re-dispatches.
10
+
11
+ ## Input contract
12
+
13
+ The dispatch prompt carries:
14
+
15
+ - `mode` — `post` (Mode A), `render-for-new-pr` (Mode B), or `embed` (Mode C).
16
+ - `prNumber` + repo — Mode A only, already verified to exist.
17
+ - The `E2eReport` JSON inline, **or** the run identifiers (`projectId`, per-test `runId`/`testCaseId` list) to assemble it from.
18
+
19
+ When assembling from identifiers, follow [`../skills/muggle-pr-visual-walkthrough/e2e-report-assembly.md`](../skills/muggle-pr-visual-walkthrough/e2e-report-assembly.md). The `E2eReport` schema, required fields, and the inconclusive rule live there and in the CLI's Zod schema (`src/cli/pr-section/types.ts`) — a run that couldn't produce pass/fail is `inconclusive` with a `reason`, never dropped.
20
+
21
+ ## Render
22
+
23
+ Pipe the report to the CLI; it writes `{"body": "...", "comment": "..." | null}`:
24
+
25
+ ```bash
26
+ echo "$REPORT_JSON" | muggle build-pr-section > /tmp/muggle-pr-section.json
27
+ ```
28
+
29
+ - Non-zero exit → surface the CLI's stderr verbatim; do not swallow or retry blindly.
30
+ - `comment` is non-null only in the overflow case; the CLI owns fit-vs-overflow.
31
+
32
+ ## Deliver
33
+
34
+ **Mode A (`post`)** — post `body` as a PR comment, then `comment` only if non-null. Append the Muggle Works signature to each posted body per [`../skills/_shared/vcs/post-signature.md`](../skills/_shared/vcs/post-signature.md) — this post is the walkthrough's own, so the command it names is `/muggle-pr-visual-walkthrough`:
35
+
36
+ ```bash
37
+ sig='🤖 _Posted by `/muggle-pr-visual-walkthrough` · [Muggle Works](https://github.com/multiplex-ai/muggle-ai-works)_'
38
+ { jq -r '.body' /tmp/muggle-pr-section.json; printf '\n\n%s\n' "$sig"; } | gh pr comment <prNumber> --body-file -
39
+ { jq -r '.comment' /tmp/muggle-pr-section.json; printf '\n\n%s\n' "$sig"; } | gh pr comment <prNumber> --body-file - # skip when null
40
+ ```
41
+
42
+ Report back: PR URL + whether an overflow comment was posted.
43
+
44
+ **Modes B/C (`render-for-new-pr` / `embed`)** — do not post, do not touch `gh`. Return the CLI output verbatim as your report:
45
+
46
+ ```
47
+ body:
48
+ <body>
49
+ comment:
50
+ <comment or null>
51
+ ```
52
+
53
+ ## Guardrails
54
+
55
+ - Never hand-write or modify the walkthrough markdown — the CLI is the single source of truth. No custom tables, no added "Verdict" lines, no `Tested on:`/`Project:` footers; the CLI computes the verdict and emits per-test dashboard links.
56
+ - Never invent report fields — missing `projectId`, `viewUrl`, or `screenshotUrl` → `needs-input:`, never a placeholder.
57
+ - Never post the overflow comment when `comment` is null.
58
+ - Never create a PR, never choose a mode — both belong to the dispatcher.
@@ -110,6 +110,12 @@
110
110
  "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-terminal-gate.sh\"",
111
111
  "async": false,
112
112
  "timeout": 10
113
+ },
114
+ {
115
+ "type": "command",
116
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-watch-gate.sh\"",
117
+ "async": false,
118
+ "timeout": 10
113
119
  }
114
120
  ]
115
121
  }
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+
4
+ # watcher-arm gate (Stop). When a PR was opened this session but no watcher was
5
+ # armed for it, block the turn end and point at the Stage-8 hand-off (or the
6
+ # MUGGLE_WATCH_SKIP escape hatch). Mirrors guardrail-e2e-gate.sh: synchronous
7
+ # (only a sync Stop hook can block the turn end), fires on EVERY turn end, and
8
+ # pre-filters in shell so Node spawns only when a PR was opened this session and
9
+ # no skip was recorded. The real owed-vs-armed decision (a sessions/*/ slot scan)
10
+ # runs in guardrails.mjs. On the overwhelming majority of turns no PR was opened,
11
+ # so the state file is absent or prsHandled is empty and we return {} in-shell,
12
+ # never paying Node cold-start. Degrades to {}.
13
+ payload="$(cat)"
14
+
15
+ raw_sid="$(printf '%s' "$payload" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')"
16
+ [ -n "$raw_sid" ] || raw_sid="unknown"
17
+ sid="$(printf '%s' "$raw_sid" | sed 's/[^A-Za-z0-9_-]/_/g')"
18
+
19
+ # Resolve the same home dir Node's os.homedir() uses. HOME is correct on
20
+ # macOS/Linux and on most Git Bash setups; fall back to converting USERPROFILE
21
+ # when HOME doesn't hold the state dir (some Windows shells point HOME elsewhere).
22
+ home="${HOME:-}"
23
+ if [ ! -d "$home/.muggle-ai" ] && command -v cygpath >/dev/null 2>&1 && [ -n "${USERPROFILE:-}" ]; then
24
+ home="$(cygpath -u "$USERPROFILE" 2>/dev/null || printf '%s' "$home")"
25
+ fi
26
+
27
+ # Empty array serializes as `"prsHandled": []` (one line); a non-empty array spans
28
+ # lines, so the empty match reliably tells them apart. Skip Node unless a PR was
29
+ # opened this session and no watcher skip was recorded.
30
+ state_file="$home/.muggle-ai/guardrails/$sid.json"
31
+ if [ ! -f "$state_file" ] \
32
+ || ! grep -q '"prsHandled"' "$state_file" \
33
+ || grep -q '"prsHandled": \[\]' "$state_file" \
34
+ || grep -q '"watchSkipped": true' "$state_file"; then
35
+ printf '{}'
36
+ exit 0
37
+ fi
38
+
39
+ root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
40
+ printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" watch-gate 2>/dev/null || printf '{}'
@@ -1,4 +1,4 @@
1
- import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
1
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'fs';
2
2
  import { isAbsolute, resolve, join } from 'path';
3
3
  import { homedir } from 'os';
4
4
 
@@ -45,6 +45,7 @@ var GH_PR_MERGED_LINE = /\b(?:Merged|Squashed and merged|Rebased and merged) pul
45
45
  var GH_PR_CLOSED_LINE = /\bClosed pull request [\w./-]*#(\d+)/;
46
46
  var PR_MONITOR_TERMINAL_LINE = /\bTERMINAL pr=(\d+): (MERGED|CLOSED)\b/;
47
47
  var MAX_PR_TERMINAL_BLOCKS = 3;
48
+ var MAX_WATCH_BLOCKS = 3;
48
49
  var MUGGLE_SKILL_EMIT_TOOL = /muggle-local-telemetry-skill-emit/i;
49
50
  var MUGGLE_TEST_SKILL_NAME = "muggle-test";
50
51
 
@@ -143,6 +144,72 @@ function e2eGateDecision(state, maxBlocks = MAX_E2E_BLOCKS) {
143
144
  if (blockCount >= maxBlocks) return { action: "release" /* Release */, blockCount };
144
145
  return { action: "block" /* Block */, blockCount: blockCount + 1 };
145
146
  }
147
+ var HEARTBEAT_FRESH_MS = 15 * 60 * 1e3;
148
+ var WATCH_SKIP_MARKER = /^\s*echo\s+["']?MUGGLE_WATCH_SKIP\b/;
149
+ function isWatchSkipMarker(cmd) {
150
+ return WATCH_SKIP_MARKER.test(cmd);
151
+ }
152
+ function applyWatchSkip(state, skipped) {
153
+ if (!skipped || state.watchSkipped === true) return state;
154
+ return { ...state, watchSkipped: true };
155
+ }
156
+ function slotHasArmedWatcher(slotDir) {
157
+ if (existsSync(join(slotDir, "result.md"))) return true;
158
+ const pidFile = join(slotDir, "watch.pid");
159
+ if (existsSync(pidFile)) {
160
+ const pid = Number.parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
161
+ if (Number.isInteger(pid) && pid > 0) {
162
+ try {
163
+ process.kill(pid, 0);
164
+ return true;
165
+ } catch (err) {
166
+ if (err.code === "EPERM") return true;
167
+ }
168
+ }
169
+ }
170
+ const beat = join(slotDir, "watch-heartbeat");
171
+ if (existsSync(beat)) {
172
+ try {
173
+ if (Date.now() - statSync(beat).mtimeMs < HEARTBEAT_FRESH_MS) return true;
174
+ } catch {
175
+ return false;
176
+ }
177
+ }
178
+ return false;
179
+ }
180
+ function findUnarmedHandledPrs(handledUrls, sessionsDirOverride) {
181
+ if (handledUrls.length === 0) return [];
182
+ const sessionsDir = join(homedir(), ".muggle-ai", "muggle-do", "sessions");
183
+ if (!existsSync(sessionsDir)) return [...handledUrls];
184
+ const watchedUrls = /* @__PURE__ */ new Set();
185
+ for (const slug of readdirSync(sessionsDir)) {
186
+ const slotDir = join(sessionsDir, slug);
187
+ const prsFile = join(slotDir, "prs.json");
188
+ if (!existsSync(prsFile)) continue;
189
+ let slotUrl;
190
+ try {
191
+ const parsed = JSON.parse(readFileSync(prsFile, "utf-8"));
192
+ const entry = Array.isArray(parsed) ? parsed[0] : parsed;
193
+ slotUrl = entry?.url;
194
+ } catch {
195
+ continue;
196
+ }
197
+ if (slotUrl && handledUrls.includes(slotUrl) && slotHasArmedWatcher(slotDir)) {
198
+ watchedUrls.add(slotUrl);
199
+ }
200
+ }
201
+ return handledUrls.filter((url) => !watchedUrls.has(url));
202
+ }
203
+ function watchGateDecision(state, owedUrls, maxBlocks = MAX_WATCH_BLOCKS) {
204
+ const blockCount = state.watchBlockCount ?? 0;
205
+ if (state.watchSkipped === true || owedUrls.length === 0) {
206
+ return { action: "none" /* None */, blockCount, owed: owedUrls };
207
+ }
208
+ if (blockCount >= maxBlocks) {
209
+ return { action: "release" /* Release */, blockCount, owed: owedUrls };
210
+ }
211
+ return { action: "block" /* Block */, blockCount: blockCount + 1, owed: owedUrls };
212
+ }
146
213
 
147
214
  // src/guardrails/detectBuildIntent.ts
148
215
  var BUILD = /\b(implement|build|add|create|write|fix|refactor|wire up|hook up|make (a|the|it)|change the)\b/i;
@@ -285,11 +352,12 @@ function terminalGate() {
285
352
  function recordTests() {
286
353
  const cmd = input.tool_input?.command ?? "";
287
354
  const state = readState(sessionId);
288
- const next = applyRecordedRun(state, {
355
+ const recorded = applyRecordedRun(state, {
289
356
  unitTestPassed: isTestCommand(cmd) && testsPassed(input),
290
357
  e2eRan: isE2ERun(input),
291
358
  e2eSkipped: isE2ESkipMarker(cmd)
292
359
  });
360
+ const next = applyWatchSkip(recorded, isWatchSkipMarker(cmd));
293
361
  if (next !== state) writeState(next);
294
362
  return "{}";
295
363
  }
@@ -302,6 +370,19 @@ function e2eGate() {
302
370
  const reason = decision.blockCount === 1 ? `Do not end the turn yet. Unit tests passed this session but no E2E acceptance run has happened. Per the autoE2ETest preference (default: always), run change-driven E2E now via /muggle:muggle-test, then finish. If E2E genuinely cannot run here (no app to drive, services down, no PR), tell the user why and run \`echo "MUGGLE_E2E_SKIP: <reason>"\` \u2014 that records the skip and keeps this gate quiet for the rest of the session.` : `E2E acceptance run still owed (reminder ${decision.blockCount}/${MAX_E2E_BLOCKS}): run /muggle:muggle-test, or record a legitimate skip via \`echo "MUGGLE_E2E_SKIP: <reason>"\`.`;
303
371
  return blockStop(reason, host);
304
372
  }
373
+ function watchGate() {
374
+ const state = readState(sessionId);
375
+ const owed = findUnarmedHandledPrs(state.prsHandled);
376
+ const decision = watchGateDecision(state, owed);
377
+ if (decision.action === "none" /* None */ || decision.action === "release" /* Release */) {
378
+ return "{}";
379
+ }
380
+ state.watchBlockCount = decision.blockCount;
381
+ writeState(state);
382
+ const prList = decision.owed.join(", ");
383
+ const reason = decision.blockCount === 1 ? `Do not end the turn yet. A PR was opened this session but has no armed watcher: ${prList}. muggle-do Stage 8 seeds the watcher slot and arms one watcher per opened PR \u2014 arm it now with /muggle:muggle-pr-followup ${decision.owed[0]} (or reconcile). If this PR should NOT be watched (autoWatchPR=never, a manually-opened PR, one handed off elsewhere, or already merged/closed), 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.` : `Watcher hand-off still owed for ${prList} (reminder ${decision.blockCount}/${MAX_WATCH_BLOCKS}): arm via /muggle:muggle-pr-followup, or record a legitimate skip via \`echo "MUGGLE_WATCH_SKIP: <reason>"\`.`;
384
+ return blockStop(reason, host);
385
+ }
305
386
  function reportGate() {
306
387
  const result = evaluateReportPost(input);
307
388
  if (!result.deny || !result.reason) return "{}";
@@ -323,6 +404,7 @@ var handlers = {
323
404
  "record-tests": recordTests,
324
405
  "e2e-gate": e2eGate,
325
406
  "terminal-gate": terminalGate,
407
+ "watch-gate": watchGate,
326
408
  "report-gate": reportGate,
327
409
  "build-router": buildRouter
328
410
  };
@@ -20,6 +20,23 @@
20
20
 
21
21
  MUGGLE_PR_WATCH_MAX_LIFETIME="${MUGGLE_PR_WATCH_MAX_LIFETIME:-21600}"
22
22
  MUGGLE_PR_WATCH_POLL_INTERVAL="${MUGGLE_PR_WATCH_POLL_INTERVAL:-60}"
23
+ # Consecutive failed fetches before a loop gives up. A watcher must ride through
24
+ # a GitHub / network outage — an observed drop lasted ~8 minutes — not die and
25
+ # leave the PR unwatched until the next session start. With the backoff below, 60
26
+ # spans hours; only a genuinely persistent unreachable slot (deleted repo,
27
+ # revoked auth) exhausts it.
28
+ MUGGLE_PR_WATCH_MAX_FETCH_FAILURES="${MUGGLE_PR_WATCH_MAX_FETCH_FAILURES:-60}"
29
+
30
+ # Seconds to sleep after `fails` consecutive failed fetches: the poll interval,
31
+ # then a linear back-off capped at 5 minutes so a sustained outage is retried
32
+ # calmly rather than hammered every 60s.
33
+ watcher_fetch_backoff() {
34
+ local fails="$1" base="${MUGGLE_PR_WATCH_POLL_INTERVAL}" step secs
35
+ step=$((fails * 30))
36
+ secs=$((base + step))
37
+ [ "$secs" -gt 300 ] && secs=300
38
+ echo "$secs"
39
+ }
23
40
 
24
41
  # True when watch.pid exists and names a PID other than this loop's — a newer arm
25
42
  # has taken ownership of the slot. Absent/empty watch.pid is not superseded: a
@@ -1,36 +1,39 @@
1
- # Skill authoring conventions
2
-
3
- Rules for every skill under `plugin/skills/`. Read before adding or editing one.
4
-
5
- ## One-way dependencies — no reverse references
6
-
7
- Skill cross-references form a one-way graph. If any file in skill **A** references skill **B** — a markdown link to B's files, or a documented dependency on B's internals — then **no file in B may reference A back**. Reference *downward*, toward the more general / lower-level skill you depend on; pass anything the other direction needs as input, not as a link.
8
-
9
- A reverse reference (A → B and B → A) couples the depended-on skill to its caller, creates a cycle no one can reason about in isolation, and makes every edit ripple both ways. The lower-level skill must stay reusable by callers it has never heard of.
10
-
11
- **Runtime dispatch is not a doc reference.** A dumb-pipe skill may *fire* another skill's slash command at runtime (hand off and forget) — that is an action, not a dependency. What the rule forbids is a procedure file **linking to** or **encoding the internals of** the skill it hands off to.
12
-
13
- **Worked example.** `muggle-pr-followup` (the dumb-pipe watcher) is lower-level than `muggle-do` (the executor that orchestrates it). `muggle-do` references `muggle-pr-followup`; `muggle-pr-followup`'s files must not link back to `muggle-do`. A watcher tick still dispatches `/muggle-do …` at runtime — allowed — but no watcher file links a `do/` file or restates its steps, and shared primitives like `muggle-pr-followup/finalize.md` stay dispatch-free so any caller can reuse them.
14
-
15
- When you feel the urge to link "up" to a caller, that is the smell — restructure so the caller passes what is needed in.
16
-
17
- ### Enforcement
18
-
19
- `scripts/check-skill-deps.mjs` derives the cross-skill link graph and fails on any cycle. A "reference" is a markdown file-link into another skill's directory — runtime slash-command dispatch is not a link and is not counted. It runs three ways: the `skill-deps` CI job on every PR, a `PreToolUse` hook (`.claude/settings.json`) that blocks the write mid-session with the offending link named, and `pnpm run verify:skill-deps` locally.
20
-
21
- `plugin/skills/skill-deps.config.json` declares support dirs grouped into their owning skill (`do/` → `muggle-do`), shared namespaces exploded to per-file nodes (`_shared`), and `knownReverseDeps` — pre-existing violations grandfathered so CI stays green. That list is debt: fix each link and delete its entry. A new reverse dependency is blocked whether or not it is on the list.
22
-
23
- ## Model tiers
24
-
25
- Each skill sets a `model:` in its `SKILL.md` frontmatter sized to its cognitive load. `model:` is a native Claude Code field — the override applies while the skill is active and reverts to the session model when it exits. Cheaper, faster models run the mechanical skills; the default (Opus) is reserved for the ones that actually reason. Cost and latency scale with the model, and these skills run often (the watcher fires every minute), so the tier is a real lever, not cosmetics.
26
-
27
- | Model | Skills | Why this tier |
28
- |-------|--------|---------------|
29
- | `haiku` | `muggle`, `muggle-status`, `muggle-repair`, `muggle-upgrade`, `muggle-preferences`, `muggle-feedback`, `muggle-pr-followup` | Routers and dumb pipes. They follow an explicit procedure with no open-ended reasoning: route intent to a downstream skill, run a fixed CLI sequence, CRUD a config file, format a status report, or poll provider state and branch on conditions. `muggle-pr-followup` is the canonical case — a watcher that reads GitHub state and dispatches; all judgment lives in the `muggle-do` it hands off to. |
30
- | `sonnet` | `muggle-pr-visual-walkthrough`, `muggle-test-regenerate-missing` | Multi-step orchestration with light judgment, short of deep reasoning: assemble run data and build a PR section with fit-vs-overflow handling; scan, filter, bulk-dispatch, and classify per-item failures into buckets. More moving parts than a router, but each step is well-defined. |
31
- | `opus` (explicit pin) | `muggle-test-prepare` | Pinned for **reliability**, not raw reasoning load: it's flaky on smaller models, and since other skills gate on the environment it readies, a wrong call is expensive. Pin explicitly rather than leaving `model:` unset so it stays on Opus even when the user's session runs a cheaper model. |
32
- | default (Opus) — no `model:` set | `muggle-do`, `muggle-test`, `muggle-test-feature-local`, `muggle-browser-task`, `muggle-test-import` | Reasoning-heavy. Authoring code to a PR, mapping a code diff to affected user flows and interpreting E2E results, reasoning about an arbitrary website's flow to drive a browser, translating Playwright/Cypress/PRD artifacts into Muggle test cases. Leave `model:` unset so the skill inherits the session model. |
33
-
34
- **Choosing a tier for a new skill.** Ask what the skill actually does. Pure routing / fixed procedure / CRUD / reporting → `haiku`. Several well-defined steps with some judgment or classification → `sonnet`. Open-ended reasoning, code authoring, or interpreting ambiguous real-world state → leave `model:` unset (Opus). When unsure between two tiers, pick the cheaper one and watch for misbehavior — the likeliest to need a bump is anything doing AI-based classification. If a skill proves flaky on its tier and reliability matters more than cost (other skills depend on it, or a wrong call is expensive), pin it up explicitly — `model: opus` — rather than leaving it unset, so the floor holds regardless of the user's session model.
35
-
36
- **Never set `model:` on aliases or commands.** The alias skills (`m`, `mstatus`, …) and `plugin/commands/*.md` are thin routers that re-invoke the canonical skill via the `Skill` tool. The canonical `SKILL.md`'s `model:` takes effect once it loads, so a model on the alias would only apply to the negligible one-line hand-off — and risks drifting from the canonical value.
1
+ # Skill authoring conventions
2
+
3
+ Rules for every skill under `plugin/skills/`. Read before adding or editing one.
4
+
5
+ ## One-way dependencies — no reverse references
6
+
7
+ Skill cross-references form a one-way graph. If any file in skill **A** references skill **B** — a markdown link to B's files, or a documented dependency on B's internals — then **no file in B may reference A back**. Reference *downward*, toward the more general / lower-level skill you depend on; pass anything the other direction needs as input, not as a link.
8
+
9
+ A reverse reference (A → B and B → A) couples the depended-on skill to its caller, creates a cycle no one can reason about in isolation, and makes every edit ripple both ways. The lower-level skill must stay reusable by callers it has never heard of.
10
+
11
+ **Runtime dispatch is not a doc reference.** A dumb-pipe skill may *fire* another skill's slash command at runtime (hand off and forget) — that is an action, not a dependency. What the rule forbids is a procedure file **linking to** or **encoding the internals of** the skill it hands off to.
12
+
13
+ **Worked example.** `muggle-pr-followup` (the dumb-pipe watcher) is lower-level than `muggle-do` (the executor that orchestrates it). `muggle-do` references `muggle-pr-followup`; `muggle-pr-followup`'s files must not link back to `muggle-do`. A watcher tick still dispatches `/muggle-do …` at runtime — allowed — but no watcher file links a `do/` file or restates its steps, and shared primitives like `muggle-pr-followup/finalize.md` stay dispatch-free so any caller can reuse them.
14
+
15
+ When you feel the urge to link "up" to a caller, that is the smell — restructure so the caller passes what is needed in.
16
+
17
+ ### Enforcement
18
+
19
+ `scripts/check-skill-deps.mjs` derives the cross-skill link graph and fails on any cycle. A "reference" is a markdown file-link into another skill's directory — runtime slash-command dispatch is not a link and is not counted. It runs three ways: the `skill-deps` CI job on every PR, a `PreToolUse` hook (`.claude/settings.json`) that blocks the write mid-session with the offending link named, and `pnpm run verify:skill-deps` locally.
20
+
21
+ `plugin/skills/skill-deps.config.json` declares support dirs grouped into their owning skill (`do/` → `muggle-do`), shared namespaces exploded to per-file nodes (`_shared`), and `knownReverseDeps` — pre-existing violations grandfathered so CI stays green. That list is debt: fix each link and delete its entry. A new reverse dependency is blocked whether or not it is on the list.
22
+
23
+ ## Model tiers
24
+
25
+ Each skill sets a `model:` in its `SKILL.md` frontmatter sized to its cognitive load. `model:` is a native Claude Code field — the override applies while the skill is active and reverts to the session model when it exits. Cheaper, faster models run the mechanical skills; the default (Opus) is reserved for the ones that actually reason. Cost and latency scale with the model, and these skills run often (the watcher fires every minute), so the tier is a real lever, not cosmetics.
26
+
27
+ | Model | Skills | Why this tier |
28
+ |-------|--------|---------------|
29
+ | `haiku` | `muggle`, `muggle-status`, `muggle-repair`, `muggle-upgrade`, `muggle-preferences`, `muggle-feedback`, `muggle-pr-followup` | Routers and dumb pipes. They follow an explicit procedure with no open-ended reasoning: route intent to a downstream skill, run a fixed CLI sequence, CRUD a config file, format a status report, or poll provider state and branch on conditions. `muggle-pr-followup` is the canonical case — a watcher that reads GitHub state and dispatches; all judgment lives in the `muggle-do` it hands off to. |
30
+ | `sonnet` | `muggle-pr-visual-walkthrough` (executes via the `visual-walkthrough-builder` agent), `muggle-test-regenerate-missing` | Multi-step orchestration with light judgment, short of deep reasoning: assemble run data and build a PR section with fit-vs-overflow handling; scan, filter, bulk-dispatch, and classify per-item failures into buckets. More moving parts than a router, but each step is well-defined. |
31
+ | `opus` (explicit pin) | `muggle-test-prepare` (executes via the `test-prepare-runner` agent) | Pinned for **reliability**, not raw reasoning load: it's flaky on smaller models, and since other skills gate on the environment it readies, a wrong call is expensive. Pin explicitly rather than leaving `model:` unset so it stays on Opus even when the user's session runs a cheaper model. |
32
+
33
+ | default (Opus) — no `model:` set | `muggle-do`, `muggle-test`, `muggle-test-feature-local`, `muggle-browser-task`, `muggle-test-import` | Reasoning-heavy. Authoring code to a PR, mapping a code diff to affected user flows and interpreting E2E results, reasoning about an arbitrary website's flow to drive a browser, translating Playwright/Cypress/PRD artifacts into Muggle test cases. Leave `model:` unset so the skill inherits the session model. |
34
+
35
+ **A `SKILL.md` `model:` only bites when the skill starts a fresh session; invoked mid-session the session model keeps running.** A skill whose pin must hold regardless resolves its user interaction in the `SKILL.md`, then dispatches a `plugin/agents/*` agent that carries the pin — the harness applies an agent's `model:` on every dispatch. That is why `muggle-pr-visual-walkthrough`, `muggle-test-prepare`, and `muggle-pr-followup` execute through agents. Agents have no user channel; an unresolved decision comes back as `needs-input:` for the skill to resolve.
36
+
37
+ **Choosing a tier for a new skill.** Ask what the skill actually does. Pure routing / fixed procedure / CRUD / reporting → `haiku`. Several well-defined steps with some judgment or classification → `sonnet`. Open-ended reasoning, code authoring, or interpreting ambiguous real-world state → leave `model:` unset (Opus). When unsure between two tiers, pick the cheaper one and watch for misbehavior — the likeliest to need a bump is anything doing AI-based classification. If a skill proves flaky on its tier and reliability matters more than cost (other skills depend on it, or a wrong call is expensive), pin it up explicitly — `model: opus` — rather than leaving it unset, so the floor holds regardless of the user's session model.
38
+
39
+ **Never set `model:` on aliases or commands.** The alias skills (`m`, `mstatus`, …) and `plugin/commands/*.md` are thin routers that re-invoke the canonical skill via the `Skill` tool. The canonical `SKILL.md`'s `model:` takes effect once it loads, so a model on the alias would only apply to the negligible one-line hand-off — and risks drifting from the canonical value.
@@ -3,10 +3,10 @@
3
3
  How an orchestrating session starts the watch on one PR. Every arming point runs this same sequence: [`bootstrap.md`](bootstrap.md) Step 8, [`auto-track.md`](auto-track.md) Step 6, and the executor's post-cycle settle.
4
4
 
5
5
  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.
6
- 2. **Seed the watermark.** 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.
6
+ 2. **Seed the watermark.** 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.
7
7
  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.
8
8
 
9
- Otherwise write the loop as `watch.sh` in the slot and start it as a **persistent background monitor** in the orchestrating session — the command is the one-line script path, and the label is `PR #<n> — <title>`. Both matter: some task surfaces show the label, others show the command, and a slot-path command keeps the watch identifiable everywhere a raw script blob would not. One monitor per PR, alive from arm to terminal: it is the watch's visible handle, showing as a running task the entire time the PR is polled. Its loop checks about every 60 seconds, re-reading the watermark 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), or **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) — it prints one line and **keeps watching**, advancing its in-memory floor so each event fires the tick exactly once. The review and thread floors are monotonic ids; the CI 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. A head whose checks are still **pending** is never a wake: pending checks may yet go green, and the tick would idle on them (Step 6) regardless. Quiet iterations print nothing and cost nothing — no model tokens are spent while the watch is quiet.
9
+ Otherwise write the loop as `watch.sh` in the slot and start it as a **persistent background monitor** in the orchestrating session — the command is the one-line script path, and the label is `PR #<n> — <title>`. Both matter: some task surfaces show the label, others show the command, and a slot-path command keeps the watch identifiable everywhere a raw script blob would not. One monitor per PR, alive from arm to terminal: it is the watch's visible handle, showing as a running task the entire time the PR is polled. Its loop checks about every 60 seconds, re-reading the watermark 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 and **keeps watching**, advancing its in-memory floor so each event fires the tick exactly once. 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.
10
10
 
11
11
  The loop is **self-terminating**, so it never becomes one of those orphans. It begins by sourcing the shipped guard library and claiming the slot, with the guard path resolved to an **absolute** path at arm time (from `${CLAUDE_PLUGIN_ROOT}/scripts/pr-watch-guards.sh`) so it still resolves after the arming session is gone:
12
12
 
@@ -17,6 +17,8 @@ How an orchestrating session starts the watch on one PR. Every arming point runs
17
17
  started=$(date +%s)
18
18
  ```
19
19
 
20
+ A failed fetch — a GitHub or network outage, which reaches the loop as an empty result and a `dial tcp … / error connecting to api.github.com` line — must not be mistaken for a terminal PR: the loop counts it toward `MUGGLE_PR_WATCH_MAX_FETCH_FAILURES` (60), sleeping `watcher_fetch_backoff "$fails"` between tries so a sustained outage is retried calmly, and appends the error to `<slot>/watch-fetch.log`. 60 backed-off failures span hours; only a genuinely persistent unreachable slot (deleted repo, revoked auth) exhausts the budget, and a successful fetch resets the counter. Terminal exit stays reserved for an observed `MERGED`/`CLOSED`.
21
+
20
22
  and the top of every iteration, before any `gh` call, checks the two guards from [`../../scripts/pr-watch-guards.sh`](../../scripts/pr-watch-guards.sh):
21
23
 
22
24
  ```sh
@@ -24,7 +26,7 @@ How an orchestrating session starts the watch on one PR. Every arming point runs
24
26
  watcher_lifetime_exceeded "$started" "$(date +%s)" && exit 0 # 6h cap → any orphan dies on its own; reconcile re-arms an open PR in a live session
25
27
  ```
26
28
 
27
- So it exits when the PR goes terminal, after five consecutive failed fetches, when a newer arm supersedes it, or once it passes the lifetime cap — never `while true` unbounded.
29
+ So it exits when the PR goes terminal, after `MUGGLE_PR_WATCH_MAX_FETCH_FAILURES` consecutive failed fetches (default 60, back-off between each — hours of outage tolerance), when a newer arm supersedes it, or once it passes the lifetime cap — never `while true` unbounded.
28
30
  4. **On event.** Polling never enters the session: the cadence lives entirely in the detached monitor loop (step 3), and the only thing the session ever receives is the monitor's one event line. That line wakes the session: the wake turn runs the tick (step 1) with `--wake=<event>` — the flag that tells routing's live-watcher gate this poll was prompted, not idle curiosity ([`SKILL.md`](SKILL.md#routing)) — and whatever cycle the tick produces runs **inline in the owning session** — never in a subagent. The session's full history is part of the cycle's context (decisions, review nuance, owner phrasing); a subagent only knows its briefing plus disk/provider state, and anything the briefing omits is silently missing from the cycle. The token cost of inline cycles is accepted — context beats cost. The tick still derives everything from live provider state; a terminal PR finalizes there while the monitor exits on its own. Persistent fetch failure → surface the reason; [`reconcile.md`](reconcile.md) re-arms the slot at the next session start.
29
31
 
30
32
  **After a cycle** — advance the watermark to the **handled wave's snapshot**, in the same dispatched context that ran the cycle. Capture the max review-id and comment-id **at the start of handling** — the ids the cycle actually read when it derived the wave, before any reply is posted — and advance the watermark to exactly those. **Never** re-derive it from a live-max fetched after the replies land: a reviewer comment that arrives during the handling window — between reading the wave and posting the replies — lands below that post-reply max and is marked seen without ever being read. Snapshotting at the start-of-handling read keeps such a comment above the watermark, so the next tick surfaces it. That advance is also what stops the loop from reporting its own reply; a cycle that skips it leaves the watermark stale, and the next event is an echo.
@@ -19,7 +19,7 @@ Any component moving means the block may have cleared — a new push (`head_sha`
19
19
  When an idle tick is a durable human-block and `last_seen.blocked` is not already set:
20
20
 
21
21
  1. Increment `last_seen.idle_tick_count`.
22
- 2. Write `last_seen.blocked = { reason, since: <now>, fingerprint }` (reuse the `latest_review_id` / `ci_digest` already fetched this tick).
22
+ 2. Write `last_seen.blocked = { reason, since: <now>, fingerprint }` (reuse the `latest_review_id` / `ci_digest` already fetched this tick). Mirror the fingerprint's `ci_digest` into the watch-watermark's `BLOCKED_CIDIGEST` ([`state-schemas.md`](state-schemas.md#watch-watermarkenv)) — a whole-file rewrite of `watch-watermark.env` that leaves the other floors untouched. That non-empty value is what arms the monitor's blocked-resume probe, so a block resumes on any CI move ([`arm-watcher.md`](arm-watcher.md)), not just red — without it the probe stays dormant.
23
23
  3. **Remind the owner** — emit the one-line reminder per [`output-templates/blocked-reminder.md`](output-templates/blocked-reminder.md): the pending act plus a reference back to the decision context. This is the block's **only** reminder.
24
24
  4. Append a `blocked reason=<reason>` line to `followup.log` per [`output-templates/watcher-log.md`](output-templates/watcher-log.md); emit a `tick` event with `idle: true`, `blocked: true`, and the same other fields as a transient idle. Exit.
25
25
 
@@ -28,7 +28,7 @@ When an idle tick is a durable human-block and `last_seen.blocked` is not alread
28
28
  Every subsequent tick while `last_seen.blocked` is present: recompute the fingerprint and compare to `last_seen.blocked.fingerprint`.
29
29
 
30
30
  - **Unchanged** → still blocked. Stay **silent** — the reminder went out when the block was flagged. Increment `last_seen.idle_tick_count`, append a `blocked reason=<reason>` line to `followup.log`, emit a `tick` event with `idle: true`, `blocked: true`. Exit.
31
- - **Changed** → clear `last_seen.blocked` and **fall through to [`contract.md`](contract.md) Step 3** to re-evaluate against the moved state this same tick: a dispatch hands the PR to the cycle (its exit settles the watch); a transient idle changes nothing; idling back into a block re-flags per Step 7 — a new block, which sends its own single reminder.
31
+ - **Changed** → clear `last_seen.blocked`, clear the watch-watermark's `BLOCKED_CIDIGEST` to empty (whole-file rewrite — disarms the monitor's blocked-resume probe now that the watch is unblocked), and **fall through to [`contract.md`](contract.md) Step 3** to re-evaluate against the moved state this same tick: a dispatch hands the PR to the cycle (its exit settles the watch); a transient idle changes nothing; idling back into a block re-flags per Step 7 — a new block, which sends its own single reminder (and re-arms `BLOCKED_CIDIGEST` afresh).
32
32
 
33
33
  ## Invariants
34
34
 
@@ -6,7 +6,9 @@ Deletes this slot's watcher cron, and nothing more — the caller owns whatever
6
6
 
7
7
  Two lookups, recorded-id first so the delete still works when `CronList` has gone blind to the cron (survived a session continue / compaction — see [`state-schemas.md`](state-schemas.md#cronjson)):
8
8
 
9
- 1. **By recorded id.** Read `cron.json` ([`state-schemas.md`](state-schemas.md#cronjson)). If `cron_id` is non-null, call `CronDelete` with it. This is the only handle that survives `CronList` blindness.
10
- 2. **By `CronList` match (fallback).** Call `CronList`, find the job whose command ends with `/muggle:muggle-pr-followup <slug> <n>` — the exact two-arg match for this slot's PR — and `CronDelete` its id. This catches a stale recorded id (a since-respawned cron whose new id no tick has recorded yet) and the case where `cron.json` is absent.
9
+ 1. **By recorded id.** Read `cron.json` ([`state-schemas.md`](state-schemas.md#cronjson)). If `cron_id` is non-null, call `CronDelete` with it. This is the only handle that survives `CronList` blindness. It **finds** a cron when the id names a live scheduled cron the delete removes; a `cron_id` that is null or already gone is a harmless no-op that finds nothing.
10
+ 2. **By `CronList` match (fallback).** Call `CronList`, find the job whose command ends with `/muggle:muggle-pr-followup <slug> <n>` — the exact two-arg match for this slot's PR — and `CronDelete` its id. This catches a stale recorded id (a since-respawned cron whose new id no tick has recorded yet) and the case where `cron.json` is absent. It **finds** a cron when the match surfaces a live job to delete; an empty `CronList` (already blind) or no matching job finds nothing.
11
11
 
12
- Both lookups are no-ops when nothing matches a manually-run tick, or a cron that already expired. Deleting an already-gone id is harmless. Do **not** delete `cron.json` itself here; a terminal slot keeps it as a record, and reconcile ([`reconcile.md`](reconcile.md)) may still read it to sweep a straggler.
12
+ **Reported result `found` or `not-found`.** cancel-cron reports **found** when either lookup located and cancelled a live cron: the cron was reachable in this runtime, so any stale fires that follow are just the finite queued-drain backlog emptying. It reports **not-found** when both lookups found nothing to cancel. A **not-found** that keeps recurring — the cron fires again yet no cancel can reach it is the orphan signal the stale-fire guard keys on ([`contract.md`](contract.md) Step 0). Callers that only tear down (finalize [`finalize.md`](finalize.md), the single-thread stops in [`contract.md`](contract.md) Steps 4–6) do not branch on the result; only Step 0's orphan gate consults it. Deleting an already-gone id is harmless either way.
13
+
14
+ Do **not** delete `cron.json` itself here; a terminal slot keeps it as a record, and reconcile ([`reconcile.md`](reconcile.md)) may still read it to sweep a straggler.
@@ -33,9 +33,15 @@ Every `increment`/`reset` this procedure applies to `last_seen.json`, and the `p
33
33
 
34
34
  **Stopped-slot absorb — before anything else, including the disk reads above.** If the kill file `~/.muggle-ai/muggle-do/polling.disabled` exists, or the slot dir is absent but `~/.muggle-ai/muggle-do/sessions/<slug>.stopped/` exists (the owner ran [`stop.md`](stop.md)), absorb: no fetch, no state write, no log line — output one line (`stopped: <slug> — absorbed`) and exit. This gate is what makes an **unreachable orphaned cron** harmless: a cron whose handle a compaction severed can neither be enumerated by `CronList` nor deleted by its recorded id, so it fires until its session dies — the absorb caps each fire at one line of output and nothing else. Never "fix" an orphan by deleting ids `CronList` does surface; those belong to other live watchers.
35
35
 
36
- If `prs.json[0].state` on disk is already `merged` or `closed`, this slot was finalized by a prior tick and this is a stale (queued) fire — per-minute cron fires enqueued while the session was busy still drain after the cron is cancelled. Defensively cancel any lingering cron for this slug per [`cancel-cron.md`](cancel-cron.md) (no-op if none), append a `stale-tick` line to `followup.log`, and exit. Do not re-fetch or re-finalize.
36
+ If `prs.json[0].state` on disk is already `merged` or `closed`, this slot was finalized by a prior tick and this is a stale (queued) fire — per-minute cron fires enqueued while the session was busy still drain after the cron is cancelled. Defensively cancel any lingering cron for this slug per [`cancel-cron.md`](cancel-cron.md), which reports **found** (a reachable cron was cancelled) or **not-found** (neither lookup matched a live cron). Append a `stale-tick` line to `followup.log` that records the result — `stale-tick cancel=<found|not-found>` — and exit. Do not re-fetch or re-finalize.
37
37
 
38
- **Orphan escalation — on the third or later stale fire.** Count prior `stale-tick` lines for this slot in `followup.log` before appending this one. Three or more means both `cancel-cron.md` lookups have already run on earlier stale fires and the cron still fires it is orphaned in a session runtime neither the recorded id nor `CronList` can reach, so it will keep firing until the 7-day expiry. Escalate to the owner **once**: if `followup.log` has no `stale-orphan-escalated` line, print one line `orphaned cron: <slug> keeps firing after finalize; no in-session cancel can reach it — restarting the Claude session is the only clean clear` and append a `stale-orphan-escalated` line so every later stale fire absorbs silently again. Never respond to an unreachable cron by guess-deleting ids `CronList` does surface: those belong to other live watchers, and deleting one silently kills a wanted loop.
38
+ **Orphan escalation — when no cancel ever reaches the cron, yet stale fires persist.** The signal is *reachability*, not a fire count. Once any cancel of this slug's cron has reported **found** finalize's teardown cancel ([`finalize.md`](finalize.md) Step 4) or an earlier stale fire's defensive cancel the cron was deletable in its runtime, so the fires that follow are the finite queued-drain backlog emptying: never escalate. The orphan is the opposite: the cron lives in a runtime neither the recorded id nor `CronList` can reach (a session continue / compaction severed every handle), so **every** cancel reports **not-found** and the fires keep arriving until the 7-day expiry. Escalate only when all three hold:
39
+
40
+ - this fire's defensive cancel reported **not-found**, **and**
41
+ - a prior `stale-tick` line for this slot already exists in `followup.log` — this is not the first stale fire, so it is not a lone drain, a manually-run tick, or a just-expired cron, **and**
42
+ - no `cancel=found` has ever been recorded for this slot (neither finalize's teardown line nor any earlier `stale-tick` line) — the cron was never reachable, so these fires are not a drained backlog.
43
+
44
+ Escalate to the owner **once**: if `followup.log` has no `stale-orphan-escalated` line, print one line — `orphaned cron: <slug> keeps firing after finalize; no in-session cancel can reach it — restarting the Claude session is the only clean clear` — and append a `stale-orphan-escalated` line so every later stale fire absorbs silently again. Never respond to an unreachable cron by guess-deleting ids `CronList` does surface: those belong to other live watchers, and deleting one silently kills a wanted loop.
39
45
 
40
46
  Otherwise, self-record this watcher's cron id per [`record-cron-id.md`](record-cron-id.md) before proceeding. Recording every tick — while `CronList` can still see the cron — is what keeps the id a valid `CronDelete` target after a session continue / compaction blinds `CronList` to it, so teardown ([`finalize.md`](finalize.md), [`reconcile.md`](reconcile.md)) can always kill the orphan.
41
47