scenescout 3.9.0 → 3.10.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.
@@ -1,4 +1,13 @@
1
1
  import { redactSecrets } from "./memory.js";
2
+ /** How an HTTP error violation states its request. One formatter, so a reader of the detail (the check) cannot drift from its writer. */
3
+ export function httpErrorDetail(method, url, status) {
4
+ return `${method} ${url.slice(0, 200)} → HTTP ${status}`;
5
+ }
6
+ /** The status an http_error detail names, or null when the detail is not one. */
7
+ export function httpStatusOf(detail) {
8
+ const m = /→ HTTP (\d{3})$/.exec(detail.replace(/ \[\d+ secrets? redacted\]$/, ""));
9
+ return m ? Number(m[1]) : null;
10
+ }
2
11
  /** URLs whose failures are noise, not findings (favicons, source maps). */
3
12
  const BENIGN_URL_RE = /favicon|\.map($|\?)/i;
4
13
  /**
@@ -35,6 +44,68 @@ export function isPolicyInduced(v, msSincePolicyBlock) {
35
44
  return false;
36
45
  return /ERR_BLOCKED_BY_CLIENT|Failed to fetch|NetworkError when attempting to fetch|Load failed|the server responded with a status of 403\b/i.test(v.detail);
37
46
  }
47
+ /**
48
+ * The request a console message is the browser's own echo of, or null when it
49
+ * is not one.
50
+ *
51
+ * Chromium and WebKit print "Failed to load resource: …" for a subresource
52
+ * that answered with an error status or got no answer, and give the
53
+ * resource's address as the message's location (Firefox prints nothing; see
54
+ * `echoesFailedLoads` in browsers.ts). The line carries no address in its
55
+ * text, so without this it could only be charged to the page. Returned in the
56
+ * form `requestKey` gives a request's URL, so the two can be matched.
57
+ */
58
+ export function failedLoadEchoOf(text, locationUrl, pageUrl) {
59
+ if (!/^Failed to load resource: /.test(text) || !locationUrl)
60
+ return null;
61
+ try {
62
+ return requestKey(new URL(locationUrl, pageUrl).href);
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /** A request's address as the echo lookup keys it: absolute, and without its fragment, which is never sent. */
69
+ export function requestKey(url) {
70
+ try {
71
+ const u = new URL(url);
72
+ u.hash = "";
73
+ return u.href;
74
+ }
75
+ catch {
76
+ return url;
77
+ }
78
+ }
79
+ /**
80
+ * Which recent requests an embed sent, by address, so the browser's echo of
81
+ * one that failed is charged where the request was.
82
+ *
83
+ * Only addresses an embed requested are kept, and the latest request to an
84
+ * address decides: when the app then requests the same address, its echo is
85
+ * the app's again. Bounded, dropping the oldest, so a long run does not hold
86
+ * every address it saw.
87
+ */
88
+ export class EmbedRequestLog {
89
+ cap;
90
+ byKey = new Map();
91
+ constructor(cap = 500) {
92
+ this.cap = cap;
93
+ }
94
+ note(url, embed) {
95
+ const key = requestKey(url);
96
+ this.byKey.delete(key);
97
+ if (!embed)
98
+ return;
99
+ this.byKey.set(key, embed);
100
+ if (this.byKey.size > this.cap)
101
+ this.byKey.delete(this.byKey.keys().next().value);
102
+ }
103
+ /** The embed whose request a console message echoes, or null when it echoes none of theirs. */
104
+ embedOfEcho(text, locationUrl, pageUrl) {
105
+ const key = failedLoadEchoOf(text, locationUrl, pageUrl);
106
+ return key === null ? null : (this.byKey.get(key) ?? null);
107
+ }
108
+ }
38
109
  /**
39
110
  * Invariant oracles: passive listeners that record violations regardless of
40
111
  * what the agent is doing. The engine drains the buffer after every action and
@@ -58,8 +129,12 @@ export class OracleMonitor {
58
129
  severity: "high",
59
130
  detail: text.slice(0, 500),
60
131
  url: page.url(),
132
+ // The one console line that names its request: the browser's echo of an embed's failed load is the embed's too.
133
+ embed: this.embedRequests.embedOfEcho(text, msg.location().url, page.url()) ?? undefined,
61
134
  });
62
135
  });
136
+ // Noted as each request starts, which is always before the browser can echo its failure.
137
+ page.on("request", (req) => this.embedRequests.note(req.url(), this.embedOfRequest(req)));
63
138
  page.on("pageerror", (err) => {
64
139
  this.record({
65
140
  kind: "page_error",
@@ -105,7 +180,7 @@ export class OracleMonitor {
105
180
  this.record({
106
181
  kind: "http_error",
107
182
  severity: status >= 500 ? "high" : "medium",
108
- detail: `${res.request().method()} ${res.url().slice(0, 200)} → HTTP ${status}`,
183
+ detail: httpErrorDetail(res.request().method(), res.url(), status),
109
184
  url: page.url(),
110
185
  embed: this.embedOfRequest(res.request()) ?? undefined,
111
186
  });
@@ -131,12 +206,14 @@ export class OracleMonitor {
131
206
  */
132
207
  policyAttributed = 0;
133
208
  embedOfRequest = () => null;
209
+ embedRequests = new EmbedRequestLog();
134
210
  /**
135
211
  * The engine knows which frame a request came from; a failing request is
136
212
  * attributed to an embed through this. Console and page errors are not
137
213
  * attributed: a console message says where its script was served from, not
138
214
  * which frame ran it, so an SDK the app's page loads from the embed's own
139
- * site would be taken for the embed.
215
+ * site would be taken for the embed. The exception is the browser's echo of
216
+ * a failed load, which names its request and goes where the request went.
140
217
  */
141
218
  setEmbedAttribution(ofRequest) {
142
219
  this.embedOfRequest = ofRequest;
@@ -1,10 +1,23 @@
1
+ import { FORMS_READ_FAILED, FORMS_SUBMIT_UNMATCHED } from "./forms.js";
1
2
  /**
2
3
  * Log entries that are not actions: a stated task, an attach, the note that a
3
4
  * resource was created. They carry no browser work and take no time, so
4
5
  * counting them inflates the action count and drags the median gap toward
5
6
  * zero. One real run logged 325 entries of which 107 were stated tasks.
6
7
  */
7
- const MARKER_ACTIONS = new Set(["task", "attach", "close", "lane-report", "created-resource", "journey:start", "journey:end", "record:full", "record:failed"]);
8
+ const MARKER_ACTIONS = new Set([
9
+ "task",
10
+ "attach",
11
+ "close",
12
+ "lane-report",
13
+ "created-resource",
14
+ "journey:start",
15
+ "journey:end",
16
+ "record:full",
17
+ "record:failed",
18
+ FORMS_READ_FAILED,
19
+ FORMS_SUBMIT_UNMATCHED,
20
+ ]);
8
21
  /** Whether a log entry represents work the browser actually did. */
9
22
  export function isActing(action) {
10
23
  return !MARKER_ACTIONS.has(action);
@@ -35,12 +35,13 @@ import { z } from "zod";
35
35
  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
- import { LANE_NAME_MAX, laneReportInstruction, parseLaneReport, summarizeLaneReport } from "./engine/lane.js";
38
+ import { decodedEntitiesNote, LANE_NAME_MAX, LaneLedger, laneCloseGuard, laneReportInstruction, parseLaneReport, summarizeLaneReport } from "./engine/lane.js";
39
39
  import { MAX_UNFILED_NAMED, unfiledDefects } from "./engine/calibration.js";
40
40
  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 { formatNeverSubmittedEmpty } from "./engine/forms.js";
44
45
  import { computeGaps, formatRouteCoverage, formatUnchosenOptions, generateReport, replayDocument, reportEvidence } from "./engine/report.js";
45
46
  import { describeVerdict, formatWorklist, unknownIds, VERDICTS, verifyWorklist } from "./engine/verify.js";
46
47
  import { RECORD_MAX_FRAMES, resolveFrame } from "./engine/replay.js";
@@ -500,6 +501,9 @@ server.server.setRequestHandler(GetPromptRequestSchema, (request) => {
500
501
  // Splitting the app between lanes: the other half of the parallel protocol.
501
502
  // scout_lane_report is how a lane hands its answers back; this is what the
502
503
  // planner hands it in the first place.
504
+ // Which sessions those two tools have named as lanes, and whose report was
505
+ // folded, so scout_close can refuse to drop a lane's decisions.
506
+ const laneLedger = new LaneLedger();
503
507
  server.registerTool("scout_lane_brief", {
504
508
  description: "For a run split across parallel agents (lanes). Divides the app's known routes between N lanes and returns each lane's session name, the `objective` to attach it with, and the routes it owns — whole modules per lane, balanced by route count, so no two lanes audit the same area and none is left unopened. Call it after the first crawl, when route knowledge is complete. Touches no browser; pass the briefs to your lane agents, then use scout_lane_report for what they hand back.",
505
509
  inputSchema: {
@@ -512,7 +516,9 @@ server.registerTool("scout_lane_brief", {
512
516
  try {
513
517
  const eng = engineFor(session);
514
518
  const all = routes && routes.length > 0 ? routes : eng.allKnownRoutes();
515
- return text(formatBriefs(planLanes(all, lanes, { goal, mode: eng.mode, role: eng.role }), { goal, mode: eng.mode, role: eng.role }), session);
519
+ const briefs = planLanes(all, lanes, { goal, mode: eng.mode, role: eng.role });
520
+ laneLedger.nameBriefed(briefs.map((b) => b.lane), (s) => engines.has(s));
521
+ return text(formatBriefs(briefs, { goal, mode: eng.mode, role: eng.role }), session);
516
522
  }
517
523
  catch (err) {
518
524
  return errorText(err);
@@ -527,10 +533,13 @@ server.registerTool("scout_lane_report", {
527
533
  },
528
534
  }, async ({ lane, reply }) => {
529
535
  try {
530
- if (reply === undefined)
536
+ if (reply === undefined) {
537
+ laneLedger.name(lane);
531
538
  return { content: [{ type: "text", text: laneReportInstruction(lane) }] };
539
+ }
532
540
  const parsed = parseLaneReport(reply, lane);
533
541
  if (!parsed.ok) {
542
+ laneLedger.refuse(lane, engines.get(lane)?.attached === true);
534
543
  return {
535
544
  content: [
536
545
  { type: "text", text: `Lane report REFUSED: ${parsed.reason}. Ask the lane once for the corrected object; do not re-judge its prose.` },
@@ -575,8 +584,12 @@ server.registerTool("scout_lane_report", {
575
584
  (unfiled.length > MAX_UNFILED_NAMED ? `\n … +${unfiled.length - MAX_UNFILED_NAMED} more` : "") +
576
585
  `\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.`
577
586
  : "";
587
+ laneLedger.fold(lane, engines.get(lane)?.attached === true);
578
588
  const around = parsed.aroundIgnored ? `\n(The text around the report's JSON block was discarded unread.)` : "";
579
- return { content: [{ type: "text", text: `Lane report accepted — ${summarizeLaneReport(parsed.report)}${note}${around}${followUp}` }] };
589
+ const decoded = decodedEntitiesNote(parsed.entitiesDecoded);
590
+ return {
591
+ content: [{ type: "text", text: `Lane report accepted — ${summarizeLaneReport(parsed.report)}${note}${around}${decoded}${followUp}` }],
592
+ };
580
593
  }
581
594
  catch (err) {
582
595
  return errorText(err);
@@ -1162,7 +1175,7 @@ server.registerTool("scout_finding", {
1162
1175
  }
1163
1176
  }));
1164
1177
  server.registerTool("scout_coverage", {
1165
- description: "Show exploration coverage: states visited across all runs, which elements remain unexercised, and which options of a dropdown used this run no session has chosen yet. Use to decide where to explore next and when the level's budget is satisfied.",
1178
+ description: "Show exploration coverage: states visited across all runs, which elements remain unexercised, which options of a dropdown used this run no session has chosen yet, and which forms seen this run no session has submitted with every text field blank. Use to decide where to explore next and when the level's budget is satisfied.",
1166
1179
  inputSchema: { session: sessionParam },
1167
1180
  }, serializedPerSession("scout_coverage", async (_args, session) => {
1168
1181
  try {
@@ -1182,6 +1195,7 @@ server.registerTool("scout_coverage", {
1182
1195
  `Unexercised elements by route:`,
1183
1196
  ...cov.unexercised.slice(0, 25).map((u) => ` ${u.state}: ${u.keys.slice(0, 6).join(", ")}${u.keys.length > 6 ? ` … +${u.keys.length - 6}` : ""}`),
1184
1197
  ...formatUnchosenOptions(eng.memory.unchosenOptions()),
1198
+ ...formatNeverSubmittedEmpty(eng.memory.formsNeverSubmittedEmpty()),
1185
1199
  ];
1186
1200
  return text(lines.join("\n"), session);
1187
1201
  }
@@ -1333,15 +1347,23 @@ server.registerTool("scout_verify", {
1333
1347
  }
1334
1348
  }));
1335
1349
  server.registerTool("scout_close", {
1336
- description: "Close a session's browser (memory persists on disk). Default: the DEFAULT session. Pass session to close a specific one, or all=true to close every live session at the end of a multi-role run.",
1350
+ description: "Close a session's browser (memory persists on disk). Default: the DEFAULT session. Pass session to close a specific one, or all=true to close every live session at the end of a multi-role run. " +
1351
+ "A session that is a lane of a parallel run (named by scout_lane_brief or scout_lane_report) is not closed until its lane report has been accepted by scout_lane_report, since folding needs the session attached; the refusal names every such lane.",
1337
1352
  inputSchema: {
1338
1353
  session: z.string().max(40).optional().describe("Session to close (default: the default session)"),
1339
1354
  all: z.boolean().default(false).describe("Close every live session"),
1355
+ force: z
1356
+ .boolean()
1357
+ .default(false)
1358
+ .describe("Close even a lane whose report has not been folded yet. Its decisions are then lost: nothing is kept for calibration and nothing checks its defects were filed. Prefer folding it with scout_lane_report first."),
1340
1359
  },
1341
- }, serializedControl(async ({ session, all }) => {
1360
+ }, serializedControl(async ({ session, all, force }) => {
1342
1361
  try {
1343
1362
  if (all) {
1344
1363
  const names = [...engines.keys()];
1364
+ const guard = laneCloseGuard(names, laneLedger, force);
1365
+ if (!guard.ok)
1366
+ return text(`${guard.message}\nNo session was closed.`, activeName);
1345
1367
  // Closes are independent per-browser — run them in parallel so N wedged
1346
1368
  // sessions cost one 8s teardown cap total, not N of them.
1347
1369
  const dirs = new Set();
@@ -1359,6 +1381,7 @@ server.registerTool("scout_close", {
1359
1381
  store.endRun();
1360
1382
  sessionQueue.clear();
1361
1383
  board.clear();
1384
+ laneLedger.clear();
1362
1385
  lastWriter = null;
1363
1386
  for (const dir of dirs)
1364
1387
  flushStatus(dir);
@@ -1368,6 +1391,9 @@ server.registerTool("scout_close", {
1368
1391
  const eng = engines.get(name);
1369
1392
  if (!eng)
1370
1393
  return text(`No live session "${name}".`, name);
1394
+ const guard = laneCloseGuard([name], laneLedger, force);
1395
+ if (!guard.ok)
1396
+ return text(guard.message, name);
1371
1397
  keepReport(eng);
1372
1398
  live?.dropSession(name);
1373
1399
  await eng.close();
@@ -1378,6 +1404,11 @@ server.registerTool("scout_close", {
1378
1404
  eng.memory.endRun();
1379
1405
  sessionQueue.forget(name);
1380
1406
  board.remove(name);
1407
+ laneLedger.forget(name);
1408
+ // The last session closing ends the run, and its lanes with it: a brief's
1409
+ // names that never attached must not make a later run's session a lane.
1410
+ if (engines.size === 0)
1411
+ laneLedger.clear();
1381
1412
  if (lastWriter?.session === name)
1382
1413
  lastWriter = null;
1383
1414
  if (eng.memory?.dir)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "3.9.0",
3
+ "version": "3.10.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,13 +71,14 @@
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 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",
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 check-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",
78
78
  "fixture-test": "tsx --test scripts/fixture-test.ts",
79
79
  "dispatch-test": "tsx --test scripts/dispatch-test.ts",
80
80
  "design-test": "tsx --test scripts/design-test.ts",
81
+ "check-test": "tsx --test scripts/check-test.ts",
81
82
  "contract-test": "tsx --test scripts/contract-test.ts",
82
83
  "lane-test": "tsx --test scripts/lane-test.ts",
83
84
  "memory-test": "tsx --test scripts/memory-test.ts",
@@ -104,7 +105,8 @@
104
105
  "@changesets/cli": "3.0.3",
105
106
  "@types/node": "^20.19.0",
106
107
  "prettier": "3.9.8",
107
- "tsx": "^4.23.13",
108
- "typescript": "^5.9.3"
108
+ "tsx": "^4.23.15",
109
+ "typescript": "^5.9.3",
110
+ "yaml": "^2.9.1"
109
111
  }
110
112
  }
@@ -42,7 +42,7 @@ You are the brain of an exploratory UI tester. The SceneScout MCP server gives y
42
42
  8. **Design-connoisseur pass without pixels: `scout_design_audit`.** Run it once per representative page (dashboard, a form, a detail view, a data table). Its output has two tiers: **⚠ measurable defects** (WCAG contrast, tiny targets, clipped text, aspect-distorted images, horizontal overflow, keyboard tab stops with no visible focus indicator — sampled with real Tab presses) and **→ craft suggestions** (line measure and line-height rhythm, spacing-grid adherence, typography entropy, gray census and accent-hue count, pure-#000 body text, elevation/control consistency, heading structure, indistinguishable links, AI-slop tells like gradient text/glassmorphism/side-stripe borders/identical card grids), closing with a SYSTEM SUMMARY of design-system coherence. Judge every line with product context (dense tables legitimately have small targets; a chart page legitimately uses many hues). File ⚠ defects as `visual`/`a11y`, and genuine → opportunities as `ux-polish` findings **quoting the concrete numbers** — "~142 characters per line (65–75 ideal)" beats "text feels wide". Every audit ends with a **PAGE SCORE** (0–100 overall + a11y/craft/consistency/task-clarity subscores) persisted per route — the report ranks pages worst-first, so re-runs show whether pages got better or worse. Separately, every `scout_snapshot` runs an **overlay/modal probe** automatically: an empty dialog over a grayed page, a backdrop with no dialog, a far-off-centre dialog leaving a blank band, or a dialog extending unreachably below the viewport appear as OVERLAY lines in GEOMETRY issues — treat these as high-value findings (the user is visually stuck). This is where "how could this page be better" gets answered, not just "is it broken".
43
43
  9. **Measure task EASE with `scout_journey`, not just correctness.** Wrap each module's primary task (`{action:"start", goal:"Create an order"}` → do it → `{action:"end", completed:…}`). Navigate by CLICKING like a first-time user — typing a known deep URL shortcuts the very thing being measured (a route you can only reach by editing the address bar is itself a finding). The result gives interaction count, distinct screens, the path taken, and BACKTRACKS — returning to a screen already left is the clearest evidence the next step wasn't discoverable. An abandoned journey (`completed:false`) is a high-severity finding: the task is blocked or undiscoverable, which no passing e2e suite would ever reveal.
44
44
  10. **Walk the auth surface too — anonymously.** Attach a second session WITHOUT a storage-state file (a fresh logged-out profile) and exercise signup, login failure states, and forgot/reset-password **as far as they physically go**. The mailbox wall is expected — reaching "check your email" IS the success condition; everything before it is what you're testing: does submit actually fire (a dead signup button is a high finding), are errors specific and actionable, can the user resend or recover from a typo, does the flow dead-end. Use plausible synthetic identities only (invent `qa-<runid>@example.com`-style addresses, never a real person's), submit each form valid AND invalid, and judge the feedback. Two classic findings live here: a forgot-password that answers "no account with that email" is an **account-enumeration leak** (file as security; "if an account exists, we sent a link" is the correct shape), and a signup that accepts the form then lands on a blank or logged-out page with no guidance is a **journey dead-end**. Signup creates a record, so what this pass may do depends on the mode. In `observe`, fill and submit the auth forms for their CLIENT-SIDE behaviour only: the engine blocks signup, password change and reset, and lets only a login itself go out. Disclose the server-side half as a gap. Actually creating an account needs the user's explicit okay and safe-write mode; the engine tracks the created account like any other creation.
45
- 11. **`scout_coverage` decides what's next** — it lists unvisited routes, unexercised elements, and the options of each dropdown you used that no session has chosen this run (a filter counts as exercised after one choice, and the option you skipped can be the one whose request fails). Trust it over your memory. Prefer reaching routes by clicking real navigation; fall back to direct URLs for coverage completeness and re-verification, and say which you used when it affects the finding (see the provenance rule below).
45
+ 11. **`scout_coverage` decides what's next** — it lists unvisited routes, unexercised elements, and the options of each dropdown you used that no session has chosen this run (a filter counts as exercised after one choice, and the option you skipped can be the one whose request fails), and each `<form>` seen this run that no session has submitted with every text field blank (submit it once empty: a submit that silently does nothing — no request, no message — is the commonest defect there, and filling a form in first never finds it). Trust it over your memory. Prefer reaching routes by clicking real navigation; fall back to direct URLs for coverage completeness and re-verification, and say which you used when it affects the finding (see the provenance rule below).
46
46
 
47
47
  ## Levels (completion contracts — the engine ENFORCES them via `scout_report {level}`)
48
48
 
@@ -72,7 +72,7 @@ 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 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.
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. `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
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
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