scenescout 3.13.0 → 3.14.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.
@@ -41,14 +41,18 @@ import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
41
41
  import { FIXTURE_KINDS } from "./engine/fixtures.js";
42
42
  import { feedForSession, LIVE_ENV, writeStatusFile, LIVE_TOKEN_FILE, liveEngines, liveTokenFileName, pidAlive, statusFileName, LiveServer, StatusBoard, } from "./engine/live.js";
43
43
  import { formatBriefs, MAX_LANES, planLanes } from "./engine/brief.js";
44
+ import { DEFAULT_EXPIRY_MARGIN_MINUTES, DEFAULT_RUN_MINUTES, judgeProfileFile } from "./engine/expiry.js";
45
+ import { loginCommand } from "./engine/profiles.js";
44
46
  import { formatNeverSubmittedEmpty } from "./engine/forms.js";
45
47
  import { computeGaps, formatRouteCoverage, formatUnchosenOptions, generateReport, replayDocument, reportEvidence } from "./engine/report.js";
46
48
  import { describeVerdict, formatWorklist, unknownIds, VERDICTS, verifyWorklist } from "./engine/verify.js";
49
+ import { ACTION_TIMEOUT_ENV, DEFAULT_ACTION_TIMEOUT_MS, DEFAULT_CRAWL_NAV_TIMEOUT_MS, DEFAULT_NAV_TIMEOUT_MS, LIMIT_BOUNDS, NAV_TIMEOUT_ENV, watchdogFor, } from "./engine/limits.js";
47
50
  import { RECORD_MAX_FRAMES, resolveFrame } from "./engine/replay.js";
48
51
  import { describePace, normalizePace } from "./engine/settle.js";
49
52
  import { needsTask, taskRefusal, TASK_MAX } from "./engine/task.js";
50
53
  import { EXPLORE_PROMPT_ARGUMENTS, explorePrompt, loadPlaybook, PLAYBOOK_PROMPT, PLAYBOOK_TOOL, SERVER_INSTRUCTIONS } from "./playbook.js";
51
54
  import { formatScan, scanProject } from "./scan.js";
55
+ import { CAPTURE_MARGIN, CAPTURES_DIRNAME, captureFileName, captureResultText, MAX_CAPTURE_MARGIN } from "./engine/capture.js";
52
56
  /** Live sessions: each name owns an independent BrowserEngine (browser + auth). */
53
57
  const engines = new Map();
54
58
  /**
@@ -292,7 +296,7 @@ async function ensureLive(dir) {
292
296
  return;
293
297
  publishLiveToken(dir);
294
298
  // The port is new, so a reader polling status.json needs it rewritten.
295
- flushStatus(dir);
299
+ void flushStatus(dir);
296
300
  }
297
301
  function startLiveServer() {
298
302
  const starting = new LiveServer(liveProvider);
@@ -324,11 +328,11 @@ function liveLine() {
324
328
  `(current tool, page thumbnail, optional live stream). It opens on this machine only and cannot act on the run.`);
325
329
  }
326
330
  function flushStatus(dir) {
327
- // Fire-and-forget: status is best-effort observability on every tool call's
328
- // hot path and must never add blocking filesystem latency. The writer
329
- // queues writes per directory and lands each by rename, so a reader never
330
- // sees a torn file.
331
- void writeStatusFile(dir, JSON.stringify({
331
+ // Not awaited on a tool call's hot path: status is best-effort observability
332
+ // and must never add blocking filesystem latency there. The writer queues
333
+ // writes per directory and lands each by rename, so a reader never sees a
334
+ // torn file. scout_close awaits it through settleProjectWrites.
335
+ return writeStatusFile(dir, JSON.stringify({
332
336
  pid: process.pid,
333
337
  phase: lastWriter?.phase ?? "idle",
334
338
  tool: lastWriter?.tool ?? "",
@@ -342,6 +346,21 @@ function flushStatus(dir) {
342
346
  ...(liveAddress ? { live: { port: liveAddress.port } } : liveError ? { live: { error: liveError } } : {}),
343
347
  }, null, 2));
344
348
  }
349
+ /** The latest ensureLive per project, so a close can wait for the token and status writes it starts. */
350
+ const liveStarts = new Map();
351
+ /**
352
+ * Everything the server has started writing into a project, landed: the live
353
+ * view's start (it writes the token file and rewrites status once its port is
354
+ * known), the token file, then this status write, queued behind any earlier
355
+ * one. scout_close awaits it, so once a close has answered nothing more is
356
+ * written into the project, which may then be removed. None of these reject.
357
+ */
358
+ async function settleProjectWrites(dir) {
359
+ await liveStarts.get(dir);
360
+ liveStarts.delete(dir);
361
+ await liveTokenWrites.get(dir);
362
+ await flushStatus(dir);
363
+ }
345
364
  /**
346
365
  * Live status for the tested project (`scenescout status <project>` or any
347
366
  * supervising layer reads this): what every session is doing right now.
@@ -364,8 +383,8 @@ function writeStatus(session, phase, tool, budgetMs) {
364
383
  ...(objective ? { objective: redactSecrets(objective) } : {}),
365
384
  ...(task ? { task: redactSecrets(task) } : {}),
366
385
  });
367
- void ensureLive(dir);
368
- flushStatus(dir);
386
+ liveStarts.set(dir, ensureLive(dir));
387
+ void flushStatus(dir);
369
388
  }
370
389
  /** The watchdog's timeout answer — a diagnosable result, not a hang. */
371
390
  function watchdogTimeout(label, ms) {
@@ -398,9 +417,12 @@ function serializedPerSession(label, fn, timeoutMs = 60_000) {
398
417
  return Promise.resolve(text(taskRefusal(label), session));
399
418
  }
400
419
  const exec = async () => {
401
- writeStatus(session, "running", label, timeoutMs);
420
+ // A session that raised its time limits gets its watchdog raised by as much (limits.ts).
421
+ const current = engines.get(session);
422
+ const watchdogMs = current ? watchdogFor(timeoutMs, current.timeLimits) : timeoutMs;
423
+ writeStatus(session, "running", label, watchdogMs);
402
424
  try {
403
- const out = await withWatchdog(label, fn(args, session), timeoutMs, watchdogTimeout);
425
+ const out = await withWatchdog(label, fn(args, session), watchdogMs, watchdogTimeout);
404
426
  // `activeName` is process-global and every scout_attach moves it. With
405
427
  // several sessions live — the multi-role runs this tool encourages —
406
428
  // an omitted `session` silently binds to whichever browser attached
@@ -510,15 +532,46 @@ server.registerTool("scout_lane_brief", {
510
532
  lanes: z.number().int().min(1).max(MAX_LANES).describe(`How many lanes to split across (1–${MAX_LANES})`),
511
533
  goal: z.string().max(200).optional().describe("What the whole run is for; each lane's objective is written against it"),
512
534
  routes: z.array(z.string()).max(500).optional().describe("Routes to split. Omit to split every route this project knows about."),
535
+ runMinutes: z
536
+ .number()
537
+ .int()
538
+ .min(1)
539
+ .max(1440)
540
+ .optional()
541
+ .describe(`How long the lanes will run, in minutes (default ${DEFAULT_RUN_MINUTES}). When they attach by a saved role, the brief is refused if that login will not last this long plus the margin.`),
542
+ expiryMarginMinutes: z
543
+ .number()
544
+ .int()
545
+ .min(0)
546
+ .max(240)
547
+ .optional()
548
+ .describe(`How long past the run a saved role's login must still last, in minutes (default ${DEFAULT_EXPIRY_MARGIN_MINUTES}).`),
513
549
  session: sessionParam,
514
550
  },
515
- }, serializedPerSession("scout_lane_brief", async ({ lanes, goal, routes }, session) => {
551
+ }, serializedPerSession("scout_lane_brief", async ({ lanes, goal, routes, runMinutes, expiryMarginMinutes, }, session) => {
516
552
  try {
517
553
  const eng = engineFor(session);
554
+ // Lanes that attach by a saved role all sign in from one file: check it
555
+ // lasts the run before handing out briefs that would die part-way.
556
+ let expiryNote = "";
557
+ if (eng.auth.kind === "role") {
558
+ const verdict = judgeProfileFile(eng.auth.storageStatePath, {
559
+ url: eng.baseUrl,
560
+ now: Date.now(),
561
+ runMs: (runMinutes ?? DEFAULT_RUN_MINUTES) * 60_000,
562
+ marginMs: (expiryMarginMinutes ?? DEFAULT_EXPIRY_MARGIN_MINUTES) * 60_000,
563
+ role: eng.auth.role,
564
+ rerun: loginCommand(eng.auth.role, eng.baseUrl),
565
+ });
566
+ if (verdict.kind === "refuse")
567
+ return errorText(new Error(verdict.message));
568
+ if (verdict.kind !== "ok")
569
+ expiryNote = `⚠ ${verdict.message}\n\n`;
570
+ }
518
571
  const all = routes && routes.length > 0 ? routes : eng.allKnownRoutes();
519
572
  const briefs = planLanes(all, lanes, { goal, mode: eng.mode, role: eng.role });
520
573
  laneLedger.nameBriefed(briefs.map((b) => b.lane), (s) => engines.has(s));
521
- return text(formatBriefs(briefs, { goal, mode: eng.mode, role: eng.role }), session);
574
+ return text(expiryNote + formatBriefs(briefs, { goal, mode: eng.mode, role: eng.role, roleProfile: eng.auth.kind === "role" }), session);
522
575
  }
523
576
  catch (err) {
524
577
  return errorText(err);
@@ -588,10 +641,21 @@ server.registerTool("scout_lane_report", {
588
641
  `\nFile each with scout_finding (the same evidence), or confirm which finding already covers it, before closing the lane's session. A judged defect that is never filed is not in the report.`
589
642
  : "";
590
643
  laneLedger.fold(lane, engines.get(lane)?.attached === true);
644
+ // A lane that lost its sign-in and re-attached from its role's profile
645
+ // says so here, where the planner folds it, not only in the lane's own calls.
646
+ const reattach = engines.get(lane)?.reattachSummary() ?? "";
647
+ const reattached = reattach ? `\n↻ Session ${JSON.stringify(lane)}: ${reattach}.` : "";
648
+ const refreshed = engines.get(lane)?.refreshSummary() ?? "";
649
+ const brokered = refreshed ? `\n↻ Session ${JSON.stringify(lane)}: ${refreshed}.` : "";
591
650
  const around = parsed.aroundIgnored ? `\n(The text around the report's JSON block was discarded unread.)` : "";
592
651
  const decoded = decodedEntitiesNote(parsed.entitiesDecoded) + ignoredConventionsNote(parsed.conventionsIgnored);
593
652
  return {
594
- content: [{ type: "text", text: `Lane report accepted — ${summarizeLaneReport(parsed.report)}${note}${around}${decoded}${followUp}` }],
653
+ content: [
654
+ {
655
+ type: "text",
656
+ text: `Lane report accepted — ${summarizeLaneReport(parsed.report)}${note}${reattached}${brokered}${around}${decoded}${followUp}`,
657
+ },
658
+ ],
595
659
  };
596
660
  }
597
661
  catch (err) {
@@ -610,11 +674,18 @@ server.registerTool("scout_scan", {
610
674
  }
611
675
  }));
612
676
  server.registerTool("scout_attach", {
613
- description: "Launch a browser and attach to a running web app. First attach in this conversation and you have read neither the SceneScout skill nor scout_playbook? Call scout_playbook before this. Write policy is enforced at the NETWORK layer: mode='observe' blocks EVERY request that is not a GET (login and token refresh excepted) — choose it for a target that holds real data, where even an ordinary form submission would create a record; mode='read-only' (default) blocks destructive-labeled elements AND all PUT/PATCH/DELETE + destructive POSTs, but lets ordinary form POSTs through; mode='safe-write' allows creating data and permits updates/deletes ONLY on resources this session created (use when the user wants create/edit flows tested); mode='destructive' allows everything — ONLY when the user explicitly confirmed a disposable/seeded environment. Pass a Playwright storage-state JSON to explore as an authenticated role. Pass `session` to keep MULTIPLE roles alive at once (one browser each, genuinely concurrent) for collaboration testing — target each directly with every tool's `session` param, or use scout_session to set which one is the default; coverage and findings merge into one project memory.",
677
+ description: "Launch a browser and attach to a running web app. First attach in this conversation and you have read neither the SceneScout skill nor scout_playbook? Call scout_playbook before this. Write policy is enforced at the NETWORK layer: mode='observe' blocks EVERY request that is not a GET (login and token refresh excepted) — choose it for a target that holds real data, where even an ordinary form submission would create a record; mode='read-only' (default) blocks destructive-labeled elements AND all PUT/PATCH/DELETE + destructive POSTs, but lets ordinary form POSTs through; mode='safe-write' allows creating data and permits updates/deletes ONLY on resources this session created (use when the user wants create/edit flows tested); mode='destructive' allows everything — ONLY when the user explicitly confirmed a disposable/seeded environment. Pass `role` to sign in with a login the user saved by `scenescout login <url> --role <name>`, or a Playwright storage-state JSON as storageStatePath. Pass `session` to keep MULTIPLE roles alive at once (one browser each, genuinely concurrent) for collaboration testing — target each directly with every tool's `session` param, or use scout_session to set which one is the default; coverage and findings merge into one project memory.",
614
678
  inputSchema: {
615
679
  url: z.string().describe("Base URL of the running app, e.g. http://localhost:3000"),
616
680
  projectPath: z.string().describe("Absolute path to the project (memory + report live in .scenescout/ here)"),
617
- storageStatePath: z.string().optional().describe("Optional Playwright storage-state JSON path for authenticated exploration"),
681
+ storageStatePath: z.string().optional().describe("Optional Playwright storage-state JSON path for authenticated exploration. Not with `role`."),
682
+ role: z
683
+ .string()
684
+ .max(40)
685
+ .optional()
686
+ .describe("Sign in as a role whose login was saved with `scenescout login <url> --role <name>` (kept in the project's .scenescout/auth/). " +
687
+ "Every session given the same role gets its own browser built from that one login. Not with storageStatePath. " +
688
+ "No saved login for the role: the attach is refused and names the command to run — ask the user to run it, since it opens a window for them to sign in."),
618
689
  mode: z
619
690
  .enum(["observe", "read-only", "safe-write", "destructive"])
620
691
  .default("read-only")
@@ -656,13 +727,27 @@ server.registerTool("scout_attach", {
656
727
  .default(false)
657
728
  .describe("Keep a frame of the page after every action, under .scenescout/recordings/, and show it beside that step in report.html. " +
658
729
  "Off by default: a recording is pictures of the app under test sitting in the project folder. Turn it on for QA work, where the run is evidence and not only a report."),
730
+ actionTimeoutMs: z
731
+ .number()
732
+ .int()
733
+ .min(LIMIT_BOUNDS.action.min)
734
+ .max(LIMIT_BOUNDS.action.max)
735
+ .optional()
736
+ .describe(`How long one click, keystroke, hover or pick may take, in ms. Default: the ${ACTION_TIMEOUT_ENV} environment variable, else ${DEFAULT_ACTION_TIMEOUT_MS}. Raise it only when timeouts come from a loaded machine rather than the app.`),
737
+ navTimeoutMs: z
738
+ .number()
739
+ .int()
740
+ .min(LIMIT_BOUNDS.nav.min)
741
+ .max(LIMIT_BOUNDS.nav.max)
742
+ .optional()
743
+ .describe(`How long a page may take to load, in ms. Default: the ${NAV_TIMEOUT_ENV} environment variable, else ${DEFAULT_NAV_TIMEOUT_MS} (crawled pages ${DEFAULT_CRAWL_NAV_TIMEOUT_MS}; a value set here applies to them too). Raise it only when timeouts come from a loaded machine rather than the app.`),
659
744
  session: z
660
745
  .string()
661
746
  .max(40)
662
747
  .optional()
663
748
  .describe("Session name for multi-role runs (e.g. 'admin', 'qa'). Creates/replaces that session's browser and makes it the default. Default: 'default'."),
664
749
  },
665
- }, serializedControl(async ({ url, projectPath, storageStatePath, mode, headed, browser, viewportWidth, viewportHeight, objective, task, record, trustedEmbeds, paceMs, session, }) => {
750
+ }, serializedControl(async ({ url, projectPath, storageStatePath, role, mode, headed, browser, viewportWidth, viewportHeight, objective, task, record, trustedEmbeds, paceMs, actionTimeoutMs, navTimeoutMs, session, }) => {
666
751
  try {
667
752
  const target = session ?? activeName;
668
753
  if (session) {
@@ -733,6 +818,7 @@ server.registerTool("scout_attach", {
733
818
  url,
734
819
  projectDir: projectPath,
735
820
  storageStatePath,
821
+ role,
736
822
  mode,
737
823
  headed,
738
824
  browser,
@@ -746,9 +832,10 @@ server.registerTool("scout_attach", {
746
832
  task: objective ? task : undefined,
747
833
  record,
748
834
  trustedEmbeds,
835
+ actionTimeoutMs,
836
+ navTimeoutMs,
749
837
  memoryStore: store,
750
838
  });
751
- eng.role = storageStatePath ? path.basename(storageStatePath).replace(/\.json$/i, "") : "anonymous";
752
839
  // Put the session on the board now, so the live view shows it before its
753
840
  // first tool call. liveLine() needs the port, so the server is awaited
754
841
  // here rather than started in the background by writeStatus.
@@ -1139,6 +1226,36 @@ server.registerTool("scout_screenshot", {
1139
1226
  return errorText(err);
1140
1227
  }
1141
1228
  }));
1229
+ server.registerTool("scout_capture", {
1230
+ description: "Save a PNG of ONE element — its bounds plus a margin, from a real screenshot — under .scenescout/captures/, to show a person how it looks. Give the element's ref from the latest scout_snapshot. Not a way to judge design: scout_design_audit measures it.",
1231
+ inputSchema: {
1232
+ ref: z.string().max(20).optional().describe("The element's ref from the latest scout_snapshot"),
1233
+ key: z
1234
+ .string()
1235
+ .max(300)
1236
+ .optional()
1237
+ .describe("Instead of a ref: the element's key from an earlier scout_capture result, to capture the same element on another deployment"),
1238
+ name: z.string().max(40).optional().describe("The file's name: letters, digits and dashes. Default: capture"),
1239
+ margin: z.number().int().min(0).max(MAX_CAPTURE_MARGIN).optional().describe(`CSS pixels kept around the element. Default ${CAPTURE_MARGIN}`),
1240
+ session: sessionParam,
1241
+ },
1242
+ }, serializedPerSession("scout_capture", async ({ ref, key, name, margin }, session) => {
1243
+ try {
1244
+ const eng = engineFor(session);
1245
+ const dir = eng.memory?.dir;
1246
+ if (!dir)
1247
+ throw new Error("Not attached — call scout_attach first.");
1248
+ const shot = await eng.captureElement({ ref, key }, margin ?? CAPTURE_MARGIN);
1249
+ const file = path.join(dir, CAPTURES_DIRNAME, captureFileName(name));
1250
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1251
+ fs.writeFileSync(file, shot.png);
1252
+ const size = { width: shot.png.readUInt32BE(16), height: shot.png.readUInt32BE(20) };
1253
+ return text(captureResultText({ file, key: shot.key, label: shot.label, url: shot.url, ...size }), session);
1254
+ }
1255
+ catch (err) {
1256
+ return errorText(err);
1257
+ }
1258
+ }));
1142
1259
  server.registerTool("scout_finding", {
1143
1260
  description: "Record a structured finding (bug, UX issue, or improvement). Deduplicates across runs; automatically captures the recent action trace as the repro. Use for anything worth reporting: crashes, oracle violations you confirmed, dead ends, confusing UX, permission leaks, missing testids — and design-audit improvement opportunities (ux-polish) with their concrete measurements.",
1144
1261
  inputSchema: {
@@ -1397,8 +1514,7 @@ server.registerTool("scout_close", {
1397
1514
  board.clear();
1398
1515
  laneLedger.clear();
1399
1516
  lastWriter = null;
1400
- for (const dir of dirs)
1401
- flushStatus(dir);
1517
+ await Promise.all([...dirs].map((dir) => settleProjectWrites(dir)));
1402
1518
  return text(`All sessions closed (${names.join(", ") || "none were live"}). Memory and reports remain in .scenescout/.`, activeName);
1403
1519
  }
1404
1520
  const name = session ?? activeName;
@@ -1426,7 +1542,7 @@ server.registerTool("scout_close", {
1426
1542
  if (lastWriter?.session === name)
1427
1543
  lastWriter = null;
1428
1544
  if (eng.memory?.dir)
1429
- flushStatus(eng.memory.dir);
1545
+ await settleProjectWrites(eng.memory.dir);
1430
1546
  if (activeName === name)
1431
1547
  activeName = engines.keys().next().value ?? "default";
1432
1548
  return text(`Session "${name}" closed. Memory and report remain in .scenescout/.` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "3.13.0",
3
+ "version": "3.14.0",
4
4
  "description": "SceneScout — exploratory UI testing for AI coding agents. An MCP server that gives any agent (Claude Code, Cursor, VS Code Copilot, Codex, Gemini CLI and others) a structured view of a running web app, always-on oracles, a network-level write policy, memory across runs and a gap-checked report.",
5
5
  "license": "MIT",
6
6
  "author": "brunoboto96",
@@ -72,7 +72,7 @@
72
72
  "mcp-check": "npm run build && npm run mcp-check:run",
73
73
  "mcp-check:run": "tsx scripts/mcp-check.ts",
74
74
  "test": "npm run build && npm run test:unit && npm run smoke:run && npm run mcp-check:run",
75
- "test:unit": "npm run scan-test && npm run oracle-test && npm run policy-test && npm run fixture-test && npm run dispatch-test && npm run design-test && npm run check-test && npm run ci-test && npm run qa-test && npm run contract-test && npm run pace-test && npm run request-test && npm run settle-test && npm run claims-test && npm run verify-test && npm run brief-test && npm run lane-test && npm run calibration-test && npm run bench-test && npm run memory-test && npm run install-test && npm run live-test && npm run demo-test && npm run holdout-test && npm run hygiene-test",
75
+ "test:unit": "npm run scan-test && npm run oracle-test && npm run policy-test && npm run fixture-test && npm run dispatch-test && npm run design-test && npm run check-test && npm run ci-test && npm run qa-test && npm run contract-test && npm run pace-test && npm run limits-test && npm run request-test && npm run settle-test && npm run claims-test && npm run verify-test && npm run brief-test && npm run lane-test && npm run calibration-test && npm run bench-test && npm run memory-test && npm run install-test && npm run profiles-test && npm run refresh-test && npm run scripted-login-test && npm run live-test && npm run demo-test && npm run holdout-test && npm run hygiene-test",
76
76
  "scan-test": "tsx scripts/scan-test.ts",
77
77
  "oracle-test": "tsx --test scripts/oracle-test.ts",
78
78
  "policy-test": "tsx --test scripts/policy-test.ts",
@@ -86,11 +86,15 @@
86
86
  "lane-test": "tsx --test scripts/lane-test.ts",
87
87
  "memory-test": "tsx --test scripts/memory-test.ts",
88
88
  "install-test": "tsx --test scripts/install-test.ts",
89
+ "profiles-test": "tsx --test scripts/profiles-test.ts",
90
+ "refresh-test": "tsx --test scripts/refresh-test.ts",
91
+ "scripted-login-test": "tsx --test scripts/scripted-login-test.ts",
89
92
  "live-test": "tsx --test scripts/live-test.ts",
90
93
  "demo-test": "tsx --test scripts/demo-test.ts",
91
94
  "holdout-test": "tsx --test scripts/holdout-test.ts",
92
95
  "hygiene-test": "tsx --test scripts/hygiene-test.ts",
93
96
  "pace-test": "tsx --test scripts/pace-test.ts",
97
+ "limits-test": "tsx --test scripts/limits-test.ts",
94
98
  "request-test": "tsx --test scripts/request-test.ts",
95
99
  "settle-test": "tsx --test scripts/settle-test.ts",
96
100
  "claims-test": "tsx --test scripts/claims-test.ts",
@@ -98,6 +102,7 @@
98
102
  "brief-test": "tsx --test scripts/brief-test.ts",
99
103
  "calibration-test": "tsx --test scripts/calibration-test.ts",
100
104
  "bench": "tsx scripts/bench.ts",
105
+ "bench:ci": "tsx scripts/bench/ci-record.ts",
101
106
  "bench-test": "tsx --test scripts/bench-test.ts",
102
107
  "dedup-bench": "tsx scripts/dedup-bench.ts"
103
108
  },
@@ -19,14 +19,14 @@ You are the brain of an exploratory UI tester. The SceneScout MCP server gives y
19
19
  - **No source here?** When the target is a remote URL (staging, a deployed site) and the scan reports no frontend workspace, that is a supported mode, not an error: you are a black-box QA tester. Skip step 3's launch logic and keep the current directory as `projectPath` (memory and the report still need a home). Link harvesting builds the route list, and `scout_crawl` has nothing to crawl until it does — so snapshot the landing page and main navigation first, then `scout_crawl`, and `scout_crawl` again to pick up what those pages linked to. With no scanned auth states, `--role` is a path to a Playwright storage-state JSON. A remote target is far more likely to hold real data: confirm the user is authorized to test it if that is not evident, attach in `observe` mode unless the user says form submissions are acceptable on this target (then `read-only`), and never go past `read-only` unless they say the environment is disposable. In `observe` you may fill and submit forms freely: the engine blocks the request, so you still see client-side validation, and nothing is created. Claims that something is *absent* cannot be source-checked here: file them as behaviour-only and say so. `extensive` still needs ≥2 login states. The report cannot record where routes came from, so say it in your summary to the user and in an `scout_note`: routes were discovered from same-origin links only, and pages nothing links to are outside the contract.
20
20
  - **Source available?** Use it beyond the scan: when you file a finding, read the component or handler behind it and name the file and the likely fix — that is the difference between "the save button does nothing" and a finding a developer can act on in one step.
21
21
  3. **Ensure the app is running.** Full-stack markers in the scan → do NOT launch the stack yourself; confirm the URL responds (you may curl it) or ask the user. Auto-launch only simple single-package frontends.
22
- 4. **Pick auth.** `--role X` → pass that storage-state file. Unspecified → least-privileged role; say so.
23
- 5. **`scout_attach`** with url, projectPath, storageStatePath, an `objective` (this session's whole remit, shown live), and the write **mode** — the DB behind the app may be live, so the engine enforces this at the network layer:
22
+ 4. **Pick auth.** `--role X` → if the user saved a login under that name with `scenescout login <url> --role X` (it lives at `.scenescout/auth/X.json`), attach with `role: "X"`; otherwise pass the storage-state file the scan found for it, or the path they gave, as `storageStatePath`. Never both. Unspecified → least-privileged role; say so. **No login saved for the role and no storage state either** (attach says so and names the command): ask the user to run `scenescout login <url> --role X` — it opens a window for THEM to sign in (SSO, MFA), so you cannot do it for them — then attach again. One saved login serves every session attached with that role, each in its own browser.
23
+ 5. **`scout_attach`** with url, projectPath, `role` or storageStatePath, an `objective` (this session's whole remit, shown live), and the write **mode** — the DB behind the app may be live, so the engine enforces this at the network layer:
24
24
  - `observe` (`--observe`): NOTHING but GET requests leaves the page (login and token refresh excepted) — no form submission reaches the server. **This is your default for a remote URL with no source** (see step 2): such a target is far more likely to hold real data, and in `read-only` every form you submit creates a record somebody has to clean up. Forms you fill cannot be submitted; the gap ledger says so and that is the honest result. Move to `read-only` only when the user says form submissions are acceptable there.
25
25
  - `read-only` (default when the source is here, i.e. a local or dev app): destructive labels blocked in the UI AND all PUT/PATCH/DELETE + destructive POSTs blocked on the wire. Ordinary form POSTs still go through. Use unless told otherwise.
26
26
  - `safe-write` (`--safe-write`, or the user asks to test creating/editing things): create freely — **prioritize testing CREATE flows** — then edit/delete ONLY the records you created (the engine tracks your creations and blocks mutations on anything else). Never attempt to clean up or modify pre-existing data.
27
27
  - `destructive` (`--allow-destructive` only): everything allowed. Requires the user to explicitly confirm the environment is disposable/seeded. Never decide this yourself.
28
28
  A `🛡 WRITE-POLICY blocked` notice in a tool result is the engine's safety net, NOT an app bug — never file a finding for the error UI it causes; note it and move on (or suggest the user re-run with a laxer mode if that flow matters). The page's own requests are answered with a 403 in the server's place, so the page's handling of a refusal is real: an error message after a block is correct behaviour, but a page that claims the change was saved or deleted is lying to its user — the engine reports that as `false_success`, and it IS a finding.
29
- **`⚠ AUTH FAILED` on attach means the storage state is stale** — its token has expired and the browser is sitting on a login page. Stop and ask the user to regenerate it (usually their Playwright auth-setup project); do not explore, and do not file findings from a logged-out session. The message quotes whatever an earlier run wrote down about regenerating it, so relay that first rather than asking from scratch — and the moment the user tells you what actually worked, write it down with `scout_note {section:'setup', note}` (the exact command, what has to be on PATH, how long a token lasts). A login state expires on a timer nobody remembers, and this is the single learning a run most reliably pays for and then forgets. Mid-run, `⚠ SESSION AUTH LOST` means the same thing happened after N navigations: everything since is meaningless, so re-attach rather than pressing on. Routes bounced this way are recorded as NOT covered, so the gap ledger will still show them.
29
+ **`⚠ AUTH FAILED` on attach means the storage state is stale** — its token has expired and the browser is sitting on a login page. Stop and ask the user to regenerate it (for a `role`, the message names the `scenescout login` command that records it again; for a file, usually their Playwright auth-setup project); do not explore, and do not file findings from a logged-out session. The message quotes whatever an earlier run wrote down about regenerating it, so relay that first rather than asking from scratch — and the moment the user tells you what actually worked, write it down with `scout_note {section:'setup', note}` (the exact command, what has to be on PATH, how long a token lasts). A login state expires on a timer nobody remembers, and this is the single learning a run most reliably pays for and then forgets. Mid-run, `⚠ SESSION AUTH LOST` means the same thing happened after N navigations: everything since is meaningless, so re-attach rather than pressing on. Routes bounced this way are recorded as NOT covered, so the gap ledger will still show them. A session attached by `role` answers its first loss itself: it re-reads that role's saved profile from disk (someone may have recorded it again since), re-attaches once, goes back to the page it asked for, and says `↻ SESSION RE-ATTACHED` — carry on, and visit again the routes that notice lists as still NOT covered (a crawl revisits them itself). That happens once per session: a second loss, or a profile that is dead too, is reported as `SESSION AUTH LOST` and needs the profile recorded again. A lane that re-attached is named as such when its report is folded. A session attached with a storage-state file, or none, is never re-attached automatically.
30
30
  6. **`scout_note {action:'read'}` immediately after attaching.** ASSUMPTIONS.md is the run-over-run written memory: what the app is, who each role is FOR, constraints discovered the hard way ("an order can only ship once approved"). Starting without reading it means re-learning what a previous run already paid to find out. Throughout the run, `scout_note {action:'add'}` every DURABLE learning the moment you confirm it — app model, role personas, conventions, constraints, risks, glossary. Not session facts (ids, counts); knowledge a future run should start with.
31
31
 
32
32
  ## The efficient loop (this is the core method)
@@ -34,7 +34,7 @@ You are the brain of an exploratory UI tester. The SceneScout MCP server gives y
34
34
  1. **`scout_crawl` first, always.** One call visits every known route (pass `paths` to sweep a specific subset instead), records coverage, and returns per-route health. This is the whole breadth pass — do not visit routes one-by-one with navigate+snapshot.
35
35
  2. **Investigate what the crawl flagged.** For each problem route (violations, dead-ends, auth-redirects): navigate there, `scout_snapshot`, reproduce, then `scout_finding`.
36
36
  3. **Run journeys with `scout_run_plan {steps}`.** Mechanical sequences (fill form → submit → check) go in ONE plan call — `steps` is an ordered list of `{action, target, value}` — with `testid=`/`text=`/`label=`/`role=button[name="Save"]` targets — not one LLM turn per click. The plan aborts at the first violation and tells you where; that's your cue to investigate interactively. When the user wants a flow kept working, save the plan that walked it as `.scenescout/flows/<name>.json` — `{"name", "steps"}`, the same steps, starting with a `navigate`, plus `{"action":"expect-text","text"}`, `{"action":"expect-url","pattern"}` or `{"action":"expect-request","request":"GET /api/…","status":200}` where the outcome shows — and `scenescout check` replays it on every pull request. Save only flows that read: a check replays flows under observe's rule unless the project passes `--flow-writes allow`, so a step that sends a write is reported as "could not run" and the check exits 2.
37
- 4. **Snapshot economics:** `scout_snapshot` after landing somewhere new; re-snapshots of the same route return *diffs* with stable refs — "No element changes" costs you almost nothing. `scout_screenshot` ONLY for suspected pixel-native issues (a canvas, a rendering glitch); geometry problems (overlap, off-screen, a covered control) are already in the snapshot as GEOMETRY issues, and images that failed to load are listed under BROKEN IMAGES — file those, quoting the line. Controls inside embeds (`<iframe>`) are listed with the page's, each marked `⟨in … frame⟩`, and act by ref like any other. A frame of the app's own origin is the app: test it fully. A frame of another site (`cross-origin`) is someone else's system that the user has not authorised you to test: click and type there as a user would, to see the embed render and respond, but never send it hostile input — the engine refuses markup, values over 200 characters, control characters, repeated-click probes and uploads there, and you must not try SQL, template or other injection shapes either — and file what you find there as the embed's behaviour, naming its origin, not as the app's bug. A failing request such a frame sent outside the app says so (`in an embed of …`) and is kept at medium; console errors cannot be told apart by frame and stay the app's; its controls are counted apart and never enter the app's coverage or gap ledger. Its container text is masked, and the writes it sends outside the app are refused in every mode but destructive — unless the user names that origin as a trusted embed (a provider in test mode, say): pass it in `scout_attach {trustedEmbeds: ["https://…"]}` and, in safe-write only, its writes go out. Never add an origin the user did not name. The FRAMES line names frames that were not read; say in your summary that their contents were not explored.
37
+ 4. **Snapshot economics:** `scout_snapshot` after landing somewhere new; re-snapshots of the same route return *diffs* with stable refs — "No element changes" costs you almost nothing. `scout_screenshot` ONLY for suspected pixel-native issues (a canvas, a rendering glitch); geometry problems (overlap, off-screen, a covered control) are already in the snapshot as GEOMETRY issues, and images that failed to load are listed under BROKEN IMAGES — file those, quoting the line. `scout_capture {ref}` saves a PNG of one element (plus a `margin` of pixels around it) only when the user asks to see how something looks; it shows, it does not judge. Controls inside embeds (`<iframe>`) are listed with the page's, each marked `⟨in … frame⟩`, and act by ref like any other. A frame of the app's own origin is the app: test it fully. A frame of another site (`cross-origin`) is someone else's system that the user has not authorised you to test: click and type there as a user would, to see the embed render and respond, but never send it hostile input — the engine refuses markup, values over 200 characters, control characters, repeated-click probes and uploads there, and you must not try SQL, template or other injection shapes either — and file what you find there as the embed's behaviour, naming its origin, not as the app's bug. A failing request such a frame sent outside the app says so (`in an embed of …`) and is kept at medium; console errors cannot be told apart by frame and stay the app's; its controls are counted apart and never enter the app's coverage or gap ledger. Its container text is masked, and the writes it sends outside the app are refused in every mode but destructive — unless the user names that origin as a trusted embed (a provider in test mode, say): pass it in `scout_attach {trustedEmbeds: ["https://…"]}` and, in safe-write only, its writes go out. Never add an origin the user did not name. The FRAMES line names frames that were not read; say in your summary that their contents were not explored.
38
38
  5. **Native-user behaviours.** `scout_type {ref, textValue}` (or its alias `value`, matching `scout_select` and a plan step) APPENDS when a field already has content (menu clicks often insert @-mention chips or commands into composers — appending preserves them; the result reports what was already there); pass `replace=true` only to deliberately clear, and `pressEnter=true` to submit from the field the way a user would. Before concluding a badge, icon, or "N errors" indicator *does nothing*, `scout_hover` it — tooltips and hover cards are invisible to snapshots and clicks, and hover output includes what appeared. In HEADED mode (`scout_attach {headed:true}`, which the user asks for when they want to watch) the user's physical mouse competes with the synthetic pointer: if a hover reveals nothing and the finding matters, ask the user to move their mouse off the browser window and retry before filing. **Scroll long pages with `scout_scroll`** — the design audit and snapshot measure at the current scroll position, so judge deep sections by scrolling then re-auditing; it refuses to scroll where a real user couldn't and reports SCROLL LOCKED (the leaked modal scroll-lock that silently amputates everything below the fold — snapshots also flag it passively as an OVERLAY line), and scrolling triggers lazy-loaded content whose failures surface as fresh oracle violations. Elements fully clipped inside an overflow-hidden container are flagged UNREACHABLE in GEOMETRY issues — no amount of scrolling reveals them; that's a high-value layout bug, distinct from merely below-the-fold content. **A page can hold SEVERAL independent scroll regions** and plain `scout_scroll` moves the largest one, so a sidebar nav beside a taller main pane never budges: pass `scout_scroll {target:"testid=…"}` to scroll one region. Never report a nav item, tab or list row as missing/truncated until you have scrolled ITS container — content scrolled out of a secondary pane looks exactly like content that was cut off.
39
39
  6. **The rest of the input vocabulary.** `scout_select` sets a `<select>` option by value or visible label — use it rather than clicking a native dropdown open, which does not render as page DOM. `scout_press` sends a real key to the focused element (`Escape` to dismiss a modal, `Tab` to walk focus order, `Enter` to submit from a field); it is also how the keyboard-only pass at `extensive` is performed, and it vets the focused control first so a destructive action cannot be triggered blind in read-only mode. **`scout_upload {ref}` attaches a file the way a user does** — `ref` is a visible `<input type=file>` (snapshots list these with role `file`; `scout_type` on one redirects here) OR the button/label/dropzone that opens the file chooser (the chooser is intercepted and answered — that is how the hidden input behind a styled "Choose file" control is reached); omit `ref` when the page has exactly one file input, hidden or not (snapshots disclose hidden ones on a FILE INPUTS line). Nothing needs to exist on disk: a small VALID fixture is generated in memory, its kind inferred from the input's `accept` attribute or chosen with `fixture` (`pdf`, `png`, `txt`, `csv`, `json`); `filePath` uploads a real file but must live inside the attached project (fenced, like navigation is fenced to the origin); `name` overrides the filename. The result flags a file that violates `accept` (a mismatch the app then ACCEPTS is a validation finding), warns when the app cleared the input after selection, and says whether a state-changing request fired on selection — if none did, either click the form's submit or read the next snapshot for a client-side rejection. Plans take `{action:"upload", target, value:"pdf"}` steps (`target` required). When the input or its trigger was addressed by `ref`, the gap ledger counts an attached-but-unsent file as filled-never-submitted; the ref-less path has no listed element to mark.
40
40
  7. **Say what you are doing: `task` is required before a tool acts.** A session shows two lines to whoever is watching. Its **objective** is the whole remit you were given, set once at `scout_attach {objective}` ("Admin lane: §2 registers, §7 plan gating", "Approve and reject orders as a manager"). Its **task** is what you are doing *right now*, and every tool that changes the app or the page — `scout_navigate`, `scout_back`, `scout_click`, `scout_type`, `scout_select`, `scout_press`, `scout_upload`, `scout_run_plan` — takes it: a few words for the batch in front of you ("Filtering the documents register by status", "Filling the deviation form with invalid dates", "Signing in as QA_Team"). Say what you are DOING, not what you are checking — "§2.4 filtering narrows the set and is reflected in the URL" is the acceptance criteria, which is the result you will judge, not the batch you are running; naming the item is fine ("§2.4: filtering the documents register"). The task STAYS SET until you pass a different one, so a batch costs a few words, not one per call — pass a fresh one whenever you move on. Acting with none standing is refused: the person watching would otherwise see a session clicking through their app with nothing to say why. `scout_journey {action:"start", goal:…}` sets the task too while it runs — use a journey when you are MEASURING a whole user task, the parameter for everything else.
@@ -60,9 +60,11 @@ Route knowledge is generic: routes read from source (file-based routing, or Reac
60
60
 
61
61
  **Slowing a run down so a person can follow it.** By default every session runs as fast as its page allows: the engine waits on the requests the action actually fired, not on a fixed sleep. When the user is watching and wants to keep up — taking notes alongside the agent, demonstrating a flow to someone — `scout_attach {paceMs: 5000}` makes that session pause at least five seconds between actions, and `scout_session {paceMs: 5000}` changes it mid-run (with no `name`, it applies to every attached session at once). `paceMs: 0` puts it back to full speed. Set it only when the user asks to slow down; it is a floor on every action, so a run at 5000 takes as long as it sounds.
62
62
 
63
+ **Timeouts that belong to the machine.** A click, keystroke, hover or pick may take 5 s and a page 20 s to load. A timeout says which limit ran out and how to raise it. When several run out on controls and pages that work when retried, and the machine is known to be busy (a shared CI runner, a build running beside you), re-attach with `scout_attach {actionTimeoutMs: 15000, navTimeoutMs: 60000}` rather than filing them as the app's bugs. A timeout on one control that others around it do not have is still worth a look as the app's. Never raise the limits to make a genuinely slow page pass: a page that takes 20 s to load is a finding.
64
+
63
65
  ## Multi-role collaboration (named sessions)
64
66
 
65
- Some flows need a TEAM — a document one role submits and another approves, a review one role assigns and another completes, permission checks that only mean something side-by-side. `scout_attach {session: "admin", storageStatePath: …}` then `scout_attach {session: "qa", storageStatePath: …}` keeps BOTH browsers live and authenticated. Coverage and findings from all roles merge into one project memory.
67
+ Some flows need a TEAM — a document one role submits and another approves, a review one role assigns and another completes, permission checks that only mean something side-by-side. `scout_attach {session: "admin", role: "admin"}` then `scout_attach {session: "qa", role: "qa"}` (or `storageStatePath: …` for a storage-state file) keeps BOTH browsers live and authenticated. Two sessions may share one role — two lanes as the same user — and each still gets its own browser from that one login. Coverage and findings from all roles merge into one project memory.
66
68
 
67
69
  **Every per-session tool takes a `session` param — use it.** Calls targeting DIFFERENT sessions run genuinely CONCURRENTLY (issue several in one turn and they execute in parallel); calls targeting the SAME session still serialize, because one browser's ref table can't process overlapping actions. `scout_session {name}` only sets the default for calls that omit `session` — convenience for single-role stretches, not the mechanism for multi-role work. Once more than one session is live the default is genuinely unsafe to rely on: it points at whichever session attached most recently, so a *concurrent* attach elsewhere silently re-points your un-parameterized calls at another role's browser. The engine appends `⚠ AMBIGUOUS SESSION` when that happens — treat it as a mistake to fix, not a note.
68
70
 
@@ -73,7 +75,7 @@ Some flows need a TEAM — a document one role submits and another approves, a r
73
75
  - **Infer the PERSONA behind each role, and write it down.** From what a role can see and do (its nav, its dashboard, the capability matrix in the report), state what this person is FOR: "qa = reviewer — approves orders, assigns reviewers, no admin" / "user = front-line user — reads documents, completes reviews, raises orders". Record it with `scout_note {section:'roles'}`. Then test the persona's WORLD, not just the permissions: does the operator's landing page serve an operator? Is anything they need N clicks deep? The capability matrix's divergent rows are questions, not verdicts — each is either a correct boundary or a gap ("should this role be able to do this?"); say which you believe it is and why.
74
76
  - `scout_close {all: true}` at the end of a multi-role run; `scout_close {session}` to drop one role early.
75
77
  - **Several agents in parallel** (subagents or a workflow, each driving its own session): each agent attaches its session when it STARTS, and the PLANNER closes it by name once it has folded that lane's report. A lane must NOT close its own session before reporting: `scout_lane_report` keeps its decisions against that session's project memory, so a lane that closes first hands back decisions with nowhere to write — the tool says so rather than accepting silently, and the run's calibration section ends up with nothing to measure. Fold, then close. `scout_close` enforces it: a session that `scout_lane_brief` or `scout_lane_report` named as a lane is not closed until a report from it has been ACCEPTED (a refused one does not count, so re-send the corrected object before closing), and `scout_close {all: true}` names every such lane and closes nothing. Pass `force: true` only when a lane will never report, knowing its decisions are lost. Keep the planner's own session open until the last lane is folded: closing every session ends the run, and with it the record of which sessions are lanes. Never open sessions ahead for agents that have not started, and never hand an open session from one agent to the next: an agent waiting for its turn should hold no browser. Give each session an `objective` when you attach it (`scout_attach {session, objective:"Approve and reject orders as a manager"}`) and wrap each goal in `scout_journey`: the live view shows that objective and the task it is on beside the session's feed, which is how the person watching knows what every agent is for. Keep that goal TRUE: one journey per goal, one goal per thing you are checking ("Save a settings change as the auditor", not "Check every page"), ended the moment it is decided and the next one started before you move on. A journey that outlives its goal shows the viewer an objective the session left behind minutes ago. Run roughly as many agents at once as the machine has cores, less two, since each drives a real browser; beyond that they only queue. Exploring one area is well within a mid-tier model, so run these agents on one (Sonnet or its equivalent in your client) unless the user names a model; keep the larger model for the agent that plans the split and writes the report. No agent may call `scout_close {all: true}` while others run — only the last step, once every agent has finished.
76
- - **Let the engine divide the app: `scout_lane_brief {lanes, goal}`.** Call it after the first crawl, when route knowledge is complete. It splits the known routes into whole modules — everything under `/orders` goes to one lane, so that lane carries state between its own steps instead of re-learning the app on every route — deals the modules out so the lanes come out within a route or two of each other, and returns each lane's session name, the `objective` to attach it with, the routes it owns, the route it lands on (its own first route: lanes that all attached on the home page all met its defects first, and several filed the same one), and the rules each lane must follow — file each defect as it is judged, check a list's request status before calling it empty or stuck, submit one markup value on a create form and open where it is listed, probe a withheld control's endpoint, check coverage, and leave the session open for you to fold. Pass `routes` to split a subset instead. Hand each brief to its agent verbatim. Dividing by hand fails in two ways that a finished run cannot tell apart from success: two lanes audit the same register while a third module is never opened, and route coverage reads complete either way; and lanes launch without an objective, so the person watching sees browsers clicking through their app with nothing to say why.
78
+ - **Let the engine divide the app: `scout_lane_brief {lanes, goal}`.** Call it after the first crawl, when route knowledge is complete. It splits the known routes into whole modules — everything under `/orders` goes to one lane, so that lane carries state between its own steps instead of re-learning the app on every route — deals the modules out so the lanes come out within a route or two of each other, and returns each lane's session name, the `objective` to attach it with, the routes it owns, the route it lands on (its own first route: lanes that all attached on the home page all met its defects first, and several filed the same one), and the rules each lane must follow — file each defect as it is judged, check a list's request status before calling it empty or stuck, submit one markup value on a create form and open where it is listed, probe a withheld control's endpoint, check coverage, and leave the session open for you to fold. Pass `routes` to split a subset instead. When your session attached by a saved `role`, the lanes will all sign in from that one login, so pass `runMinutes` (how long the lanes will run; default 60): the brief is refused if the login will not last that long plus `expiryMarginMinutes` (default 10), and the refusal names the `scenescout login` command for the user to run again — ask them, then call `scout_lane_brief` again. A login the engine cannot be sure about (a session cookie, a token with no expiry, a cookie for another host) is a warning at the top of the brief, not a refusal. Hand each brief to its agent verbatim. Dividing by hand fails in two ways that a finished run cannot tell apart from success: two lanes audit the same register while a third module is never opened, and route coverage reads complete either way; and lanes launch without an objective, so the person watching sees browsers clicking through their app with nothing to say why.
77
79
  - **Lanes report in typed decisions, not prose.** Each lane files its findings with `scout_finding` as it goes, so the report already has them; what the lane hands BACK to the planner is one JSON object, the lane report, and nothing else. Get the paragraph to put in a lane's prompt from `scout_lane_report {lane}`: it names the shape (`status`, one decision per observation with `verdict`, `severity`, `category`, a calibrated `confidence` and a machine-signature `evidence`, the `routes` covered, `blocked_by`; the verdict `worth_a_look` carries a `convention` naming what would decide it, and is never scored for calibration), every value's closed set (the categories are `scout_finding`'s) and every length limit, because a limit a lane is not told refuses good replies. When the lane hands back, pass its text to `scout_lane_report {lane, reply}`: it returns the one-line fold (defects, highs, unsure, mean confidence, routes, what blocked it) or the reason the reply was refused, and keeps each decision so the confidence the lane stated can be checked against what the run went on to file — the report's calibration section is built from that, and it only appears once enough decisions exist to mean something. **So the confidence is worth stating honestly**: a lane that writes 0.95 on everything makes the section say so. The fold also lists every defect the lane judged that no finding on the project matches yet: have the lane file each with the same `evidence` (a finding filed without evidence cannot be matched), or name the finding that covers it, before you close its session, because a defect that is only in a lane report never reaches the run's report. Prose around ONE fenced JSON block is discarded unread and the report accepted; anything less clear-cut is refused. Relay a refusal to the lane once and ask for the corrected object; never re-judge its prose yourself, and tell every lane that the object IS its final report, since a lane that writes the object and then hands back a summary of it has failed. The planner then folds lanes by counting, not by reading: a lane's reply is a few hundred tokens whatever it found, and a value the schema refuses is caught at the boundary instead of becoming a severity like "Low-Medium" in the report. Where the client lets you set a lane's reasoning effort, use **medium**: in this project's benchmark it judged as well as high in three-quarters of the time and under half of xhigh, low inflated severities, and max took three minutes per lane for no better agreement.
78
80
 
79
81
  ## The impatient-user pass (extensive)
@@ -93,6 +95,7 @@ The engine is self-healing (orphaned browsers reaped, wedged calls time out with
93
95
  - **Ground absence-claims in the source before filing.** Black-box behaviour tells you what happened, never *why*. Any finding asserting a control is MISSING ("no permission check", "no validation", "no segregation of duties", "not implemented") must be checked against the codebase first — you have Read/Grep/Bash, use them. Three outcomes, all valuable: (1) genuinely absent → file it with the file:line you checked, which makes it actionable instead of speculative; (2) implemented but not reached on your path → you found a *routing/config* bug, a different and often better finding; (3) implemented elsewhere in the same codebase but not here → **the strongest kind of finding**, an inconsistency against the team's own established pattern, with the reference implementation attached as the fix. A grep costs seconds and is the difference between "I think this is wrong" and "here is the line". Behaviour-only findings (a crash, a 500, a dead end) need no such check — the evidence is self-contained.
94
96
  - Oracle violations are *evidence* — reproduce/contextualize, then `scout_finding`. **Severity anchors:** crash, data loss, security/permission breach, or a flow the user cannot complete or escape (infinite spinner, no-way-out page, broken primary journey) = **high** — a dead end is high even without a crash. Broken but recoverable = medium. Polish/friction = low.
95
97
  - **A `refused_empty` or `false_success` violation is the highest-value thing the engine hands you — file it, always.** Both mean the page CONTRADICTED the server. `refused_empty`: a list request was refused (401/403/5xx) and the page showed its empty state with no error, so the user is told they have nothing when the truth is that nothing could be loaded — this is how a permission regression ships without anyone noticing, and it is a `data-inconsistency` or `security` finding at **high**, not a cosmetic one. `false_success`: a save was refused and the page said it worked, so the user walks away believing their change was kept — **high**, `data-inconsistency`. The engine only raises either when a request genuinely failed AND the page admitted nothing, so do not re-litigate whether the refusal happened; reproduce it, name the endpoint and the message the user saw, and read the handler in the source to say which branch swallowed the response. A page that shows an error is correct behaviour and never raises these — quote that contrast in the finding, since it is usually the fix. When the violation says the refusal was the write policy's **stand-in**, the server was never asked: the finding is about the page's handling of a refusal (`data-inconsistency`), not about the server's permissions, so never file it as a permission regression or `security`.
98
+ - **A `postmessage_token` violation is a `security` finding at high.** The page called `postMessage` with targetOrigin `"*"` and a token-shaped value in the message (a JWT, a `Bearer` value, or an opaque value under a key such as `access_token`), so whatever origin the receiving window holds can read the credential. The violation names the path inside the message, the shape and the token's first four characters and length; it never carries the token, and neither should your finding. Find the call in the source and name the origin it should post to instead. The engine sees calls on the page's own window and on same-origin windows (an opener, a parent, a frame); a call into another site's frame is not visible to it, so a silent run is not proof that none exist.
96
99
  - **Provenance for deep-link findings:** if you reached a state by direct URL (crawl or scout_navigate) and it looks broken (no app shell, dead end), check whether real users can reach it through the UI before filing — many apps render tab content at internal URLs that users only ever reach via `?tab=` links. Always state in the finding how the state was reached.
97
100
  - Tabs and views (`?tab=…`) are distinct states in coverage — prefer clicking the actual tab controls when testing a flow; deep-link only to re-verify.
98
101
  - **Lifecycle:** if the user says something is fixed, or the evidence no longer reproduces on a route you re-tested, mark it with `scout_resolve {id}` (spelled `findingId` if you prefer the long form). Historical findings in the report are unverified — don't re-report them as new.