@muggleai/works 5.8.1 → 5.10.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 (83) hide show
  1. package/README.md +8 -0
  2. package/dist/{chunk-5PYK7DWB.js → chunk-7NC2DJTK.js} +10 -4
  3. package/dist/{chunk-LVUJ4E62.js → chunk-OCLMXAVJ.js} +245 -167
  4. package/dist/cli.js +2 -2
  5. package/dist/index.js +2 -2
  6. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  7. package/dist/plugin/.cursor-plugin/plugin.json +1 -1
  8. package/dist/plugin/README.md +19 -13
  9. package/dist/plugin/agents/test-prepare-runner.md +1 -0
  10. package/dist/plugin/hooks/hooks.json +6 -0
  11. package/dist/plugin/scripts/ensure-electron-app.sh +62 -47
  12. package/dist/plugin/scripts/guardrail-watch-gate.sh +40 -0
  13. package/dist/plugin/scripts/guardrails.mjs +88 -5
  14. package/dist/plugin/scripts/pr-watch-guards.sh +17 -0
  15. package/dist/plugin/skills/_shared/resolve-e2e-validation-context.md +1 -0
  16. package/dist/plugin/skills/muggle/SKILL.md +1 -1
  17. package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +5 -3
  18. package/dist/plugin/skills/muggle-pr-followup/blocked-tick.md +2 -2
  19. package/dist/plugin/skills/muggle-pr-followup/cancel-cron.md +5 -3
  20. package/dist/plugin/skills/muggle-pr-followup/contract.md +8 -2
  21. package/dist/plugin/skills/muggle-pr-followup/evals/evals.json +30 -3
  22. package/dist/plugin/skills/muggle-pr-followup/finalize.md +1 -1
  23. package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +3 -1
  24. package/dist/plugin/skills/muggle-preferences/SKILL.md +1 -1
  25. package/dist/plugin/skills/muggle-preferences/evals/evals.json +45 -0
  26. package/dist/plugin/skills/muggle-preferences/ops/change-one.md +1 -1
  27. package/dist/plugin/skills/muggle-preferences/ops/configure.md +4 -5
  28. package/dist/plugin/skills/muggle-preferences/ops/list.md +1 -1
  29. package/dist/plugin/skills/muggle-preferences/ops/set.md +3 -4
  30. package/dist/plugin/skills/muggle-preferences/preference-gates/README.md +3 -3
  31. package/dist/plugin/skills/muggle-preferences/preference-gates/autoSelectLocalHost.md +2 -2
  32. package/dist/plugin/skills/muggle-preferences/preference-gates/autoSelectProject.md +1 -1
  33. package/dist/plugin/skills/muggle-test/SKILL.md +1 -1
  34. package/dist/plugin/skills/muggle-test/execute-local.md +2 -0
  35. package/dist/plugin/skills/muggle-test-feature-local/SKILL.md +3 -1
  36. package/dist/plugin/skills/muggle-test-import/SKILL.md +1 -1
  37. package/dist/plugin/skills/muggle-test-prepare/SKILL.md +10 -4
  38. package/dist/plugin/skills/muggle-test-prepare/steps/check-running.md +1 -1
  39. package/dist/plugin/skills/muggle-test-prepare/steps/e2e-instructions.md +100 -0
  40. package/dist/plugin/skills/muggle-test-prepare/steps/readiness-report.md +9 -4
  41. package/dist/plugin/skills/muggle-test-prepare/steps/reuse-plan.md +5 -4
  42. package/dist/plugin/skills/muggle-test-regenerate-missing/SKILL.md +1 -1
  43. package/dist/release-manifest.json +4 -4
  44. package/dist/src-AWUORQZZ.js +1 -0
  45. package/package.json +6 -6
  46. package/plugin/.claude-plugin/plugin.json +1 -1
  47. package/plugin/.cursor-plugin/plugin.json +1 -1
  48. package/plugin/README.md +19 -13
  49. package/plugin/agents/test-prepare-runner.md +1 -0
  50. package/plugin/hooks/hooks.json +6 -0
  51. package/plugin/scripts/ensure-electron-app.sh +62 -47
  52. package/plugin/scripts/guardrail-watch-gate.sh +40 -0
  53. package/plugin/scripts/guardrails.mjs +88 -5
  54. package/plugin/scripts/pr-watch-guards.sh +17 -0
  55. package/plugin/skills/_shared/resolve-e2e-validation-context.md +1 -0
  56. package/plugin/skills/muggle/SKILL.md +1 -1
  57. package/plugin/skills/muggle-pr-followup/arm-watcher.md +5 -3
  58. package/plugin/skills/muggle-pr-followup/blocked-tick.md +2 -2
  59. package/plugin/skills/muggle-pr-followup/cancel-cron.md +5 -3
  60. package/plugin/skills/muggle-pr-followup/contract.md +8 -2
  61. package/plugin/skills/muggle-pr-followup/evals/evals.json +30 -3
  62. package/plugin/skills/muggle-pr-followup/finalize.md +1 -1
  63. package/plugin/skills/muggle-pr-followup/state-schemas.md +3 -1
  64. package/plugin/skills/muggle-preferences/SKILL.md +1 -1
  65. package/plugin/skills/muggle-preferences/evals/evals.json +45 -0
  66. package/plugin/skills/muggle-preferences/ops/change-one.md +1 -1
  67. package/plugin/skills/muggle-preferences/ops/configure.md +4 -5
  68. package/plugin/skills/muggle-preferences/ops/list.md +1 -1
  69. package/plugin/skills/muggle-preferences/ops/set.md +3 -4
  70. package/plugin/skills/muggle-preferences/preference-gates/README.md +3 -3
  71. package/plugin/skills/muggle-preferences/preference-gates/autoSelectLocalHost.md +2 -2
  72. package/plugin/skills/muggle-preferences/preference-gates/autoSelectProject.md +1 -1
  73. package/plugin/skills/muggle-test/SKILL.md +1 -1
  74. package/plugin/skills/muggle-test/execute-local.md +2 -0
  75. package/plugin/skills/muggle-test-feature-local/SKILL.md +3 -1
  76. package/plugin/skills/muggle-test-import/SKILL.md +1 -1
  77. package/plugin/skills/muggle-test-prepare/SKILL.md +10 -4
  78. package/plugin/skills/muggle-test-prepare/steps/check-running.md +1 -1
  79. package/plugin/skills/muggle-test-prepare/steps/e2e-instructions.md +100 -0
  80. package/plugin/skills/muggle-test-prepare/steps/readiness-report.md +9 -4
  81. package/plugin/skills/muggle-test-prepare/steps/reuse-plan.md +5 -4
  82. package/plugin/skills/muggle-test-regenerate-missing/SKILL.md +1 -1
  83. package/dist/src-FDK2KQSZ.js +0 -1
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { runCli } from './chunk-5PYK7DWB.js';
3
- import './chunk-LVUJ4E62.js';
2
+ import { runCli } from './chunk-7NC2DJTK.js';
3
+ import './chunk-OCLMXAVJ.js';
4
4
 
5
5
  // src/cli/main.ts
6
6
  runCli().catch((error) => {
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-5PYK7DWB.js';
2
- export { createChildLogger, e2e_exports as e2e, getConfig, getLocalQaTools, getLogger, getQaTools, local_exports as localQa, mcp_exports as mcp, e2e_exports as qa, src_exports as shared } from './chunk-LVUJ4E62.js';
1
+ export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-7NC2DJTK.js';
2
+ export { createChildLogger, e2e_exports as e2e, getConfig, getLocalQaTools, getLogger, getQaTools, local_exports as localQa, mcp_exports as mcp, e2e_exports as qa, src_exports as shared } from './chunk-OCLMXAVJ.js';
@@ -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.1",
4
+ "version": "5.10.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.1",
5
+ "version": "5.10.0",
6
6
  "author": {
7
7
  "name": "Muggle AI",
8
8
  "email": "support@muggle-ai.com"
@@ -21,19 +21,25 @@ This updates the CLI, configures Cursor MCP (`~/.cursor/mcp.json`), and syncs `m
21
21
 
22
22
  Type `muggle` to discover the full command family.
23
23
 
24
- | Skill | What it does |
25
- |:---|:---|
26
- | `/muggle:muggle` | Router and menu for all Muggle Test commands. |
27
- | `/muggle:muggle-do` | Autonomous dev pipeline: requirements, code, unit tests, E2E acceptance tests, PR. |
28
- | `/muggle:muggle-test` | Change-driven E2E acceptance router: detects code changes, maps to use cases, runs test generation locally or remotely, publishes to dashboard, opens in browser, posts E2E acceptance results to PR. |
29
- | `/muggle:muggle-test-feature-local` | Test a feature on localhost with AI-driven browser automation. Offers publish to cloud after each run. |
30
- | `/muggle:muggle-test-import` | Import existing tests into Muggle Test from Playwright/Cypress specs, PRDs, Gherkin feature files, test plan docs, or any test artifact. |
31
- | `/muggle:muggle-test-regenerate-missing` | Bulk-regenerate test scripts for every test case in a project that doesn't currently have an active script. Scans DRAFT + GENERATION_PENDING, confirms the list with the user, and dispatches remote generation workflows for each. |
32
- | `/muggle:muggle-status` | Health check for Electron browser test runner, MCP server, and authentication. |
33
- | `/muggle:muggle-repair` | Diagnose and fix broken installation automatically. |
34
- | `/muggle:muggle-upgrade` | Update Electron browser test runner and MCP server to latest version. |
35
-
36
- Each skill above also ships a short alias to save typing — `m` (router), `mtest`, `mdo`, `mpr`, `mprefs`, `mstatus`, `mrepair`, `mupgrade`, `mfeedback`, `mimport`, `mtestlocal`, `mtestprep`, `mregen`. Type `/m` (Claude Code) or `m` (Cursor) to open the menu, or jump straight to one (e.g. `/mtest`).
24
+ | Skill | Shorthand | What it does |
25
+ |:---|:---|:---|
26
+ | `/muggle:muggle` | `/m` | Router and menu for all Muggle Test commands. |
27
+ | `/muggle:muggle-do` | `/mdo` | Autonomous dev pipeline: requirements, code, unit tests, E2E acceptance tests, PR. |
28
+ | `/muggle:muggle-test` | `/mtest` | Change-driven E2E acceptance router: detects code changes, maps to use cases, runs test generation locally or remotely, publishes to dashboard, opens in browser, posts E2E acceptance results to PR. |
29
+ | `/muggle:muggle-test-feature-local` | `/mtestlocal` | Test a feature on localhost with AI-driven browser automation. Offers publish to cloud after each run. |
30
+ | `/muggle:muggle-test-prepare` | `/mtestprep` | Verify the dev servers and sibling services a test run needs, and start whatever is missing. |
31
+ | `/muggle:muggle-test-import` | `/mimport` | Import existing tests into Muggle Test from Playwright/Cypress specs, PRDs, Gherkin feature files, test plan docs, or any test artifact. |
32
+ | `/muggle:muggle-test-regenerate-missing` | `/mregen` | Bulk-regenerate test scripts for every test case in a project that doesn't currently have an active script. Scans DRAFT + GENERATION_PENDING, confirms the list with the user, and dispatches remote generation workflows for each. |
33
+ | `/muggle:muggle-browser-task` | `/mbt` | Perform a real action on a website from plain English — log in and submit the form, create the ticket, refund the charge. |
34
+ | `/muggle:muggle-pr-visual-walkthrough` | `/mpr` | Post per-test-case dashboard links, step-by-step screenshots, and a pass/fail summary to a PR. |
35
+ | `/muggle:muggle-pr-followup` | `/mprfollowup` | Watch one PR's review thread and dispatch the work to address incoming feedback. |
36
+ | `/muggle:muggle-feedback` | `/mfeedback` | Flag a generated action script, or one step in it, as wrong so Muggle can analyze and regenerate affected scripts. |
37
+ | `/muggle:muggle-preferences` | `/mprefs` | View, set, or reset the preferences that gate Muggle Test behavior. |
38
+ | `/muggle:muggle-status` | `/mstatus` | Health check for Electron browser test runner, MCP server, and authentication. |
39
+ | `/muggle:muggle-repair` | `/mrepair` | Diagnose and fix broken installation automatically. |
40
+ | `/muggle:muggle-upgrade` | `/mupgrade` | Update Electron browser test runner and MCP server to latest version. |
41
+
42
+ Every skill ships the short alias in the table above. Type `/m` (Claude Code) or `m` (Cursor) to open the menu, or jump straight to one (e.g. `/mtest`). Claude Code namespaces plugin commands, so the aliases resolve as `/muggle:mtest`; mirror them into `~/.claude/commands/` to type `/mtest` bare.
37
43
 
38
44
  ## MCP Tools
39
45
 
@@ -16,6 +16,7 @@ The dispatch prompt carries the resolved prepare plan:
16
16
  - `testingScope` and `excludedServices[]` (with reasons).
17
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
18
  - Resolved gate values the stages read (`autoRebase` outcome already applied or explicitly skipped upstream).
19
+ - The E2E run instructions — startup order, manual steps, local gotchas — already captured from the user. Honour the recorded startup order when starting services, and write the instructions out per the readiness-report stage. Absent means the skill had nothing to record; never invent them.
19
20
 
20
21
  ## Stages
21
22
 
@@ -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
  }
@@ -110,6 +110,10 @@ version_check() {
110
110
  version_check || true
111
111
 
112
112
  # --- Preferences injection ---
113
+ # Preferences are user-level: defaults overlaid by the global file only. A legacy
114
+ # <cwd>/.muggle-ai/preferences.json is left on disk but no longer read, so the
115
+ # keys it can no longer apply are named once — the stamp file suppresses the
116
+ # repeat until that key set changes.
113
117
  prefs_global_file="${HOME}/.muggle-ai/preferences.json"
114
118
  prefs_line=""
115
119
  prefs_file_note=""
@@ -119,8 +123,13 @@ if [ -f "$prefs_global_file" ]; then
119
123
  # Uses node for reliable JSON parsing (already required for muggle).
120
124
  prefs_line=$(node -e "
121
125
  const fs = require('fs');
126
+ const os = require('os');
127
+ const path = require('path');
122
128
  try {
123
- const g = JSON.parse(fs.readFileSync('${prefs_global_file}', 'utf-8')).preferences || {};
129
+ // Resolved through node, not the shell's \$HOME: under Git Bash the shell
130
+ // reports a POSIX path that Windows node cannot open.
131
+ const globalFile = path.join(os.homedir(), '.muggle-ai', 'preferences.json');
132
+ const g = JSON.parse(fs.readFileSync(globalFile, 'utf-8')).preferences || {};
124
133
  const defaults = {
125
134
  autoLogin:'ask', autoSelectProject:'ask', autoSelectLocalHost:'ask',
126
135
  showElectronBrowser:'ask', openTestResultsAfterRun:'ask',
@@ -131,15 +140,30 @@ if [ -f "$prefs_global_file" ]; then
131
140
  autoUseWorktree:'ask', autoRebase:'ask', autoCleanup:'ask',
132
141
  autoE2ETest:'always', autoRouteBuildToMuggleDo:'ask'
133
142
  };
143
+ const resolved = { ...defaults, ...g };
144
+ const line = Object.entries(resolved).map(([k,v]) => k+'='+v).join(' ');
145
+ const blocks = ['Muggle Test Preferences (~/.muggle-ai/preferences.json):\\\\n' + line];
146
+
134
147
  const cwd = process.env.CLAUDE_CWD || process.env.CURSOR_CWD || process.cwd();
135
- const pPath = require('path').join(cwd, '.muggle-ai', 'preferences.json');
148
+ const pPath = path.join(cwd, '.muggle-ai', 'preferences.json');
136
149
  let p = {};
137
150
  try { p = JSON.parse(fs.readFileSync(pPath, 'utf-8')).preferences || {}; } catch {}
138
- const merged = { ...defaults, ...g, ...p };
139
- const hasProject = Object.keys(p).length > 0;
140
- const note = hasProject ? ', project overrides active' : '';
141
- const line = Object.entries(merged).map(([k,v]) => k+'='+v).join(' ');
142
- console.log('Muggle Test Preferences (~/.muggle-ai/preferences.json' + note + '):\\\\n' + line);
151
+ const inertKeys = Object.keys(p).filter((k) => p[k] !== resolved[k]).sort();
152
+ if (inertKeys.length > 0) {
153
+ const stampDir = path.join(os.homedir(), '.cache', 'muggle');
154
+ const stampFile = path.join(stampDir, 'project-prefs-inert');
155
+ const stamp = cwd + '|' + inertKeys.join(',');
156
+ let lastStamp = '';
157
+ try { lastStamp = fs.readFileSync(stampFile, 'utf-8'); } catch {}
158
+ if (lastStamp !== stamp) {
159
+ try { fs.mkdirSync(stampDir, { recursive: true }); fs.writeFileSync(stampFile, stamp); } catch {}
160
+ blocks.push(
161
+ 'Muggle Test: per-project preferences were removed — ' + pPath + ' is no longer read, so these keys no longer take effect: ' + inertKeys.join(', ') + '.\\\\n' +
162
+ 'Tell the user to re-apply any they want everywhere with \`/muggle-preferences\`; the file is safe to delete.'
163
+ );
164
+ }
165
+ }
166
+ console.log(blocks.join('\\\\n\\\\n'));
143
167
  } catch { console.log(''); }
144
168
  " 2>/dev/null || true)
145
169
  if [ -n "$prefs_line" ]; then
@@ -149,53 +173,44 @@ else
149
173
  prefs_file_note="\\n\\nMuggle Test Preferences: not configured. Run \\\`muggle setup\\\` or tell the agent to set preferences."
150
174
  fi
151
175
 
152
- # --- Last-project cache injection ---
153
- # Per-repo "last used Muggle Test project" cache. Lives at <cwd>/.muggle-ai/last-project.json
154
- # and is honored by skills when autoSelectProject = always.
155
- last_project_line=""
156
- last_project_note=""
157
- last_project_line=$(node -e "
158
- const fs = require('fs');
159
- const path = require('path');
160
- try {
161
- const cwd = process.env.CLAUDE_CWD || process.env.CURSOR_CWD || process.cwd();
162
- const lpPath = path.join(cwd, '.muggle-ai', 'last-project.json');
163
- if (!fs.existsSync(lpPath)) { console.log(''); return; }
164
- const raw = JSON.parse(fs.readFileSync(lpPath, 'utf-8'));
165
- const lp = raw && raw.lastProject;
166
- if (!lp || !lp.projectId) { console.log(''); return; }
167
- const safeName = String(lp.projectName || '').replace(/\"/g, '\\\\\"');
168
- console.log('Muggle Test Last Project: id=' + lp.projectId + ' url=' + lp.projectUrl + ' name=\"' + safeName + '\"');
169
- } catch { console.log(''); }
170
- " 2>/dev/null || true)
171
- if [ -n "$last_project_line" ]; then
172
- last_project_note="\\n\\n${last_project_line}"
173
- fi
174
-
175
- # --- Last-host cache injection ---
176
- # Per-repo cache of the local dev server URL the user picked on the previous
177
- # run. Lives at <cwd>/.muggle-ai/last-host.json. Skills silently reuse it
178
- # when the user has set the autoSelectLocalHost preference to "always".
179
- last_host_line=""
180
- last_host_note=""
181
- last_host_line=$(node -e "
176
+ # --- Last-used cache injection ---
177
+ # The "last used Muggle Test project" and "last used local dev server URL"
178
+ # caches live in ~/.muggle-ai/, keyed by working directory, and are honored by
179
+ # skills when autoSelectProject / autoSelectLocalHost = always. A cache written
180
+ # before the move to the home directory still sits in <cwd>/.muggle-ai/ and is
181
+ # read as a fallback, so those sessions keep their context lines.
182
+ last_cache_notes=""
183
+ last_cache_notes=$(node -e "
182
184
  const fs = require('fs');
185
+ const os = require('os');
183
186
  const path = require('path');
184
187
  try {
185
188
  const cwd = process.env.CLAUDE_CWD || process.env.CURSOR_CWD || process.cwd();
186
- const lhPath = path.join(cwd, '.muggle-ai', 'last-host.json');
187
- if (!fs.existsSync(lhPath)) { console.log(''); return; }
188
- const raw = JSON.parse(fs.readFileSync(lhPath, 'utf-8'));
189
- const lh = raw && raw.lastHost;
190
- if (!lh || !lh.host) { console.log(''); return; }
191
- console.log('Muggle Test Last Host: ' + lh.host);
189
+ const parseFile = (filePath) => {
190
+ try { return JSON.parse(fs.readFileSync(filePath, 'utf-8')); } catch { return null; }
191
+ };
192
+ const readEntry = (fileName, legacyEntryKey) => {
193
+ const home = parseFile(path.join(os.homedir(), '.muggle-ai', fileName));
194
+ const homeEntry = home && home.entries && home.entries[path.resolve(cwd)];
195
+ if (homeEntry) { return homeEntry; }
196
+ const legacy = parseFile(path.join(cwd, '.muggle-ai', fileName));
197
+ return (legacy && legacy[legacyEntryKey]) || null;
198
+ };
199
+ const lines = [];
200
+ const lastProject = readEntry('last-project.json', 'lastProject');
201
+ if (lastProject && lastProject.projectId) {
202
+ const safeName = String(lastProject.projectName || '').replace(/\"/g, '\\\\\"');
203
+ lines.push('Muggle Test Last Project: id=' + lastProject.projectId + ' url=' + lastProject.projectUrl + ' name=\"' + safeName + '\"');
204
+ }
205
+ const lastHost = readEntry('last-host.json', 'lastHost');
206
+ if (lastHost && lastHost.host) {
207
+ lines.push('Muggle Test Last Host: ' + lastHost.host);
208
+ }
209
+ console.log(lines.map((line) => '\\\\n\\\\n' + line).join(''));
192
210
  } catch { console.log(''); }
193
211
  " 2>/dev/null || true)
194
- if [ -n "$last_host_line" ]; then
195
- last_host_note="\\n\\n${last_host_line}"
196
- fi
197
212
 
198
- context="<EXTREMELY_IMPORTANT>\nYou have access to Muggle AI — a real-browser E2E acceptance testing tool.\n\nWhenever the user asks you to test, validate, verify, or check if their web app works — use the muggle MCP tools. This includes:\n- Testing user flows (signup, login, checkout, forms, dashboards)\n- Verifying UI changes didn't break anything\n- Running regression tests after code changes\n- Validating frontend behavior on localhost or a dev server\n- Checking if a feature works before merging a PR\n\nMuggle Test launches a real Electron browser that clicks buttons, fills forms, navigates pages, and captures screenshots. It generates replayable test scripts that persist across sessions.\n\nDo NOT write test code (Playwright, Cypress, Selenium) or try to test UI manually when muggle tools are available. Use the muggle skill or muggle MCP tools instead — they are faster, capture visual evidence, and produce reusable test scripts.\n\nTrigger phrases: 'test my app', 'check if it works', 'run E2E acceptance tests', 'validate the UI', 'verify the flow', 'regression test', 'make sure it still works', 'test before merging'.\n</EXTREMELY_IMPORTANT>${upgrade_notice}${prefs_file_note}${last_project_note}${last_host_note}"
213
+ context="<EXTREMELY_IMPORTANT>\nYou have access to Muggle AI — a real-browser E2E acceptance testing tool.\n\nWhenever the user asks you to test, validate, verify, or check if their web app works — use the muggle MCP tools. This includes:\n- Testing user flows (signup, login, checkout, forms, dashboards)\n- Verifying UI changes didn't break anything\n- Running regression tests after code changes\n- Validating frontend behavior on localhost or a dev server\n- Checking if a feature works before merging a PR\n\nMuggle Test launches a real Electron browser that clicks buttons, fills forms, navigates pages, and captures screenshots. It generates replayable test scripts that persist across sessions.\n\nDo NOT write test code (Playwright, Cypress, Selenium) or try to test UI manually when muggle tools are available. Use the muggle skill or muggle MCP tools instead — they are faster, capture visual evidence, and produce reusable test scripts.\n\nTrigger phrases: 'test my app', 'check if it works', 'run E2E acceptance tests', 'validate the UI', 'verify the flow', 'regression test', 'make sure it still works', 'test before merging'.\n</EXTREMELY_IMPORTANT>${upgrade_notice}${prefs_file_note}${last_cache_notes}"
199
214
 
200
215
  escaped_context=$(escape_for_json "$context")
201
216
 
@@ -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 } from 'fs';
2
2
  import { isAbsolute, resolve, join } from 'path';
3
3
  import { homedir } from 'os';
4
4
 
@@ -43,8 +43,10 @@ ${input2.tool_response?.output ?? ""}`;
43
43
  // src/guardrails/constants.ts
44
44
  var GH_PR_MERGED_LINE = /\b(?:Merged|Squashed and merged|Rebased and merged) pull request [\w./-]*#(\d+)/;
45
45
  var GH_PR_CLOSED_LINE = /\bClosed pull request [\w./-]*#(\d+)/;
46
+ var GH_PR_REOPENED_LINE = /\bReopened pull request [\w./-]*#(\d+)/;
46
47
  var PR_MONITOR_TERMINAL_LINE = /\bTERMINAL pr=(\d+): (MERGED|CLOSED)\b/;
47
48
  var MAX_PR_TERMINAL_BLOCKS = 3;
49
+ var MAX_WATCH_BLOCKS = 3;
48
50
  var MUGGLE_SKILL_EMIT_TOOL = /muggle-local-telemetry-skill-emit/i;
49
51
  var MUGGLE_TEST_SKILL_NAME = "muggle-test";
50
52
 
@@ -70,6 +72,23 @@ function detectPrTerminal(input2) {
70
72
  }
71
73
  return null;
72
74
  }
75
+ function detectPrReopened(input2) {
76
+ if (input2.tool_name !== "Bash") return null;
77
+ const response = input2.tool_response;
78
+ const haystack = [response?.stdout, response?.stderr, response?.output, response?.content].filter((part) => typeof part === "string").join("\n");
79
+ const reopenedMatch = haystack.match(GH_PR_REOPENED_LINE);
80
+ return reopenedMatch ? Number(reopenedMatch[1]) : null;
81
+ }
82
+ function applyPrReopened(state, prNumber) {
83
+ const pending = state.terminalPending ?? [];
84
+ const handled = state.terminalHandled ?? [];
85
+ if (!pending.includes(prNumber) && !handled.includes(prNumber)) return state;
86
+ return {
87
+ ...state,
88
+ terminalPending: pending.filter((number) => number !== prNumber),
89
+ terminalHandled: handled.filter((number) => number !== prNumber)
90
+ };
91
+ }
73
92
  function applyPrTerminalDetected(state, prNumber) {
74
93
  const pending = state.terminalPending ?? [];
75
94
  const handled = state.terminalHandled ?? [];
@@ -143,6 +162,48 @@ function e2eGateDecision(state, maxBlocks = MAX_E2E_BLOCKS) {
143
162
  if (blockCount >= maxBlocks) return { action: "release" /* Release */, blockCount };
144
163
  return { action: "block" /* Block */, blockCount: blockCount + 1 };
145
164
  }
165
+ var WATCH_SKIP_MARKER = /^\s*echo\s+["']?MUGGLE_WATCH_SKIP\b/;
166
+ function isWatchSkipMarker(cmd) {
167
+ return WATCH_SKIP_MARKER.test(cmd);
168
+ }
169
+ function applyWatchSkip(state, skipped) {
170
+ if (!skipped || state.watchSkipped === true) return state;
171
+ return { ...state, watchSkipped: true };
172
+ }
173
+ function findUntrackedHandledPrs(handledUrls, sessionsDirOverride) {
174
+ if (handledUrls.length === 0) return [];
175
+ const sessionsDir = join(homedir(), ".muggle-ai", "muggle-do", "sessions");
176
+ if (!existsSync(sessionsDir)) return [...handledUrls];
177
+ const trackedUrls = /* @__PURE__ */ new Set();
178
+ for (const slug of readdirSync(sessionsDir)) {
179
+ const prsFile = join(sessionsDir, slug, "prs.json");
180
+ if (!existsSync(prsFile)) continue;
181
+ let slotUrl;
182
+ try {
183
+ const parsed = JSON.parse(readFileSync(prsFile, "utf-8"));
184
+ const entry = Array.isArray(parsed) ? parsed[0] : parsed;
185
+ slotUrl = entry?.url;
186
+ } catch {
187
+ continue;
188
+ }
189
+ if (slotUrl) trackedUrls.add(slotUrl);
190
+ }
191
+ return handledUrls.filter((url) => !trackedUrls.has(url));
192
+ }
193
+ function watchGateDecision(state, untrackedPrUrls, maxBlocks = MAX_WATCH_BLOCKS) {
194
+ const blockCount = state.watchBlockCount ?? 0;
195
+ if (state.watchSkipped === true || untrackedPrUrls.length === 0) {
196
+ return { action: "none" /* None */, blockCount, untracked: untrackedPrUrls };
197
+ }
198
+ if (blockCount >= maxBlocks) {
199
+ return { action: "release" /* Release */, blockCount, untracked: untrackedPrUrls };
200
+ }
201
+ return {
202
+ action: "block" /* Block */,
203
+ blockCount: blockCount + 1,
204
+ untracked: untrackedPrUrls
205
+ };
206
+ }
146
207
 
147
208
  // src/guardrails/detectBuildIntent.ts
148
209
  var BUILD = /\b(implement|build|add|create|write|fix|refactor|wire up|hook up|make (a|the|it)|change the)\b/i;
@@ -256,6 +317,13 @@ Per the autoWatchPR preference, a muggle-pr-followup watcher should handle its i
256
317
  return envelope("PostToolUse", ctx, host);
257
318
  }
258
319
  function prTerminal() {
320
+ const reopenedPrNumber = detectPrReopened(input);
321
+ if (reopenedPrNumber !== null) {
322
+ const state2 = readState(sessionId);
323
+ const next2 = applyPrReopened(state2, reopenedPrNumber);
324
+ if (next2 !== state2) writeState(next2);
325
+ return "{}";
326
+ }
259
327
  const terminalEvent = detectPrTerminal(input);
260
328
  if (!terminalEvent) return "{}";
261
329
  const state = readState(sessionId);
@@ -285,11 +353,12 @@ function terminalGate() {
285
353
  function recordTests() {
286
354
  const cmd = input.tool_input?.command ?? "";
287
355
  const state = readState(sessionId);
288
- const next = applyRecordedRun(state, {
356
+ const recorded = applyRecordedRun(state, {
289
357
  unitTestPassed: isTestCommand(cmd) && testsPassed(input),
290
358
  e2eRan: isE2ERun(input),
291
359
  e2eSkipped: isE2ESkipMarker(cmd)
292
360
  });
361
+ const next = applyWatchSkip(recorded, isWatchSkipMarker(cmd));
293
362
  if (next !== state) writeState(next);
294
363
  return "{}";
295
364
  }
@@ -302,10 +371,23 @@ function e2eGate() {
302
371
  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
372
  return blockStop(reason, host);
304
373
  }
374
+ function watchGate() {
375
+ const state = readState(sessionId);
376
+ const untrackedPrUrls = findUntrackedHandledPrs(state.prsHandled);
377
+ const decision = watchGateDecision(state, untrackedPrUrls);
378
+ if (decision.action === "none" /* None */ || decision.action === "release" /* Release */) {
379
+ return "{}";
380
+ }
381
+ state.watchBlockCount = decision.blockCount;
382
+ writeState(state);
383
+ const prList = decision.untracked.join(", ");
384
+ 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>"\`.`;
385
+ return blockStop(reason, host);
386
+ }
305
387
  function reportGate() {
306
- const result = evaluateReportPost(input);
307
- if (!result.deny || !result.reason) return "{}";
308
- return denyTool(result.reason, host);
388
+ const reportPostVerdict = evaluateReportPost(input);
389
+ if (!reportPostVerdict.deny || !reportPostVerdict.reason) return "{}";
390
+ return denyTool(reportPostVerdict.reason, host);
309
391
  }
310
392
  function buildRouter() {
311
393
  if (!detectBuildIntent(input.prompt ?? "")) return "{}";
@@ -323,6 +405,7 @@ var handlers = {
323
405
  "record-tests": recordTests,
324
406
  "e2e-gate": e2eGate,
325
407
  "terminal-gate": terminalGate,
408
+ "watch-gate": watchGate,
326
409
  "report-gate": reportGate,
327
410
  "build-router": buildRouter
328
411
  };
@@ -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
@@ -25,6 +25,7 @@ Resolve without prompting; use as questionnaire defaults:
25
25
  3. Candidate projects — `muggle-remote-project-list`, ranked against the repo's dev URL and the PR title.
26
26
  4. Existing test-user secrets — `muggle-remote-secret-list` per candidate project (`managed_profile_email` / `managed_profile_password`).
27
27
  5. Auth0 tenant for local dev — grep the repo env file for `*AUTH0_DOMAIN*`.
28
+ 6. E2E run instructions — read `~/.muggle-ai/e2e-instructions/<key>.md` when it exists, keyed on this stack's identity. It records startup order, manual steps, and local gotchas. Treat it as read-only input: surface its gotchas rather than re-deriving them, and never re-ask what it already answers. Absent is normal — proceed without it.
28
29
 
29
30
  ## Questions
30
31
 
@@ -12,7 +12,7 @@ Use this as the top-level Muggle Test command router.
12
12
 
13
13
  ## Preferences
14
14
 
15
- User preferences are injected by the SessionStart hook into a `Muggle Test Preferences` line in session context (key=value pairs). Resolution: defaults → `~/.muggle-ai/preferences.json` (global) `<repo>/.muggle-ai/preferences.json` (project). Treat absent prefs as `ask`.
15
+ User preferences are injected by the SessionStart hook into a `Muggle Test Preferences` line in session context (key=value pairs). Resolution: defaults → `~/.muggle-ai/preferences.json`. Preferences are user-level and apply to every repo. Treat absent prefs as `ask`.
16
16
 
17
17
  This router skill itself does not gate any decision on a preference — it just routes user intent to a downstream skill. Each downstream skill consults its own preferences. For example, `checkForUpdates` is consulted by `muggle-status` (Check 4), not here.
18
18
 
@@ -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.