@muggleai/works 5.28.0 → 5.29.0-staging.256

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/dist/{chunk-N3G4AX6M.js → chunk-XEKNDWXD.js} +7 -4
  4. package/dist/cli.js +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  7. package/dist/plugin/.codex-mcp.json +1 -1
  8. package/dist/plugin/.codex-plugin/plugin.json +1 -1
  9. package/dist/plugin/.cursor-plugin/plugin.json +1 -1
  10. package/dist/plugin/.mcp.json +1 -1
  11. package/dist/plugin/hooks/README.md +3 -3
  12. package/dist/plugin/scripts/guardrail-dispatch.sh +6 -0
  13. package/dist/plugin/scripts/guardrail-e2e-gate.sh +24 -0
  14. package/dist/plugin/scripts/guardrail-lib.sh +1 -0
  15. package/dist/plugin/scripts/guardrails.mjs +119 -45
  16. package/dist/plugin/scripts/pr-watch-arm.sh +14 -10
  17. package/dist/plugin/scripts/pr-watch-events.sh +14 -5
  18. package/dist/plugin/scripts/pr-watch-fetch.sh +1 -1
  19. package/dist/plugin/scripts/pr-watch-loop.sh +6 -5
  20. package/dist/plugin/scripts/pr-watch-state.jq +15 -5
  21. package/dist/plugin/scripts/pr-watch-verdict.mjs +22 -7
  22. package/dist/plugin/skills/_shared/ci-check-to-command.md +1 -1
  23. package/dist/plugin/skills/do/e2e-acceptance.md +1 -1
  24. package/dist/plugin/skills/do/fix-ci.md +9 -1
  25. package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +2 -2
  26. package/dist/plugin/skills/muggle-pr-followup/bootstrap.md +2 -0
  27. package/dist/plugin/skills/muggle-pr-followup/contract.md +2 -2
  28. package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +12 -12
  29. package/dist/release-manifest.json +3 -3
  30. package/package.json +2 -2
  31. package/plugin/.claude-plugin/plugin.json +1 -1
  32. package/plugin/.codex-mcp.json +1 -1
  33. package/plugin/.codex-plugin/plugin.json +1 -1
  34. package/plugin/.cursor-plugin/plugin.json +1 -1
  35. package/plugin/.mcp.json +1 -1
  36. package/plugin/hooks/README.md +3 -3
  37. package/plugin/scripts/guardrail-dispatch.sh +6 -0
  38. package/plugin/scripts/guardrail-e2e-gate.sh +24 -0
  39. package/plugin/scripts/guardrail-lib.sh +1 -0
  40. package/plugin/scripts/guardrails.mjs +119 -45
  41. package/plugin/scripts/pr-watch-arm.sh +14 -10
  42. package/plugin/scripts/pr-watch-events.sh +14 -5
  43. package/plugin/scripts/pr-watch-fetch.sh +1 -1
  44. package/plugin/scripts/pr-watch-loop.sh +6 -5
  45. package/plugin/scripts/pr-watch-state.jq +15 -5
  46. package/plugin/scripts/pr-watch-verdict.mjs +22 -7
  47. package/plugin/skills/_shared/ci-check-to-command.md +1 -1
  48. package/plugin/skills/do/e2e-acceptance.md +1 -1
  49. package/plugin/skills/do/fix-ci.md +9 -1
  50. package/plugin/skills/muggle-pr-followup/arm-watcher.md +2 -2
  51. package/plugin/skills/muggle-pr-followup/bootstrap.md +2 -0
  52. package/plugin/skills/muggle-pr-followup/contract.md +2 -2
  53. package/plugin/skills/muggle-pr-followup/state-schemas.md +12 -12
@@ -28,6 +28,30 @@ guardrail_wants() {
28
28
  && ! guardrail_state_has '"e2eRun": true'
29
29
  }
30
30
 
31
+ # Returns the block guardrails.mjs rendered ahead of time (writeE2eStopVerdict), without starting Node, while the
32
+ # budget it shares with Node's own blocks lasts. A Stop hook that outlives its one-second cap is dropped and the turn
33
+ # ends unblocked, and Node's cold start alone has taken seconds on a loaded Windows machine, so a block that waits on
34
+ # Node is one that can silently never land. Returns 1, leaving the decision and the release to Node, when nothing is
35
+ # rendered or the budget is spent.
36
+ guardrail_shell_verdict() {
37
+ local verdict_file="${GUARDRAIL_STATE_FILE%.json}.e2e-stop-verdict"
38
+ local shell_blocks_file="${GUARDRAIL_STATE_FILE%.json}.e2e-shell-blocks"
39
+ [[ -f $verdict_file ]] || return 1
40
+ local verdict=""
41
+ IFS= read -r -d '' verdict <"$verdict_file" || true
42
+ local max_blocks="${verdict%%$'\n'*}"
43
+ local hook_output="${verdict#*$'\n'}"
44
+ [[ $max_blocks =~ ^[0-9]+$ && -n $hook_output && $hook_output != "$verdict" ]] || return 1
45
+ local recorded_blocks_pattern='"e2eBlockCount"[[:space:]]*:[[:space:]]*([0-9]+)'
46
+ local recorded_blocks=0
47
+ [[ $GUARDRAIL_STATE =~ $recorded_blocks_pattern ]] && recorded_blocks="${BASH_REMATCH[1]}"
48
+ local shell_blocks=""
49
+ [[ -f $shell_blocks_file ]] && { IFS= read -r -d '' shell_blocks <"$shell_blocks_file" || true; }
50
+ ((recorded_blocks + ${#shell_blocks} < max_blocks)) || return 1
51
+ { printf 'x' >>"$shell_blocks_file"; } 2>/dev/null || return 1
52
+ printf '%s' "$hook_output"
53
+ }
54
+
31
55
  if guardrail_is_hook_script; then
32
56
  guardrail_run_standalone "$GUARDRAIL_SUBCOMMAND"
33
57
  fi
@@ -154,6 +154,7 @@ guardrail_run_node() {
154
154
  guardrail_run_standalone() {
155
155
  guardrail_read_payload
156
156
  if guardrail_wants; then
157
+ declare -F guardrail_shell_verdict >/dev/null && guardrail_shell_verdict && return 0
157
158
  guardrail_run_node "$1"
158
159
  else
159
160
  printf '{}'
@@ -105,6 +105,10 @@ var SUBAGENT_DISPATCH_TOOL_NAMES = ["Agent", "Task"];
105
105
  var ACCEPTANCE_TESTER_SUBAGENT_TYPE = /(?:^|:)acceptance-tester$/;
106
106
  var ACCEPTANCE_REPORT_MARKERS = ["## Test Summary", "**Overall:**"];
107
107
  var DELEGATED_E2E_WINDOW_MS = 60 * 6e4;
108
+ var E2E_STOP_VERDICT_SUFFIX = ".e2e-stop-verdict";
109
+ var E2E_SHELL_BLOCKS_SUFFIX = ".e2e-shell-blocks";
110
+ var AUTO_E2E_TEST_PREFERENCE_KEY = "autoE2ETest";
111
+ var POST_PR_VISUAL_WALKTHROUGH_PREFERENCE_KEY = "postPRVisualWalkthrough";
108
112
 
109
113
  // src/guardrails/store/fileLock.ts
110
114
  function isProcessAlive(pid) {
@@ -220,6 +224,25 @@ function readPrStateScan(sessionId2, dirOverride) {
220
224
  function writePrStateScan(sessionId2, scan, dirOverride) {
221
225
  writeAtomically(fileFor(sessionId2, dirOverride, PR_STATE_SCAN_SUFFIX), scan, dirOverride);
222
226
  }
227
+ function writeE2eStopVerdict(sessionId2, maxBlocks, hookOutput, dirOverride) {
228
+ mkdirSync(baseDir(dirOverride), { recursive: true });
229
+ const target = fileFor(sessionId2, dirOverride, E2E_STOP_VERDICT_SUFFIX);
230
+ const staging = `${target}.${process.pid}.tmp`;
231
+ writeFileSync(staging, `${maxBlocks}
232
+ ${hookOutput}`);
233
+ renameSync(staging, target);
234
+ writeFileSync(fileFor(sessionId2, dirOverride, E2E_SHELL_BLOCKS_SUFFIX), "", { flag: "a" });
235
+ }
236
+ function readE2eShellBlockCount(sessionId2, dirOverride) {
237
+ try {
238
+ return readFileSync(fileFor(sessionId2, dirOverride, E2E_SHELL_BLOCKS_SUFFIX), "utf-8").length;
239
+ } catch {
240
+ return 0;
241
+ }
242
+ }
243
+ function clearE2eShellBlocks(sessionId2, dirOverride) {
244
+ rmSync(fileFor(sessionId2, dirOverride, E2E_SHELL_BLOCKS_SUFFIX), { force: true });
245
+ }
223
246
  var lockFor = (sessionId2, dirOverride) => `${fileFor(sessionId2, dirOverride)}.lock`;
224
247
  function commitUnderHeldLock(sessionId2, mutate, dirOverride) {
225
248
  const current = readState(sessionId2, dirOverride);
@@ -280,7 +303,6 @@ var GuardrailBackgroundJob = /* @__PURE__ */ ((GuardrailBackgroundJob2) => {
280
303
  GuardrailBackgroundJob2["ReserveWalkthroughComment"] = "reserve-walkthrough-comment";
281
304
  GuardrailBackgroundJob2["SettleWalkthroughSkipped"] = "settle-walkthrough-skipped";
282
305
  GuardrailBackgroundJob2["SettleBranchWalkthroughSkipped"] = "settle-branch-walkthrough-skipped";
283
- GuardrailBackgroundJob2["SettleWalkthroughUnreasoned"] = "settle-walkthrough-unreasoned";
284
306
  GuardrailBackgroundJob2["SettleWalkthroughNoAppLogicChange"] = "settle-walkthrough-no-app-logic-change";
285
307
  GuardrailBackgroundJob2["NoteWalkthroughRerun"] = "note-walkthrough-rerun";
286
308
  GuardrailBackgroundJob2["ScanWatchPrStates"] = "scan-watch-pr-states";
@@ -629,6 +651,7 @@ function applyE2eSkipScope(state, verifiedChange) {
629
651
  var REPORT_SENTINEL = "muggle-pr-section";
630
652
  var WALKTHROUGH_SLOT_MARKER = "<!-- muggle-pr-walkthrough:v1 -->";
631
653
  var WALKTHROUGH_SKIPPED_MARKER = "<!-- muggle-pr-walkthrough-status:skipped -->";
654
+ var RETIRED_UNREASONED_SKIP_HEADLINE = "**No E2E run, and no verified reason given.**";
632
655
  var WALKTHROUGH_COMMENT_HEADING = "### Muggle AI \u2014 PR visual walkthrough";
633
656
  var REPORT_SENTINEL_TAG = new RegExp(`<!--\\s*${REPORT_SENTINEL}[^>]*-->\\s*`, "g");
634
657
  var LAST_FULL_RUN_OPENING = "<details><summary>Last full E2E run on this PR</summary>";
@@ -1024,7 +1047,15 @@ function renderReservedComment() {
1024
1047
  return `${WALKTHROUGH_SLOT_MARKER}
1025
1048
  ${WALKTHROUGH_COMMENT_HEADING}
1026
1049
 
1027
- _Awaiting the E2E acceptance run. Muggle edits this comment in place when the run finishes \u2014 or records why E2E does not apply to this change._`;
1050
+ **Waiting for the E2E feasibility check.** This comment updates in place \u2014 next with the E2E run in progress or why E2E is not feasible for this change, then with the E2E report.`;
1051
+ }
1052
+ function renderRunningComment(startedAt) {
1053
+ return `${WALKTHROUGH_SLOT_MARKER}
1054
+ ${WALKTHROUGH_COMMENT_HEADING}
1055
+
1056
+ ${RERUN_NOTICE_OPENING}
1057
+ **E2E running \u2014 waiting for the report.** Started ${formatUtcMinute(startedAt)}. This comment becomes the E2E report when the run finishes.
1058
+ ${RERUN_NOTICE_CLOSING}`;
1028
1059
  }
1029
1060
  function renderSkippedComment(code, detail) {
1030
1061
  const stated = detail.trim();
@@ -1034,24 +1065,17 @@ function renderSkippedComment(code, detail) {
1034
1065
  ${WALKTHROUGH_SKIPPED_MARKER}
1035
1066
  ${WALKTHROUGH_COMMENT_HEADING}
1036
1067
 
1037
- **No E2E run.** Muggle verified \`${code}\` \u2014 ${E2E_SKIP_CODE_CLAIMS[code]}${qualifier}.`;
1068
+ **E2E not feasible.** Muggle verified \`${code}\` \u2014 ${E2E_SKIP_CODE_CLAIMS[code]}${qualifier}.`;
1038
1069
  }
1039
1070
  function renderNotApplicableComment(reason) {
1040
1071
  return `${WALKTHROUGH_SLOT_MARKER}
1041
1072
  ${WALKTHROUGH_SKIPPED_MARKER}
1042
1073
  ${WALKTHROUGH_COMMENT_HEADING}
1043
1074
 
1044
- **No E2E run \u2014 not applicable.** ${reason.replace(/\.$/, "")}.
1075
+ **E2E not feasible.** ${reason.replace(/\.$/, "")}.
1045
1076
 
1046
1077
  _The coding agent judged that a browser run cannot exercise this change (\`${"NOT_APPLICABLE" /* NotApplicable */}\`). Muggle did not verify this reason._`;
1047
1078
  }
1048
- function renderUnreasonedSkipComment() {
1049
- return `${WALKTHROUGH_SLOT_MARKER}
1050
- ${WALKTHROUGH_SKIPPED_MARKER}
1051
- ${WALKTHROUGH_COMMENT_HEADING}
1052
-
1053
- **No E2E run, and no verified reason given.** The session ended still owing an acceptance run and cited no skip code that Muggle could verify. Treat this PR as unvalidated by E2E.`;
1054
- }
1055
1079
  var shortSha = (sha) => sha.slice(0, SHORT_SHA_LENGTH);
1056
1080
  function renderNoAppLogicChangeComment(declared, headSha, lastFullRun) {
1057
1081
  const cycleStart = declared.preCycleSha.toLowerCase();
@@ -1101,7 +1125,9 @@ ${RERUN_NOTICE_CLOSING}
1101
1125
  function classifyComment(body) {
1102
1126
  if (body.includes(REPORT_SENTINEL)) return "reported" /* Reported */;
1103
1127
  if (!body.includes(WALKTHROUGH_SLOT_MARKER)) return "not-designated" /* NotDesignated */;
1104
- if (body.includes(WALKTHROUGH_SKIPPED_MARKER)) return "skipped" /* Skipped */;
1128
+ if (body.includes(WALKTHROUGH_SKIPPED_MARKER) && !body.includes(RETIRED_UNREASONED_SKIP_HEADLINE)) {
1129
+ return "skipped" /* Skipped */;
1130
+ }
1105
1131
  return "pending" /* Pending */;
1106
1132
  }
1107
1133
  function walkthroughVerdict(bodies) {
@@ -1813,10 +1839,14 @@ function applyStateMutation(state, mutation) {
1813
1839
  function commitStateMutation(sessionId2, mutation, observedState) {
1814
1840
  if (observedState && applyStateMutation(observedState, mutation) === observedState) return observedState;
1815
1841
  const committed = commitState(sessionId2, (state) => applyStateMutation(state, mutation));
1816
- if (committed) return committed;
1842
+ if (committed) return withShellBlocksReset(committed);
1817
1843
  deferStateMutation(sessionId2, mutation);
1818
1844
  startBackgroundJob("commit-deferred-state" /* CommitDeferredState */, [sessionId2]);
1819
- return applyStateMutation(readState(sessionId2), mutation);
1845
+ return withShellBlocksReset(applyStateMutation(readState(sessionId2), mutation));
1846
+ }
1847
+ function withShellBlocksReset(state) {
1848
+ if (state.e2eRun === true) clearE2eShellBlocks(state.sessionId);
1849
+ return state;
1820
1850
  }
1821
1851
  function commitDeferredSessionState(sessionId2) {
1822
1852
  commitDeferredStateMutations(sessionId2, applyStateMutation);
@@ -1922,19 +1952,52 @@ function isE2ERun(input2) {
1922
1952
  if (MUGGLE_E2E_RUN_TOOL.test(input2.tool_name ?? "")) return true;
1923
1953
  return MUGGLE_SKILL_EMIT_TOOL.test(input2.tool_name ?? "") && input2.tool_input?.skillName === MUGGLE_TEST_SKILL_NAME;
1924
1954
  }
1955
+
1956
+ // src/guardrails/e2eOwedReason.ts
1957
+ var describePreference = (preferenceKey, configuredValue) => `${preferenceKey}=${configuredValue ?? "unset"}`;
1958
+ function buildE2eOwedReason(input2) {
1959
+ const isE2eMandated = input2.isE2eMandatedByUser;
1960
+ const isWalkthroughMandated = input2.postPRVisualWalkthrough === ALWAYS_PREFERENCE_VALUE;
1961
+ const preferenceSummary = `${describePreference(AUTO_E2E_TEST_PREFERENCE_KEY, input2.autoE2ETest)}, ${describePreference(POST_PR_VISUAL_WALKTHROUGH_PREFERENCE_KEY, input2.postPRVisualWalkthrough)} (~/.muggle-ai/preferences.json)`;
1962
+ const walkthroughStep = isWalkthroughMandated ? `, then post the run's walkthrough to the PR via ${input2.walkthroughSkill}` : "";
1963
+ const offeredSkipCodes = isE2eMandated ? E2E_SKIP_CODES.filter((skipCode) => skipCode !== "NOT_APPLICABLE" /* NotApplicable */) : E2E_SKIP_CODES;
1964
+ const skipClause = `To skip, run \`echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"\` citing one of ${offeredSkipCodes.join(", ")}. Muggle verifies each code itself, so one that does not hold leaves this gate blocked.`;
1965
+ if (input2.blockCount > 1) {
1966
+ return `E2E acceptance run still owed (reminder ${input2.blockCount}/${input2.maxBlocks}; ${preferenceSummary}): run ${input2.muggleTestSkill}${walkthroughStep}, or cite a verifiable code via \`echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"\`.`;
1967
+ }
1968
+ const owedSentence = `Do not end the turn yet. This session went unit-green or opened a PR, but no E2E acceptance run has happened. Preferences: ${preferenceSummary}. `;
1969
+ if (isE2eMandated) {
1970
+ return owedSentence + `That is the user's standing answer: do not ask them whether to test, and do not end the turn handing the decision back. Run change-driven E2E now via ${input2.muggleTestSkill}${walkthroughStep}, then finish. ${skipClause} A judgment that the change is not worth a run is not a skip.`;
1971
+ }
1972
+ return owedSentence + `Run change-driven E2E now via ${input2.muggleTestSkill}${walkthroughStep}, then finish. ${skipClause} Only when no verifiable code fits and a browser run genuinely cannot exercise the change, cite ${"NOT_APPLICABLE" /* NotApplicable */} with the reason \u2014 the PR shows it as your unverified judgment.`;
1973
+ }
1925
1974
  function parseMaxCatchUpRebases(configuredValue) {
1926
1975
  if (configuredValue === UNBOUNDED_PREFERENCE_VALUE) return null;
1927
1976
  const spelled = typeof configuredValue === "number" ? String(configuredValue) : configuredValue;
1928
1977
  return typeof spelled === "string" && POSITIVE_INTEGER.test(spelled) ? Number(spelled) : void 0;
1929
1978
  }
1979
+ var readUserPreferences = (homeDir) => asRecord(readJsonRecord(join(homeDir, ...USER_PREFERENCES_PATH_SEGMENTS)).preferences);
1980
+ function readPreferenceLayers(homeDir, pluginRoot) {
1981
+ return [readUserPreferences(homeDir), readJsonRecord(join(pluginRoot, ...PREFERENCE_DEFAULTS_PATH_SEGMENTS))];
1982
+ }
1983
+ function pickPreferenceValue(preferenceLayers, preferenceKey) {
1984
+ const configuredValue = preferenceLayers.map((layer) => layer[preferenceKey]).find((layerValue) => layerValue != null);
1985
+ return typeof configuredValue === "string" ? configuredValue : void 0;
1986
+ }
1987
+ function resolvePreferenceValue(preferenceKey, homeDir = homedir(), pluginRoot = resolvePluginRoot()) {
1988
+ return pickPreferenceValue(readPreferenceLayers(homeDir, pluginRoot), preferenceKey);
1989
+ }
1990
+ function resolveUserPreferenceValue(preferenceKey, homeDir = homedir()) {
1991
+ return pickPreferenceValue([readUserPreferences(homeDir)], preferenceKey);
1992
+ }
1930
1993
  function resolveWatchPreferences(homeDir = homedir(), pluginRoot = resolvePluginRoot()) {
1931
- const shippedDefaults = readJsonRecord(join(pluginRoot, ...PREFERENCE_DEFAULTS_PATH_SEGMENTS));
1932
- const userPreferences = asRecord(readJsonRecord(join(homeDir, ...USER_PREFERENCES_PATH_SEGMENTS)).preferences);
1933
- const autoRebase = userPreferences[AUTO_REBASE_PREFERENCE_KEY] ?? shippedDefaults[AUTO_REBASE_PREFERENCE_KEY];
1994
+ const preferenceLayers = readPreferenceLayers(homeDir, pluginRoot);
1995
+ const [userPreferences, shippedDefaults] = preferenceLayers;
1996
+ const autoRebase = pickPreferenceValue(preferenceLayers, AUTO_REBASE_PREFERENCE_KEY);
1934
1997
  const userCap = parseMaxCatchUpRebases(userPreferences[MAX_CATCH_UP_REBASES_PREFERENCE_KEY]);
1935
1998
  const shippedCap = parseMaxCatchUpRebases(shippedDefaults[MAX_CATCH_UP_REBASES_PREFERENCE_KEY]);
1936
1999
  return {
1937
- autoRebase: typeof autoRebase === "string" ? autoRebase : void 0,
2000
+ autoRebase,
1938
2001
  maxCatchUpRebases: userCap !== void 0 ? userCap : shippedCap ?? null
1939
2002
  };
1940
2003
  }
@@ -2059,19 +2122,16 @@ function settleWalkthroughCommentForNoAppLogicChange(prUrl, detail, run = defaul
2059
2122
  const body = renderNoAppLogicChangeComment(declared, readPrHeadSha(pr, run), lastFullRun);
2060
2123
  return designated ? patchPrComment(pr, designated.id, body, run) : postPrComment(pr, body, run);
2061
2124
  }
2062
- function noteWalkthroughRerunStarted(prUrl, run = defaultGhRunner, startedAt = /* @__PURE__ */ new Date()) {
2125
+ function noteE2eRunStarted(prUrl, run = defaultGhRunner, startedAt = /* @__PURE__ */ new Date()) {
2063
2126
  const pr = parsePrUrl(prUrl);
2064
2127
  if (!pr) return false;
2065
2128
  const comments = listPrComments(pr, run);
2066
2129
  if (comments === null) return false;
2067
2130
  const designated = designatedComment(comments);
2068
2131
  if (!designated) return false;
2069
- if (classifyComment(designated.body) === "pending" /* Pending */) return false;
2070
2132
  if (hasRerunNotice(designated.body)) return false;
2071
- return patchPrComment(pr, designated.id, withRerunNotice(designated.body, startedAt), run);
2072
- }
2073
- function settleWalkthroughCommentAsUnreasoned(prUrl, run = defaultGhRunner) {
2074
- return settleWith(prUrl, renderUnreasonedSkipComment(), run);
2133
+ const body = classifyComment(designated.body) === "pending" /* Pending */ ? renderRunningComment(startedAt) : withRerunNotice(designated.body, startedAt);
2134
+ return patchPrComment(pr, designated.id, body, run);
2075
2135
  }
2076
2136
 
2077
2137
  // src/e2e-skip/parseSkipDeclaration.ts
@@ -2216,7 +2276,12 @@ var verifyNoAppLogicChange = (context, detail) => {
2216
2276
  if (appLogicPaths.length > 0) return refuted(`app-logic paths changed since ${sha}: ${namePaths(appLogicPaths)}`);
2217
2277
  return verified;
2218
2278
  };
2219
- var verifyNotApplicable = (_context, detail) => {
2279
+ var verifyNotApplicable = (context, detail) => {
2280
+ if (context.userAutoE2ETest === ALWAYS_PREFERENCE_VALUE) {
2281
+ return refuted(
2282
+ `autoE2ETest=always in ~/.muggle-ai/preferences.json, so a judgment that the change needs no run is not a skip the user allows \u2014 run the E2E, or cite a code Muggle can verify`
2283
+ );
2284
+ }
2220
2285
  const words = detail.trim().split(/\s+/).filter(Boolean).length;
2221
2286
  if (words >= MIN_NOT_APPLICABLE_REASON_WORDS) return verified;
2222
2287
  return refuted(
@@ -2261,12 +2326,14 @@ function explainRejection(judged) {
2261
2326
  return `The skip cited ${judged.claimedCode}, but that could not be verified: ${judged.failure}. E2E is still owed \u2014 cite a code that holds, or run it.`;
2262
2327
  }
2263
2328
  function judgeE2eSkip(input2, state, probes = defaultSkipProbes) {
2329
+ const homeDir = homedir();
2264
2330
  return resolveSkipDeclaration(input2.tool_input?.command ?? "", {
2265
2331
  cwd: input2.cwd,
2266
2332
  prsHandled: state.prsHandled,
2267
2333
  transcriptPath: input2.transcript_path,
2268
- homeDir: homedir(),
2269
- probe: probes
2334
+ homeDir,
2335
+ probe: probes,
2336
+ userAutoE2ETest: resolveUserPreferenceValue(AUTO_E2E_TEST_PREFERENCE_KEY, homeDir)
2270
2337
  });
2271
2338
  }
2272
2339
 
@@ -2688,6 +2755,7 @@ function prOpened() {
2688
2755
  prUrl: url,
2689
2756
  prChange
2690
2757
  });
2758
+ renderE2eStopVerdict(stateAfterOpen);
2691
2759
  const declaredSkip = isPrCoveredBySkip(stateAfterOpen, url) ? stateAfterOpen.e2eSkipDeclaration : void 0;
2692
2760
  if (declaredSkip) {
2693
2761
  startBackgroundJob("settle-walkthrough-skipped" /* SettleWalkthroughSkipped */, [url, declaredSkip.code, declaredSkip.detail]);
@@ -2766,6 +2834,7 @@ function recordTests() {
2766
2834
  failedRunId: detectFailedRunId(input)
2767
2835
  };
2768
2836
  const next = commitStateMutation(sessionId, outcome);
2837
+ if (outcome.run.unitTestPassed) renderE2eStopVerdict(next);
2769
2838
  startWalkthroughScanOnGateArming(stateBefore, next, walkthroughPosted);
2770
2839
  if (acceptedSkip) recordSkipReasonOnPrs(next, acceptedSkip);
2771
2840
  if (judgedSkip && !judgedSkip.accepted) {
@@ -2869,19 +2938,30 @@ function debugPathGate() {
2869
2938
  const reason = decision.blockCount === 1 ? `Do not end the turn yet. These runs failed and never went through the debug path: ${runList}. muggle-test Step 7C makes that mandatory \u2014 route each through _shared/debug-failed-run.md: gather the attempted steps and the failing screenshot, diagnose the bucket per _shared/failure-mode-handling.md \xA7B/\xA7C with its classified telemetry emit, and present the offer in which "give feedback & rerun" is always available. A summarized-and-dropped failure is the run a reviewer most needs to see. If a run genuinely cannot be debugged, run \`echo "MUGGLE_DEBUG_SKIP: <runId> <reason>"\` \u2014 that clears just that run.` : `Failed runs still owe the debug path (reminder ${decision.blockCount}/${MAX_DEBUG_BLOCKS}): ${runList}. Route each through _shared/debug-failed-run.md, or record a legitimate skip via \`echo "MUGGLE_DEBUG_SKIP: <runId> <reason>"\`.`;
2870
2939
  return blockStop(reason, host);
2871
2940
  }
2941
+ function e2eOwedReason(blockCount) {
2942
+ return buildE2eOwedReason({
2943
+ blockCount,
2944
+ maxBlocks: MAX_E2E_BLOCKS,
2945
+ autoE2ETest: resolvePreferenceValue(AUTO_E2E_TEST_PREFERENCE_KEY),
2946
+ postPRVisualWalkthrough: resolvePreferenceValue(POST_PR_VISUAL_WALKTHROUGH_PREFERENCE_KEY),
2947
+ isE2eMandatedByUser: resolveUserPreferenceValue(AUTO_E2E_TEST_PREFERENCE_KEY) === ALWAYS_PREFERENCE_VALUE,
2948
+ muggleTestSkill: muggleSkill("/muggle:muggle-test"),
2949
+ walkthroughSkill: muggleSkill("/muggle:muggle-pr-visual-walkthrough")
2950
+ });
2951
+ }
2952
+ function renderE2eStopVerdict(state) {
2953
+ if (!shouldRunE2E(state)) return;
2954
+ writeE2eStopVerdict(sessionId, MAX_E2E_BLOCKS, blockStop(e2eOwedReason(1), host));
2955
+ }
2872
2956
  function e2eGate() {
2873
2957
  const state = readState(sessionId);
2874
- const decision = e2eGateDecision(state);
2875
- if (decision.action === "release" /* Release */) {
2876
- for (const prUrl of state.prsHandled) {
2877
- startBackgroundJob("settle-walkthrough-unreasoned" /* SettleWalkthroughUnreasoned */, [prUrl]);
2878
- }
2879
- return releaseGate("e2eReleased");
2880
- }
2958
+ const shellBlockCount = readE2eShellBlockCount(sessionId);
2959
+ const decision = e2eGateDecision({ ...state, e2eBlockCount: (state.e2eBlockCount ?? 0) + shellBlockCount });
2960
+ if (decision.action === "release" /* Release */) return releaseGate("e2eReleased");
2881
2961
  if (decision.action === "none" /* None */) return "{}";
2882
- recordGateBlock("e2eBlockCount", decision.blockCount);
2883
- const reason = decision.blockCount === 1 ? `Do not end the turn yet. This session went unit-green or opened a PR, but no E2E acceptance run has happened. Per the autoE2ETest preference (default: always), run change-driven E2E now via ${muggleSkill("/muggle:muggle-test")}, then finish. To skip, run \`echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"\` citing one of ${E2E_SKIP_CODES.join(", ")}. Muggle verifies each code itself, so one that does not hold leaves this gate blocked. Only when no verifiable code fits and a browser run genuinely cannot exercise the change, cite ${"NOT_APPLICABLE" /* NotApplicable */} with the reason \u2014 the PR shows it as your unverified judgment.` : `E2E acceptance run still owed (reminder ${decision.blockCount}/${MAX_E2E_BLOCKS}): run ${muggleSkill("/muggle:muggle-test")}, or cite a verifiable code via \`echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"\`.`;
2884
- return blockStop(reason, host);
2962
+ recordGateBlock("e2eBlockCount", decision.blockCount - shellBlockCount);
2963
+ renderE2eStopVerdict(state);
2964
+ return blockStop(e2eOwedReason(decision.blockCount), host);
2885
2965
  }
2886
2966
  function watchGate() {
2887
2967
  const state = readState(sessionId);
@@ -2933,7 +3013,7 @@ function watchFollowThroughGate() {
2933
3013
  const holdInstruction = `Only the user can hold it: their own message containing \`${WATCH_HOLD_PHRASE_PREFIX}<pr-number>\` for that PR. Ask if you must; never decide it for them.`;
2934
3014
  const reason = decision.blockCount === 1 ? `Do not end the turn yet. A muggle-pr-followup watch this session owns found work due that nothing acted on:
2935
3015
  ${owedList}
2936
- autoRebase=always makes a due rebase binding, and how long a CI run or an eval takes is never a reason to wait. Act now \u2014 if the tick finds actionable review feedback it dispatches that first. A recorded attempt (conflict_resolve_attempts / ci_fix_attempts) or a new head clears this; an escalation entry with no attempt behind it does not, and neither does re-arming the watch. ${holdInstruction}` : `Watcher work still owed (reminder ${decision.blockCount}):
3016
+ autoRebase=always makes a due rebase binding, and how long a CI run or an eval takes is never a reason to wait. Act now: run the cycle the tick dispatches \u2014 if it finds actionable review feedback it dispatches that first. That cycle's first step records its attempt (conflict_resolve_attempts / ci_fix_attempts), which, like a new head, clears this. Writing the count without running the cycle \u2014 or for an E2E run queued for a different PR \u2014 leaves the PR red and the watch quiet. An escalation entry with no attempt behind it does not clear this, and neither does re-arming the watch. ${holdInstruction}` : `Watcher work still owed (reminder ${decision.blockCount}):
2937
3017
  ${owedList}
2938
3018
  ${holdInstruction}`;
2939
3019
  return blockStop(reason, host);
@@ -3162,11 +3242,6 @@ function settleBranchWalkthroughSkippedJob() {
3162
3242
  if (prUrl) settleWalkthroughCommentAsSkipped(prUrl, code, detail ?? "");
3163
3243
  return "{}";
3164
3244
  }
3165
- function settleWalkthroughUnreasonedJob() {
3166
- const [prUrl] = jobArgs;
3167
- if (prUrl) settleWalkthroughCommentAsUnreasoned(prUrl);
3168
- return "{}";
3169
- }
3170
3245
  function settleWalkthroughNoAppLogicChangeJob() {
3171
3246
  const [detail] = jobArgs;
3172
3247
  const prUrl = defaultPrWalkthroughLookup.branchPrUrl();
@@ -3175,7 +3250,7 @@ function settleWalkthroughNoAppLogicChangeJob() {
3175
3250
  }
3176
3251
  function noteWalkthroughRerunJob() {
3177
3252
  const prUrl = defaultPrWalkthroughLookup.branchPrUrl();
3178
- if (prUrl) noteWalkthroughRerunStarted(prUrl);
3253
+ if (prUrl) noteE2eRunStarted(prUrl);
3179
3254
  return "{}";
3180
3255
  }
3181
3256
  function scanWatchPrStatesJob() {
@@ -3197,7 +3272,6 @@ var backgroundJobs = {
3197
3272
  ["reserve-walkthrough-comment" /* ReserveWalkthroughComment */]: reserveWalkthroughCommentJob,
3198
3273
  ["settle-walkthrough-skipped" /* SettleWalkthroughSkipped */]: settleWalkthroughSkippedJob,
3199
3274
  ["settle-branch-walkthrough-skipped" /* SettleBranchWalkthroughSkipped */]: settleBranchWalkthroughSkippedJob,
3200
- ["settle-walkthrough-unreasoned" /* SettleWalkthroughUnreasoned */]: settleWalkthroughUnreasonedJob,
3201
3275
  ["settle-walkthrough-no-app-logic-change" /* SettleWalkthroughNoAppLogicChange */]: settleWalkthroughNoAppLogicChangeJob,
3202
3276
  ["note-walkthrough-rerun" /* NoteWalkthroughRerun */]: noteWalkthroughRerunJob,
3203
3277
  ["scan-watch-pr-states" /* ScanWatchPrStates */]: scanWatchPrStatesJob,
@@ -13,10 +13,12 @@
13
13
  # seen without ever being reported. A re-arm keeps the review, comment and thread
14
14
  # floors the session already holds, for the same reason.
15
15
  #
16
- # A stale branch or a red head is floored only when the slot proves a cycle ran
17
- # for it. Flooring whatever was outstanding at arm time let a session that never
18
- # rebased or fixed anything re-arm at every rollover and silence the loop each
19
- # time; the drain line said "handled" and nothing checked.
16
+ # A stale branch is floored only when the slot proves a rebase ran for it, and a
17
+ # red head only once fix-ci is finished with it — escalated, or out of attempts.
18
+ # Flooring whatever was outstanding at arm time let a session that never rebased
19
+ # or fixed anything re-arm at every rollover and silence the loop each time; the
20
+ # drain line said "handled" and nothing checked. A bare attempt count is no
21
+ # better proof for CI: writing it costs nothing and changes nothing on the PR.
20
22
  set -uo pipefail
21
23
 
22
24
  script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -77,6 +79,7 @@ latest_comment="${STATE_FIELDS[5]-}"
77
79
  unresolved_threads="${STATE_FIELDS[6]-}"
78
80
  pending_checks="${STATE_FIELDS[7]-}"
79
81
  failed_checks="${STATE_FIELDS[8]-}"
82
+ ci_red_key="$(watch_ci_red_key "$head_sha" "${STATE_FIELDS[10]-}")"
80
83
 
81
84
  if [ "$pr_state" = "MERGED" ] || [ "$pr_state" = "CLOSED" ]; then
82
85
  echo "TERMINAL pr=$pr_number state=$pr_state — nothing to arm"
@@ -92,7 +95,8 @@ rebase_due_key=""
92
95
  behind_count="$(watch_fetch_behind_count "$repo" "$base_branch" "$head_sha" "$slot")"
93
96
  watch_branch_needs_rebase "$mergeable" "$behind_count" && rebase_due_key="$rebase_key"
94
97
 
95
- # Whether a cycle already ran for this key and this head, and the blocked
98
+ # Whether a rebase already ran for this key, whether fix-ci is done with this
99
+ # head, and the blocked
96
100
  # fingerprint's digest. A slot re-armed while blocked keeps its resume probe
97
101
  # live: empty is the not-blocked state, so seeding it empty would leave the loop
98
102
  # asleep through the very CI move the block is waiting on.
@@ -105,14 +109,14 @@ watch_branch_needs_rebase "$mergeable" "$behind_count" && rebase_due_key="$rebas
105
109
  last_seen_file="${slot}/last_seen.json"
106
110
  [ -f "$last_seen_file" ] || last_seen_file=/dev/null
107
111
  rebase_attempted=0
108
- ci_attempted=0
112
+ ci_settled=0
109
113
  blocked_digest_floor=""
110
114
  rebase_budget_exhausted=0
111
115
  while IFS= read -r evidence_line; do
112
116
  evidence_line="${evidence_line%$'\r'}"
113
117
  case "$evidence_line" in
114
118
  REBASE_ATTEMPTED=*) rebase_attempted="${evidence_line#REBASE_ATTEMPTED=}" ;;
115
- CI_ATTEMPTED=*) ci_attempted="${evidence_line#CI_ATTEMPTED=}" ;;
119
+ CI_SETTLED=*) ci_settled="${evidence_line#CI_SETTLED=}" ;;
116
120
  BLOCKED_CIDIGEST=*) blocked_digest_floor="${evidence_line#BLOCKED_CIDIGEST=}" ;;
117
121
  REBASE_BUDGET_EXHAUSTED=*) rebase_budget_exhausted="${evidence_line#REBASE_BUDGET_EXHAUSTED=}" ;;
118
122
  esac
@@ -120,7 +124,7 @@ done < <(node "${script_dir}/pr-watch-verdict.mjs" arm-floors --pr-key "${repo}#
120
124
  --head "$head_sha" --rebase-key "$rebase_key" < "$last_seen_file" 2>/dev/null)
121
125
 
122
126
  ci_red_floor=""
123
- [ -n "$ci_red_head" ] && [ "$ci_attempted" = "1" ] && ci_red_floor="$ci_red_head"
127
+ [ -n "$ci_red_head" ] && [ "$ci_settled" = "1" ] && ci_red_floor="$ci_red_key"
124
128
  rebase_floor=""
125
129
  [ -n "$rebase_due_key" ] && [ "$rebase_attempted" = "1" ] && rebase_floor="$rebase_due_key"
126
130
 
@@ -145,9 +149,9 @@ echo "DRAIN unresolved-threads=$thread_count latest-review=${latest_review:-0} l
145
149
  echo "DRAIN checks pending=${pending_checks:-0} failed=${failed_checks:-0} behind-base=${behind_count:-unknown} mergeable=${mergeable:-UNKNOWN}"
146
150
  if [ -n "$ci_red_head" ]; then
147
151
  if [ -n "$ci_red_floor" ]; then
148
- echo "DRAIN ci red at arm time — a fix-ci cycle already ran for head ${head_sha:0:8}, floored"
152
+ echo "DRAIN ci red at arm time — fix-ci escalated or spent its attempts on head ${head_sha:0:8}, floored"
149
153
  else
150
- echo "DRAIN ci red at arm time — no fix-ci attempt recorded for head ${head_sha:0:8}, not floored: dispatch fix-ci"
154
+ echo "DRAIN ci red at arm time — fix-ci not finished with head ${head_sha:0:8}, not floored: dispatch fix-ci"
151
155
  fi
152
156
  fi
153
157
  if [ -n "$rebase_due_key" ]; then
@@ -80,15 +80,24 @@ watch_checks_settled_red() {
80
80
  [ "${failed_count:-0}" -gt 0 ] 2>/dev/null
81
81
  }
82
82
 
83
+ # The identity of one red verdict: the head plus the run ids of its failed
84
+ # checks (the state line's red-check-runs).
85
+ watch_ci_red_key() {
86
+ printf '%s@%s' "$1" "$2"
87
+ }
88
+
83
89
  # The head's checks having settled red (watch_checks_settled_red).
84
90
  #
85
- # The floor is the head SHA rather than a monotonic id because a check rollup is
86
- # not monotonic: it flips green to red and resets on every push. Keying on the
87
- # red head fires once per red head and re-arms on the next push.
91
+ # The floor is a red key (watch_ci_red_key) rather than a monotonic id because a
92
+ # check rollup is not monotonic: it flips green to red and resets on every push.
93
+ # The head alone is not enough: a fix-ci cycle that settles the walkthrough or
94
+ # reruns a check keeps the head, and when that new run comes back red too it is
95
+ # news the cycle is waiting on. A head-only floor swallowed it, so the re-run's
96
+ # verdict never reached anyone.
88
97
  watch_wake_ci_red() {
89
- local pr="$1" pending_count="$2" failed_count="$3" head_sha="$4" floor="$5"
98
+ local pr="$1" pending_count="$2" failed_count="$3" head_sha="$4" red_key="$5" floor="$6"
90
99
  [ -n "$head_sha" ] || return 1
91
- [ "$head_sha" != "$floor" ] || return 1
100
+ [ "$red_key" != "$floor" ] || return 1
92
101
  watch_checks_settled_red "$pending_count" "$failed_count" || return 1
93
102
  echo "EVENT pr=$pr checks settled red head=$head_sha"
94
103
  }
@@ -25,7 +25,7 @@ query($owner: String!, $name: String!, $number: Int!) {
25
25
  contexts(first: 100) {
26
26
  nodes {
27
27
  __typename
28
- ... on CheckRun { name status conclusion }
28
+ ... on CheckRun { databaseId name status conclusion }
29
29
  ... on StatusContext { context state }
30
30
  }
31
31
  }
@@ -178,6 +178,7 @@ while :; do
178
178
  pending_checks="${STATE_FIELDS[7]-}"
179
179
  failed_checks="${STATE_FIELDS[8]-}"
180
180
  ci_digest="${STATE_FIELDS[9]-}"
181
+ ci_red_key="$(watch_ci_red_key "$head_sha" "${STATE_FIELDS[10]-}")"
181
182
 
182
183
  if [ "$pr_state" = "MERGED" ] || [ "$pr_state" = "CLOSED" ]; then
183
184
  echo "TERMINAL pr=$pr_number state=$pr_state"
@@ -206,12 +207,12 @@ while :; do
206
207
  done
207
208
  fi
208
209
 
209
- # Either floor quiets the head: the watermark's when a fix already ran for
210
- # it, the reported one when a loop already told the session it went red.
210
+ # Either floor quiets the red verdict: the watermark's when fix-ci settled
211
+ # it, the reported one when a loop already told the session about it.
211
212
  ci_red_floor="$watermark_ci_red"
212
- [ "$floor_ci_red" = "$head_sha" ] && ci_red_floor="$floor_ci_red"
213
- if watch_wake_ci_red "$pr_number" "$pending_checks" "$failed_checks" "$head_sha" "$ci_red_floor"; then
214
- floor_ci_red="$head_sha"
213
+ [ "$floor_ci_red" = "$ci_red_key" ] && ci_red_floor="$floor_ci_red"
214
+ if watch_wake_ci_red "$pr_number" "$pending_checks" "$failed_checks" "$head_sha" "$ci_red_key" "$ci_red_floor"; then
215
+ floor_ci_red="$ci_red_key"
215
216
  has_reported=1
216
217
  fi
217
218
 
@@ -5,7 +5,14 @@
5
5
  #
6
6
  # Field order is the contract pr-watch-loop.sh reads positionally:
7
7
  # state, headRefOid, base-tip, mergeable, latest-review, latest-comment,
8
- # unresolved-thread-ids, pending-checks, failed-checks, check-digest
8
+ # unresolved-thread-ids, pending-checks, failed-checks, check-digest,
9
+ # red-check-runs
10
+ #
11
+ # red-check-runs names each failed check with its run id (`name#id`, sorted).
12
+ # A settle, a rerun or a comment-triggered re-check publishes a new run on the
13
+ # same head, so this is what tells a fresh red verdict from the one already
14
+ # reported. check-digest cannot carry the ids: it must equal the digest the
15
+ # blocked tick builds from `gh pr checks`, which has none.
9
16
  #
10
17
  # base-tip is where the base branch points now, the base half of the rebase key.
11
18
  # Not baseRefOid: GitHub moves that only when the head is pushed, so a key built
@@ -19,9 +26,11 @@ def loop_marked: ((.body // "") | contains("<!-- muggle-do:bot -->"));
19
26
  | (($pr.commits.nodes[0].commit.statusCheckRollup.contexts.nodes) // []) as $contexts
20
27
  | ($contexts | map(
21
28
  if .__typename == "CheckRun"
22
- then {name: .name, verdict: (if .status != "COMPLETED" then "PENDING" else (.conclusion // "NEUTRAL") end)}
23
- else {name: .context, verdict: (.state // "PENDING")}
29
+ then {name: .name, runId: (.databaseId // ""), verdict: (if .status != "COMPLETED" then "PENDING" else (.conclusion // "NEUTRAL") end)}
30
+ else {name: .context, runId: "", verdict: (.state // "PENDING")}
24
31
  end)) as $checks
32
+ | ($checks | map(select(.verdict == "FAILURE" or .verdict == "ERROR" or .verdict == "TIMED_OUT" or .verdict == "STARTUP_FAILURE")))
33
+ as $failedChecks
25
34
 
26
35
  # Every comment on a live thread that belongs to a submitted review. A PENDING
27
36
  # review is the reviewer's own draft and is not feedback yet.
@@ -53,6 +62,7 @@ def loop_marked: ((.body // "") | contains("<!-- muggle-do:bot -->"));
53
62
  ([$pr.reviewThreads.nodes[] | select(.isResolved == false) | select(.isOutdated == false)
54
63
  | select((.comments.nodes[0].pullRequestReview.state // "SUBMITTED") != "PENDING") | .id] | join(";")),
55
64
  ($checks | map(select(.verdict == "PENDING")) | length),
56
- ($checks | map(select(.verdict == "FAILURE" or .verdict == "ERROR" or .verdict == "TIMED_OUT" or .verdict == "STARTUP_FAILURE")) | length),
57
- ($checks | sort_by(.name) | map(.name + ":" + .verdict) | join(","))
65
+ ($failedChecks | length),
66
+ ($checks | sort_by(.name) | map(.name + ":" + .verdict) | join(",")),
67
+ ($failedChecks | map(.name + "#" + (.runId | tostring)) | sort | join(","))
58
68
  ] | @tsv
@@ -48,6 +48,12 @@ function isRebaseKeyEscalated(lastSeenEntry, rebaseKey) {
48
48
  function isCiHeadEscalated(lastSeenEntry, headSha) {
49
49
  return (lastSeenEntry.ci_escalated_shas ?? []).includes(headSha) && isCiHeadAttempted(lastSeenEntry, headSha);
50
50
  }
51
+ function isCiHeadBudgetSpent(lastSeenEntry, headSha) {
52
+ return ciFixAttemptsFor(lastSeenEntry, headSha) >= MAX_CI_FIX_ATTEMPTS_PER_HEAD;
53
+ }
54
+ function isCiHeadSettled(lastSeenEntry, headSha) {
55
+ return isCiHeadEscalated(lastSeenEntry, headSha) || isCiHeadBudgetSpent(lastSeenEntry, headSha);
56
+ }
51
57
  function isRebaseBudgetExhausted(lastSeenEntry, maxCatchUpRebases) {
52
58
  const catchUpRebases = lastSeenEntry.catch_up_rebases;
53
59
  return maxCatchUpRebases !== null && typeof catchUpRebases === "number" && catchUpRebases >= maxCatchUpRebases;
@@ -58,7 +64,7 @@ function resolveArmFloorEvidence(lastSeenEntry, headSha, rebaseKey, maxCatchUpRe
58
64
  const blockedCiDigest = lastSeenEntry.blocked?.fingerprint?.ci_digest;
59
65
  return {
60
66
  isRebaseKeyAttempted: rebaseKey !== "" && isRebaseKeyAttempted(lastSeenEntry, rebaseKey),
61
- isCiHeadAttempted: headSha !== "" && isCiHeadAttempted(lastSeenEntry, headSha),
67
+ isCiHeadSettled: headSha !== "" && isCiHeadSettled(lastSeenEntry, headSha),
62
68
  isRebaseBudgetExhausted: isRebaseBudgetExhausted(lastSeenEntry, maxCatchUpRebases),
63
69
  blockedCiDigest: typeof blockedCiDigest === "string" && !LINE_BREAK.test(blockedCiDigest) ? blockedCiDigest : ""
64
70
  };
@@ -104,7 +110,8 @@ function resolveWatchTickVerdict(lastSeenEntry, observation, preferences) {
104
110
  if (isCiHeadBudgetLeft && !isCiHeadEscalated(lastSeenEntry, headSha)) return "dispatch-fix-ci" /* DispatchFixCi */;
105
111
  }
106
112
  if (isRebaseKeyEscalated(lastSeenEntry, rebaseKey)) return "block-conflict-escalated" /* BlockConflictEscalated */;
107
- if (isCiHeadEscalated(lastSeenEntry, headSha)) return "block-ci-escalated" /* BlockCiEscalated */;
113
+ const isRedHeadOutOfAttempts = observation.ciBucket === "red" /* Red */ && isCiHeadBudgetSpent(lastSeenEntry, headSha);
114
+ if (isCiHeadEscalated(lastSeenEntry, headSha) || isRedHeadOutOfAttempts) return "block-ci-escalated" /* BlockCiEscalated */;
108
115
  if ((lastSeenEntry.escalated_review_ids ?? []).length > 0) return "block-reviews-escalated" /* BlockReviewsEscalated */;
109
116
  return "idle" /* Idle */;
110
117
  }
@@ -141,7 +148,7 @@ function runPrWatchVerdictCommand(commandLine, lastSeenJson, resolvePreferences)
141
148
  const evidence = resolveArmFloorEvidence(lastSeenEntry, headSha, rebaseKey, resolvePreferences().maxCatchUpRebases);
142
149
  return {
143
150
  stdout: `REBASE_ATTEMPTED=${evidence.isRebaseKeyAttempted ? 1 : 0}
144
- CI_ATTEMPTED=${evidence.isCiHeadAttempted ? 1 : 0}
151
+ CI_SETTLED=${evidence.isCiHeadSettled ? 1 : 0}
145
152
  BLOCKED_CIDIGEST=${evidence.blockedCiDigest}
146
153
  REBASE_BUDGET_EXHAUSTED=${evidence.isRebaseBudgetExhausted ? 1 : 0}
147
154
  `,
@@ -173,14 +180,22 @@ function parseMaxCatchUpRebases(configuredValue) {
173
180
  const spelled = typeof configuredValue === "number" ? String(configuredValue) : configuredValue;
174
181
  return typeof spelled === "string" && POSITIVE_INTEGER.test(spelled) ? Number(spelled) : void 0;
175
182
  }
183
+ var readUserPreferences = (homeDir) => asRecord(readJsonRecord(join(homeDir, ...USER_PREFERENCES_PATH_SEGMENTS)).preferences);
184
+ function readPreferenceLayers(homeDir, pluginRoot) {
185
+ return [readUserPreferences(homeDir), readJsonRecord(join(pluginRoot, ...PREFERENCE_DEFAULTS_PATH_SEGMENTS))];
186
+ }
187
+ function pickPreferenceValue(preferenceLayers, preferenceKey) {
188
+ const configuredValue = preferenceLayers.map((layer) => layer[preferenceKey]).find((layerValue) => layerValue != null);
189
+ return typeof configuredValue === "string" ? configuredValue : void 0;
190
+ }
176
191
  function resolveWatchPreferences(homeDir = homedir(), pluginRoot = resolvePluginRoot()) {
177
- const shippedDefaults = readJsonRecord(join(pluginRoot, ...PREFERENCE_DEFAULTS_PATH_SEGMENTS));
178
- const userPreferences = asRecord(readJsonRecord(join(homeDir, ...USER_PREFERENCES_PATH_SEGMENTS)).preferences);
179
- const autoRebase = userPreferences[AUTO_REBASE_PREFERENCE_KEY] ?? shippedDefaults[AUTO_REBASE_PREFERENCE_KEY];
192
+ const preferenceLayers = readPreferenceLayers(homeDir, pluginRoot);
193
+ const [userPreferences, shippedDefaults] = preferenceLayers;
194
+ const autoRebase = pickPreferenceValue(preferenceLayers, AUTO_REBASE_PREFERENCE_KEY);
180
195
  const userCap = parseMaxCatchUpRebases(userPreferences[MAX_CATCH_UP_REBASES_PREFERENCE_KEY]);
181
196
  const shippedCap = parseMaxCatchUpRebases(shippedDefaults[MAX_CATCH_UP_REBASES_PREFERENCE_KEY]);
182
197
  return {
183
- autoRebase: typeof autoRebase === "string" ? autoRebase : void 0,
198
+ autoRebase,
184
199
  maxCatchUpRebases: userCap !== void 0 ? userCap : shippedCap ?? null
185
200
  };
186
201
  }
@@ -7,6 +7,6 @@ Map a red CI check to the local command that reproduces and fixes it. Repo-agnos
7
7
  | lint / format | Run the repo's lint `--fix` / formatter; restage. |
8
8
  | typecheck | Run the typecheck script; read the errors; edit the types. |
9
9
  | unit / test | Run the suite; read the failures; fix the code or the test. |
10
- | `muggle-walkthrough` — the Muggle E2E walkthrough check | In scope. Its check-run summary names each failed or inconclusive case with the failing step and a link to the run; the walkthrough comment's hidden `muggle-e2e-result` record carries the same cases with their test case ids. Repair each, re-run the **whole** E2E suite, and settle the walkthrough comment with the new result — editing the comment re-runs the check. A summary saying the comment is still empty means no run was ever posted: run the suite and post it. |
10
+ | `muggle-walkthrough` — the Muggle E2E walkthrough check | In scope. Its check-run summary names each failed or inconclusive case with the failing step and a link to the run; the walkthrough comment's hidden `muggle-e2e-result` record carries the same cases with their test case ids. Repair each, re-run the **whole** E2E suite, and settle the walkthrough comment with the new result — editing the comment re-runs the check. A summary saying the comment is still empty means no run was ever posted: run the suite and post it, or — when the session holds no E2E plan or a `unit-only`/`skip` one — settle it with the verified skip code that holds. Deterministic: it reads the comment, so re-running it unchanged returns the same verdict. |
11
11
  | non-deterministic — an LLM-judged eval gate (e.g. `skill-routing-eval`, `skill-gate-eval`, `agent-gate-eval`) or another check whose verdict can flip with nothing changed | Re-run its failed jobs once per head, without a push: `gh run rerun <run-id> --failed` (GitLab: retry the failed job). The re-run counts as a fix attempt. Escalate only if it fails again on the same head. How long the check takes is never a reason to skip the re-run. |
12
12
  | out of scope — any other E2E-in-CI, build/deploy infra, or unknown | Do **not** attempt; record for escalation. |