@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.
- package/dist/{chunk-H2KMRAQZ.js → chunk-4M3P45WX.js} +14 -0
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/.cursor-plugin/plugin.json +1 -1
- package/dist/plugin/hooks/hooks.json +6 -0
- package/dist/plugin/scripts/guardrail-walkthrough-heading.sh +24 -0
- package/dist/plugin/scripts/guardrails.mjs +303 -28
- package/dist/plugin/skills/do/e2e-acceptance.md +6 -4
- package/dist/plugin/skills/muggle-do/SKILL.md +1 -1
- package/dist/plugin/skills/muggle-pr-visual-walkthrough/SKILL.md +1 -0
- package/dist/release-manifest.json +3 -3
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.cursor-plugin/plugin.json +1 -1
- package/plugin/hooks/hooks.json +6 -0
- package/plugin/scripts/guardrail-walkthrough-heading.sh +24 -0
- package/plugin/scripts/guardrails.mjs +303 -28
- package/plugin/skills/do/e2e-acceptance.md +6 -4
- package/plugin/skills/muggle-do/SKILL.md +1 -1
- package/plugin/skills/muggle-pr-visual-walkthrough/SKILL.md +1 -0
|
@@ -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
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-
|
|
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.
|
|
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.
|
|
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
|
|
710
|
+
const verified2 = [];
|
|
715
711
|
for (const prUrl of candidates) {
|
|
716
|
-
if (lookup.prCarriesWalkthrough(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(
|
|
741
|
-
const stated =
|
|
742
|
-
|
|
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
|
|
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
|
|
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/
|
|
844
|
-
var
|
|
845
|
-
|
|
846
|
-
const
|
|
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
|
|
849
|
-
return
|
|
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:
|
|
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,
|
|
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,
|
|
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,
|
|
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 */)
|
|
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.
|
|
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: <
|
|
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
|
|
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
|
|
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
|
|
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]
|
|
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-
|
|
4
|
-
"commitSha": "
|
|
5
|
-
"buildTime": "2026-09-
|
|
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.
|
|
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.
|
|
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.
|
|
5
|
+
"version": "5.20.0-staging.135",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Muggle AI",
|
|
8
8
|
"email": "support@muggle-ai.com"
|
package/plugin/hooks/hooks.json
CHANGED
|
@@ -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
|
|
710
|
+
const verified2 = [];
|
|
715
711
|
for (const prUrl of candidates) {
|
|
716
|
-
if (lookup.prCarriesWalkthrough(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(
|
|
741
|
-
const stated =
|
|
742
|
-
|
|
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
|
|
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
|
|
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/
|
|
844
|
-
var
|
|
845
|
-
|
|
846
|
-
const
|
|
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
|
|
849
|
-
return
|
|
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:
|
|
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,
|
|
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,
|
|
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,
|
|
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 */)
|
|
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.
|
|
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: <
|
|
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
|
|
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
|
|
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
|
|
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]
|
|
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.
|