@muggleai/works 5.19.0-staging.132 → 5.20.0-staging.135

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.
@@ -1963,6 +1963,20 @@ async function statusCommand() {
1963
1963
  }
1964
1964
  }
1965
1965
 
1966
+ // src/e2e-skip/types.ts
1967
+ var E2eSkipCode = /* @__PURE__ */ ((E2eSkipCode2) => {
1968
+ E2eSkipCode2["NoWebSurface"] = "NO_WEB_SURFACE";
1969
+ E2eSkipCode2["DevServerUnreachable"] = "DEV_SERVER_UNREACHABLE";
1970
+ E2eSkipCode2["EmptyDiff"] = "EMPTY_DIFF";
1971
+ E2eSkipCode2["MuggleAuthDown"] = "MUGGLE_AUTH_DOWN";
1972
+ E2eSkipCode2["NoPr"] = "NO_PR";
1973
+ E2eSkipCode2["UserWaived"] = "USER_WAIVED";
1974
+ return E2eSkipCode2;
1975
+ })(E2eSkipCode || {});
1976
+
1977
+ // src/e2e-skip/constants.ts
1978
+ Object.values(E2eSkipCode);
1979
+
1966
1980
  // src/pr-walkthrough/constants.ts
1967
1981
  var REPORT_SENTINEL = "muggle-pr-section";
1968
1982
  var WALKTHROUGH_SLOT_MARKER = "<!-- muggle-pr-walkthrough:v1 -->";
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { runCli } from './chunk-H2KMRAQZ.js';
2
+ import { runCli } from './chunk-4M3P45WX.js';
3
3
  import './chunk-ZA5KHVX5.js';
4
4
 
5
5
  // src/cli/main.ts
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-H2KMRAQZ.js';
1
+ export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-4M3P45WX.js';
2
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-ZA5KHVX5.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.19.0-staging.132",
4
+ "version": "5.20.0-staging.135",
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.19.0-staging.132",
5
+ "version": "5.20.0-staging.135",
6
6
  "author": {
7
7
  "name": "Muggle AI",
8
8
  "email": "support@muggle-ai.com"
@@ -40,6 +40,12 @@
40
40
  "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-resolve-gate.sh\"",
41
41
  "async": false,
42
42
  "timeout": 10
43
+ },
44
+ {
45
+ "type": "command",
46
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-walkthrough-heading.sh\"",
47
+ "async": false,
48
+ "timeout": 10
43
49
  }
44
50
  ]
45
51
  },
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+
4
+ # Walkthrough-heading gate (PreToolUse, Bash|PowerShell). Denies a
5
+ # `gh pr comment|create|edit` whose body wears the Muggle walkthrough heading or
6
+ # slot marker when no acceptance run has been recorded this session, so another
7
+ # tool's output cannot be published under Muggle's name.
8
+ #
9
+ # The report-format gate next to this one catches a hand-written *report*; a
10
+ # comment can carry the heading while containing no report structure at all,
11
+ # which is the shape that put Muggle's name on a run it never did.
12
+ #
13
+ # Must stay synchronous (only a sync PreToolUse hook can deny). The same keyword
14
+ # pre-filter keeps Node off the hot path for ordinary commands, and any failure
15
+ # degrades to {} so an unrelated command is never blocked.
16
+ payload="$(cat)"
17
+
18
+ if ! grep -Eiq 'gh[[:space:]]+pr[[:space:]]+(comment|create|edit)|issues/comments/[0-9]' <<<"$payload"; then
19
+ printf '{}'
20
+ exit 0
21
+ fi
22
+
23
+ root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
24
+ printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" walkthrough-heading-gate 2>/dev/null || printf '{}'
@@ -506,13 +506,9 @@ function debugGateDecision(state, maxBlocks = MAX_DEBUG_BLOCKS) {
506
506
  var TEST_CMD = /\b(pnpm|npm|yarn)\s+(run\s+)?test\b|\b(jest|vitest|pytest)\b|\bgo\s+test\b|\bcargo\s+test\b/;
507
507
  var FAIL = /\b\d+\s+failed\b|\bFAIL\b|✗/;
508
508
  var E2E_TOOL = /muggle.*(execute|test-generation|replay)/i;
509
- var E2E_SKIP_MARKER = /^\s*echo\s+["']?MUGGLE_E2E_SKIP\b/;
510
509
  function isTestCommand(cmd) {
511
510
  return TEST_CMD.test(cmd);
512
511
  }
513
- function isE2ESkipMarker(cmd) {
514
- return E2E_SKIP_MARKER.test(cmd);
515
- }
516
512
  function testsPassed(input2) {
517
513
  const out = `${input2.tool_response?.stdout ?? ""}
518
514
  ${input2.tool_response?.stderr ?? ""}`;
@@ -711,12 +707,12 @@ function scanForOwedWalkthroughs(state, lookup = defaultPrWalkthroughLookup) {
711
707
  const branchPrUrl = lookup.branchPrUrl();
712
708
  if (branchPrUrl) candidates.add(branchPrUrl);
713
709
  const owed = [];
714
- const verified = [];
710
+ const verified2 = [];
715
711
  for (const prUrl of candidates) {
716
- if (lookup.prCarriesWalkthrough(prUrl)) verified.push(prUrl);
712
+ if (lookup.prCarriesWalkthrough(prUrl)) verified2.push(prUrl);
717
713
  else owed.push(prUrl);
718
714
  }
719
- return { owed, verified };
715
+ return { owed, verified: verified2 };
720
716
  }
721
717
  function walkthroughGateDecision(state, owedPrUrls, maxBlocks = MAX_WALKTHROUGH_BLOCKS) {
722
718
  const blockCount = state.walkthroughBlockCount ?? 0;
@@ -730,6 +726,56 @@ function walkthroughGateDecision(state, owedPrUrls, maxBlocks = MAX_WALKTHROUGH_
730
726
  return { action: "block" /* Block */, blockCount: blockCount + 1, owed: owedPrUrls };
731
727
  }
732
728
 
729
+ // src/e2e-skip/types.ts
730
+ var E2eSkipCode = /* @__PURE__ */ ((E2eSkipCode3) => {
731
+ E2eSkipCode3["NoWebSurface"] = "NO_WEB_SURFACE";
732
+ E2eSkipCode3["DevServerUnreachable"] = "DEV_SERVER_UNREACHABLE";
733
+ E2eSkipCode3["EmptyDiff"] = "EMPTY_DIFF";
734
+ E2eSkipCode3["MuggleAuthDown"] = "MUGGLE_AUTH_DOWN";
735
+ E2eSkipCode3["NoPr"] = "NO_PR";
736
+ E2eSkipCode3["UserWaived"] = "USER_WAIVED";
737
+ return E2eSkipCode3;
738
+ })(E2eSkipCode || {});
739
+
740
+ // src/e2e-skip/constants.ts
741
+ var E2E_SKIP_DECLARATION = /^\s*echo\s+["']?MUGGLE_E2E_SKIP:(.*)$/;
742
+ var SKIP_CODE_TOKEN = /^[\s:]*([A-Za-z_]+)/;
743
+ var E2E_SKIP_CODES = Object.values(E2eSkipCode);
744
+ var E2E_SKIP_CODE_CLAIMS = {
745
+ ["NO_WEB_SURFACE" /* NoWebSurface */]: "the repo ships no web surface to drive",
746
+ ["DEV_SERVER_UNREACHABLE" /* DevServerUnreachable */]: "the named dev server does not answer",
747
+ ["EMPTY_DIFF" /* EmptyDiff */]: "the branch carries no diff against its base",
748
+ ["MUGGLE_AUTH_DOWN" /* MuggleAuthDown */]: "the stored Muggle session is missing or expired",
749
+ ["NO_PR" /* NoPr */]: "no pull request was handled this session",
750
+ ["USER_WAIVED" /* UserWaived */]: "the user waived E2E in their own words"
751
+ };
752
+ var USER_WAIVE_PHRASE = "SKIP E2E";
753
+ var DEV_SERVER_PROBE_TIMEOUT_MS = 2e3;
754
+ var WEB_SERVER_COMMAND = /\b(vite|next\s+(dev|start)|react-scripts|ng\s+serve|nuxt|astro\s+dev|remix\s+dev|webpack(-dev)?-server|http-server|serve\s+-s|gatsby\s+develop|vue-cli-service\s+serve)\b/;
755
+ var BROWSER_FRAMEWORK_PACKAGES = [
756
+ "react-dom",
757
+ "next",
758
+ "vue",
759
+ "svelte",
760
+ "@angular/core",
761
+ "nuxt",
762
+ "astro",
763
+ "@remix-run/react",
764
+ "preact",
765
+ "solid-js"
766
+ ];
767
+ var WEB_SURFACE_CONFIG_FILES = [
768
+ "next.config.js",
769
+ "next.config.mjs",
770
+ "next.config.ts",
771
+ "vite.config.js",
772
+ "vite.config.ts",
773
+ "index.html",
774
+ "public/index.html"
775
+ ];
776
+ var OAUTH_SESSION_FILE = /^oauth-session[\w.-]*\.json$/;
777
+ var MUGGLE_HOME_DIR_NAME = ".muggle-ai";
778
+
733
779
  // src/pr-walkthrough/comment.ts
734
780
  function renderReservedComment() {
735
781
  return `${WALKTHROUGH_SLOT_MARKER}
@@ -737,14 +783,21 @@ ${WALKTHROUGH_COMMENT_HEADING}
737
783
 
738
784
  _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._`;
739
785
  }
740
- function renderSkippedComment(reason) {
741
- const stated = reason.trim();
742
- if (!stated) return renderReservedComment();
786
+ function renderSkippedComment(code, detail) {
787
+ const stated = detail.trim();
788
+ const qualifier = stated ? ` \u2014 ${stated}` : "";
743
789
  return `${WALKTHROUGH_SLOT_MARKER}
744
790
  ${WALKTHROUGH_SKIPPED_MARKER}
745
791
  ${WALKTHROUGH_COMMENT_HEADING}
746
792
 
747
- **E2E skipped** \u2014 ${stated}`;
793
+ **No E2E run.** Muggle verified \`${code}\` \u2014 ${E2E_SKIP_CODE_CLAIMS[code]}${qualifier}.`;
794
+ }
795
+ function renderUnreasonedSkipComment() {
796
+ return `${WALKTHROUGH_SLOT_MARKER}
797
+ ${WALKTHROUGH_SKIPPED_MARKER}
798
+ ${WALKTHROUGH_COMMENT_HEADING}
799
+
800
+ **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.`;
748
801
  }
749
802
  function classifyComment(body) {
750
803
  if (body.includes(REPORT_SENTINEL)) return "reported" /* Reported */;
@@ -827,26 +880,237 @@ function reserveWalkthroughComment(prUrl, run = defaultGhRunner) {
827
880
  if (comments === null) return false;
828
881
  return reserveComment(pr, comments, run);
829
882
  }
830
- function settleWalkthroughCommentAsSkipped(prUrl, reason, run = defaultGhRunner) {
831
- if (!reason.trim()) return false;
883
+ function settleWith(prUrl, body, run) {
832
884
  const pr = parsePrUrl(prUrl);
833
885
  if (!pr) return false;
834
886
  const comments = listPrComments(pr, run);
835
887
  if (comments === null) return false;
836
888
  if (commentWithStatus(comments, "reported" /* Reported */)) return false;
837
889
  if (commentWithStatus(comments, "skipped" /* Skipped */)) return false;
838
- const body = renderSkippedComment(reason);
839
890
  const reserved = commentWithStatus(comments, "pending" /* Pending */);
840
891
  return reserved ? patchPrComment(pr, reserved.id, body, run) : postPrComment(pr, body, run);
841
892
  }
893
+ function settleWalkthroughCommentAsSkipped(prUrl, code, detail, run = defaultGhRunner) {
894
+ return settleWith(prUrl, renderSkippedComment(code, detail), run);
895
+ }
896
+ function settleWalkthroughCommentAsUnreasoned(prUrl, run = defaultGhRunner) {
897
+ return settleWith(prUrl, renderUnreasonedSkipComment(), run);
898
+ }
842
899
 
843
- // src/guardrails/skipReason.ts
844
- var SKIP_DECLARATION = /^\s*echo\s+["']?MUGGLE_(?:E2E|WALKTHROUGH)_SKIP:\s*(.+)$/;
845
- function skipReasonFrom(cmd) {
846
- const declared = cmd.match(SKIP_DECLARATION);
900
+ // src/e2e-skip/parseSkipDeclaration.ts
901
+ var stripQuotes = (text) => text.replace(/["']\s*$/, "").trim();
902
+ var asSkipCode = (candidate) => {
903
+ const upper = candidate.toUpperCase();
904
+ return E2E_SKIP_CODES.find((code) => code === upper) ?? null;
905
+ };
906
+ function judgeSkipDeclaration(cmd) {
907
+ const declared = cmd.match(E2E_SKIP_DECLARATION);
847
908
  if (!declared) return null;
848
- const reason = declared[1].replace(/["']\s*$/, "").trim();
849
- return reason || null;
909
+ const stated = stripQuotes(declared[1] ?? "");
910
+ if (!stated) return { accepted: false, rejection: "missing-code" /* MissingCode */ };
911
+ const token = stated.match(SKIP_CODE_TOKEN)?.[1];
912
+ if (!token) return { accepted: false, rejection: "missing-code" /* MissingCode */ };
913
+ const code = asSkipCode(token);
914
+ if (!code) return { accepted: false, rejection: "unknown-code" /* UnknownCode */, claimedCode: token };
915
+ const detail = stated.slice(stated.indexOf(token) + token.length).replace(/^[\s:]+/, "").trim();
916
+ return { accepted: true, skip: { code, detail } };
917
+ }
918
+ var verified = { verified: true };
919
+ var refuted = (failure) => ({ verified: false, failure });
920
+ var URL_IN_DETAIL = /https?:\/\/[^\s"']+|localhost:\d+/i;
921
+ var parseManifest = (manifest) => {
922
+ if (!manifest) return {};
923
+ try {
924
+ return JSON.parse(manifest);
925
+ } catch {
926
+ return {};
927
+ }
928
+ };
929
+ var verifyNoWebSurface = (context) => {
930
+ if (!context.cwd) return refuted("no working directory was available to inspect");
931
+ const manifest = parseManifest(context.probe.readTextFile(join(context.cwd, "package.json")));
932
+ const serving = Object.entries(manifest.scripts ?? {}).find(
933
+ ([, command]) => WEB_SERVER_COMMAND.test(command)
934
+ );
935
+ if (serving) return refuted(`the "${serving[0]}" script runs a web server (${serving[1]})`);
936
+ const dependencies = { ...manifest.dependencies, ...manifest.devDependencies };
937
+ const framework = BROWSER_FRAMEWORK_PACKAGES.find((name) => name in dependencies);
938
+ if (framework) return refuted(`${framework} is a declared dependency`);
939
+ const config = WEB_SURFACE_CONFIG_FILES.find(
940
+ (file) => context.probe.readTextFile(join(context.cwd, file)) !== null
941
+ );
942
+ if (config) return refuted(`the repo contains ${config}`);
943
+ return verified;
944
+ };
945
+ var verifyDevServerUnreachable = (context, detail) => {
946
+ const url = detail.match(URL_IN_DETAIL)?.[0];
947
+ if (!url) return refuted("the detail names no url to probe");
948
+ if (context.probe.isReachable(url)) return refuted(`${url} answered the probe`);
949
+ return verified;
950
+ };
951
+ var verifyEmptyDiff = (context) => {
952
+ if (!context.cwd) return refuted("no working directory was available to inspect");
953
+ const head = context.probe.runGit(["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], context.cwd);
954
+ const base = head?.trim();
955
+ if (!base) return refuted("the repo's base branch could not be resolved");
956
+ const diff = context.probe.runGit(["diff", `${base}...HEAD`, "--stat"], context.cwd);
957
+ if (diff === null) return refuted("the diff against the base branch could not be read");
958
+ if (diff.trim()) return refuted(`the branch carries a diff against ${base}`);
959
+ return verified;
960
+ };
961
+ var liveSessionExpiry = (session) => {
962
+ if (!session) return null;
963
+ try {
964
+ const expiresAt = JSON.parse(session).expiresAt;
965
+ if (!expiresAt) return null;
966
+ const expiry = Date.parse(expiresAt);
967
+ return !Number.isNaN(expiry) && expiry > Date.now() ? expiresAt : null;
968
+ } catch {
969
+ return null;
970
+ }
971
+ };
972
+ var verifyMuggleAuthDown = (context) => {
973
+ const muggleHome = join(context.homeDir, MUGGLE_HOME_DIR_NAME);
974
+ const sessionFiles = context.probe.listFiles(muggleHome).filter((name) => OAUTH_SESSION_FILE.test(name));
975
+ for (const name of sessionFiles) {
976
+ const expiresAt = liveSessionExpiry(context.probe.readTextFile(join(muggleHome, name)));
977
+ if (expiresAt) return refuted(`${name} holds a session valid until ${expiresAt}`);
978
+ }
979
+ return verified;
980
+ };
981
+ var verifyNoPr = (context) => {
982
+ if (context.prsHandled.length === 0) return verified;
983
+ return refuted(`this session handled ${context.prsHandled.join(", ")}`);
984
+ };
985
+ var verifyUserWaived = (context) => {
986
+ if (!context.transcriptPath) return refuted("no transcript was available to read the waive from");
987
+ const transcript = context.probe.readTextFile(context.transcriptPath);
988
+ if (!transcript) return refuted("the transcript could not be read");
989
+ const tail = transcript.slice(-64e3);
990
+ const waived = tail.split("\n").some((line) => {
991
+ if (!line.includes(USER_WAIVE_PHRASE)) return false;
992
+ try {
993
+ const entry = JSON.parse(line);
994
+ const isUserTurn = entry.type === "user" || entry.message?.role === "user";
995
+ return isUserTurn && JSON.stringify(entry.message?.content ?? "").includes(USER_WAIVE_PHRASE);
996
+ } catch {
997
+ return false;
998
+ }
999
+ });
1000
+ if (waived) return verified;
1001
+ return refuted(`no user turn in the transcript contains "${USER_WAIVE_PHRASE}"`);
1002
+ };
1003
+ var VERIFIERS = {
1004
+ ["NO_WEB_SURFACE" /* NoWebSurface */]: verifyNoWebSurface,
1005
+ ["DEV_SERVER_UNREACHABLE" /* DevServerUnreachable */]: verifyDevServerUnreachable,
1006
+ ["EMPTY_DIFF" /* EmptyDiff */]: verifyEmptyDiff,
1007
+ ["MUGGLE_AUTH_DOWN" /* MuggleAuthDown */]: verifyMuggleAuthDown,
1008
+ ["NO_PR" /* NoPr */]: verifyNoPr,
1009
+ ["USER_WAIVED" /* UserWaived */]: verifyUserWaived
1010
+ };
1011
+ function verifyDeclaredSkip(skip, context) {
1012
+ return VERIFIERS[skip.code](context, skip.detail);
1013
+ }
1014
+
1015
+ // src/e2e-skip/resolveSkip.ts
1016
+ function resolveSkipDeclaration(cmd, context) {
1017
+ const judged = judgeSkipDeclaration(cmd);
1018
+ if (!judged || !judged.accepted) return judged;
1019
+ const checked = verifyDeclaredSkip(judged.skip, context);
1020
+ if (checked.verified) return judged;
1021
+ return {
1022
+ accepted: false,
1023
+ rejection: "verification-failed" /* VerificationFailed */,
1024
+ claimedCode: judged.skip.code,
1025
+ failure: checked.failure
1026
+ };
1027
+ }
1028
+ var codeMenu = () => E2E_SKIP_CODES.map((code) => `${code} (${E2E_SKIP_CODE_CLAIMS[code]})`).join(", ");
1029
+ function explainRejection(judged) {
1030
+ if (judged.rejection === "missing-code" /* MissingCode */) {
1031
+ return `That skip declaration states no code. E2E is skipped only by citing one of: ${codeMenu()}.`;
1032
+ }
1033
+ if (judged.rejection === "unknown-code" /* UnknownCode */) {
1034
+ return `"${judged.claimedCode}" is not a skip code, so E2E is still owed. A skip must name a fact about the environment, not a judgment about the change: ${codeMenu()}. If none of them is true, the run has to happen.`;
1035
+ }
1036
+ 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.`;
1037
+ }
1038
+ var readTextFile = (path) => {
1039
+ try {
1040
+ return readFileSync(path, "utf8");
1041
+ } catch {
1042
+ return null;
1043
+ }
1044
+ };
1045
+ var listFiles = (dir) => {
1046
+ try {
1047
+ return readdirSync(dir);
1048
+ } catch {
1049
+ return [];
1050
+ }
1051
+ };
1052
+ var runGit = (args, cwd) => {
1053
+ try {
1054
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
1055
+ } catch {
1056
+ return null;
1057
+ }
1058
+ };
1059
+ var hostAndPort = (url) => {
1060
+ const withScheme = /^https?:\/\//i.test(url) ? url : `http://${url}`;
1061
+ try {
1062
+ const parsed = new URL(withScheme);
1063
+ const port = parsed.port ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80;
1064
+ return { host: parsed.hostname, port };
1065
+ } catch {
1066
+ return null;
1067
+ }
1068
+ };
1069
+ var isReachable = (url) => {
1070
+ const target = hostAndPort(url);
1071
+ if (!target) return false;
1072
+ const script = `const net=require('net');const s=net.connect(${target.port},${JSON.stringify(target.host)});s.setTimeout(${DEV_SERVER_PROBE_TIMEOUT_MS});s.on('connect',()=>{s.destroy();process.exit(0)});s.on('timeout',()=>{s.destroy();process.exit(1)});s.on('error',()=>process.exit(1));`;
1073
+ try {
1074
+ execFileSync(process.execPath, ["-e", script], {
1075
+ timeout: DEV_SERVER_PROBE_TIMEOUT_MS * 2,
1076
+ stdio: "ignore"
1077
+ });
1078
+ return true;
1079
+ } catch {
1080
+ return false;
1081
+ }
1082
+ };
1083
+ var defaultSkipProbes = {
1084
+ readTextFile,
1085
+ listFiles,
1086
+ runGit,
1087
+ isReachable
1088
+ };
1089
+
1090
+ // src/guardrails/skipReason.ts
1091
+ function judgeE2eSkip(input2, state, probes = defaultSkipProbes) {
1092
+ return resolveSkipDeclaration(input2.tool_input?.command ?? "", {
1093
+ cwd: input2.cwd,
1094
+ prsHandled: state.prsHandled,
1095
+ transcriptPath: input2.transcript_path,
1096
+ homeDir: homedir(),
1097
+ probe: probes
1098
+ });
1099
+ }
1100
+
1101
+ // src/guardrails/walkthroughHeadingGate.ts
1102
+ function evaluateWalkthroughHeadingPost(input2, state, read = defaultFileReader) {
1103
+ if (!isShellToolCall(input2)) return { deny: false };
1104
+ if (state.e2eRun === true) return { deny: false };
1105
+ const cmd = input2.tool_input?.command ?? "";
1106
+ if (!isPrReportPostCommand(cmd)) return { deny: false };
1107
+ const text = collectPrPostText(cmd, input2.cwd, read);
1108
+ const claimsIdentity = text.includes(WALKTHROUGH_COMMENT_HEADING) || text.includes(WALKTHROUGH_SLOT_MARKER);
1109
+ if (!claimsIdentity) return { deny: false };
1110
+ return {
1111
+ deny: true,
1112
+ reason: 'Blocked: this comment carries the Muggle walkthrough heading, but no Muggle acceptance run has been recorded this session. That slot is settled by Muggle itself \u2014 either from a real run, or from a verified skip code via `echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"`. If you verified this change some other way, post that as its own comment without the Muggle heading, slot marker, or report sentinel.'
1113
+ };
850
1114
  }
851
1115
 
852
1116
  // src/guardrails/ledger/constants.ts
@@ -1230,10 +1494,11 @@ function terminalGate() {
1230
1494
  function recordTests() {
1231
1495
  const cmd = input.tool_input?.command ?? "";
1232
1496
  const state = readState(sessionId);
1497
+ const judgedSkip = judgeE2eSkip(input, state);
1233
1498
  const recorded = applyRecordedRun(state, {
1234
1499
  unitTestPassed: isTestCommand(cmd) && testsPassed(input),
1235
1500
  e2eRan: isE2ERun(input),
1236
- e2eSkipped: isE2ESkipMarker(cmd)
1501
+ e2eSkipped: judgedSkip?.accepted === true
1237
1502
  });
1238
1503
  const withWatchSkip = applyWatchSkip(recorded, isWatchSkipMarker(cmd));
1239
1504
  const withBuildSkip = applyBuildSkip(withWatchSkip, isBuildSkipMarker(cmd));
@@ -1242,14 +1507,15 @@ function recordTests() {
1242
1507
  const failedRunId = detectFailedRunId(input);
1243
1508
  const next = failedRunId ? applyFailedRun(withWalkthroughSkip, failedRunId) : withWalkthroughSkip;
1244
1509
  if (next !== state) writeState(next);
1245
- recordSkipReasonOnPrs(next, cmd);
1510
+ if (judgedSkip?.accepted) recordSkipReasonOnPrs(next, judgedSkip.skip);
1511
+ if (judgedSkip && !judgedSkip.accepted) {
1512
+ return envelope("PostToolUse", explainRejection(judgedSkip), host);
1513
+ }
1246
1514
  return "{}";
1247
1515
  }
1248
- function recordSkipReasonOnPrs(state, cmd) {
1249
- const reason = skipReasonFrom(cmd);
1250
- if (!reason) return;
1516
+ function recordSkipReasonOnPrs(state, skip) {
1251
1517
  for (const prUrl of state.prsHandled) {
1252
- settleWalkthroughCommentAsSkipped(prUrl, reason);
1518
+ settleWalkthroughCommentAsSkipped(prUrl, skip.code, skip.detail);
1253
1519
  }
1254
1520
  }
1255
1521
  function skillStages() {
@@ -1325,11 +1591,14 @@ function debugPathGate() {
1325
1591
  function e2eGate() {
1326
1592
  const state = readState(sessionId);
1327
1593
  const decision = e2eGateDecision(state);
1328
- if (decision.action === "release" /* Release */) return releaseGate("e2eReleased");
1594
+ if (decision.action === "release" /* Release */) {
1595
+ for (const prUrl of state.prsHandled) settleWalkthroughCommentAsUnreasoned(prUrl);
1596
+ return releaseGate("e2eReleased");
1597
+ }
1329
1598
  if (decision.action === "none" /* None */) return "{}";
1330
1599
  state.e2eBlockCount = decision.blockCount;
1331
1600
  writeState(state);
1332
- 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 /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>"\`.`;
1601
+ 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 /muggle:muggle-test, then finish. A skip states a fact about the environment, never a judgment about the change: run \`echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"\` citing one of ${E2E_SKIP_CODES.join(", ")}. Muggle verifies the code itself, so one that does not hold leaves this gate blocked.` : `E2E acceptance run still owed (reminder ${decision.blockCount}/${MAX_E2E_BLOCKS}): run /muggle:muggle-test, or cite a verifiable code via \`echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"\`.`;
1333
1602
  return blockStop(reason, host);
1334
1603
  }
1335
1604
  function watchGate() {
@@ -1435,6 +1704,11 @@ function reportGate() {
1435
1704
  if (!reportPostVerdict.deny || !reportPostVerdict.reason) return "{}";
1436
1705
  return denyTool(reportPostVerdict.reason, host);
1437
1706
  }
1707
+ function walkthroughHeadingGate() {
1708
+ const headingVerdict = evaluateWalkthroughHeadingPost(input, readState(sessionId));
1709
+ if (!headingVerdict.deny || !headingVerdict.reason) return "{}";
1710
+ return denyTool(headingVerdict.reason, host);
1711
+ }
1438
1712
  function resolveGate() {
1439
1713
  const resolveVerdict = evaluateReviewThreadResolve(input);
1440
1714
  if (!resolveVerdict.deny || !resolveVerdict.reason) return "{}";
@@ -1482,6 +1756,7 @@ var handlers = {
1482
1756
  "record-comment-replies": recordCommentReplies,
1483
1757
  "comment-reply-gate": commentReplyGate,
1484
1758
  "report-gate": reportGate,
1759
+ "walkthrough-heading-gate": walkthroughHeadingGate,
1485
1760
  "resolve-gate": resolveGate,
1486
1761
  "build-router": buildRouter,
1487
1762
  "skill-stages": skillStages,
@@ -21,7 +21,9 @@ This stage is **mode-driven by pre-flight**:
21
21
  - `local-e2e` runs the local browser flow (`test-feature-local` approach).
22
22
  - `unit-only` or `skip` does not execute browser runs and must emit an explicit non-pass verdict (`SKIPPED` / `UNIT-ONLY` equivalent in downstream reporting).
23
23
 
24
- **Every SKIPPED exit from this stage** (Step 0 poll-only, Step 1 `unit-only`/`skip`, Step 1.5 placeholder branch) also runs `echo "MUGGLE_E2E_SKIP: <one-line reason>"` as a single Bash call before exiting — it records the skip in guardrail session state so the Stop-hook E2E gate releases instead of blocking the turn.
24
+ **Every SKIPPED exit from this stage** (Step 0 poll-only, Step 1 `unit-only`/`skip`, Step 1.5 placeholder branch) also runs `echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"` as a single Bash call before exiting — it records the skip in guardrail session state so the Stop-hook E2E gate releases instead of blocking the turn.
25
+
26
+ `<CODE>` is one of `NO_WEB_SURFACE`, `DEV_SERVER_UNREACHABLE`, `EMPTY_DIFF`, `MUGGLE_AUTH_DOWN`, `NO_PR`, `USER_WAIVED`, and the guardrail verifies it against the environment before the gate releases. Every code names a fact about the environment; none names a judgment about the change, so "static page", "layout only", "unit tests cover it" and "verified with another tool" have no way through — cite a code that holds, or run the tests. `USER_WAIVED` needs the user's own literal `SKIP E2E` in the transcript, never an inferred go-ahead. A rejected declaration is answered with the check that failed, and the turn stays blocked.
25
27
  - `staging-replay` is not executed in this stage path and should be surfaced as `INCONCLUSIVE` unless the caller has already routed to a dedicated staging runner.
26
28
 
27
29
  For local runs, the tool boundaries are:
@@ -50,7 +52,7 @@ You receive everything from `state.md` already — pre-flight resolved it:
50
52
 
51
53
  Read `state.md`.
52
54
 
53
- **No `## Pre-flight answers` block at all** → the session was seeded poll-only (e.g. by auto-track, [`../muggle-pr-followup/auto-track.md`](../muggle-pr-followup/auto-track.md)). Treat `Validation` as `skip`: emit a `SKIPPED` report with reason `no validation context seeded` and exit cleanly. The watcher owns no E2E context by design; "no context" is a clean skip, not a failure.
55
+ **No `## Pre-flight answers` block at all** → the session was seeded poll-only (e.g. by auto-track, [`../muggle-pr-followup/auto-track.md`](../muggle-pr-followup/auto-track.md)). Treat `Validation` as `skip`: emit a `SKIPPED` report citing `NO_PR` and exit cleanly. The watcher owns no E2E context by design; "no context" is a clean skip, not a failure.
54
56
 
55
57
  Otherwise the block was seeded by pre-flight or bootstrap per [`../_shared/resolve-e2e-validation-context.md`](../_shared/resolve-e2e-validation-context.md) — read it the same way regardless of seeder. The persisted `Validation` field (`local-e2e`, `staging-replay`, `unit-only`, `skip`) picks execution vs early-exit below. In a forward run, [`autoE2ETest`](../muggle-preferences/preference-gates/autoE2ETest.md) `ask` was resolved by pre-flight Q13; in a watcher cycle there is no per-tick pre-flight, so `Validation` **is** the standing decision — don't re-resolve `ask`.
56
58
 
@@ -68,13 +70,13 @@ Before launching the local runner:
68
70
 
69
71
  Pre-flight handled auth. If `muggle-remote-auth-status` somehow shows expired here (session clock skew, etc.), re-auth silently via `muggle-remote-auth-login` + `muggle-remote-auth-poll` — but do not ask the user "continue with this account?" again.
70
72
 
71
- If validation is `unit-only` or `skip`, emit a `SKIPPED` report with a one-line reason and exit cleanly.
73
+ If validation is `unit-only` or `skip`, emit a `SKIPPED` report citing the code that holds — `NO_WEB_SURFACE` when the repo has nothing to drive, `DEV_SERVER_UNREACHABLE` when it does but nothing answers — and exit cleanly.
72
74
 
73
75
  If validation is `staging-replay`, emit `INCONCLUSIVE` with reason `staging replay not handled in Stage 6 local runner path` and exit cleanly.
74
76
 
75
77
  ### Step 1.5: Placeholder branch detection
76
78
 
77
- Read `pathClassification` from the impact-analysis output (emitted by `do/impact-analysis.md`). If it is `none` — i.e. `git diff <default-branch>...HEAD --stat` was empty after rebase — there is no code under test and running test cases would only re-test master. Write a one-paragraph SKIPPED result to the E2E report (or return a SKIPPED verdict to the caller) and exit the stage cleanly. **Do not** synthesize test cases or run anything.
79
+ Read `pathClassification` from the impact-analysis output (emitted by `do/impact-analysis.md`). If it is `none` — i.e. `git diff <default-branch>...HEAD --stat` was empty after rebase — there is no code under test and running test cases would only re-test master. Write a SKIPPED result citing `EMPTY_DIFF` to the E2E report (or return that verdict to the caller) and exit the stage cleanly. **Do not** synthesize test cases or run anything.
78
80
 
79
81
  ### Step 1.7: Route + project classification
80
82
 
@@ -45,7 +45,7 @@ Do not create or update a PR until each line holds, or is waived by a one-line r
45
45
  - Build clean — typecheck + lint on changed files
46
46
  - New/changed logic carries unit tests (authored in Stage 3; Stage 5 only runs the suite)
47
47
  - Unit suite run, PASS recorded
48
- - E2E verdict recorded with `runId` per `autoE2ETest` — or `[E2E FAILING]` / `SKIPPED` + reason
48
+ - E2E verdict recorded with `runId` per `autoE2ETest` — or `[E2E FAILING]`, or `SKIPPED` citing one of the verified skip codes (`NO_WEB_SURFACE`, `DEV_SERVER_UNREACHABLE`, `EMPTY_DIFF`, `MUGGLE_AUTH_DOWN`, `NO_PR`, `USER_WAIVED`). Free-text reasons are rejected by the gate
49
49
  - UI changes verified in a real browser with evidence (screenshot path or muggle `runId`); `curl` + `grep` is not verification
50
50
 
51
51
  Opening a PR with an unchecked, unwaived line is a cycle failure.
@@ -39,3 +39,4 @@ Callers consult the `postPRVisualWalkthrough` gate **before** invoking this skil
39
39
  - Mode is chosen by the caller, not the user.
40
40
  - Never create a PR without confirmation in Mode A.
41
41
  - Don't run tests — this skill only dispatches rendering/posting of existing results. No report and no run identifiers in context → redirect the caller to `muggle-test`, `muggle-test-feature-local`, or `muggle-do`.
42
+ - The designated comment carries a real Muggle run or a verified `MUGGLE_E2E_SKIP` code, and nothing else. Never put another tool's findings — puppeteer output, a unit-test summary, a manual check — under the walkthrough heading or the slot marker: that heading is Muggle vouching for a run, and a PreToolUse guard denies the post outright when no run was recorded. Supplemental evidence is welcome as its own comment, without the heading or marker.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "release": "5.18.0",
3
- "buildId": "run-132-1",
4
- "commitSha": "2172e219eab739a676b60437a8d6052b0ada3b58",
5
- "buildTime": "2026-09-23T06:28:29Z",
3
+ "buildId": "run-135-1",
4
+ "commitSha": "605057934e0d2ad35b957e511584bfac7cd7d400",
5
+ "buildTime": "2026-09-24T17:28:38Z",
6
6
  "serviceName": "muggle-ai-works-mcp"
7
7
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@muggleai/works",
3
3
  "mcpName": "io.github.multiplex-ai/muggle",
4
- "version": "5.19.0-staging.132",
4
+ "version": "5.20.0-staging.135",
5
5
  "description": "Ship quality products with AI-powered E2E acceptance testing that validates your web app like a real user — from Claude Code and Cursor to PR.",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -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.19.0-staging.132",
4
+ "version": "5.20.0-staging.135",
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.19.0-staging.132",
5
+ "version": "5.20.0-staging.135",
6
6
  "author": {
7
7
  "name": "Muggle AI",
8
8
  "email": "support@muggle-ai.com"
@@ -40,6 +40,12 @@
40
40
  "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-resolve-gate.sh\"",
41
41
  "async": false,
42
42
  "timeout": 10
43
+ },
44
+ {
45
+ "type": "command",
46
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-walkthrough-heading.sh\"",
47
+ "async": false,
48
+ "timeout": 10
43
49
  }
44
50
  ]
45
51
  },
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+
4
+ # Walkthrough-heading gate (PreToolUse, Bash|PowerShell). Denies a
5
+ # `gh pr comment|create|edit` whose body wears the Muggle walkthrough heading or
6
+ # slot marker when no acceptance run has been recorded this session, so another
7
+ # tool's output cannot be published under Muggle's name.
8
+ #
9
+ # The report-format gate next to this one catches a hand-written *report*; a
10
+ # comment can carry the heading while containing no report structure at all,
11
+ # which is the shape that put Muggle's name on a run it never did.
12
+ #
13
+ # Must stay synchronous (only a sync PreToolUse hook can deny). The same keyword
14
+ # pre-filter keeps Node off the hot path for ordinary commands, and any failure
15
+ # degrades to {} so an unrelated command is never blocked.
16
+ payload="$(cat)"
17
+
18
+ if ! grep -Eiq 'gh[[:space:]]+pr[[:space:]]+(comment|create|edit)|issues/comments/[0-9]' <<<"$payload"; then
19
+ printf '{}'
20
+ exit 0
21
+ fi
22
+
23
+ root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
24
+ printf '%s' "$payload" | node "${root}/scripts/guardrails.mjs" walkthrough-heading-gate 2>/dev/null || printf '{}'
@@ -506,13 +506,9 @@ function debugGateDecision(state, maxBlocks = MAX_DEBUG_BLOCKS) {
506
506
  var TEST_CMD = /\b(pnpm|npm|yarn)\s+(run\s+)?test\b|\b(jest|vitest|pytest)\b|\bgo\s+test\b|\bcargo\s+test\b/;
507
507
  var FAIL = /\b\d+\s+failed\b|\bFAIL\b|✗/;
508
508
  var E2E_TOOL = /muggle.*(execute|test-generation|replay)/i;
509
- var E2E_SKIP_MARKER = /^\s*echo\s+["']?MUGGLE_E2E_SKIP\b/;
510
509
  function isTestCommand(cmd) {
511
510
  return TEST_CMD.test(cmd);
512
511
  }
513
- function isE2ESkipMarker(cmd) {
514
- return E2E_SKIP_MARKER.test(cmd);
515
- }
516
512
  function testsPassed(input2) {
517
513
  const out = `${input2.tool_response?.stdout ?? ""}
518
514
  ${input2.tool_response?.stderr ?? ""}`;
@@ -711,12 +707,12 @@ function scanForOwedWalkthroughs(state, lookup = defaultPrWalkthroughLookup) {
711
707
  const branchPrUrl = lookup.branchPrUrl();
712
708
  if (branchPrUrl) candidates.add(branchPrUrl);
713
709
  const owed = [];
714
- const verified = [];
710
+ const verified2 = [];
715
711
  for (const prUrl of candidates) {
716
- if (lookup.prCarriesWalkthrough(prUrl)) verified.push(prUrl);
712
+ if (lookup.prCarriesWalkthrough(prUrl)) verified2.push(prUrl);
717
713
  else owed.push(prUrl);
718
714
  }
719
- return { owed, verified };
715
+ return { owed, verified: verified2 };
720
716
  }
721
717
  function walkthroughGateDecision(state, owedPrUrls, maxBlocks = MAX_WALKTHROUGH_BLOCKS) {
722
718
  const blockCount = state.walkthroughBlockCount ?? 0;
@@ -730,6 +726,56 @@ function walkthroughGateDecision(state, owedPrUrls, maxBlocks = MAX_WALKTHROUGH_
730
726
  return { action: "block" /* Block */, blockCount: blockCount + 1, owed: owedPrUrls };
731
727
  }
732
728
 
729
+ // src/e2e-skip/types.ts
730
+ var E2eSkipCode = /* @__PURE__ */ ((E2eSkipCode3) => {
731
+ E2eSkipCode3["NoWebSurface"] = "NO_WEB_SURFACE";
732
+ E2eSkipCode3["DevServerUnreachable"] = "DEV_SERVER_UNREACHABLE";
733
+ E2eSkipCode3["EmptyDiff"] = "EMPTY_DIFF";
734
+ E2eSkipCode3["MuggleAuthDown"] = "MUGGLE_AUTH_DOWN";
735
+ E2eSkipCode3["NoPr"] = "NO_PR";
736
+ E2eSkipCode3["UserWaived"] = "USER_WAIVED";
737
+ return E2eSkipCode3;
738
+ })(E2eSkipCode || {});
739
+
740
+ // src/e2e-skip/constants.ts
741
+ var E2E_SKIP_DECLARATION = /^\s*echo\s+["']?MUGGLE_E2E_SKIP:(.*)$/;
742
+ var SKIP_CODE_TOKEN = /^[\s:]*([A-Za-z_]+)/;
743
+ var E2E_SKIP_CODES = Object.values(E2eSkipCode);
744
+ var E2E_SKIP_CODE_CLAIMS = {
745
+ ["NO_WEB_SURFACE" /* NoWebSurface */]: "the repo ships no web surface to drive",
746
+ ["DEV_SERVER_UNREACHABLE" /* DevServerUnreachable */]: "the named dev server does not answer",
747
+ ["EMPTY_DIFF" /* EmptyDiff */]: "the branch carries no diff against its base",
748
+ ["MUGGLE_AUTH_DOWN" /* MuggleAuthDown */]: "the stored Muggle session is missing or expired",
749
+ ["NO_PR" /* NoPr */]: "no pull request was handled this session",
750
+ ["USER_WAIVED" /* UserWaived */]: "the user waived E2E in their own words"
751
+ };
752
+ var USER_WAIVE_PHRASE = "SKIP E2E";
753
+ var DEV_SERVER_PROBE_TIMEOUT_MS = 2e3;
754
+ var WEB_SERVER_COMMAND = /\b(vite|next\s+(dev|start)|react-scripts|ng\s+serve|nuxt|astro\s+dev|remix\s+dev|webpack(-dev)?-server|http-server|serve\s+-s|gatsby\s+develop|vue-cli-service\s+serve)\b/;
755
+ var BROWSER_FRAMEWORK_PACKAGES = [
756
+ "react-dom",
757
+ "next",
758
+ "vue",
759
+ "svelte",
760
+ "@angular/core",
761
+ "nuxt",
762
+ "astro",
763
+ "@remix-run/react",
764
+ "preact",
765
+ "solid-js"
766
+ ];
767
+ var WEB_SURFACE_CONFIG_FILES = [
768
+ "next.config.js",
769
+ "next.config.mjs",
770
+ "next.config.ts",
771
+ "vite.config.js",
772
+ "vite.config.ts",
773
+ "index.html",
774
+ "public/index.html"
775
+ ];
776
+ var OAUTH_SESSION_FILE = /^oauth-session[\w.-]*\.json$/;
777
+ var MUGGLE_HOME_DIR_NAME = ".muggle-ai";
778
+
733
779
  // src/pr-walkthrough/comment.ts
734
780
  function renderReservedComment() {
735
781
  return `${WALKTHROUGH_SLOT_MARKER}
@@ -737,14 +783,21 @@ ${WALKTHROUGH_COMMENT_HEADING}
737
783
 
738
784
  _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._`;
739
785
  }
740
- function renderSkippedComment(reason) {
741
- const stated = reason.trim();
742
- if (!stated) return renderReservedComment();
786
+ function renderSkippedComment(code, detail) {
787
+ const stated = detail.trim();
788
+ const qualifier = stated ? ` \u2014 ${stated}` : "";
743
789
  return `${WALKTHROUGH_SLOT_MARKER}
744
790
  ${WALKTHROUGH_SKIPPED_MARKER}
745
791
  ${WALKTHROUGH_COMMENT_HEADING}
746
792
 
747
- **E2E skipped** \u2014 ${stated}`;
793
+ **No E2E run.** Muggle verified \`${code}\` \u2014 ${E2E_SKIP_CODE_CLAIMS[code]}${qualifier}.`;
794
+ }
795
+ function renderUnreasonedSkipComment() {
796
+ return `${WALKTHROUGH_SLOT_MARKER}
797
+ ${WALKTHROUGH_SKIPPED_MARKER}
798
+ ${WALKTHROUGH_COMMENT_HEADING}
799
+
800
+ **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.`;
748
801
  }
749
802
  function classifyComment(body) {
750
803
  if (body.includes(REPORT_SENTINEL)) return "reported" /* Reported */;
@@ -827,26 +880,237 @@ function reserveWalkthroughComment(prUrl, run = defaultGhRunner) {
827
880
  if (comments === null) return false;
828
881
  return reserveComment(pr, comments, run);
829
882
  }
830
- function settleWalkthroughCommentAsSkipped(prUrl, reason, run = defaultGhRunner) {
831
- if (!reason.trim()) return false;
883
+ function settleWith(prUrl, body, run) {
832
884
  const pr = parsePrUrl(prUrl);
833
885
  if (!pr) return false;
834
886
  const comments = listPrComments(pr, run);
835
887
  if (comments === null) return false;
836
888
  if (commentWithStatus(comments, "reported" /* Reported */)) return false;
837
889
  if (commentWithStatus(comments, "skipped" /* Skipped */)) return false;
838
- const body = renderSkippedComment(reason);
839
890
  const reserved = commentWithStatus(comments, "pending" /* Pending */);
840
891
  return reserved ? patchPrComment(pr, reserved.id, body, run) : postPrComment(pr, body, run);
841
892
  }
893
+ function settleWalkthroughCommentAsSkipped(prUrl, code, detail, run = defaultGhRunner) {
894
+ return settleWith(prUrl, renderSkippedComment(code, detail), run);
895
+ }
896
+ function settleWalkthroughCommentAsUnreasoned(prUrl, run = defaultGhRunner) {
897
+ return settleWith(prUrl, renderUnreasonedSkipComment(), run);
898
+ }
842
899
 
843
- // src/guardrails/skipReason.ts
844
- var SKIP_DECLARATION = /^\s*echo\s+["']?MUGGLE_(?:E2E|WALKTHROUGH)_SKIP:\s*(.+)$/;
845
- function skipReasonFrom(cmd) {
846
- const declared = cmd.match(SKIP_DECLARATION);
900
+ // src/e2e-skip/parseSkipDeclaration.ts
901
+ var stripQuotes = (text) => text.replace(/["']\s*$/, "").trim();
902
+ var asSkipCode = (candidate) => {
903
+ const upper = candidate.toUpperCase();
904
+ return E2E_SKIP_CODES.find((code) => code === upper) ?? null;
905
+ };
906
+ function judgeSkipDeclaration(cmd) {
907
+ const declared = cmd.match(E2E_SKIP_DECLARATION);
847
908
  if (!declared) return null;
848
- const reason = declared[1].replace(/["']\s*$/, "").trim();
849
- return reason || null;
909
+ const stated = stripQuotes(declared[1] ?? "");
910
+ if (!stated) return { accepted: false, rejection: "missing-code" /* MissingCode */ };
911
+ const token = stated.match(SKIP_CODE_TOKEN)?.[1];
912
+ if (!token) return { accepted: false, rejection: "missing-code" /* MissingCode */ };
913
+ const code = asSkipCode(token);
914
+ if (!code) return { accepted: false, rejection: "unknown-code" /* UnknownCode */, claimedCode: token };
915
+ const detail = stated.slice(stated.indexOf(token) + token.length).replace(/^[\s:]+/, "").trim();
916
+ return { accepted: true, skip: { code, detail } };
917
+ }
918
+ var verified = { verified: true };
919
+ var refuted = (failure) => ({ verified: false, failure });
920
+ var URL_IN_DETAIL = /https?:\/\/[^\s"']+|localhost:\d+/i;
921
+ var parseManifest = (manifest) => {
922
+ if (!manifest) return {};
923
+ try {
924
+ return JSON.parse(manifest);
925
+ } catch {
926
+ return {};
927
+ }
928
+ };
929
+ var verifyNoWebSurface = (context) => {
930
+ if (!context.cwd) return refuted("no working directory was available to inspect");
931
+ const manifest = parseManifest(context.probe.readTextFile(join(context.cwd, "package.json")));
932
+ const serving = Object.entries(manifest.scripts ?? {}).find(
933
+ ([, command]) => WEB_SERVER_COMMAND.test(command)
934
+ );
935
+ if (serving) return refuted(`the "${serving[0]}" script runs a web server (${serving[1]})`);
936
+ const dependencies = { ...manifest.dependencies, ...manifest.devDependencies };
937
+ const framework = BROWSER_FRAMEWORK_PACKAGES.find((name) => name in dependencies);
938
+ if (framework) return refuted(`${framework} is a declared dependency`);
939
+ const config = WEB_SURFACE_CONFIG_FILES.find(
940
+ (file) => context.probe.readTextFile(join(context.cwd, file)) !== null
941
+ );
942
+ if (config) return refuted(`the repo contains ${config}`);
943
+ return verified;
944
+ };
945
+ var verifyDevServerUnreachable = (context, detail) => {
946
+ const url = detail.match(URL_IN_DETAIL)?.[0];
947
+ if (!url) return refuted("the detail names no url to probe");
948
+ if (context.probe.isReachable(url)) return refuted(`${url} answered the probe`);
949
+ return verified;
950
+ };
951
+ var verifyEmptyDiff = (context) => {
952
+ if (!context.cwd) return refuted("no working directory was available to inspect");
953
+ const head = context.probe.runGit(["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], context.cwd);
954
+ const base = head?.trim();
955
+ if (!base) return refuted("the repo's base branch could not be resolved");
956
+ const diff = context.probe.runGit(["diff", `${base}...HEAD`, "--stat"], context.cwd);
957
+ if (diff === null) return refuted("the diff against the base branch could not be read");
958
+ if (diff.trim()) return refuted(`the branch carries a diff against ${base}`);
959
+ return verified;
960
+ };
961
+ var liveSessionExpiry = (session) => {
962
+ if (!session) return null;
963
+ try {
964
+ const expiresAt = JSON.parse(session).expiresAt;
965
+ if (!expiresAt) return null;
966
+ const expiry = Date.parse(expiresAt);
967
+ return !Number.isNaN(expiry) && expiry > Date.now() ? expiresAt : null;
968
+ } catch {
969
+ return null;
970
+ }
971
+ };
972
+ var verifyMuggleAuthDown = (context) => {
973
+ const muggleHome = join(context.homeDir, MUGGLE_HOME_DIR_NAME);
974
+ const sessionFiles = context.probe.listFiles(muggleHome).filter((name) => OAUTH_SESSION_FILE.test(name));
975
+ for (const name of sessionFiles) {
976
+ const expiresAt = liveSessionExpiry(context.probe.readTextFile(join(muggleHome, name)));
977
+ if (expiresAt) return refuted(`${name} holds a session valid until ${expiresAt}`);
978
+ }
979
+ return verified;
980
+ };
981
+ var verifyNoPr = (context) => {
982
+ if (context.prsHandled.length === 0) return verified;
983
+ return refuted(`this session handled ${context.prsHandled.join(", ")}`);
984
+ };
985
+ var verifyUserWaived = (context) => {
986
+ if (!context.transcriptPath) return refuted("no transcript was available to read the waive from");
987
+ const transcript = context.probe.readTextFile(context.transcriptPath);
988
+ if (!transcript) return refuted("the transcript could not be read");
989
+ const tail = transcript.slice(-64e3);
990
+ const waived = tail.split("\n").some((line) => {
991
+ if (!line.includes(USER_WAIVE_PHRASE)) return false;
992
+ try {
993
+ const entry = JSON.parse(line);
994
+ const isUserTurn = entry.type === "user" || entry.message?.role === "user";
995
+ return isUserTurn && JSON.stringify(entry.message?.content ?? "").includes(USER_WAIVE_PHRASE);
996
+ } catch {
997
+ return false;
998
+ }
999
+ });
1000
+ if (waived) return verified;
1001
+ return refuted(`no user turn in the transcript contains "${USER_WAIVE_PHRASE}"`);
1002
+ };
1003
+ var VERIFIERS = {
1004
+ ["NO_WEB_SURFACE" /* NoWebSurface */]: verifyNoWebSurface,
1005
+ ["DEV_SERVER_UNREACHABLE" /* DevServerUnreachable */]: verifyDevServerUnreachable,
1006
+ ["EMPTY_DIFF" /* EmptyDiff */]: verifyEmptyDiff,
1007
+ ["MUGGLE_AUTH_DOWN" /* MuggleAuthDown */]: verifyMuggleAuthDown,
1008
+ ["NO_PR" /* NoPr */]: verifyNoPr,
1009
+ ["USER_WAIVED" /* UserWaived */]: verifyUserWaived
1010
+ };
1011
+ function verifyDeclaredSkip(skip, context) {
1012
+ return VERIFIERS[skip.code](context, skip.detail);
1013
+ }
1014
+
1015
+ // src/e2e-skip/resolveSkip.ts
1016
+ function resolveSkipDeclaration(cmd, context) {
1017
+ const judged = judgeSkipDeclaration(cmd);
1018
+ if (!judged || !judged.accepted) return judged;
1019
+ const checked = verifyDeclaredSkip(judged.skip, context);
1020
+ if (checked.verified) return judged;
1021
+ return {
1022
+ accepted: false,
1023
+ rejection: "verification-failed" /* VerificationFailed */,
1024
+ claimedCode: judged.skip.code,
1025
+ failure: checked.failure
1026
+ };
1027
+ }
1028
+ var codeMenu = () => E2E_SKIP_CODES.map((code) => `${code} (${E2E_SKIP_CODE_CLAIMS[code]})`).join(", ");
1029
+ function explainRejection(judged) {
1030
+ if (judged.rejection === "missing-code" /* MissingCode */) {
1031
+ return `That skip declaration states no code. E2E is skipped only by citing one of: ${codeMenu()}.`;
1032
+ }
1033
+ if (judged.rejection === "unknown-code" /* UnknownCode */) {
1034
+ return `"${judged.claimedCode}" is not a skip code, so E2E is still owed. A skip must name a fact about the environment, not a judgment about the change: ${codeMenu()}. If none of them is true, the run has to happen.`;
1035
+ }
1036
+ 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.`;
1037
+ }
1038
+ var readTextFile = (path) => {
1039
+ try {
1040
+ return readFileSync(path, "utf8");
1041
+ } catch {
1042
+ return null;
1043
+ }
1044
+ };
1045
+ var listFiles = (dir) => {
1046
+ try {
1047
+ return readdirSync(dir);
1048
+ } catch {
1049
+ return [];
1050
+ }
1051
+ };
1052
+ var runGit = (args, cwd) => {
1053
+ try {
1054
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
1055
+ } catch {
1056
+ return null;
1057
+ }
1058
+ };
1059
+ var hostAndPort = (url) => {
1060
+ const withScheme = /^https?:\/\//i.test(url) ? url : `http://${url}`;
1061
+ try {
1062
+ const parsed = new URL(withScheme);
1063
+ const port = parsed.port ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80;
1064
+ return { host: parsed.hostname, port };
1065
+ } catch {
1066
+ return null;
1067
+ }
1068
+ };
1069
+ var isReachable = (url) => {
1070
+ const target = hostAndPort(url);
1071
+ if (!target) return false;
1072
+ const script = `const net=require('net');const s=net.connect(${target.port},${JSON.stringify(target.host)});s.setTimeout(${DEV_SERVER_PROBE_TIMEOUT_MS});s.on('connect',()=>{s.destroy();process.exit(0)});s.on('timeout',()=>{s.destroy();process.exit(1)});s.on('error',()=>process.exit(1));`;
1073
+ try {
1074
+ execFileSync(process.execPath, ["-e", script], {
1075
+ timeout: DEV_SERVER_PROBE_TIMEOUT_MS * 2,
1076
+ stdio: "ignore"
1077
+ });
1078
+ return true;
1079
+ } catch {
1080
+ return false;
1081
+ }
1082
+ };
1083
+ var defaultSkipProbes = {
1084
+ readTextFile,
1085
+ listFiles,
1086
+ runGit,
1087
+ isReachable
1088
+ };
1089
+
1090
+ // src/guardrails/skipReason.ts
1091
+ function judgeE2eSkip(input2, state, probes = defaultSkipProbes) {
1092
+ return resolveSkipDeclaration(input2.tool_input?.command ?? "", {
1093
+ cwd: input2.cwd,
1094
+ prsHandled: state.prsHandled,
1095
+ transcriptPath: input2.transcript_path,
1096
+ homeDir: homedir(),
1097
+ probe: probes
1098
+ });
1099
+ }
1100
+
1101
+ // src/guardrails/walkthroughHeadingGate.ts
1102
+ function evaluateWalkthroughHeadingPost(input2, state, read = defaultFileReader) {
1103
+ if (!isShellToolCall(input2)) return { deny: false };
1104
+ if (state.e2eRun === true) return { deny: false };
1105
+ const cmd = input2.tool_input?.command ?? "";
1106
+ if (!isPrReportPostCommand(cmd)) return { deny: false };
1107
+ const text = collectPrPostText(cmd, input2.cwd, read);
1108
+ const claimsIdentity = text.includes(WALKTHROUGH_COMMENT_HEADING) || text.includes(WALKTHROUGH_SLOT_MARKER);
1109
+ if (!claimsIdentity) return { deny: false };
1110
+ return {
1111
+ deny: true,
1112
+ reason: 'Blocked: this comment carries the Muggle walkthrough heading, but no Muggle acceptance run has been recorded this session. That slot is settled by Muggle itself \u2014 either from a real run, or from a verified skip code via `echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"`. If you verified this change some other way, post that as its own comment without the Muggle heading, slot marker, or report sentinel.'
1113
+ };
850
1114
  }
851
1115
 
852
1116
  // src/guardrails/ledger/constants.ts
@@ -1230,10 +1494,11 @@ function terminalGate() {
1230
1494
  function recordTests() {
1231
1495
  const cmd = input.tool_input?.command ?? "";
1232
1496
  const state = readState(sessionId);
1497
+ const judgedSkip = judgeE2eSkip(input, state);
1233
1498
  const recorded = applyRecordedRun(state, {
1234
1499
  unitTestPassed: isTestCommand(cmd) && testsPassed(input),
1235
1500
  e2eRan: isE2ERun(input),
1236
- e2eSkipped: isE2ESkipMarker(cmd)
1501
+ e2eSkipped: judgedSkip?.accepted === true
1237
1502
  });
1238
1503
  const withWatchSkip = applyWatchSkip(recorded, isWatchSkipMarker(cmd));
1239
1504
  const withBuildSkip = applyBuildSkip(withWatchSkip, isBuildSkipMarker(cmd));
@@ -1242,14 +1507,15 @@ function recordTests() {
1242
1507
  const failedRunId = detectFailedRunId(input);
1243
1508
  const next = failedRunId ? applyFailedRun(withWalkthroughSkip, failedRunId) : withWalkthroughSkip;
1244
1509
  if (next !== state) writeState(next);
1245
- recordSkipReasonOnPrs(next, cmd);
1510
+ if (judgedSkip?.accepted) recordSkipReasonOnPrs(next, judgedSkip.skip);
1511
+ if (judgedSkip && !judgedSkip.accepted) {
1512
+ return envelope("PostToolUse", explainRejection(judgedSkip), host);
1513
+ }
1246
1514
  return "{}";
1247
1515
  }
1248
- function recordSkipReasonOnPrs(state, cmd) {
1249
- const reason = skipReasonFrom(cmd);
1250
- if (!reason) return;
1516
+ function recordSkipReasonOnPrs(state, skip) {
1251
1517
  for (const prUrl of state.prsHandled) {
1252
- settleWalkthroughCommentAsSkipped(prUrl, reason);
1518
+ settleWalkthroughCommentAsSkipped(prUrl, skip.code, skip.detail);
1253
1519
  }
1254
1520
  }
1255
1521
  function skillStages() {
@@ -1325,11 +1591,14 @@ function debugPathGate() {
1325
1591
  function e2eGate() {
1326
1592
  const state = readState(sessionId);
1327
1593
  const decision = e2eGateDecision(state);
1328
- if (decision.action === "release" /* Release */) return releaseGate("e2eReleased");
1594
+ if (decision.action === "release" /* Release */) {
1595
+ for (const prUrl of state.prsHandled) settleWalkthroughCommentAsUnreasoned(prUrl);
1596
+ return releaseGate("e2eReleased");
1597
+ }
1329
1598
  if (decision.action === "none" /* None */) return "{}";
1330
1599
  state.e2eBlockCount = decision.blockCount;
1331
1600
  writeState(state);
1332
- 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 /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>"\`.`;
1601
+ 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 /muggle:muggle-test, then finish. A skip states a fact about the environment, never a judgment about the change: run \`echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"\` citing one of ${E2E_SKIP_CODES.join(", ")}. Muggle verifies the code itself, so one that does not hold leaves this gate blocked.` : `E2E acceptance run still owed (reminder ${decision.blockCount}/${MAX_E2E_BLOCKS}): run /muggle:muggle-test, or cite a verifiable code via \`echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"\`.`;
1333
1602
  return blockStop(reason, host);
1334
1603
  }
1335
1604
  function watchGate() {
@@ -1435,6 +1704,11 @@ function reportGate() {
1435
1704
  if (!reportPostVerdict.deny || !reportPostVerdict.reason) return "{}";
1436
1705
  return denyTool(reportPostVerdict.reason, host);
1437
1706
  }
1707
+ function walkthroughHeadingGate() {
1708
+ const headingVerdict = evaluateWalkthroughHeadingPost(input, readState(sessionId));
1709
+ if (!headingVerdict.deny || !headingVerdict.reason) return "{}";
1710
+ return denyTool(headingVerdict.reason, host);
1711
+ }
1438
1712
  function resolveGate() {
1439
1713
  const resolveVerdict = evaluateReviewThreadResolve(input);
1440
1714
  if (!resolveVerdict.deny || !resolveVerdict.reason) return "{}";
@@ -1482,6 +1756,7 @@ var handlers = {
1482
1756
  "record-comment-replies": recordCommentReplies,
1483
1757
  "comment-reply-gate": commentReplyGate,
1484
1758
  "report-gate": reportGate,
1759
+ "walkthrough-heading-gate": walkthroughHeadingGate,
1485
1760
  "resolve-gate": resolveGate,
1486
1761
  "build-router": buildRouter,
1487
1762
  "skill-stages": skillStages,
@@ -21,7 +21,9 @@ This stage is **mode-driven by pre-flight**:
21
21
  - `local-e2e` runs the local browser flow (`test-feature-local` approach).
22
22
  - `unit-only` or `skip` does not execute browser runs and must emit an explicit non-pass verdict (`SKIPPED` / `UNIT-ONLY` equivalent in downstream reporting).
23
23
 
24
- **Every SKIPPED exit from this stage** (Step 0 poll-only, Step 1 `unit-only`/`skip`, Step 1.5 placeholder branch) also runs `echo "MUGGLE_E2E_SKIP: <one-line reason>"` as a single Bash call before exiting — it records the skip in guardrail session state so the Stop-hook E2E gate releases instead of blocking the turn.
24
+ **Every SKIPPED exit from this stage** (Step 0 poll-only, Step 1 `unit-only`/`skip`, Step 1.5 placeholder branch) also runs `echo "MUGGLE_E2E_SKIP: <CODE>: <detail>"` as a single Bash call before exiting — it records the skip in guardrail session state so the Stop-hook E2E gate releases instead of blocking the turn.
25
+
26
+ `<CODE>` is one of `NO_WEB_SURFACE`, `DEV_SERVER_UNREACHABLE`, `EMPTY_DIFF`, `MUGGLE_AUTH_DOWN`, `NO_PR`, `USER_WAIVED`, and the guardrail verifies it against the environment before the gate releases. Every code names a fact about the environment; none names a judgment about the change, so "static page", "layout only", "unit tests cover it" and "verified with another tool" have no way through — cite a code that holds, or run the tests. `USER_WAIVED` needs the user's own literal `SKIP E2E` in the transcript, never an inferred go-ahead. A rejected declaration is answered with the check that failed, and the turn stays blocked.
25
27
  - `staging-replay` is not executed in this stage path and should be surfaced as `INCONCLUSIVE` unless the caller has already routed to a dedicated staging runner.
26
28
 
27
29
  For local runs, the tool boundaries are:
@@ -50,7 +52,7 @@ You receive everything from `state.md` already — pre-flight resolved it:
50
52
 
51
53
  Read `state.md`.
52
54
 
53
- **No `## Pre-flight answers` block at all** → the session was seeded poll-only (e.g. by auto-track, [`../muggle-pr-followup/auto-track.md`](../muggle-pr-followup/auto-track.md)). Treat `Validation` as `skip`: emit a `SKIPPED` report with reason `no validation context seeded` and exit cleanly. The watcher owns no E2E context by design; "no context" is a clean skip, not a failure.
55
+ **No `## Pre-flight answers` block at all** → the session was seeded poll-only (e.g. by auto-track, [`../muggle-pr-followup/auto-track.md`](../muggle-pr-followup/auto-track.md)). Treat `Validation` as `skip`: emit a `SKIPPED` report citing `NO_PR` and exit cleanly. The watcher owns no E2E context by design; "no context" is a clean skip, not a failure.
54
56
 
55
57
  Otherwise the block was seeded by pre-flight or bootstrap per [`../_shared/resolve-e2e-validation-context.md`](../_shared/resolve-e2e-validation-context.md) — read it the same way regardless of seeder. The persisted `Validation` field (`local-e2e`, `staging-replay`, `unit-only`, `skip`) picks execution vs early-exit below. In a forward run, [`autoE2ETest`](../muggle-preferences/preference-gates/autoE2ETest.md) `ask` was resolved by pre-flight Q13; in a watcher cycle there is no per-tick pre-flight, so `Validation` **is** the standing decision — don't re-resolve `ask`.
56
58
 
@@ -68,13 +70,13 @@ Before launching the local runner:
68
70
 
69
71
  Pre-flight handled auth. If `muggle-remote-auth-status` somehow shows expired here (session clock skew, etc.), re-auth silently via `muggle-remote-auth-login` + `muggle-remote-auth-poll` — but do not ask the user "continue with this account?" again.
70
72
 
71
- If validation is `unit-only` or `skip`, emit a `SKIPPED` report with a one-line reason and exit cleanly.
73
+ If validation is `unit-only` or `skip`, emit a `SKIPPED` report citing the code that holds — `NO_WEB_SURFACE` when the repo has nothing to drive, `DEV_SERVER_UNREACHABLE` when it does but nothing answers — and exit cleanly.
72
74
 
73
75
  If validation is `staging-replay`, emit `INCONCLUSIVE` with reason `staging replay not handled in Stage 6 local runner path` and exit cleanly.
74
76
 
75
77
  ### Step 1.5: Placeholder branch detection
76
78
 
77
- Read `pathClassification` from the impact-analysis output (emitted by `do/impact-analysis.md`). If it is `none` — i.e. `git diff <default-branch>...HEAD --stat` was empty after rebase — there is no code under test and running test cases would only re-test master. Write a one-paragraph SKIPPED result to the E2E report (or return a SKIPPED verdict to the caller) and exit the stage cleanly. **Do not** synthesize test cases or run anything.
79
+ Read `pathClassification` from the impact-analysis output (emitted by `do/impact-analysis.md`). If it is `none` — i.e. `git diff <default-branch>...HEAD --stat` was empty after rebase — there is no code under test and running test cases would only re-test master. Write a SKIPPED result citing `EMPTY_DIFF` to the E2E report (or return that verdict to the caller) and exit the stage cleanly. **Do not** synthesize test cases or run anything.
78
80
 
79
81
  ### Step 1.7: Route + project classification
80
82
 
@@ -45,7 +45,7 @@ Do not create or update a PR until each line holds, or is waived by a one-line r
45
45
  - Build clean — typecheck + lint on changed files
46
46
  - New/changed logic carries unit tests (authored in Stage 3; Stage 5 only runs the suite)
47
47
  - Unit suite run, PASS recorded
48
- - E2E verdict recorded with `runId` per `autoE2ETest` — or `[E2E FAILING]` / `SKIPPED` + reason
48
+ - E2E verdict recorded with `runId` per `autoE2ETest` — or `[E2E FAILING]`, or `SKIPPED` citing one of the verified skip codes (`NO_WEB_SURFACE`, `DEV_SERVER_UNREACHABLE`, `EMPTY_DIFF`, `MUGGLE_AUTH_DOWN`, `NO_PR`, `USER_WAIVED`). Free-text reasons are rejected by the gate
49
49
  - UI changes verified in a real browser with evidence (screenshot path or muggle `runId`); `curl` + `grep` is not verification
50
50
 
51
51
  Opening a PR with an unchecked, unwaived line is a cycle failure.
@@ -39,3 +39,4 @@ Callers consult the `postPRVisualWalkthrough` gate **before** invoking this skil
39
39
  - Mode is chosen by the caller, not the user.
40
40
  - Never create a PR without confirmation in Mode A.
41
41
  - Don't run tests — this skill only dispatches rendering/posting of existing results. No report and no run identifiers in context → redirect the caller to `muggle-test`, `muggle-test-feature-local`, or `muggle-do`.
42
+ - The designated comment carries a real Muggle run or a verified `MUGGLE_E2E_SKIP` code, and nothing else. Never put another tool's findings — puppeteer output, a unit-test summary, a manual check — under the walkthrough heading or the slot marker: that heading is Muggle vouching for a run, and a PreToolUse guard denies the post outright when no run was recorded. Supplemental evidence is welcome as its own comment, without the heading or marker.