@muggleai/works 5.1.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/{chunk-2BHC6TQR.js → chunk-IGXNFT4M.js} +11 -4
  2. package/dist/{chunk-MOHR7QUN.js → chunk-MPLQNDZX.js} +52 -20
  3. package/dist/cli.js +2 -2
  4. package/dist/index.js +2 -2
  5. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  6. package/dist/plugin/.cursor-plugin/plugin.json +1 -1
  7. package/dist/plugin/hooks/README.md +19 -8
  8. package/dist/plugin/hooks/hooks.json +22 -0
  9. package/dist/plugin/scripts/guardrail-report-format.sh +9 -0
  10. package/dist/plugin/scripts/guardrails.mjs +104 -15
  11. package/dist/plugin/skills/_shared/dev-loop/run.md +1 -1
  12. package/dist/plugin/skills/_shared/failure-mode-handling.md +1 -1
  13. package/dist/plugin/skills/do/e2e-acceptance.md +1 -1
  14. package/dist/plugin/skills/muggle-browser-task/SKILL.md +1 -0
  15. package/dist/plugin/skills/muggle-preferences/preference-gates/README.md +8 -5
  16. package/dist/plugin/skills/muggle-test/SKILL.md +1 -1
  17. package/dist/plugin/skills/muggle-test/execute-remote.md +1 -1
  18. package/dist/plugin/skills/muggle-test-feature-local/SKILL.md +1 -1
  19. package/dist/release-manifest.json +4 -4
  20. package/dist/{src-SS2AL3O7.js → src-MQFKDZW5.js} +1 -1
  21. package/package.json +8 -7
  22. package/plugin/.claude-plugin/plugin.json +1 -1
  23. package/plugin/.cursor-plugin/plugin.json +1 -1
  24. package/plugin/hooks/README.md +19 -8
  25. package/plugin/hooks/hooks.json +22 -0
  26. package/plugin/scripts/guardrail-report-format.sh +9 -0
  27. package/plugin/scripts/guardrails.mjs +104 -15
  28. package/plugin/skills/_shared/dev-loop/run.md +1 -1
  29. package/plugin/skills/_shared/failure-mode-handling.md +1 -1
  30. package/plugin/skills/do/e2e-acceptance.md +1 -1
  31. package/plugin/skills/muggle-browser-task/SKILL.md +1 -0
  32. package/plugin/skills/muggle-preferences/preference-gates/README.md +8 -5
  33. package/plugin/skills/muggle-test/SKILL.md +1 -1
  34. package/plugin/skills/muggle-test/execute-remote.md +1 -1
  35. package/plugin/skills/muggle-test-feature-local/SKILL.md +1 -1
@@ -1,4 +1,4 @@
1
- import { __export, getLogger, getConfig, createChildLogger, buildElectronAppReleaseAssetUrl, getAuthService, hasApiKey, getElectronAppVersion, getElectronAppDir, getPlatformKey, isFirstRun, writePreferences, DEFAULT_PREFERENCES, getDataDir, PREFERENCES_FILE_NAME, isElectronAppInstalled, getElectronAppChecksums, getChecksumForPlatform, verifyFileChecksum, calculateFileChecksum, initTelemetry, Surface, ServiceName, track, EventName, getQaTools, getLocalQaTools, performLogout, performLogin, toolRequiresAuth, getCallerCredentials, hasShownDisclosure, getDisclosureCopy, markDisclosureShown, getBundledElectronAppVersion, getElectronAppVersionSource, getCredentialsFilePath, buildElectronAppChecksumsUrl, __require } from './chunk-MOHR7QUN.js';
1
+ import { __export, getLogger, getConfig, createChildLogger, buildElectronAppReleaseAssetUrl, getAuthService, hasApiKey, getElectronAppVersion, getElectronAppDir, getPlatformKey, isFirstRun, writePreferences, DEFAULT_PREFERENCES, getDataDir, PREFERENCES_FILE_NAME, isElectronAppInstalled, getElectronAppChecksums, getChecksumForPlatform, verifyFileChecksum, calculateFileChecksum, initTelemetry, Surface, ServiceName, track, EventName, getQaTools, getLocalQaTools, performLogout, performLogin, toolRequiresAuth, getCallerCredentials, hasShownDisclosure, getDisclosureCopy, markDisclosureShown, getBundledElectronAppVersion, getElectronAppVersionSource, getCredentialsFilePath, buildElectronAppChecksumsUrl, __require } from './chunk-MPLQNDZX.js';
2
2
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
3
  import { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
4
  import { v4 } from 'uuid';
@@ -736,7 +736,7 @@ async function resolveGsScreenshotUrls(report, opts) {
736
736
  if (gsUrls.length === 0) {
737
737
  return report;
738
738
  }
739
- const mcps = await import('./src-SS2AL3O7.js');
739
+ const mcps = await import('./src-MQFKDZW5.js');
740
740
  const credentials = await mcps.getCallerCredentialsAsync();
741
741
  if (!credentials.bearerToken && !credentials.apiKey) {
742
742
  stderrWrite(
@@ -784,6 +784,9 @@ async function resolveGsScreenshotUrls(report, opts) {
784
784
 
785
785
  // src/cli/build-pr-section.ts
786
786
  var DEFAULT_MAX_BODY_BYTES = 6e4;
787
+ var REPORT_SECTION_SENTINEL = "<!-- muggle-pr-section:v1 -->";
788
+ var withSentinel = (s) => s ? `${REPORT_SECTION_SENTINEL}
789
+ ${s}` : s;
787
790
  async function readAll(stream) {
788
791
  const chunks = [];
789
792
  for await (const chunk of stream) {
@@ -828,8 +831,12 @@ ${err.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n")}
828
831
  return 1;
829
832
  }
830
833
  const resolvedReport = await resolveGsScreenshotUrls(report, { stderrWrite: opts.stderrWrite });
831
- const result = buildPrSection(resolvedReport, { maxBodyBytes: opts.maxBodyBytes });
832
- opts.stdoutWrite(JSON.stringify({ body: result.body, comment: result.comment }));
834
+ const sentinelCost = Buffer.byteLength(`${REPORT_SECTION_SENTINEL}
835
+ `, "utf-8");
836
+ const result = buildPrSection(resolvedReport, { maxBodyBytes: opts.maxBodyBytes - sentinelCost });
837
+ opts.stdoutWrite(
838
+ JSON.stringify({ body: withSentinel(result.body), comment: withSentinel(result.comment) })
839
+ );
833
840
  return 0;
834
841
  }
835
842
  async function buildPrSectionCommand(options) {
@@ -3938,7 +3938,8 @@ var TestCaseUpdateInputSchema = z.object({
3938
3938
  });
3939
3939
  var TestScriptListInputSchema = z.object({
3940
3940
  projectId: IdSchema.describe("Project ID (UUID) to list test scripts for"),
3941
- testCaseId: IdSchema.optional().describe("Optional test case ID (UUID) to filter scripts by")
3941
+ testCaseId: IdSchema.optional().describe("Optional test case ID (UUID) to filter scripts by"),
3942
+ runEnvironmentType: RunEnvironmentInputSchema
3942
3943
  }).merge(PaginationInputSchema);
3943
3944
  var TestScriptGetInputSchema = z.object({
3944
3945
  testScriptId: IdSchema.describe("Test script ID (UUID) to retrieve")
@@ -4912,7 +4913,7 @@ var bulkPreviewTools = [
4912
4913
  var testScriptTools = [
4913
4914
  {
4914
4915
  name: "muggle-remote-test-script-list",
4915
- description: "List test scripts for a project, optionally filtered by test case. Returns up to 10 items per page by default (max 100). Response includes pagination metadata (totalCount, totalPages, hasMore) \u2014 check `hasMore` to decide whether to fetch additional pages.",
4916
+ description: "List test scripts for a project, optionally filtered by test case and by environment lane. Returns up to 10 items per page by default (max 100). Response includes pagination metadata (totalCount, totalPages, hasMore) \u2014 check `hasMore` to decide whether to fetch additional pages.",
4916
4917
  inputSchema: TestScriptListInputSchema,
4917
4918
  mapToUpstream: (input) => {
4918
4919
  const data = input;
@@ -4922,6 +4923,7 @@ var testScriptTools = [
4922
4923
  queryParams: {
4923
4924
  projectId: data.projectId,
4924
4925
  testCaseId: data.testCaseId,
4926
+ runEnvironmentType: data.runEnvironmentType,
4925
4927
  page: data.page,
4926
4928
  pageSize: data.pageSize,
4927
4929
  sortBy: data.sortBy,
@@ -6236,6 +6238,11 @@ var PreferenceKey = /* @__PURE__ */ ((PreferenceKey2) => {
6236
6238
  PreferenceKey2["AutoRebase"] = "autoRebase";
6237
6239
  PreferenceKey2["AutoCleanup"] = "autoCleanup";
6238
6240
  PreferenceKey2["AutoE2ETest"] = "autoE2ETest";
6241
+ PreferenceKey2["AutoResolveConflicts"] = "autoResolveConflicts";
6242
+ PreferenceKey2["AutoReuseValidationContext"] = "autoReuseValidationContext";
6243
+ PreferenceKey2["AutoRouteBuildToMuggleDo"] = "autoRouteBuildToMuggleDo";
6244
+ PreferenceKey2["AutoWatchPR"] = "autoWatchPR";
6245
+ PreferenceKey2["ReusePreparePlan"] = "reusePreparePlan";
6239
6246
  return PreferenceKey2;
6240
6247
  })(PreferenceKey || {});
6241
6248
  var PreferenceValue = /* @__PURE__ */ ((PreferenceValue2) => {
@@ -6252,23 +6259,28 @@ var PREFERENCES_FILE_NAME = "preferences.json";
6252
6259
  var PREFERENCES_PROJECT_DIR_NAME = ".muggle-ai";
6253
6260
  var PREFERENCES_VERSION = 1;
6254
6261
  var DEFAULT_PREFERENCES = {
6255
- ["autoLogin" /* AutoLogin */]: "ask" /* Ask */,
6256
- ["autoSelectProject" /* AutoSelectProject */]: "ask" /* Ask */,
6257
- ["autoSelectLocalHost" /* AutoSelectLocalHost */]: "ask" /* Ask */,
6258
- ["showElectronBrowser" /* ShowElectronBrowser */]: "ask" /* Ask */,
6259
- ["openTestResultsAfterRun" /* OpenTestResultsAfterRun */]: "ask" /* Ask */,
6260
- ["defaultExecutionMode" /* DefaultExecutionMode */]: "ask" /* Ask */,
6261
- ["suggestRelatedUseCases" /* SuggestRelatedUseCases */]: "ask" /* Ask */,
6262
- ["suggestRelatedTestCases" /* SuggestRelatedTestCases */]: "ask" /* Ask */,
6263
- ["autoDetectChanges" /* AutoDetectChanges */]: "ask" /* Ask */,
6264
- ["postPRVisualWalkthrough" /* PostPRVisualWalkthrough */]: "ask" /* Ask */,
6265
- ["autoCreatePR" /* AutoCreatePR */]: "ask" /* Ask */,
6266
- ["checkForUpdates" /* CheckForUpdates */]: "ask" /* Ask */,
6267
- ["verboseOutput" /* VerboseOutput */]: "ask" /* Ask */,
6268
- ["autoUseWorktree" /* AutoUseWorktree */]: "ask" /* Ask */,
6269
- ["autoRebase" /* AutoRebase */]: "ask" /* Ask */,
6270
- ["autoCleanup" /* AutoCleanup */]: "ask" /* Ask */,
6271
- ["autoE2ETest" /* AutoE2ETest */]: "always" /* Always */
6262
+ ["autoLogin" /* AutoLogin */]: "always" /* Always */,
6263
+ ["autoSelectProject" /* AutoSelectProject */]: "always" /* Always */,
6264
+ ["autoSelectLocalHost" /* AutoSelectLocalHost */]: "always" /* Always */,
6265
+ ["showElectronBrowser" /* ShowElectronBrowser */]: "always" /* Always */,
6266
+ ["openTestResultsAfterRun" /* OpenTestResultsAfterRun */]: "always" /* Always */,
6267
+ ["defaultExecutionMode" /* DefaultExecutionMode */]: "local" /* Local */,
6268
+ ["suggestRelatedUseCases" /* SuggestRelatedUseCases */]: "always" /* Always */,
6269
+ ["suggestRelatedTestCases" /* SuggestRelatedTestCases */]: "always" /* Always */,
6270
+ ["autoDetectChanges" /* AutoDetectChanges */]: "always" /* Always */,
6271
+ ["postPRVisualWalkthrough" /* PostPRVisualWalkthrough */]: "always" /* Always */,
6272
+ ["autoCreatePR" /* AutoCreatePR */]: "always" /* Always */,
6273
+ ["checkForUpdates" /* CheckForUpdates */]: "always" /* Always */,
6274
+ ["verboseOutput" /* VerboseOutput */]: "never" /* Never */,
6275
+ ["autoUseWorktree" /* AutoUseWorktree */]: "always" /* Always */,
6276
+ ["autoRebase" /* AutoRebase */]: "always" /* Always */,
6277
+ ["autoCleanup" /* AutoCleanup */]: "always" /* Always */,
6278
+ ["autoE2ETest" /* AutoE2ETest */]: "always" /* Always */,
6279
+ ["autoResolveConflicts" /* AutoResolveConflicts */]: "always" /* Always */,
6280
+ ["autoReuseValidationContext" /* AutoReuseValidationContext */]: "always" /* Always */,
6281
+ ["autoRouteBuildToMuggleDo" /* AutoRouteBuildToMuggleDo */]: "always" /* Always */,
6282
+ ["autoWatchPR" /* AutoWatchPR */]: "always" /* Always */,
6283
+ ["reusePreparePlan" /* ReusePreparePlan */]: "always" /* Always */
6272
6284
  };
6273
6285
  var ALWAYS_ASK_NEVER = [
6274
6286
  "always" /* Always */,
@@ -6301,7 +6313,12 @@ var PREFERENCE_ALLOWED_VALUES = {
6301
6313
  ["autoUseWorktree" /* AutoUseWorktree */]: ALWAYS_ASK_NEVER,
6302
6314
  ["autoRebase" /* AutoRebase */]: ALWAYS_ASK_NEVER,
6303
6315
  ["autoCleanup" /* AutoCleanup */]: ALWAYS_ASK_NEVER,
6304
- ["autoE2ETest" /* AutoE2ETest */]: ALWAYS_ASK
6316
+ ["autoE2ETest" /* AutoE2ETest */]: ALWAYS_ASK,
6317
+ ["autoResolveConflicts" /* AutoResolveConflicts */]: ALWAYS_ASK_NEVER,
6318
+ ["autoReuseValidationContext" /* AutoReuseValidationContext */]: ALWAYS_ASK_NEVER,
6319
+ ["autoRouteBuildToMuggleDo" /* AutoRouteBuildToMuggleDo */]: ALWAYS_ASK_NEVER,
6320
+ ["autoWatchPR" /* AutoWatchPR */]: ALWAYS_ASK_NEVER,
6321
+ ["reusePreparePlan" /* ReusePreparePlan */]: ALWAYS_ASK_NEVER
6305
6322
  };
6306
6323
  var PREFERENCES_SCHEMA = {
6307
6324
  ["autoLogin" /* AutoLogin */]: {
@@ -6354,6 +6371,21 @@ var PREFERENCES_SCHEMA = {
6354
6371
  },
6355
6372
  ["autoE2ETest" /* AutoE2ETest */]: {
6356
6373
  description: "Run Stage 6 (E2E acceptance) at the end of every /muggle-do cycle (default always \u2014 running E2E is the point of muggle-do; never is not an option)"
6374
+ },
6375
+ ["autoResolveConflicts" /* AutoResolveConflicts */]: {
6376
+ description: "When a rebase onto the default branch hits conflicts, resolve them autonomously behind a verify-or-rollback gate instead of aborting and escalating"
6377
+ },
6378
+ ["autoReuseValidationContext" /* AutoReuseValidationContext */]: {
6379
+ description: "When a prior session left an E2E validation context for this working tree, reuse it instead of re-asking the validation questions"
6380
+ },
6381
+ ["autoRouteBuildToMuggleDo" /* AutoRouteBuildToMuggleDo */]: {
6382
+ description: "When a prompt looks like a build/implement/fix request, route it through the /muggle-do pipeline (requirements \u2192 build \u2192 impact \u2192 tests \u2192 E2E \u2192 PR \u2192 watcher)"
6383
+ },
6384
+ ["autoWatchPR" /* AutoWatchPR */]: {
6385
+ description: "After a test run opens a PR, start a muggle-pr-followup watcher that polls for new reviews and hands them to /muggle-do"
6386
+ },
6387
+ ["reusePreparePlan" /* ReusePreparePlan */]: {
6388
+ description: "Reuse the saved prepare plan for this stack (skip discovery, jump to check-running + smoke-test) instead of rediscovering from scratch"
6357
6389
  }
6358
6390
  };
6359
6391
 
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { runCli } from './chunk-2BHC6TQR.js';
3
- import './chunk-MOHR7QUN.js';
2
+ import { runCli } from './chunk-IGXNFT4M.js';
3
+ import './chunk-MPLQNDZX.js';
4
4
 
5
5
  // src/cli/main.ts
6
6
  runCli().catch((error) => {
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-2BHC6TQR.js';
2
- export { createChildLogger, e2e_exports as e2e, getConfig, getLocalQaTools, getLogger, getQaTools, local_exports as localQa, mcp_exports as mcp, e2e_exports as qa, src_exports as shared } from './chunk-MOHR7QUN.js';
1
+ export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-IGXNFT4M.js';
2
+ export { createChildLogger, e2e_exports as e2e, getConfig, getLocalQaTools, getLogger, getQaTools, local_exports as localQa, mcp_exports as mcp, e2e_exports as qa, src_exports as shared } from './chunk-MPLQNDZX.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.1.0",
4
+ "version": "5.3.0",
5
5
  "author": {
6
6
  "name": "Muggle AI",
7
7
  "email": "support@muggle-ai.com"
@@ -2,7 +2,7 @@
2
2
  "name": "muggle",
3
3
  "displayName": "Muggle AI",
4
4
  "description": "Ship quality products with AI-powered end-to-end (E2E) acceptance testing that validates your web app like a real user — from Claude Code and Cursor to PR.",
5
- "version": "5.1.0",
5
+ "version": "5.3.0",
6
6
  "author": {
7
7
  "name": "Muggle AI",
8
8
  "email": "support@muggle-ai.com"
@@ -9,18 +9,29 @@ Condition-triggered hooks that make Muggle Test's high-value handoffs fire path-
9
9
  - **Claude Code layer** — the agent runtime that fires these hooks. A guardrail is a Claude-Code-layer trigger, nothing more.
10
10
  - **Muggle Test layer** — the product (muggle-do, muggle-test, the watcher). This is what a guardrail *invokes*.
11
11
 
12
- A guardrail injects an advisory directive (`additionalContext`); the model then runs the Muggle Test flow. The guardrail never reimplements the flow.
12
+ A guardrail steers the model toward a Muggle Test flow; it never reimplements the flow.
13
13
 
14
- Design rationale: `muggle-ai-brain/architecture/2026-06-02-harness-pipeline-integration-design.md`.
14
+ Design rationale: `muggle-ai-brain/architecture/2026-06-02-harness-pipeline-integration-design.md` (original advisory design; the E2E gate and report gate below now enforce rather than advise).
15
+
16
+ ## Advise vs enforce
17
+
18
+ A guardrail emits one of two strengths:
19
+
20
+ - **Advise** — `additionalContext` (PostToolUse/UserPromptSubmit) or a plain Stop message. A soft nudge the model can ignore.
21
+ - **Enforce** — a `Stop` `decision: "block"` that refuses to end the turn, or a `PreToolUse` `permissionDecision: "deny"` that refuses a tool call. The model cannot proceed until the condition is met.
22
+
23
+ Enforcement is reserved for the handoffs that were being skipped: the E2E acceptance run and posting a deterministically-rendered report. Each enforcing gate carries an escape so it can't trap a turn — the E2E gate releases to advisory after `MAX_E2E_BLOCKS` (3) blocks; the report gate only denies a body it can positively see is a hand-written report and fails open otherwise.
15
24
 
16
25
  ## Mechanism
17
26
 
18
- Each guardrail is a thin bash wrapper in `../scripts/` registered in `hooks.json`. The wrapper pipes the event payload (stdin JSON) to the bundled `../scripts/guardrails.mjs <subcommand>`, which holds the decision logic (built from `src/guardrails/`, vitest-covered). Per-session state in `~/.muggle-ai/guardrails/<session_id>.json` makes each guardrail fire once. Any failure degrades to `{}` — a guardrail must never block a turn.
27
+ Each guardrail is a thin bash wrapper in `../scripts/` registered in `hooks.json`. The wrapper pipes the event payload (stdin JSON) to the bundled `../scripts/guardrails.mjs <subcommand>`, which holds the decision logic (built from `src/guardrails/`, vitest-covered). Per-session state in `~/.muggle-ai/guardrails/<session_id>.json` tracks what fired. Any *failure* degrades to `{}` (allow) — a gate blocks only by an explicit, tested decision, never by accident.
19
28
 
20
29
  ## Guardrails
21
30
 
22
- | Hook event | Wrapper | Condition | Preference | Flow invoked |
23
- | :--------- | :------ | :-------- | :--------- | :----------- |
24
- | `PostToolUse` (Bash) | `guardrail-pr-opened.sh` | a `gh pr create`/`gh pr ready` just succeeded | `autoWatchPR` | start a `muggle-pr-followup` watcher on the new PR |
25
- | `Stop` | `guardrail-e2e-gate.sh` | unit tests passed this session and no E2E ran yet (recorded by `guardrail-record-tests.sh`) | `autoE2ETest` | run change-driven E2E via `muggle-test` before finishing |
26
- | `UserPromptSubmit` | `guardrail-build-router.sh` | a build/implement/fix request (first one this session) | `autoRouteBuildToMuggleDo` | route the work through `muggle-do` (build delegated to superpowers) |
31
+ | Hook event | Wrapper | Strength | Condition | Preference | Effect |
32
+ | :--------- | :------ | :------- | :-------- | :--------- | :----- |
33
+ | `PostToolUse` (Bash) | `guardrail-pr-opened.sh` | advise | a `gh pr create`/`gh pr ready` just succeeded | `autoWatchPR` | start a `muggle-pr-followup` watcher on the new PR |
34
+ | `PostToolUse` (Bash + muggle execute/replay MCP tools) | `guardrail-record-tests.sh` | record | a unit-test command passed, or an E2E run happened | — | set `unitTestsGreen` / `e2eRun` session state |
35
+ | `PreToolUse` (Bash) | `guardrail-report-format.sh` | **enforce** | a `gh pr comment\|create\|edit` body reads like an E2E report but lacks the `build-pr-section` sentinel | — | **deny** — render via `muggle build-pr-section` instead |
36
+ | `Stop` | `guardrail-e2e-gate.sh` | **enforce** | unit tests passed this session and no E2E ran yet | `autoE2ETest` | **block** the turn until E2E runs via `muggle-test` (releases after 3 blocks) |
37
+ | `UserPromptSubmit` | `guardrail-build-router.sh` | advise | a build/implement/fix request (first one this session) | `autoRouteBuildToMuggleDo` | route the work through `muggle-do` (build delegated to superpowers) |
@@ -12,6 +12,18 @@
12
12
  ]
13
13
  }
14
14
  ],
15
+ "PreToolUse": [
16
+ {
17
+ "matcher": "Bash",
18
+ "hooks": [
19
+ {
20
+ "type": "command",
21
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-report-format.sh\"",
22
+ "async": false
23
+ }
24
+ ]
25
+ }
26
+ ],
15
27
  "PostToolUse": [
16
28
  {
17
29
  "matcher": "Bash",
@@ -27,6 +39,16 @@
27
39
  "async": false
28
40
  }
29
41
  ]
42
+ },
43
+ {
44
+ "matcher": "mcp__.*muggle.*(execute|replay)",
45
+ "hooks": [
46
+ {
47
+ "type": "command",
48
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-record-tests.sh\"",
49
+ "async": false
50
+ }
51
+ ]
30
52
  }
31
53
  ],
32
54
  "Stop": [
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Report-format gate (PreToolUse, Bash). Denies a `gh pr comment|create|edit`
5
+ # whose body reads like a hand-written E2E report — one that lacks the
6
+ # build-pr-section sentinel — so every posted walkthrough goes through the
7
+ # deterministic renderer. Degrades to {} so it never blocks an unrelated command.
8
+ root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
9
+ node "${root}/scripts/guardrails.mjs" report-gate 2>/dev/null || printf '{}'
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
2
- import { join } from 'path';
2
+ import { isAbsolute, resolve, join } from 'path';
3
3
  import { homedir } from 'os';
4
4
 
5
5
  // src/guardrails/cli.ts
@@ -60,9 +60,26 @@ function isE2ERun(input2) {
60
60
  }
61
61
 
62
62
  // src/guardrails/shouldRunE2E.ts
63
+ var MAX_E2E_BLOCKS = 3;
63
64
  function shouldRunE2E(state) {
64
65
  return state.unitTestsGreen === true && state.e2eRun !== true;
65
66
  }
67
+ function applyRecordedRun(state, run) {
68
+ let next = state;
69
+ if (run.unitTestPassed) {
70
+ next = { ...next, unitTestsGreen: true, e2eRun: false, e2eBlockCount: 0 };
71
+ }
72
+ if (run.e2eRan) {
73
+ next = { ...next, e2eRun: true };
74
+ }
75
+ return next;
76
+ }
77
+ function e2eGateDecision(state, maxBlocks = MAX_E2E_BLOCKS) {
78
+ const blockCount = state.e2eBlockCount ?? 0;
79
+ if (!shouldRunE2E(state)) return { action: "none" /* None */, blockCount };
80
+ if (blockCount >= maxBlocks) return { action: "release" /* Release */, blockCount };
81
+ return { action: "block" /* Block */, blockCount: blockCount + 1 };
82
+ }
66
83
 
67
84
  // src/guardrails/detectBuildIntent.ts
68
85
  var BUILD = /\b(implement|build|add|create|write|fix|refactor|wire up|hook up|make (a|the|it)|change the)\b/i;
@@ -74,6 +91,60 @@ function detectBuildIntent(prompt) {
74
91
  if (QUESTION.test(p)) return false;
75
92
  return BUILD.test(p) || DEVCYCLE.test(p);
76
93
  }
94
+ var REPORT_SENTINEL = "muggle-pr-section";
95
+ var PR_POST_CMD = /\bgh\s+pr\s+(comment|create|edit)\b/;
96
+ var defaultReader = (path, cwd) => {
97
+ try {
98
+ const abs = isAbsolute(path) ? path : resolve(cwd ?? process.cwd(), path);
99
+ if (!existsSync(abs)) return null;
100
+ return readFileSync(abs, "utf-8");
101
+ } catch {
102
+ return null;
103
+ }
104
+ };
105
+ function unquote(s) {
106
+ const t = s.trim();
107
+ if (t.startsWith('"') && t.endsWith('"') || t.startsWith("'") && t.endsWith("'")) {
108
+ return t.slice(1, -1);
109
+ }
110
+ return t;
111
+ }
112
+ function looksLikeE2EReport(text) {
113
+ const t = text.toLowerCase();
114
+ const statusEmojis = (text.match(/[✅❌⚠]/gu) ?? []).length;
115
+ const tally = /\b\d+\s+(tests?\s+)?passed\b/.test(t) && /\b\d+\s+(tests?\s+)?(failed|inconclusive)\b/.test(t);
116
+ const slashTally = /\bpassed\b\s*[/|]\s*\d*\s*(tests?\s*)?\bfailed\b/.test(t) || /\bfailed\b\s*[/|]\s*\d*\s*(tests?\s*)?\bpassed\b/.test(t);
117
+ const resultsStructure = /acceptance results/.test(t) || tally || slashTally || statusEmojis >= 2;
118
+ const muggleContext = /\bmuggle\b/.test(t) || /muggle-ai\.com/.test(t) || /\be2e\b/.test(t) || /\bacceptance\b/.test(t);
119
+ return resultsStructure && muggleContext;
120
+ }
121
+ function collectInspectableText(cmd, cwd, read) {
122
+ let text = cmd;
123
+ for (const m of cmd.matchAll(/--body-file[=\s]+("[^"]+"|'[^']+'|\S+)/g)) {
124
+ const p = unquote(m[1]);
125
+ if (p && p !== "-") {
126
+ const c = read(p, cwd);
127
+ if (c) text += "\n" + c;
128
+ }
129
+ }
130
+ for (const m of cmd.matchAll(/jq\b[^|]*?("[^"]+\.json"|'[^']+\.json'|\S+\.json)/g)) {
131
+ const c = read(unquote(m[1]), cwd);
132
+ if (c) text += "\n" + c;
133
+ }
134
+ return text;
135
+ }
136
+ function evaluateReportPost(input2, read = defaultReader) {
137
+ if (input2.tool_name !== "Bash") return { deny: false };
138
+ const cmd = input2.tool_input?.command ?? "";
139
+ if (!PR_POST_CMD.test(cmd)) return { deny: false };
140
+ const text = collectInspectableText(cmd, input2.cwd, read);
141
+ if (text.includes(REPORT_SENTINEL)) return { deny: false };
142
+ if (!looksLikeE2EReport(text)) return { deny: false };
143
+ return {
144
+ deny: true,
145
+ reason: "Blocked: this looks like a hand-written E2E test report. Muggle requires the deterministic renderer \u2014 build the run's E2eReport JSON and pipe it through `muggle build-pr-section` (or invoke /muggle:muggle-pr-visual-walkthrough), then post that output. Never hand-write the walkthrough markdown."
146
+ };
147
+ }
77
148
 
78
149
  // src/guardrails/emit.ts
79
150
  function envelope(eventName, context, host2) {
@@ -83,6 +154,22 @@ function envelope(eventName, context, host2) {
83
154
  hookSpecificOutput: { hookEventName: eventName, additionalContext: context }
84
155
  });
85
156
  }
157
+ function blockStop(reason, host2) {
158
+ if (!reason) return "{}";
159
+ if (host2 === "cursor") return JSON.stringify({ additional_context: reason });
160
+ return JSON.stringify({ decision: "block", reason });
161
+ }
162
+ function denyTool(reason, host2) {
163
+ if (!reason) return "{}";
164
+ if (host2 === "cursor") return JSON.stringify({ additional_context: reason });
165
+ return JSON.stringify({
166
+ hookSpecificOutput: {
167
+ hookEventName: "PreToolUse",
168
+ permissionDecision: "deny",
169
+ permissionDecisionReason: reason
170
+ }
171
+ });
172
+ }
86
173
 
87
174
  // src/guardrails/cli.ts
88
175
  function readStdin() {
@@ -108,25 +195,26 @@ Per the autoWatchPR preference, a muggle-pr-followup watcher should handle its i
108
195
  function recordTests() {
109
196
  const cmd = input.tool_input?.command ?? "";
110
197
  const state = readState(sessionId);
111
- let changed = false;
112
- if (isTestCommand(cmd) && testsPassed(input)) {
113
- state.unitTestsGreen = true;
114
- changed = true;
115
- }
116
- if (isE2ERun(input)) {
117
- state.e2eRun = true;
118
- changed = true;
119
- }
120
- if (changed) writeState(state);
198
+ const next = applyRecordedRun(state, {
199
+ unitTestPassed: isTestCommand(cmd) && testsPassed(input),
200
+ e2eRan: isE2ERun(input)
201
+ });
202
+ if (next !== state) writeState(next);
121
203
  return "{}";
122
204
  }
123
205
  function e2eGate() {
124
206
  const state = readState(sessionId);
125
- if (!shouldRunE2E(state)) return "{}";
126
- state.e2eRun = true;
207
+ const decision = e2eGateDecision(state);
208
+ if (decision.action === "none" /* None */ || decision.action === "release" /* Release */) return "{}";
209
+ state.e2eBlockCount = decision.blockCount;
127
210
  writeState(state);
128
- const ctx = `Unit tests passed this session and no E2E acceptance run has happened yet. Per the autoE2ETest preference (default: always), run change-driven E2E now via /muggle:muggle-test before finishing. If autoE2ETest=never, skip.`;
129
- return envelope("Stop", ctx, host);
211
+ const reason = `Do not end the turn yet. Unit tests passed this session but no E2E acceptance run has happened. Per the autoE2ETest preference (default: always), run change-driven E2E now via /muggle:muggle-test, then finish. If E2E genuinely cannot run here (no app, services down, no PR), say so explicitly to the user \u2014 this gate releases after ${MAX_E2E_BLOCKS} attempts.`;
212
+ return blockStop(reason, host);
213
+ }
214
+ function reportGate() {
215
+ const result = evaluateReportPost(input);
216
+ if (!result.deny || !result.reason) return "{}";
217
+ return denyTool(result.reason, host);
130
218
  }
131
219
  function buildRouter() {
132
220
  if (!detectBuildIntent(input.prompt ?? "")) return "{}";
@@ -141,6 +229,7 @@ var handlers = {
141
229
  "pr-opened": prOpened,
142
230
  "record-tests": recordTests,
143
231
  "e2e-gate": e2eGate,
232
+ "report-gate": reportGate,
144
233
  "build-router": buildRouter
145
234
  };
146
235
  process.stdout.write((handlers[sub] ?? (() => "{}"))());
@@ -23,7 +23,7 @@ One local browser exists, so **execution is sequential** — one test case at a
23
23
  Per test case, branch on `mode`:
24
24
 
25
25
  **Replay**
26
- 1. `muggle-remote-test-script-get` (latest replayable script) → note `actionScriptId`.
26
+ 1. `muggle-remote-test-script-get` (latest replayable script — the lane-scoped one the caller resolved during classification per [`../failure-mode-handling.md`](../failure-mode-handling.md) §A) → note `actionScriptId`.
27
27
  2. `muggle-remote-action-script-get` with that id → full `actionScript` (see [`action-script.md`](action-script.md)).
28
28
  3. `muggle-local-execute-replay` with `testScript`, `actionScript`, `localUrl`, `cwd`, `showUi`, `freshSession` (see [`fresh-session.md`](fresh-session.md)), `timeoutMs` (see [`timeouts.md`](timeouts.md)).
29
29
 
@@ -61,7 +61,7 @@ Run during change analysis, **per impacted test case**. Picks the initial execut
61
61
 
62
62
  - The change summary from `git diff` (file paths + diff content).
63
63
  - The test case (title, description, instructions, last passing run timestamp).
64
- - Existing test scripts for that test case from `muggle-remote-test-script-list`.
64
+ - Existing test scripts for that test case from `muggle-remote-test-script-list`, scoped to the run's lane: pass `runEnvironmentType: "local"` in Local mode (Step 7A), `"remote"` in Remote mode (Step 7B). The lane is fixed at runtime — it selects the versioned runSettings the cloud resolves — so a local run keys replay-vs-regen off the localhost-lane script and a remote run off the deployed-lane script, never the other lane's.
65
65
 
66
66
  ### Rules (fire in order; first match wins)
67
67
 
@@ -98,7 +98,7 @@ Based on the changed files and the requirements goal, determine which test cases
98
98
 
99
99
  ### Step 4: Run the dev loop, gather screenshots
100
100
 
101
- For each relevant test case, run the shared loop in [`../_shared/dev-loop/run.md`](../_shared/dev-loop/run.md): `muggle-remote-test-script-list` by `testCaseId` to pick [replay vs regen](../_shared/dev-loop/run.md), [execute with `timeoutMs`](../_shared/dev-loop/timeouts.md), [fetch the result](../_shared/dev-loop/failures.md) and [interpret failures](../_shared/dev-loop/failures.md), then read the studio-published [cloud refs and per-step screenshots](../_shared/dev-loop/publish.md) off the run result.
101
+ For each relevant test case, run the shared loop in [`../_shared/dev-loop/run.md`](../_shared/dev-loop/run.md): `muggle-remote-test-script-list` by `testCaseId` with `runEnvironmentType: "local"` (this stage runs against localhost) to pick [replay vs regen](../_shared/dev-loop/run.md), [execute with `timeoutMs`](../_shared/dev-loop/timeouts.md), [fetch the result](../_shared/dev-loop/failures.md) and [interpret failures](../_shared/dev-loop/failures.md), then read the studio-published [cloud refs and per-step screenshots](../_shared/dev-loop/publish.md) off the run result.
102
102
 
103
103
  Inputs to the loop: `mode` from the script-exists check, `localUrl`/project from Step 1.7, `cwd` = the working tree recorded in `state.md`.
104
104
 
@@ -74,6 +74,7 @@ Tell the user: `Created test case: <id>`.
74
74
  Load and call `muggle-remote-test-script-list` with:
75
75
  - `projectId`: from Step 2
76
76
  - `testCaseId`: from Step 4
77
+ - `runEnvironmentType`: `"local"` — this skill executes in the local browser, so it resolves the local-lane script. The lane is an explicit env type, exactly as the remote flow passes `"remote"`; the target URL is just the lane's url and never decides the lane.
77
78
 
78
79
  Tell the user which mutations will be applied: `Mutations: <mutations[]>` (or "no mutations" if empty).
79
80
 
@@ -16,16 +16,19 @@ which uses `local` / `remote` / `ask`).
16
16
 
17
17
  ## Gate behavior
18
18
 
19
- - `always` → take the pro-action, then print silent footer.
20
- - `never` → take the skip-action, then print silent footer.
19
+ - `always` → take the pro-action, then **always** print the silent footer.
20
+ - `never` → take the skip-action, then **always** print the silent footer.
21
21
  - `ask` (or absent) → run Picker 1 (per-key file) → Picker 2 (below).
22
22
 
23
23
  `defaultExecutionMode` uses `local`/`remote` instead of `always`/`never`.
24
24
 
25
- ## Silent footer (whenever pickers are skipped)
25
+ ## Silent footer (mandatory whenever a prompt is skipped)
26
26
 
27
- The user must always be told **what happened**, **why it was silent**, and
28
- **how to change it**. Two lines:
27
+ Whenever a gate resolves to a non-`ask` value and skips its picker, the footer
28
+ below is **required** — every gate, every time, no exceptions. It tells the
29
+ user **what happened**, **why it was silent**, and **how to change it**. The
30
+ `preference-gates-lint` test enforces that this contract exists; omitting the
31
+ footer when a gate fires is a bug. Two lines:
29
32
 
30
33
  ```
31
34
  ✓ <silent action from per-key file>
@@ -219,7 +219,7 @@ Wait for user confirmation before moving to execution.
219
219
 
220
220
  ### 6f: Classify execution mode per test case (replay vs regen)
221
221
 
222
- For each selected test case, decide whether the run should be a **replay** of an existing script or a fresh **regen**, using the rules in [`_shared/failure-mode-handling.md`](../_shared/failure-mode-handling.md) section A. Inputs: the change summary from Step 2, the test case body, and the result of `muggle-remote-test-script-list` for that test case (last passing timestamp + whether any replayable script exists).
222
+ For each selected test case, decide whether the run should be a **replay** of an existing script or a fresh **regen**, using the rules in [`_shared/failure-mode-handling.md`](../_shared/failure-mode-handling.md) section A. Inputs: the change summary from Step 2, the test case body, and the result of `muggle-remote-test-script-list` for that test case (last passing timestamp + whether any replayable script exists). Scope that list call to the run's lane — `runEnvironmentType: "local"` in Local mode, `"remote"` in Remote mode (section A) — so replay reuses the script for the lane the run will execute against.
223
223
 
224
224
  Per test case, fire one `muggle-local-telemetry-event-emit` with `eventType: "pre-execution-classification"` capturing the picked mode, the rule that fired, and the matched changed-file paths.
225
225
 
@@ -28,7 +28,7 @@ Branch each test case on its `mode`, then issue **all** workflow-start calls in
28
28
  - `instructions`: From the test case
29
29
  - `expectedResult`: From the test case
30
30
 
31
- **Replay-mode test case** — `muggle-remote-workflow-start-test-script-replay` against the latest replayable script for that test case (resolve via `muggle-remote-test-script-list` if not already in hand from the classification step). Tag results with `mode: "replay"` so the router routes failures correctly.
31
+ **Replay-mode test case** — `muggle-remote-workflow-start-test-script-replay` against the latest replayable script for that test case (resolve via `muggle-remote-test-script-list` with `runEnvironmentType: "remote"` if not already in hand from the classification step). Tag results with `mode: "replay"` so the router routes failures correctly.
32
32
 
33
33
  Store each returned workflow runtime ID along with its mode tag.
34
34
 
@@ -136,7 +136,7 @@ Before deciding the target's script, resolve its prerequisite chain from the bac
136
136
 
137
137
  ### 5. Existing scripts vs new generation
138
138
 
139
- `muggle-remote-test-script-list` with `testCaseId`.
139
+ `muggle-remote-test-script-list` with `testCaseId` and `runEnvironmentType: "local"` — this skill always replays against localhost, so resolve the local-lane script (the remote-lane script carries the deployed URL).
140
140
 
141
141
  - **If any replayable/succeeded scripts exist:** use `AskUserQuestion` to present them as clickable options. Show: name, created/updated, step count per option. Include **"Generate new script"** as the last option.
142
142
  - **If none:** go straight to generation (no need to ask replay vs generate).
@@ -1,7 +1,7 @@
1
1
  {
2
- "release": "5.1.0",
3
- "buildId": "run-53-1",
4
- "commitSha": "3a0e7525c2c606afb9a64c2d78baffa5b400cf50",
5
- "buildTime": "2026-06-11T20:57:59Z",
2
+ "release": "5.3.0",
3
+ "buildId": "run-55-1",
4
+ "commitSha": "b48301cf3c16a8ff043854510aa3584808f9a7c6",
5
+ "buildTime": "2026-06-17T02:44:35Z",
6
6
  "serviceName": "muggle-ai-works-mcp"
7
7
  }
@@ -1 +1 @@
1
- export { DEFAULT_PREFERENCES, PREFERENCES_FILE_NAME, PREFERENCES_PROJECT_DIR_NAME, PREFERENCES_SCHEMA, PREFERENCES_VERSION, PREFERENCE_ALLOWED_VALUES, PreferenceKey, PreferenceValue, buildElectronAppChecksumsUrl, buildElectronAppReleaseAssetUrl, buildElectronAppReleaseTag, calculateFileChecksum, createApiKeyWithToken, createChildLogger, deleteApiKeyData, deleteCredentials, e2e_exports as e2e, formatPreferencesOneLiner, getApiKey, getApiKeyFilePath, getAuthService, getBundledElectronAppVersion, getCallerCredentials, getCallerCredentialsAsync, getChecksumForPlatform, getConfig, getCredentialsFilePath, getDataDir, getDownloadBaseUrl, getElectronAppChecksums, getElectronAppDir, getElectronAppVersion, getElectronAppVersionSource, getLocalQaTools, getLogger, getPlatformKey, getQaTools, getValidApiKeyData, getValidCredentials, hasApiKey, isElectronAppInstalled, isFirstRun, loadApiKeyData, loadCredentials, local_exports as localQa, mcp_exports as mcp, openBrowserUrl, performLogin, performLogout, pollDeviceCode, e2e_exports as qa, readGlobalPreferences, readProjectPreferences, resetConfig, resetLogger, resetPreference, resolveElectronAppPathOrNull, resolvePreferences, saveApiKey, saveApiKeyData, saveCredentials, startDeviceCodeFlow, toolRequiresAuth, validatePreference, verifyFileChecksum, writePreferences } from './chunk-MOHR7QUN.js';
1
+ export { DEFAULT_PREFERENCES, PREFERENCES_FILE_NAME, PREFERENCES_PROJECT_DIR_NAME, PREFERENCES_SCHEMA, PREFERENCES_VERSION, PREFERENCE_ALLOWED_VALUES, PreferenceKey, PreferenceValue, buildElectronAppChecksumsUrl, buildElectronAppReleaseAssetUrl, buildElectronAppReleaseTag, calculateFileChecksum, createApiKeyWithToken, createChildLogger, deleteApiKeyData, deleteCredentials, e2e_exports as e2e, formatPreferencesOneLiner, getApiKey, getApiKeyFilePath, getAuthService, getBundledElectronAppVersion, getCallerCredentials, getCallerCredentialsAsync, getChecksumForPlatform, getConfig, getCredentialsFilePath, getDataDir, getDownloadBaseUrl, getElectronAppChecksums, getElectronAppDir, getElectronAppVersion, getElectronAppVersionSource, getLocalQaTools, getLogger, getPlatformKey, getQaTools, getValidApiKeyData, getValidCredentials, hasApiKey, isElectronAppInstalled, isFirstRun, loadApiKeyData, loadCredentials, local_exports as localQa, mcp_exports as mcp, openBrowserUrl, performLogin, performLogout, pollDeviceCode, e2e_exports as qa, readGlobalPreferences, readProjectPreferences, resetConfig, resetLogger, resetPreference, resolveElectronAppPathOrNull, resolvePreferences, saveApiKey, saveApiKeyData, saveCredentials, startDeviceCodeFlow, toolRequiresAuth, validatePreference, verifyFileChecksum, writePreferences } from './chunk-MPLQNDZX.js';
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.1.0",
4
+ "version": "5.3.0",
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",
@@ -38,17 +38,18 @@
38
38
  "lint:check": "eslint .",
39
39
  "typecheck": "tsc --noEmit",
40
40
  "test": "vitest run",
41
- "test:watch": "vitest"
41
+ "test:watch": "vitest",
42
+ "test:gates:behavioral": "tsx internal/skill-gate-eval/src/run.ts"
42
43
  },
43
44
  "muggleConfig": {
44
- "electronAppVersion": "1.4.0",
45
+ "electronAppVersion": "1.5.1",
45
46
  "downloadBaseUrl": "https://github.com/multiplex-ai/muggle-ai-works/releases/download",
46
47
  "runtimeTargetDefault": "production",
47
48
  "checksums": {
48
- "darwin-arm64": "9dd7ac0a378a1d5db908a102aec4f596741e943572a7d55b08e2a62f336981bc",
49
- "darwin-x64": "70c9c6df21e03ad1529682e6cf8150ddfef306e1019e4b3bd2a4e7d025a98d64",
50
- "linux-x64": "14ee27b3c22988bf72f13f3734e1594dcbb0fb94f699d5ad789ea8cf452dc7a1",
51
- "win32-x64": "9e059917ba4605527b47d23b43bd78438cf59e7df38c4c9698f6b9487ed64a97"
49
+ "darwin-arm64": "70649bed80b3ae407893728abd426e32c54eeda03fde7856e530bb6f67e359cd",
50
+ "darwin-x64": "26a2176c0c926a6f20a4af99e4bc7362e8365ad260c3ca42d18bb1f6cf1b29d5",
51
+ "linux-x64": "69f33e49c6baa017604ffd49a4f4db2eba90260375c4fb5918326843c51d7bdd",
52
+ "win32-x64": "559d02c0e80860dec3ed5c82e8e1d3d4aa2c4dea4f73a2c62c149ad4bd6131d0"
52
53
  }
53
54
  },
54
55
  "dependencies": {
@@ -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.1.0",
4
+ "version": "5.3.0",
5
5
  "author": {
6
6
  "name": "Muggle AI",
7
7
  "email": "support@muggle-ai.com"
@@ -2,7 +2,7 @@
2
2
  "name": "muggle",
3
3
  "displayName": "Muggle AI",
4
4
  "description": "Ship quality products with AI-powered end-to-end (E2E) acceptance testing that validates your web app like a real user — from Claude Code and Cursor to PR.",
5
- "version": "5.1.0",
5
+ "version": "5.3.0",
6
6
  "author": {
7
7
  "name": "Muggle AI",
8
8
  "email": "support@muggle-ai.com"
@@ -9,18 +9,29 @@ Condition-triggered hooks that make Muggle Test's high-value handoffs fire path-
9
9
  - **Claude Code layer** — the agent runtime that fires these hooks. A guardrail is a Claude-Code-layer trigger, nothing more.
10
10
  - **Muggle Test layer** — the product (muggle-do, muggle-test, the watcher). This is what a guardrail *invokes*.
11
11
 
12
- A guardrail injects an advisory directive (`additionalContext`); the model then runs the Muggle Test flow. The guardrail never reimplements the flow.
12
+ A guardrail steers the model toward a Muggle Test flow; it never reimplements the flow.
13
13
 
14
- Design rationale: `muggle-ai-brain/architecture/2026-06-02-harness-pipeline-integration-design.md`.
14
+ Design rationale: `muggle-ai-brain/architecture/2026-06-02-harness-pipeline-integration-design.md` (original advisory design; the E2E gate and report gate below now enforce rather than advise).
15
+
16
+ ## Advise vs enforce
17
+
18
+ A guardrail emits one of two strengths:
19
+
20
+ - **Advise** — `additionalContext` (PostToolUse/UserPromptSubmit) or a plain Stop message. A soft nudge the model can ignore.
21
+ - **Enforce** — a `Stop` `decision: "block"` that refuses to end the turn, or a `PreToolUse` `permissionDecision: "deny"` that refuses a tool call. The model cannot proceed until the condition is met.
22
+
23
+ Enforcement is reserved for the handoffs that were being skipped: the E2E acceptance run and posting a deterministically-rendered report. Each enforcing gate carries an escape so it can't trap a turn — the E2E gate releases to advisory after `MAX_E2E_BLOCKS` (3) blocks; the report gate only denies a body it can positively see is a hand-written report and fails open otherwise.
15
24
 
16
25
  ## Mechanism
17
26
 
18
- Each guardrail is a thin bash wrapper in `../scripts/` registered in `hooks.json`. The wrapper pipes the event payload (stdin JSON) to the bundled `../scripts/guardrails.mjs <subcommand>`, which holds the decision logic (built from `src/guardrails/`, vitest-covered). Per-session state in `~/.muggle-ai/guardrails/<session_id>.json` makes each guardrail fire once. Any failure degrades to `{}` — a guardrail must never block a turn.
27
+ Each guardrail is a thin bash wrapper in `../scripts/` registered in `hooks.json`. The wrapper pipes the event payload (stdin JSON) to the bundled `../scripts/guardrails.mjs <subcommand>`, which holds the decision logic (built from `src/guardrails/`, vitest-covered). Per-session state in `~/.muggle-ai/guardrails/<session_id>.json` tracks what fired. Any *failure* degrades to `{}` (allow) — a gate blocks only by an explicit, tested decision, never by accident.
19
28
 
20
29
  ## Guardrails
21
30
 
22
- | Hook event | Wrapper | Condition | Preference | Flow invoked |
23
- | :--------- | :------ | :-------- | :--------- | :----------- |
24
- | `PostToolUse` (Bash) | `guardrail-pr-opened.sh` | a `gh pr create`/`gh pr ready` just succeeded | `autoWatchPR` | start a `muggle-pr-followup` watcher on the new PR |
25
- | `Stop` | `guardrail-e2e-gate.sh` | unit tests passed this session and no E2E ran yet (recorded by `guardrail-record-tests.sh`) | `autoE2ETest` | run change-driven E2E via `muggle-test` before finishing |
26
- | `UserPromptSubmit` | `guardrail-build-router.sh` | a build/implement/fix request (first one this session) | `autoRouteBuildToMuggleDo` | route the work through `muggle-do` (build delegated to superpowers) |
31
+ | Hook event | Wrapper | Strength | Condition | Preference | Effect |
32
+ | :--------- | :------ | :------- | :-------- | :--------- | :----- |
33
+ | `PostToolUse` (Bash) | `guardrail-pr-opened.sh` | advise | a `gh pr create`/`gh pr ready` just succeeded | `autoWatchPR` | start a `muggle-pr-followup` watcher on the new PR |
34
+ | `PostToolUse` (Bash + muggle execute/replay MCP tools) | `guardrail-record-tests.sh` | record | a unit-test command passed, or an E2E run happened | — | set `unitTestsGreen` / `e2eRun` session state |
35
+ | `PreToolUse` (Bash) | `guardrail-report-format.sh` | **enforce** | a `gh pr comment\|create\|edit` body reads like an E2E report but lacks the `build-pr-section` sentinel | — | **deny** — render via `muggle build-pr-section` instead |
36
+ | `Stop` | `guardrail-e2e-gate.sh` | **enforce** | unit tests passed this session and no E2E ran yet | `autoE2ETest` | **block** the turn until E2E runs via `muggle-test` (releases after 3 blocks) |
37
+ | `UserPromptSubmit` | `guardrail-build-router.sh` | advise | a build/implement/fix request (first one this session) | `autoRouteBuildToMuggleDo` | route the work through `muggle-do` (build delegated to superpowers) |
@@ -12,6 +12,18 @@
12
12
  ]
13
13
  }
14
14
  ],
15
+ "PreToolUse": [
16
+ {
17
+ "matcher": "Bash",
18
+ "hooks": [
19
+ {
20
+ "type": "command",
21
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-report-format.sh\"",
22
+ "async": false
23
+ }
24
+ ]
25
+ }
26
+ ],
15
27
  "PostToolUse": [
16
28
  {
17
29
  "matcher": "Bash",
@@ -27,6 +39,16 @@
27
39
  "async": false
28
40
  }
29
41
  ]
42
+ },
43
+ {
44
+ "matcher": "mcp__.*muggle.*(execute|replay)",
45
+ "hooks": [
46
+ {
47
+ "type": "command",
48
+ "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail-record-tests.sh\"",
49
+ "async": false
50
+ }
51
+ ]
30
52
  }
31
53
  ],
32
54
  "Stop": [
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Report-format gate (PreToolUse, Bash). Denies a `gh pr comment|create|edit`
5
+ # whose body reads like a hand-written E2E report — one that lacks the
6
+ # build-pr-section sentinel — so every posted walkthrough goes through the
7
+ # deterministic renderer. Degrades to {} so it never blocks an unrelated command.
8
+ root="${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}"
9
+ node "${root}/scripts/guardrails.mjs" report-gate 2>/dev/null || printf '{}'
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
2
- import { join } from 'path';
2
+ import { isAbsolute, resolve, join } from 'path';
3
3
  import { homedir } from 'os';
4
4
 
5
5
  // src/guardrails/cli.ts
@@ -60,9 +60,26 @@ function isE2ERun(input2) {
60
60
  }
61
61
 
62
62
  // src/guardrails/shouldRunE2E.ts
63
+ var MAX_E2E_BLOCKS = 3;
63
64
  function shouldRunE2E(state) {
64
65
  return state.unitTestsGreen === true && state.e2eRun !== true;
65
66
  }
67
+ function applyRecordedRun(state, run) {
68
+ let next = state;
69
+ if (run.unitTestPassed) {
70
+ next = { ...next, unitTestsGreen: true, e2eRun: false, e2eBlockCount: 0 };
71
+ }
72
+ if (run.e2eRan) {
73
+ next = { ...next, e2eRun: true };
74
+ }
75
+ return next;
76
+ }
77
+ function e2eGateDecision(state, maxBlocks = MAX_E2E_BLOCKS) {
78
+ const blockCount = state.e2eBlockCount ?? 0;
79
+ if (!shouldRunE2E(state)) return { action: "none" /* None */, blockCount };
80
+ if (blockCount >= maxBlocks) return { action: "release" /* Release */, blockCount };
81
+ return { action: "block" /* Block */, blockCount: blockCount + 1 };
82
+ }
66
83
 
67
84
  // src/guardrails/detectBuildIntent.ts
68
85
  var BUILD = /\b(implement|build|add|create|write|fix|refactor|wire up|hook up|make (a|the|it)|change the)\b/i;
@@ -74,6 +91,60 @@ function detectBuildIntent(prompt) {
74
91
  if (QUESTION.test(p)) return false;
75
92
  return BUILD.test(p) || DEVCYCLE.test(p);
76
93
  }
94
+ var REPORT_SENTINEL = "muggle-pr-section";
95
+ var PR_POST_CMD = /\bgh\s+pr\s+(comment|create|edit)\b/;
96
+ var defaultReader = (path, cwd) => {
97
+ try {
98
+ const abs = isAbsolute(path) ? path : resolve(cwd ?? process.cwd(), path);
99
+ if (!existsSync(abs)) return null;
100
+ return readFileSync(abs, "utf-8");
101
+ } catch {
102
+ return null;
103
+ }
104
+ };
105
+ function unquote(s) {
106
+ const t = s.trim();
107
+ if (t.startsWith('"') && t.endsWith('"') || t.startsWith("'") && t.endsWith("'")) {
108
+ return t.slice(1, -1);
109
+ }
110
+ return t;
111
+ }
112
+ function looksLikeE2EReport(text) {
113
+ const t = text.toLowerCase();
114
+ const statusEmojis = (text.match(/[✅❌⚠]/gu) ?? []).length;
115
+ const tally = /\b\d+\s+(tests?\s+)?passed\b/.test(t) && /\b\d+\s+(tests?\s+)?(failed|inconclusive)\b/.test(t);
116
+ const slashTally = /\bpassed\b\s*[/|]\s*\d*\s*(tests?\s*)?\bfailed\b/.test(t) || /\bfailed\b\s*[/|]\s*\d*\s*(tests?\s*)?\bpassed\b/.test(t);
117
+ const resultsStructure = /acceptance results/.test(t) || tally || slashTally || statusEmojis >= 2;
118
+ const muggleContext = /\bmuggle\b/.test(t) || /muggle-ai\.com/.test(t) || /\be2e\b/.test(t) || /\bacceptance\b/.test(t);
119
+ return resultsStructure && muggleContext;
120
+ }
121
+ function collectInspectableText(cmd, cwd, read) {
122
+ let text = cmd;
123
+ for (const m of cmd.matchAll(/--body-file[=\s]+("[^"]+"|'[^']+'|\S+)/g)) {
124
+ const p = unquote(m[1]);
125
+ if (p && p !== "-") {
126
+ const c = read(p, cwd);
127
+ if (c) text += "\n" + c;
128
+ }
129
+ }
130
+ for (const m of cmd.matchAll(/jq\b[^|]*?("[^"]+\.json"|'[^']+\.json'|\S+\.json)/g)) {
131
+ const c = read(unquote(m[1]), cwd);
132
+ if (c) text += "\n" + c;
133
+ }
134
+ return text;
135
+ }
136
+ function evaluateReportPost(input2, read = defaultReader) {
137
+ if (input2.tool_name !== "Bash") return { deny: false };
138
+ const cmd = input2.tool_input?.command ?? "";
139
+ if (!PR_POST_CMD.test(cmd)) return { deny: false };
140
+ const text = collectInspectableText(cmd, input2.cwd, read);
141
+ if (text.includes(REPORT_SENTINEL)) return { deny: false };
142
+ if (!looksLikeE2EReport(text)) return { deny: false };
143
+ return {
144
+ deny: true,
145
+ reason: "Blocked: this looks like a hand-written E2E test report. Muggle requires the deterministic renderer \u2014 build the run's E2eReport JSON and pipe it through `muggle build-pr-section` (or invoke /muggle:muggle-pr-visual-walkthrough), then post that output. Never hand-write the walkthrough markdown."
146
+ };
147
+ }
77
148
 
78
149
  // src/guardrails/emit.ts
79
150
  function envelope(eventName, context, host2) {
@@ -83,6 +154,22 @@ function envelope(eventName, context, host2) {
83
154
  hookSpecificOutput: { hookEventName: eventName, additionalContext: context }
84
155
  });
85
156
  }
157
+ function blockStop(reason, host2) {
158
+ if (!reason) return "{}";
159
+ if (host2 === "cursor") return JSON.stringify({ additional_context: reason });
160
+ return JSON.stringify({ decision: "block", reason });
161
+ }
162
+ function denyTool(reason, host2) {
163
+ if (!reason) return "{}";
164
+ if (host2 === "cursor") return JSON.stringify({ additional_context: reason });
165
+ return JSON.stringify({
166
+ hookSpecificOutput: {
167
+ hookEventName: "PreToolUse",
168
+ permissionDecision: "deny",
169
+ permissionDecisionReason: reason
170
+ }
171
+ });
172
+ }
86
173
 
87
174
  // src/guardrails/cli.ts
88
175
  function readStdin() {
@@ -108,25 +195,26 @@ Per the autoWatchPR preference, a muggle-pr-followup watcher should handle its i
108
195
  function recordTests() {
109
196
  const cmd = input.tool_input?.command ?? "";
110
197
  const state = readState(sessionId);
111
- let changed = false;
112
- if (isTestCommand(cmd) && testsPassed(input)) {
113
- state.unitTestsGreen = true;
114
- changed = true;
115
- }
116
- if (isE2ERun(input)) {
117
- state.e2eRun = true;
118
- changed = true;
119
- }
120
- if (changed) writeState(state);
198
+ const next = applyRecordedRun(state, {
199
+ unitTestPassed: isTestCommand(cmd) && testsPassed(input),
200
+ e2eRan: isE2ERun(input)
201
+ });
202
+ if (next !== state) writeState(next);
121
203
  return "{}";
122
204
  }
123
205
  function e2eGate() {
124
206
  const state = readState(sessionId);
125
- if (!shouldRunE2E(state)) return "{}";
126
- state.e2eRun = true;
207
+ const decision = e2eGateDecision(state);
208
+ if (decision.action === "none" /* None */ || decision.action === "release" /* Release */) return "{}";
209
+ state.e2eBlockCount = decision.blockCount;
127
210
  writeState(state);
128
- const ctx = `Unit tests passed this session and no E2E acceptance run has happened yet. Per the autoE2ETest preference (default: always), run change-driven E2E now via /muggle:muggle-test before finishing. If autoE2ETest=never, skip.`;
129
- return envelope("Stop", ctx, host);
211
+ const reason = `Do not end the turn yet. Unit tests passed this session but no E2E acceptance run has happened. Per the autoE2ETest preference (default: always), run change-driven E2E now via /muggle:muggle-test, then finish. If E2E genuinely cannot run here (no app, services down, no PR), say so explicitly to the user \u2014 this gate releases after ${MAX_E2E_BLOCKS} attempts.`;
212
+ return blockStop(reason, host);
213
+ }
214
+ function reportGate() {
215
+ const result = evaluateReportPost(input);
216
+ if (!result.deny || !result.reason) return "{}";
217
+ return denyTool(result.reason, host);
130
218
  }
131
219
  function buildRouter() {
132
220
  if (!detectBuildIntent(input.prompt ?? "")) return "{}";
@@ -141,6 +229,7 @@ var handlers = {
141
229
  "pr-opened": prOpened,
142
230
  "record-tests": recordTests,
143
231
  "e2e-gate": e2eGate,
232
+ "report-gate": reportGate,
144
233
  "build-router": buildRouter
145
234
  };
146
235
  process.stdout.write((handlers[sub] ?? (() => "{}"))());
@@ -23,7 +23,7 @@ One local browser exists, so **execution is sequential** — one test case at a
23
23
  Per test case, branch on `mode`:
24
24
 
25
25
  **Replay**
26
- 1. `muggle-remote-test-script-get` (latest replayable script) → note `actionScriptId`.
26
+ 1. `muggle-remote-test-script-get` (latest replayable script — the lane-scoped one the caller resolved during classification per [`../failure-mode-handling.md`](../failure-mode-handling.md) §A) → note `actionScriptId`.
27
27
  2. `muggle-remote-action-script-get` with that id → full `actionScript` (see [`action-script.md`](action-script.md)).
28
28
  3. `muggle-local-execute-replay` with `testScript`, `actionScript`, `localUrl`, `cwd`, `showUi`, `freshSession` (see [`fresh-session.md`](fresh-session.md)), `timeoutMs` (see [`timeouts.md`](timeouts.md)).
29
29
 
@@ -61,7 +61,7 @@ Run during change analysis, **per impacted test case**. Picks the initial execut
61
61
 
62
62
  - The change summary from `git diff` (file paths + diff content).
63
63
  - The test case (title, description, instructions, last passing run timestamp).
64
- - Existing test scripts for that test case from `muggle-remote-test-script-list`.
64
+ - Existing test scripts for that test case from `muggle-remote-test-script-list`, scoped to the run's lane: pass `runEnvironmentType: "local"` in Local mode (Step 7A), `"remote"` in Remote mode (Step 7B). The lane is fixed at runtime — it selects the versioned runSettings the cloud resolves — so a local run keys replay-vs-regen off the localhost-lane script and a remote run off the deployed-lane script, never the other lane's.
65
65
 
66
66
  ### Rules (fire in order; first match wins)
67
67
 
@@ -98,7 +98,7 @@ Based on the changed files and the requirements goal, determine which test cases
98
98
 
99
99
  ### Step 4: Run the dev loop, gather screenshots
100
100
 
101
- For each relevant test case, run the shared loop in [`../_shared/dev-loop/run.md`](../_shared/dev-loop/run.md): `muggle-remote-test-script-list` by `testCaseId` to pick [replay vs regen](../_shared/dev-loop/run.md), [execute with `timeoutMs`](../_shared/dev-loop/timeouts.md), [fetch the result](../_shared/dev-loop/failures.md) and [interpret failures](../_shared/dev-loop/failures.md), then read the studio-published [cloud refs and per-step screenshots](../_shared/dev-loop/publish.md) off the run result.
101
+ For each relevant test case, run the shared loop in [`../_shared/dev-loop/run.md`](../_shared/dev-loop/run.md): `muggle-remote-test-script-list` by `testCaseId` with `runEnvironmentType: "local"` (this stage runs against localhost) to pick [replay vs regen](../_shared/dev-loop/run.md), [execute with `timeoutMs`](../_shared/dev-loop/timeouts.md), [fetch the result](../_shared/dev-loop/failures.md) and [interpret failures](../_shared/dev-loop/failures.md), then read the studio-published [cloud refs and per-step screenshots](../_shared/dev-loop/publish.md) off the run result.
102
102
 
103
103
  Inputs to the loop: `mode` from the script-exists check, `localUrl`/project from Step 1.7, `cwd` = the working tree recorded in `state.md`.
104
104
 
@@ -74,6 +74,7 @@ Tell the user: `Created test case: <id>`.
74
74
  Load and call `muggle-remote-test-script-list` with:
75
75
  - `projectId`: from Step 2
76
76
  - `testCaseId`: from Step 4
77
+ - `runEnvironmentType`: `"local"` — this skill executes in the local browser, so it resolves the local-lane script. The lane is an explicit env type, exactly as the remote flow passes `"remote"`; the target URL is just the lane's url and never decides the lane.
77
78
 
78
79
  Tell the user which mutations will be applied: `Mutations: <mutations[]>` (or "no mutations" if empty).
79
80
 
@@ -16,16 +16,19 @@ which uses `local` / `remote` / `ask`).
16
16
 
17
17
  ## Gate behavior
18
18
 
19
- - `always` → take the pro-action, then print silent footer.
20
- - `never` → take the skip-action, then print silent footer.
19
+ - `always` → take the pro-action, then **always** print the silent footer.
20
+ - `never` → take the skip-action, then **always** print the silent footer.
21
21
  - `ask` (or absent) → run Picker 1 (per-key file) → Picker 2 (below).
22
22
 
23
23
  `defaultExecutionMode` uses `local`/`remote` instead of `always`/`never`.
24
24
 
25
- ## Silent footer (whenever pickers are skipped)
25
+ ## Silent footer (mandatory whenever a prompt is skipped)
26
26
 
27
- The user must always be told **what happened**, **why it was silent**, and
28
- **how to change it**. Two lines:
27
+ Whenever a gate resolves to a non-`ask` value and skips its picker, the footer
28
+ below is **required** — every gate, every time, no exceptions. It tells the
29
+ user **what happened**, **why it was silent**, and **how to change it**. The
30
+ `preference-gates-lint` test enforces that this contract exists; omitting the
31
+ footer when a gate fires is a bug. Two lines:
29
32
 
30
33
  ```
31
34
  ✓ <silent action from per-key file>
@@ -219,7 +219,7 @@ Wait for user confirmation before moving to execution.
219
219
 
220
220
  ### 6f: Classify execution mode per test case (replay vs regen)
221
221
 
222
- For each selected test case, decide whether the run should be a **replay** of an existing script or a fresh **regen**, using the rules in [`_shared/failure-mode-handling.md`](../_shared/failure-mode-handling.md) section A. Inputs: the change summary from Step 2, the test case body, and the result of `muggle-remote-test-script-list` for that test case (last passing timestamp + whether any replayable script exists).
222
+ For each selected test case, decide whether the run should be a **replay** of an existing script or a fresh **regen**, using the rules in [`_shared/failure-mode-handling.md`](../_shared/failure-mode-handling.md) section A. Inputs: the change summary from Step 2, the test case body, and the result of `muggle-remote-test-script-list` for that test case (last passing timestamp + whether any replayable script exists). Scope that list call to the run's lane — `runEnvironmentType: "local"` in Local mode, `"remote"` in Remote mode (section A) — so replay reuses the script for the lane the run will execute against.
223
223
 
224
224
  Per test case, fire one `muggle-local-telemetry-event-emit` with `eventType: "pre-execution-classification"` capturing the picked mode, the rule that fired, and the matched changed-file paths.
225
225
 
@@ -28,7 +28,7 @@ Branch each test case on its `mode`, then issue **all** workflow-start calls in
28
28
  - `instructions`: From the test case
29
29
  - `expectedResult`: From the test case
30
30
 
31
- **Replay-mode test case** — `muggle-remote-workflow-start-test-script-replay` against the latest replayable script for that test case (resolve via `muggle-remote-test-script-list` if not already in hand from the classification step). Tag results with `mode: "replay"` so the router routes failures correctly.
31
+ **Replay-mode test case** — `muggle-remote-workflow-start-test-script-replay` against the latest replayable script for that test case (resolve via `muggle-remote-test-script-list` with `runEnvironmentType: "remote"` if not already in hand from the classification step). Tag results with `mode: "replay"` so the router routes failures correctly.
32
32
 
33
33
  Store each returned workflow runtime ID along with its mode tag.
34
34
 
@@ -136,7 +136,7 @@ Before deciding the target's script, resolve its prerequisite chain from the bac
136
136
 
137
137
  ### 5. Existing scripts vs new generation
138
138
 
139
- `muggle-remote-test-script-list` with `testCaseId`.
139
+ `muggle-remote-test-script-list` with `testCaseId` and `runEnvironmentType: "local"` — this skill always replays against localhost, so resolve the local-lane script (the remote-lane script carries the deployed URL).
140
140
 
141
141
  - **If any replayable/succeeded scripts exist:** use `AskUserQuestion` to present them as clickable options. Show: name, created/updated, step count per option. Include **"Generate new script"** as the last option.
142
142
  - **If none:** go straight to generation (no need to ask replay vs generate).