scenescout 3.3.0 → 3.5.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.
@@ -36,6 +36,7 @@ import { BrowserEngine } from "./engine/browser.js";
36
36
  import { reapOrphanBrowsers } from "./engine/reaper.js";
37
37
  import { FINDING_CATEGORIES, MemoryStore, redactSecrets } from "./engine/memory.js";
38
38
  import { LANE_NAME_MAX, laneReportInstruction, parseLaneReport, summarizeLaneReport } from "./engine/lane.js";
39
+ import { MAX_UNFILED_NAMED, unfiledDefects } from "./engine/calibration.js";
39
40
  import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
40
41
  import { FIXTURE_KINDS } from "./engine/fixtures.js";
41
42
  import { feedForSession, LIVE_ENV, writeStatusFile, LIVE_TOKEN_FILE, liveEngines, liveTokenFileName, pidAlive, statusFileName, LiveServer, StatusBoard, } from "./engine/live.js";
@@ -238,7 +239,7 @@ function reportExtras(eng) {
238
239
  return {
239
240
  routesVisited: all.length - unvisited.length,
240
241
  routesTotal: all.length,
241
- designAudits: eng.designAuditCount,
242
+ designAudits: eng.memory?.auditsThisRun ?? eng.designAuditCount,
242
243
  createdResources: eng.createdResources,
243
244
  unvisitedRoutes: unvisited,
244
245
  mode: eng.mode,
@@ -528,10 +529,50 @@ server.registerTool("scout_lane_report", {
528
529
  if (reply === undefined)
529
530
  return { content: [{ type: "text", text: laneReportInstruction(lane) }] };
530
531
  const parsed = parseLaneReport(reply, lane);
531
- const out = parsed.ok
532
- ? `Lane report accepted — ${summarizeLaneReport(parsed.report)}`
533
- : `Lane report REFUSED: ${parsed.reason}. Ask the lane once for the corrected object; do not re-judge its prose.`;
534
- return { content: [{ type: "text", text: out }] };
532
+ if (!parsed.ok) {
533
+ return {
534
+ content: [
535
+ { type: "text", text: `Lane report REFUSED: ${parsed.reason}. Ask the lane once for the corrected object; do not re-judge its prose.` },
536
+ ],
537
+ };
538
+ }
539
+ // Keep what the lane decided, so the confidence it stated can be checked
540
+ // against what the run goes on to file. Best-effort: a lane report is
541
+ // still accepted if this project has no memory open yet, because the
542
+ // planner's fold must not depend on where the report was written.
543
+ // ONLY the lane's own session. Falling back to any engine with memory
544
+ // open put one project's decisions into another project's store whenever
545
+ // two sessions were attached to different apps — and the lane having
546
+ // already closed makes that the ordinary case, not an edge one.
547
+ const owner = engines.get(lane)?.memory;
548
+ const at = new Date().toISOString();
549
+ const kept = owner
550
+ ? owner.addLaneDecisions(lane, parsed.report.decisions.map((d) => ({ ...d, lane, at })))
551
+ : 0;
552
+ // Say when nothing was kept. Every lane closing its session before the
553
+ // planner folds its report is the order the method describes, and it
554
+ // leaves no memory to write to — reporting a bare "accepted" while the
555
+ // skill promises the decisions are kept is the kind of silence that
556
+ // makes a later calibration section look wrong rather than absent.
557
+ const note = kept > 0
558
+ ? ` (${kept} decision(s) kept for calibration)`
559
+ : ` (nothing kept for calibration, and no check that its defects were filed — session ${JSON.stringify(lane)} is not attached here, so there is no project memory to write to or read. Fold a lane report before closing that lane's session.)`;
560
+ // Follow-through: a defect judged and never filed never reaches the
561
+ // report. Checked against every finding on the lane's project, so one
562
+ // the planner or another lane filed counts. Only that project's: the
563
+ // same reason decisions are kept only there.
564
+ const unfiled = owner ? unfiledDefects(parsed.report.decisions, owner.findings) : [];
565
+ const followUp = unfiled.length > 0
566
+ ? `\n⚠ ${unfiled.length} judged defect(s) have no finding with matching evidence yet:\n` +
567
+ unfiled
568
+ .slice(0, MAX_UNFILED_NAMED)
569
+ .map((u) => ` · ${u}`)
570
+ .join("\n") +
571
+ (unfiled.length > MAX_UNFILED_NAMED ? `\n … +${unfiled.length - MAX_UNFILED_NAMED} more` : "") +
572
+ `\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.`
573
+ : "";
574
+ const around = parsed.aroundIgnored ? `\n(The text around the report's JSON block was discarded unread.)` : "";
575
+ return { content: [{ type: "text", text: `Lane report accepted — ${summarizeLaneReport(parsed.report)}${note}${around}${followUp}` }] };
535
576
  }
536
577
  catch (err) {
537
578
  return errorText(err);
@@ -655,6 +696,12 @@ server.registerTool("scout_attach", {
655
696
  catch {
656
697
  /* conflict detection is best-effort */
657
698
  }
699
+ // Moving this session to another project leaves its old one; if it was
700
+ // the last session there, that run is over. Re-attaching to the SAME
701
+ // project is the same run, and keeps what the run has learned.
702
+ const previous = eng.memory;
703
+ if (previous && previous !== store && ![...engines.values()].some((e) => e !== eng && e.memory === previous))
704
+ previous.endRun();
658
705
  const viewport = viewportWidth && viewportHeight ? { width: viewportWidth, height: viewportHeight } : undefined;
659
706
  const out = await eng.attach({
660
707
  url,
@@ -1160,8 +1207,11 @@ server.registerTool("scout_report", {
1160
1207
  (unvisited.length > 30 ? `\n … +${unvisited.length - 30} more` : "") +
1161
1208
  `\n→ Run scout_crawl (no args) to cover them in one call.`);
1162
1209
  }
1163
- if (eng.designAuditCount === 0) {
1164
- gates.push(`No scout_design_audit was run this session — run it on at least one representative page (visual/a11y coverage is part of every level).`);
1210
+ // Counted across every session on this project, not just the one
1211
+ // asking: in a parallel run the lanes audit and the planner reports.
1212
+ const auditsThisRun = eng.memory.auditsThisRun;
1213
+ if (auditsThisRun === 0) {
1214
+ gates.push(`No scout_design_audit was run in this run, by any session — run it on at least one representative page (visual/a11y coverage is part of every level).`);
1165
1215
  }
1166
1216
  const lvl = level ?? "medium";
1167
1217
  const auditedRoutes = Object.values(eng.memory.routeFacts).filter((f) => f.audited).length;
@@ -1176,7 +1226,7 @@ server.registerTool("scout_report", {
1176
1226
  const gapList = computeGaps(eng.memory, {
1177
1227
  routesVisited: all.length - unvisited.length,
1178
1228
  routesTotal: all.length,
1179
- designAudits: eng.designAuditCount,
1229
+ designAudits: auditsThisRun,
1180
1230
  unvisitedRoutes: unvisited,
1181
1231
  mode: eng.mode,
1182
1232
  });
@@ -1196,11 +1246,13 @@ server.registerTool("scout_report", {
1196
1246
  history,
1197
1247
  routesVisited: all.length - unvisited.length,
1198
1248
  routesTotal: all.length,
1199
- designAudits: eng.designAuditCount,
1249
+ designAudits: auditsThisRun,
1200
1250
  createdResources: eng.createdResources,
1201
1251
  unvisitedRoutes: unvisited,
1202
1252
  mode: eng.mode,
1203
1253
  policyAttributed: eng.oracleLog.policyAttributed,
1254
+ // Which sessions are still open decides whether a quiet one is holding a browser, and how long its trailing idle runs.
1255
+ attachedSessions: [...engines.keys()],
1204
1256
  });
1205
1257
  void p;
1206
1258
  return text(summary, session);
@@ -1287,8 +1339,11 @@ server.registerTool("scout_close", {
1287
1339
  keepReport(e);
1288
1340
  for (const name of engines.keys())
1289
1341
  live?.dropSession(name);
1342
+ const stores = new Set([...engines.values()].map((e) => e.memory).filter((m) => m !== null && m !== undefined));
1290
1343
  await Promise.allSettled([...engines.values()].map((e) => e.close()));
1291
1344
  engines.clear();
1345
+ for (const store of stores)
1346
+ store.endRun();
1292
1347
  sessionQueue.clear();
1293
1348
  board.clear();
1294
1349
  lastWriter = null;
@@ -1305,6 +1360,9 @@ server.registerTool("scout_close", {
1305
1360
  await eng.close();
1306
1361
  const saveError = eng.memory?.lastSaveError;
1307
1362
  engines.delete(name);
1363
+ // The last session on this project ends its run.
1364
+ if (eng.memory && ![...engines.values()].some((e) => e.memory === eng.memory))
1365
+ eng.memory.endRun();
1308
1366
  sessionQueue.forget(name);
1309
1367
  board.remove(name);
1310
1368
  if (lastWriter?.session === name)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "3.3.0",
3
+ "version": "3.5.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",
@@ -71,7 +71,7 @@
71
71
  "mcp-check": "npm run build && npm run mcp-check:run",
72
72
  "mcp-check:run": "tsx scripts/mcp-check.ts",
73
73
  "test": "npm run build && npm run test:unit && npm run smoke:run && npm run mcp-check:run",
74
- "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 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 memory-test && npm run install-test && npm run live-test && npm run demo-test && npm run hygiene-test",
74
+ "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 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 hygiene-test",
75
75
  "scan-test": "tsx scripts/scan-test.ts",
76
76
  "oracle-test": "tsx --test scripts/oracle-test.ts",
77
77
  "policy-test": "tsx --test scripts/policy-test.ts",
@@ -90,7 +90,10 @@
90
90
  "settle-test": "tsx --test scripts/settle-test.ts",
91
91
  "claims-test": "tsx --test scripts/claims-test.ts",
92
92
  "verify-test": "tsx --test scripts/verify-test.ts",
93
- "brief-test": "tsx --test scripts/brief-test.ts"
93
+ "brief-test": "tsx --test scripts/brief-test.ts",
94
+ "calibration-test": "tsx --test scripts/calibration-test.ts",
95
+ "bench": "tsx scripts/bench.ts",
96
+ "bench-test": "tsx --test scripts/bench-test.ts"
94
97
  },
95
98
  "dependencies": {
96
99
  "@modelcontextprotocol/sdk": "^1.12.0",
@@ -25,7 +25,7 @@ You are the brain of an exploratory UI tester. The SceneScout MCP server gives y
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
- 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).
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
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.
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
 
@@ -72,9 +72,9 @@ Some flows need a TEAM — a document one role submits and another approves, a r
72
72
  - While roles are NOT collaborating, use each one productively where its permissions matter (admin in /admin surfaces, low-privilege probing for permission leaks) — same coverage contract, different vantage points.
73
73
  - **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
74
  - `scout_close {all: true}` at the end of a multi-role run; `scout_close {session}` to drop one role early.
75
- - **Several agents in parallel** (subagents or a workflow, each driving its own session): each agent attaches its session when it STARTS and closes it by name when it FINISHES. 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, and the routes it owns. 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.
77
- - **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`), 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. 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.
75
+ - **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. 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.
77
+ - **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`), 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
78
 
79
79
  ## The impatient-user pass (extensive)
80
80
 
@@ -89,10 +89,10 @@ The engine is self-healing (orphaned browsers reaped, wedged calls time out with
89
89
 
90
90
  ## Judgment (what the engine can't do)
91
91
 
92
- - **A hidden or disabled control proves nothing: `scout_request` is how a refusal becomes evidence.** Any finding that a role CANNOT do something must show the server refusing it, not just the UI withholding the button — a control hidden client-side is a decision the browser made, and the endpoint behind it may answer 200 to anyone who asks. `scout_request {session, method, path}` calls the app's own API as that session, with the UI bypassed: it runs inside the page, so it carries the session's cookies, replays the Authorization header the app itself last sent, and meets the same write policy a click does (in safe-write a mutation on a record the session did not create is refused here too, which is the engine's safety net and never a finding). It returns the signature to quote (`GET /api/admin/users 403`), the timing, the headers that decide whether two responses are genuinely identical, and the body. Use it for every permission boundary, for comparing one route across roles, and for confirming a 402/403 the UI turned into a friendly empty state. Paths are fenced to the attached origin. Unlike a shell call, every request is recorded in the run's trail and shows up in the report.
92
+ - **A hidden or disabled control proves nothing: `scout_request` is how a refusal becomes evidence.** Any finding that a role CANNOT do something must show the server refusing it, not just the UI withholding the button — a control hidden client-side is a decision the browser made, and the endpoint behind it may answer 200 to anyone who asks. `scout_request {session, method, path}` calls the app's own API as that session, with the UI bypassed: it runs inside the page, so it carries the session's cookies, replays the Authorization header the app itself last sent, and meets the same write policy a click does (in safe-write a mutation on a record the session did not create is refused here too, and the result says `REFUSED by the write policy` instead of a status: the server was never asked, so it is not evidence either way and never a finding). It returns the signature to quote (`GET /api/admin/users 403`), the timing, the headers that decide whether two responses are genuinely identical, and the body. Use it for every permission boundary, for comparing one route across roles, and for confirming a 402/403 the UI turned into a friendly empty state. Paths are fenced to the attached origin. Unlike a shell call, every request is recorded in the run's trail and shows up in the report.
93
93
  - **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
94
  - 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
- - **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.
95
+ - **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`.
96
96
  - **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
97
  - 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
98
  - **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.