scenescout 3.15.0 → 3.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +70 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +191 -14
  5. package/dist/ci-run.js +268 -52
  6. package/dist/cli.js +107 -47
  7. package/dist/commands.js +3 -2
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1147 -286
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +244 -42
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +136 -18
  16. package/dist/engine/claims.js +159 -3
  17. package/dist/engine/collector.js +561 -30
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/design.js +281 -38
  20. package/dist/engine/export.js +877 -0
  21. package/dist/engine/fingerprint.js +92 -4
  22. package/dist/engine/flow.js +18 -6
  23. package/dist/engine/forms.js +181 -18
  24. package/dist/engine/journey.js +29 -1
  25. package/dist/engine/lane.js +13 -3
  26. package/dist/engine/launch.js +45 -6
  27. package/dist/engine/limits.js +7 -0
  28. package/dist/engine/live-page.js +49 -2
  29. package/dist/engine/live.js +4 -1
  30. package/dist/engine/memory.js +501 -47
  31. package/dist/engine/open.js +118 -0
  32. package/dist/engine/oracles.js +41 -1
  33. package/dist/engine/plain.js +268 -0
  34. package/dist/engine/png.js +127 -0
  35. package/dist/engine/policy.js +379 -9
  36. package/dist/engine/probes.js +3 -2
  37. package/dist/engine/profiles.js +45 -9
  38. package/dist/engine/project-folder.js +191 -0
  39. package/dist/engine/refresh.js +68 -3
  40. package/dist/engine/replay.js +63 -10
  41. package/dist/engine/report.js +241 -40
  42. package/dist/engine/request.js +317 -23
  43. package/dist/engine/sarif.js +120 -0
  44. package/dist/engine/settle.js +67 -0
  45. package/dist/engine/signed-in.js +256 -0
  46. package/dist/engine/status-pane-page.js +441 -0
  47. package/dist/engine/status-pane.js +128 -0
  48. package/dist/engine/tickets.js +671 -0
  49. package/dist/engine/unload.js +3 -2
  50. package/dist/export-run.js +633 -0
  51. package/dist/first-run.js +5 -0
  52. package/dist/installer.js +378 -8
  53. package/dist/intake.js +104 -0
  54. package/dist/login-run.js +250 -36
  55. package/dist/mcp-server.js +660 -65
  56. package/dist/playbook.js +5 -0
  57. package/dist/prompts.js +106 -0
  58. package/package.json +8 -5
  59. package/skills/scenescout/SKILL.md +49 -16
package/dist/playbook.js CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
12
+ import { introQuestions } from "./intake.js";
12
13
  export const PLAYBOOK_TOOL = "scout_playbook";
13
14
  export const PLAYBOOK_PROMPT = "explore";
14
15
  /** Where the method is kept, relative to the package root. It is in the package's `files`. */
@@ -74,6 +75,10 @@ export function explorePrompt(playbook, args) {
74
75
  if (level !== undefined && !LEVELS.includes(level)) {
75
76
  throw new Error(`level "${level}" is not one the method knows. Use one of: ${LEVELS.join(", ")}.`);
76
77
  }
78
+ // Nothing asked for at all: the person has chosen no settings, so the run starts with the plain questions.
79
+ if (given("url") === undefined && level === undefined && given("focus") === undefined) {
80
+ return `${playbook}\n\n---\n\nRun an exploratory test session following the method above. I gave no settings: before setup, ask me these questions in one message, then choose the settings from my answers as the method says.\n${introQuestions()}`;
81
+ }
77
82
  const asks = [
78
83
  given("url") ? `Target: ${given("url")}` : "Target: ask me for the URL of the running app, or find it from the project.",
79
84
  level ? `Level: ${level}` : "",
@@ -0,0 +1,106 @@
1
+ /**
2
+ * MCP prompts other than the testing method.
3
+ *
4
+ * `explore` lives in playbook.ts because its text is the skill. `live` and
5
+ * `login` are short instructions: hand back the loopback live-view address,
6
+ * or call scout_login for a role. Neither accepts a password. The protocol
7
+ * server in mcp-server.ts lists all three; the SDK prompt helper is not used,
8
+ * because it rejects a request that omits `arguments`.
9
+ */
10
+ import { validateRoleName } from "./engine/profiles.js";
11
+ export const LIVE_PROMPT = "live";
12
+ export const LOGIN_PROMPT = "login";
13
+ /** `live` takes nothing. A password field here would ask the person for a credential the live view does not use. */
14
+ export const LIVE_PROMPT_ARGUMENTS = [];
15
+ /** `login` names a role, and the app's address when the caller has one. There is no password argument. */
16
+ export const LOGIN_PROMPT_ARGUMENTS = [
17
+ {
18
+ name: "role",
19
+ description: "The name to save the sign-in under, e.g. admin. scout_attach { role } uses it afterwards. Not a password.",
20
+ required: true,
21
+ },
22
+ {
23
+ name: "url",
24
+ description: "The app's address or its sign-in page, when you have it, e.g. http://localhost:3000. Omit when the conversation already has it.",
25
+ required: false,
26
+ },
27
+ ];
28
+ /**
29
+ * Argument names that would carry a credential. Matched on the name only, so
30
+ * a refusal can name the argument without repeating the value the client sent.
31
+ */
32
+ const CREDENTIAL_ARGUMENT = /^(?:password|passwd|pass|secret|token|credential|credentials|otp|totp|mfa|apikey|api_key)$/i;
33
+ function refuseCredentialArguments(args) {
34
+ if (!args)
35
+ return;
36
+ for (const key of Object.keys(args)) {
37
+ if (CREDENTIAL_ARGUMENT.test(key)) {
38
+ throw new Error(`this prompt does not take ${key}: never pass a password or any other credential`);
39
+ }
40
+ }
41
+ }
42
+ /**
43
+ * The opening message of the `live` prompt. The address itself is printed by
44
+ * the engine on tool results (`Live view: http://127.0.0.1:…`); this only
45
+ * tells the model to hand that line back. Any argument is refused.
46
+ */
47
+ export function livePrompt(args) {
48
+ refuseCredentialArguments(args);
49
+ const given = Object.keys(args ?? {}).filter((key) => args?.[key] !== undefined);
50
+ if (given.length > 0)
51
+ throw new Error("the live prompt takes no arguments");
52
+ return ("Return the loopback live-view URL for the current session.\n\n" +
53
+ "The live view is one address for every session of this run. It is served on 127.0.0.1 only, and a tool result prints it as a line starting " +
54
+ '"Live view: http://127.0.0.1:". If this conversation does not already contain that line, call scout_session with no arguments and read it from the result. ' +
55
+ "Calling scout_session with a name would change the default session; do not pass one.\n\n" +
56
+ "Reply with that address. Where you can open a URL for the user, you may open it as well. " +
57
+ "If the result says the live view is unavailable, say so and do not invent an address.\n\n" +
58
+ "Do not ask for a password or any other credential. This prompt takes none.");
59
+ }
60
+ /** An http(s) address with no userinfo. The raw string is returned only after that check, so a refusal never quotes a password embedded in it. */
61
+ function publicHttpUrl(raw) {
62
+ let url;
63
+ try {
64
+ url = new URL(raw);
65
+ }
66
+ catch {
67
+ throw new Error("url must be an http or https address");
68
+ }
69
+ if (url.protocol !== "http:" && url.protocol !== "https:")
70
+ throw new Error(`only http and https URLs can be opened (got ${url.protocol})`);
71
+ if (url.username || url.password)
72
+ throw new Error("put no credentials in the URL: sign in in the window instead");
73
+ return raw;
74
+ }
75
+ /**
76
+ * The opening message of the `login` prompt: call scout_login for the role and
77
+ * wait the way that tool already waits. A missing or illegal role is refused.
78
+ * A password argument is refused without the value being repeated.
79
+ */
80
+ export function loginPrompt(args) {
81
+ refuseCredentialArguments(args);
82
+ const given = args ?? {};
83
+ const unknown = Object.keys(given).filter((key) => key !== "role" && key !== "url");
84
+ if (unknown.length > 0)
85
+ throw new Error("the login prompt takes role and url only");
86
+ const checked = validateRoleName(typeof given.role === "string" ? given.role.trim() : given.role);
87
+ if (!checked.ok)
88
+ throw new Error(checked.error);
89
+ const url = publicHttpUrlOrNone(typeof given.url === "string" ? given.url.trim() : "");
90
+ // JSON so a quote in the address cannot rewrite the instruction. The address has already been refused when it carries credentials.
91
+ const where = url
92
+ ? `Call scout_login with ${JSON.stringify({ role: checked.role, url })}.`
93
+ : `Call scout_login with role ${JSON.stringify(checked.role)} and the app's address as url. ` +
94
+ "Use the address this conversation already has; if it has none, ask for the app's address or its sign-in page, then pass that as url.";
95
+ return (`Sign in as the role "${checked.role}" by calling scout_login. ` +
96
+ "Do not ask for a password, and do not type credentials yourself: the person signs in in the browser window the tool opens.\n\n" +
97
+ 'Tell them first, in plain words: "A browser window is opening. Sign in there as you normally would; it closes by itself once you are in."\n\n' +
98
+ `${where}\n\n` +
99
+ "The call returns once they are signed in and the login is saved, or after its wait with the window still open. " +
100
+ `When it is still waiting, call scout_login again with the same role ("${checked.role}") to keep waiting, once they say they are done or to check. ` +
101
+ "Never pass a password.");
102
+ }
103
+ /** `publicHttpUrl("")` would throw. An omitted url is the optional argument left out, not an illegal address. */
104
+ function publicHttpUrlOrNone(raw) {
105
+ return raw ? publicHttpUrl(raw) : "";
106
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "3.15.0",
3
+ "version": "3.17.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",
@@ -58,8 +58,9 @@
58
58
  "setup": "node dist/cli.js install",
59
59
  "doctor": "node dist/cli.js doctor",
60
60
  "changeset": "changeset",
61
- "version-packages": "changeset version && node scripts/sync-plugin-version.mjs && npm install --package-lock-only && prettier --write package.json .claude-plugin/plugin.json",
61
+ "version-packages": "changeset version && node scripts/sync-plugin-version.mjs && npm install --package-lock-only && prettier --write package.json .claude-plugin/plugin.json desktop-extension/manifest.json",
62
62
  "release": "npm run build && changeset publish",
63
+ "mcpb": "node scripts/build-mcpb.mjs",
63
64
  "format": "prettier --write .",
64
65
  "format:check": "prettier --check .",
65
66
  "demo": "npm run build && tsx scripts/demo.ts",
@@ -72,7 +73,7 @@
72
73
  "mcp-check": "npm run build && npm run mcp-check:run",
73
74
  "mcp-check:run": "tsx scripts/mcp-check.ts",
74
75
  "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 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 && npm run guide-test",
76
+ "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 export-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 tickets-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 && npm run guide-test",
76
77
  "scan-test": "tsx scripts/scan-test.ts",
77
78
  "oracle-test": "tsx --test scripts/oracle-test.ts",
78
79
  "policy-test": "tsx --test scripts/policy-test.ts",
@@ -82,6 +83,7 @@
82
83
  "check-test": "tsx --test scripts/check-test.ts",
83
84
  "ci-test": "tsx --test scripts/ci-test.ts",
84
85
  "qa-test": "tsx --test scripts/qa-test.ts",
86
+ "export-test": "tsx --test scripts/export-test.ts",
85
87
  "contract-test": "tsx --test scripts/contract-test.ts",
86
88
  "lane-test": "tsx --test scripts/lane-test.ts",
87
89
  "memory-test": "tsx --test scripts/memory-test.ts",
@@ -101,6 +103,7 @@
101
103
  "claims-test": "tsx --test scripts/claims-test.ts",
102
104
  "verify-test": "tsx --test scripts/verify-test.ts",
103
105
  "brief-test": "tsx --test scripts/brief-test.ts",
106
+ "tickets-test": "tsx --test scripts/tickets-test.ts",
104
107
  "calibration-test": "tsx --test scripts/calibration-test.ts",
105
108
  "bench": "tsx scripts/bench.ts",
106
109
  "bench:ci": "tsx scripts/bench/ci-record.ts",
@@ -108,14 +111,14 @@
108
111
  "dedup-bench": "tsx scripts/dedup-bench.ts"
109
112
  },
110
113
  "dependencies": {
111
- "@modelcontextprotocol/sdk": "^1.12.0",
114
+ "@modelcontextprotocol/sdk": "^1.31.0",
112
115
  "playwright": "^1.63.0",
113
116
  "zod": "^3.25.0"
114
117
  },
115
118
  "devDependencies": {
116
119
  "@changesets/cli": "3.0.3",
117
120
  "@types/node": "^20.19.0",
118
- "prettier": "3.9.8",
121
+ "prettier": "3.9.9",
119
122
  "tsx": "^4.23.15",
120
123
  "typescript": "^5.9.3",
121
124
  "yaml": "^2.9.1"
@@ -5,44 +5,73 @@ description: AI exploratory UI testing — drive the SceneScout MCP browser tool
5
5
 
6
6
  # SceneScout — exploratory UI testing agent
7
7
 
8
- You are the brain of an exploratory UI tester. The SceneScout MCP server gives you deterministic browser tools (the `scout_*` tools — some clients show them under a prefix: Claude Code lists `mcp__scenescout__scout_*`, or `mcp__plugin_scenescout_scenescout__scout_*` when installed as a plugin); you provide intent, judgment, and curiosity. The engine gives you structured render-state (elements, geometry, oracles) — never parse pixels when text will do. Argument hint: `[--level minimal|medium|extensive] [--url URL] [--role NAME] [--safe-write | --allow-destructive]`.
8
+ You are the brain of an exploratory UI tester. The SceneScout MCP server gives you deterministic browser tools (the `scout_*` tools — some clients show them under a prefix: Claude Code lists `mcp__scenescout__scout_*`, or `mcp__plugin_scenescout_scenescout__scout_*` when installed as a plugin); you provide intent, judgment, and curiosity. The engine gives you structured render-state (elements, geometry, oracles) — never parse pixels when text will do. Argument hint: `[--level minimal|medium|extensive] [--url URL] [--role NAME] [--focus TEXT] [--observe | --read-only | --safe-write | --allow-destructive]`.
9
9
 
10
10
  **The mission is wider than pass/fail.** Scripted e2e suites answer "does it still work?" as a binary and say nothing about what they don't cover; a human can't manually exercise a large app. You cover both gaps: find what's broken (oracles, dead ends, permission leaks) AND report how the product could be *better* — confusing flows, weak hierarchy, design-system drift, friction. Improvement feedback with concrete measurements is a first-class deliverable, not garnish; a run that finds no crashes but produces sharp `ux-polish`/`visual` suggestions is a successful run.
11
11
 
12
+ ## Starting a run: plain questions, or flags
13
+
14
+ **No flags given** (none of `--url`, `--role`, `--focus`, `--level`, `--observe`, `--read-only`, `--safe-write`, `--allow-destructive`): before setup, ask these four questions in one message, in plain words, and choose the settings from the answers. Skip a question only when the person's request already answers it in words ("test http://localhost:3000" answers the first).
15
+
16
+ 1. What is the address of the site?
17
+ 2. Do you need to sign in to use it? If so, how: Google or Microsoft single sign-on, an email and password, or a one-time code?
18
+ 3. What should I check? Upload or paste the tickets, or describe it in a sentence. Say "everything" to look at the whole site.
19
+ 4. Does the site hold real data, such as real customers, orders or records?
20
+
21
+ | Question | Setting it chooses |
22
+ |---|---|
23
+ | The address | `scout_attach {url}` |
24
+ | Sign-in | None: attach with no role. Any way of signing in: say "A browser window will open: sign in with Google or Microsoft as you normally would" (or "…: sign in with your email and password as you normally would", or "…: sign in as you normally would and enter the code when it arrives", by the answer), call `scout_login {url, role: "user"}`, which returns once they are signed in, then attach with `role: "user"`. A server with no `scout_login` tool: ask them to run `scenescout login <url> --role user` instead |
25
+ | What to check | Tickets or a description: the `objective` (`Check the tickets: …`, or the description), and keep to that area. Tickets: once attached, read them with `scout_tickets {text}` (or `{path}` for a file they uploaded) and answer each criterion ([Answering tickets](#answering-tickets)). "Everything": `objective: "Explore the whole site"` |
26
+ | Real data | Yes, or not sure: `mode: "observe"`. No: `mode: "read-only"`, for a remote site too: a plain no is the person saying form submissions are acceptable there (setup step 2). Never higher from these answers |
27
+
28
+ Never ask the person to choose a mode, a role name or a level, and never use the words "observe" or "read-only" in the questions: that is what the answers are for. `safe-write` and `destructive` are reached only by the person asking for them by name. The level stays `medium` unless they ask for a quicker or a deeper pass.
29
+
30
+ **Flags given:** ask nothing. Use the flags as they are, and give what they leave out its default by the setup steps below (`--focus TEXT` is the objective and the area to keep to; `--read-only` is the write mode the steps choose for a local app).
31
+
12
32
  ## Setup (in order)
13
33
 
14
- 1. **Check the tools exist.** Reading this as the result of `scout_playbook` or of the `explore` prompt? Then they do: go to step 2. Otherwise look for a `scout_scan` tool under either prefix above. If there is none, stop and tell the user how to get it, then to start a fresh session (these are Claude Code's commands; the README has the config for other clients):
34
+ 1. **Check the tools exist.** Reading this as the result of `scout_playbook` or of the `explore` prompt? Then they do: go to step 2. Otherwise look for a `scout_scan` tool under either prefix above. If there is none, stop and tell the user how to get it, then to start a new chat (these are Claude Code's commands; the README has the config for other clients):
15
35
  - as a plugin: `/plugin marketplace add brunoboto96/SceneScout` then `/plugin install scenescout@scenescout-marketplace`
16
36
  - or by hand: `claude mcp add --scope user scenescout -- npx -y scenescout serve` (from a source checkout, register with an **absolute node path** instead — a bare `node` fails with "Executable not found in $PATH" under nvm/fnm: `claude mcp add --scope user scenescout -- "$(which node)" <checkout>/dist/mcp-server.js`)
17
- If attach later reports that a browser build has not been downloaded, relay the one-time command it names. Attach drives Chromium unless you pass `browser: "firefox"` or `"webkit"`; do that only when the user asks for a cross-browser pass, and say in each finding which browser showed it.
37
+ The first attach on a machine without the browser downloads it once (it says "Getting the test browser ready"); if attach instead reports that a build has not been downloaded, relay the one-time command it names. Attach drives Chromium unless you pass `browser: "firefox"` or `"webkit"`; do that only when the user asks for a cross-browser pass, and say in each finding which browser showed it.
18
38
  2. **`scout_scan`** the project's absolute path. Read routes, framework, auth states, notes.
19
39
  - **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
40
  - **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
41
  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` → 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:
42
+ 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), or the saved one has expired: call `scout_login {url, role: "X", projectPath}` with the same projectPath the attach uses, or none when the attach has none: both then pick the same folder for the site, and the result names it. Tell the user first, in plain words: "A browser window is opening. Sign in there as you normally would; it closes by itself once you are in." The sign-in is THEIRS (SSO, MFA), so never type into that window. It returns once they are signed in and the login is saved, then attach again; or, after `waitSeconds` (default 120), with the window still open: ask whether they are done and call it again with the same role. Pass `successUrl` only when the user says what the signed-in address is (the window otherwise saves once the app holds a new session, and a trip through a single sign-on provider is not mistaken for the end). With no desktop for a window, ask the user to run `scenescout login <url> --role X` where they can see it. One saved login serves every session attached with that role, each in its own browser.
43
+ 5. **`scout_attach`** with url, projectPath (omit it only when there is no folder at all, as in a client with no workspace: the result then names the folder it chose, so tell the user where the report will be), `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
44
  - `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
45
  - `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
46
  - `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
47
  - `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). 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.
48
+ 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. Each blocked endpoint is explained once per session; later blocks of it are only counted (`N repeat blocks of K known endpoint(s)`), so a repeat line is the same refusal, not news. The label check judges a control by its OWN name: a dropdown by the option you pick (choose a harmless filter value freely), a row by its own text, not the buttons inside it. "Discard changes" on an unsent form is allowed. A `⚠ LEAVE CONFIRMATION` line means the page asked to confirm leaving unsent input and the engine stayed (the default in observe and read-only): the navigation did not happen and that is not an app bug. Repeat the `scout_navigate`, `scout_click` or `scout_back` with `leave: true` when you mean to abandon the form.
29
49
  **`⚠ 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
50
  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
51
 
32
52
  ## The efficient loop (this is the core method)
33
53
 
34
54
  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
- 2. **Investigate what the crawl flagged.** For each problem route (violations, dead-ends, auth-redirects): navigate there, `scout_snapshot`, reproduce, then `scout_finding`.
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. **Reading a snapshot:** each line is `ref role "name" [flags]`. State flags come first — `pressed`, `selected`, `checked`, `expanded`, `current` — so the active filter, the chosen tab or the open section is in the text, and the diff says when one moves (`now [pressed]`, `no longer [pressed]`); `exercised` means this project already acted on that control, not that it is in any state. What the page announces is listed by its text whether or not it carries a test id: `alert "…"` and `status "…"` lines (role=alert/status, aria-live regions, `<output>`) — read them after every submit before concluding an action gave no feedback; a region that says something new shows in the diff as `~ ref alert "new text" (was …)`. The `main:` line summarises the main area besides its controls (`main: h1 "Orders" · 2 paragraphs · 180 chars of static text`, or `main: EMPTY`): a page whose main area holds only text is not a blank page, and `main: EMPTY` is the line that is. Elements listed only for their test id (wrappers, headings, decorative badges) are not controls: they are never counted as unnamed or as coverage gaps. `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.
55
+ 2. **Investigate what the crawl flagged.** For each problem route (violations, dead-ends, auth-redirects, an ERROR-VIEW or a page STILL-LOADING): navigate there, `scout_snapshot`, reproduce, then `scout_finding`.
56
+ 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. For a sweep of INDEPENDENT steps (every tab of a record, each filter, each page of a list), pass `onViolation: "continue"`: a new error status is listed on its step's line and the next step runs, so one refused tab does not cost a re-plan of the rest; a failed step, a policy refusal or any other violation still stops it. Keep the default for a form flow, where a later step depends on an earlier one. 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.
57
+ 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. A route you come back to diffs against its own last snapshot, and another tab of the same screen (`?tab=`) against that screen's last tab (`(tab changed)`). Refs survive a search or filter that rewrites only the query string (`same screen, query changed — refs still apply`), and a control a re-render replaced is found again by its unique test id (`re-bound after a re-render`); a real route change still needs a new snapshot. On a dense page the `TRUNCATED` line says what was cut by role and test-id family (`cut: 38 link [row-*], …`); pagers and "Load more" are always kept in the list. `[clickable]` and `[focusable]` mark an element with no control role that a click or Tab still reaches (a table row that opens a record): try it before concluding a list has no way in. **Reading a snapshot:** each line is `ref role "name" [flags]`. State flags come first — `pressed`, `selected`, `checked`, `expanded`, `current` — so the active filter, the chosen tab or the open section is in the text, and the diff says when one moves (`now [pressed]`, `no longer [pressed]`); `exercised` means this project already acted on that control, not that it is in any state. What the page announces is listed by its text whether or not it carries a test id: `alert "…"` and `status "…"` lines (role=alert/status, aria-live regions, `<output>`) — read them after every submit before concluding an action gave no feedback; a region that says something new shows in the diff as `~ ref alert "new text" (was …)`. The `main:` line summarises the main area besides its controls (`main: h1 "Orders" · 2 paragraphs · 180 chars of static text`, or `main: EMPTY`): a page whose main area holds only text is not a blank page, and `main: EMPTY` is the line that is. Elements listed only for their test id (wrappers, headings, decorative badges) are not controls: they are never counted as unnamed or as coverage gaps. `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 same holds for `scout_attach {readPosts: ["POST /api/search"]}`, which lets observe send POST endpoints that only read (a search or query sent as POST): pass only what the user named. When the gap ledger lists a page whose POST observe refused, tell the user and let them decide. The FRAMES line names frames that were not read; say in your summary that their contents were not explored.
38
58
  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. Controls held outside the visible width of a horizontally scrolling container (a wide table's action column) are one GEOMETRY line per container, `N controls are scrolled out of view inside a horizontally scrolling container …`: reachable by scrolling that container sideways, and worth a look rather than a defect unless the project keeps row actions in view. **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
59
  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
60
  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.
41
61
 
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), and where the answer depends on a convention of the project you cannot see — a spacing scale, link styling in navigation, touch-target size on a desktop-only app — file it as worth a look (below) rather than deciding the convention for the project. 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
- 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.
62
+ 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, controls with no accessible name and fields labelled only by a placeholder, 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), and where the answer depends on a convention of the project you cannot see — a spacing scale, link styling in navigation, touch-target size on a desktop-only app — file it as worth a look (below) rather than deciding the convention for the project. 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".
63
+ 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. Its time is ACTIVE time: a pause over 30s between steps (you thinking between turns) is left out and the result says how many were. 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
64
  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), 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).
65
+ 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). In a parallel run it shows THIS session's own work by default — the routes it reached this run and the forms it saw — so another lane's gaps never read as yours; `scope:"project"` shows every session's, tagged with who saw each. A path you crawl by name that answers as a page joins the route list, and a crawled path that landed elsewhere says `REDIRECTED → <route>`. 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).
66
+
67
+ ## Answering tickets
68
+
69
+ When the person gives tickets or acceptance criteria (pasted, or a file), the report answers each criterion: passed, failed or not tested.
70
+
71
+ 1. **Read them** once attached: `scout_tickets {text}`, or `{path}` for a file (`.md`, `.txt`, `.feature`; relative paths are from the project folder). It recognises Given/When/Then, checklists, numbered and `AC1:` criteria, and lists under an "Acceptance criteria" heading, and gives each criterion an id (`AC1`, `AC2`, …). A ticket it finds no criteria in says so: ask the person for them, and never write criteria yourself.
72
+ 2. **Plan against them.** Measure each criterion with a journey whose goal names it (`scout_journey {action:"start", goal:"PROJ-12 AC2: filter survives a reload"}`). In a parallel run pass the tickets as `scout_lane_brief`'s `goal`; every lane's brief then lists the criteria, and the lane whose routes decide one records it.
73
+ 3. **Record each one** with `scout_criterion {ticket, criterion, verdict, findings, confidence, reason}` once it is decided. `pass`: you walked it and saw it hold. `fail`: name the findings that show it, filed with `scout_finding` first; a fail with none is refused. `not-tested`: say why in `untestedBecause`: `no-access` (the role you are signed in as cannot reach it), `observe-blocked` (it needs a change sent and the session is in observe; refused in any other mode), or `out-of-scope` (an email, another system, a scheduled job). Which findings show a criterion failing is your judgement, never a match on words, so state your confidence honestly: below 0.6 the report marks the verdict unsure. Recording a criterion again from the same session replaces your verdict; a fail from any session decides it.
74
+ 4. Before `scout_report`, give every criterion a verdict: one with none is reported as not tested because no result was recorded.
46
75
 
47
76
  ## Levels (completion contracts — the engine ENFORCES them via `scout_report {level}`)
48
77
 
@@ -76,7 +105,7 @@ Some flows need a TEAM — a document one role submits and another approves, a r
76
105
  - `scout_close {all: true}` at the end of a multi-role run; `scout_close {session}` to drop one role early.
77
106
  - **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.
78
107
  - **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.
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.
108
+ - **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; `finding` is the id `scout_finding` returned for that observation), 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 name each one's finding id in `finding`, which matches exactly, or file it with the same `evidence` (a finding filed without evidence cannot be matched), 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.
80
109
 
81
110
  ## The impatient-user pass (extensive)
82
111
 
@@ -87,11 +116,12 @@ Polite, precise testing misses how real users behave. Once per module's key flow
87
116
  - **Wrong-order behaviour:** press Enter mid-form before required fields are filled; go `scout_back` mid-wizard and return; submit, then immediately back-button. State should survive all three without data loss or duplicate records.
88
117
  - Keep attribution honest: these are deliberate probes — say so in findings ("under rapid double-click…"), so a developer can reproduce exactly.
89
118
 
90
- The engine is self-healing (orphaned browsers reaped, wedged calls time out with guidance instead of hanging) and observable: `.scenescout/status.json` + `scenescout status <project>` show what every session is doing right now, and there is a live view for the person running you: one card per session with its current tool, how long it has been there, a thumbnail of its page, a feed of the actions it just took, and a stream they can switch on. It works for headless runs too. **`scout_attach` returns its address on a `Live view:` line: pass that address to the user in your next message, once, so they can watch.** `scout_session` with no arguments repeats it if they ask again, and `scenescout watch <project>` opens it from a terminal. It is for the person watching: you do not need to open it, and a session shown as stuck there is one to re-attach.
119
+ The engine is self-healing (orphaned browsers reaped, wedged calls time out with guidance instead of hanging) and observable: `.scenescout/status.json` + `scenescout status <project>` show what every session is doing right now, and there is a live view for the person running you: one card per session with its current tool, how long it has been there, a thumbnail of its page, a feed of the actions it just took, and a stream they can switch on. It works for headless runs too. **`scout_attach` returns its address on a `Live view:` line: pass that address to the user in your next message, once, so they can watch.** On a local desktop session the engine also opens it in the user's browser (an `Opened the live view` line says so), and opens report.html when `scout_report` writes it; pass `open` (`live`, `report`, `both`, `none`) only when the user asks for something else. `scout_session` with no arguments repeats it if they ask again, and `scenescout watch <project>` opens it from a terminal. **When the user wants to watch the run, or asks how it is going, call `scout_status`** (no input): a host that renders MCP Apps shows a pane that keeps itself up to date with each session's objective and task, open findings by severity, coverage and a button for the live view; every other host gets the same as text, starting with the `Live view:` address, which you pass on. It is for the person watching: you do not need to open it, and a session shown as stuck there is one to re-attach.
91
120
 
92
121
  ## Judgment (what the engine can't do)
93
122
 
94
- - **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.
123
+ - **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 to its own origin (a 401 the page's own calls do not get is flagged as a possibly stale credential, not a permission answer), 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's first 2000 characters; when the field you need is past that, ask for it with `select:"stats.open"` (one JSON value by path) or page through with `offset`/`limit`, rather than going to the source. 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.
124
+ - **When the page and the server disagree, look at what the page asked: `scout_network`.** It lists the fetch/XHR requests the current page made since it loaded — method, path, status, time, and the route each was sent from after a client-side route change — read-only, with credentials redacted; `scout_network {contains:"/api/things"}` narrows it to one endpoint. An empty list after a tab switch while the API has data is either a request that failed (it shows a status or `failed:`), one still pending, or one that never ran (it is absent): say which in the finding instead of recording unsure.
95
125
  - **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.
96
126
  - 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.
97
127
  - **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`.
@@ -102,8 +132,9 @@ The engine is self-healing (orphaned browsers reaped, wedged calls time out with
102
132
  - **Re-testing what earlier runs left open: `scout_verify`.** Against an app this project has tested before, run it right after the crawl, and run it again whenever the user says a wave of fixes has landed. Called bare it returns the open findings in the order to re-test them — worst route first, grouped so you walk a route once instead of once per finding — each with the evidence that identifies it and the steps that produced it; `scout_verify {ids}` narrows it to specific ones, and names any that are not open rather than quietly shortening the list. Re-test a finding, then record what you saw with `scout_verify {id, verdict, note}`: `"gone"` resolves it, `"present"` stamps it confirmed so the report dates the confirmation instead of calling it unverified, `"changed"` keeps it open and says the behaviour differs — and if it is now a different bug, file that as its own finding. A campaign is far cheaper than re-deriving the same list from the report by hand, and it is the only way the historical section stops being a pile nobody trusts.
103
133
  - **A `SHARED CHROME` block in a design audit is ONE finding, not one per page.** Those elements (sidebar, header, breadcrumb bar) are the app shell; the audit already excludes them from the page's score and reports them once. File a single finding for the shell and move on — filing per-page produced five separate tickets for two CSS declarations in a real run.
104
134
  - **`scout_finding` takes `severity`, `category`, `title`, `detail` and `evidence`.** `category` is the finding's kind — `console-error`, `page-error`, `http-error`, `network`, `dead-end`, `ux-confusing`, `ux-polish`, `visual`, `a11y`, `permission-leak`, `data-inconsistency`, `stale-state`, `data-loss`, `performance`, `security`, `missing-testid`, `other` — and is what groups the report.
105
- - **Worth a look: real, and a defect only under a convention you cannot see.** SceneScout is used on any app, so it does not decide a project's conventions. When an observation is certain but whether it matters depends on one — paddings off a 4px grid (a spacing scale), links styled like body text in navigation, a control with no `data-testid` (test ids on every control), touch-target size (above WCAG 2.2's 24px minimum, which is a published standard and a plain defect) on an app that may be desktop-only, no dominant action on a list page, a list order the page never promises — file it with `scout_finding {…, convention}`, naming the convention in a few words ("a 4px spacing scale"). The report lists it under **Worth a look**, below the findings, as "a defect only if your project uses …", and does not count it as a defect. When the convention IS visible — `scout_scan` found the project's test-id convention, ASSUMPTIONS.md records a spacing scale, the page's own design system states it — the observation is a plain defect or not one: judge it. It is **not** for "I could not tell whether this is broken": look closer, or in a lane report say `unsure`. Filing the same thing later as a defect makes it one; a defect is never turned back into a worth-a-look.
135
+ - **Worth a look: real, and a defect only under a convention you cannot see.** SceneScout is used on any app, so it does not decide a project's conventions. When an observation is certain but whether it matters depends on one — paddings off a 4px grid (a spacing scale), links styled like body text in navigation, a control with no `data-testid` (test ids on every control), touch-target size (above WCAG 2.2's 24px minimum, which is a published standard and a plain defect) on an app that may be desktop-only, no dominant action on a list page, a list order the page never promises — file it with `scout_finding {…, convention}`, naming the convention in a few words ("a 4px spacing scale"). The report lists it under **Worth a look**, below the findings, as "a defect only if your project uses …", and does not count it as a defect. When the convention IS visible — `scout_scan` found the project's test-id convention, ASSUMPTIONS.md records a spacing scale, the page's own design system states it — the observation is a plain defect or not one: judge it. It is **not** for "I could not tell whether this is broken": look closer, or in a lane report say `unsure`. Filing the same thing later as a defect makes it one; a defect is never turned back into a worth-a-look. To correct a convention or detail you got wrong, file it again from the same session in the same run: that filing replaces yours and the result says so (another session's filing keeps the first wording, and also says so).
106
136
  - **Always pass `evidence`** to `scout_finding` — a canonical machine signature like `GET /api/reports/dashboard 403` or `widget dashboard-summary-widget shows 0`. It's what deduplicates the same bug across runs when titles get rephrased.
137
+ - **Pass `ref` to `scout_finding` when the finding is about one element** — its ref from the latest `scout_snapshot`. Every finding is filed with a picture: that element and around it, or without a ref the page as it is, so file while the defect is on screen. The picture is kept for `report.html` and, unless the user chose otherwise, comes back in the result for the person following along; say in your own words what it shows rather than describing it pixel by pixel. For QA work the user wants recorded end to end, attach with `record: true` as well. Pass `evidence: "off"` on `scout_attach` when the page shows secrets: the redaction reads text, not pictures.
107
138
  - File judgment findings too: confusing flows, no-feedback actions, state lost on refresh, permission leaks (low-privilege role reaching admin surface), `missing-testid` (low, where the project's test-id convention is known; worth a look otherwise), unnamed interactables (a11y, low).
108
139
  - Respect refusals — never retry or route around a policy refusal; note it and move on.
109
140
  - Duplicates are fine; `scout_finding` dedups across runs. When it says a model judge merged your filing into another finding, your title, category, severity and evidence are kept on that finding; refile only if it is a different defect, with a title that says what differs. Pass `scout_attach {dedup: "judge"}` only when the user asks for model-judged dedup: it sends finding titles, categories and evidence, and the page's path, to a model provider, and needs a key in the server's environment.
@@ -112,4 +143,6 @@ The engine is self-healing (orphaned browsers reaped, wedged calls time out with
112
143
 
113
144
  `scout_report` prints the findings THIS run made in full, and lists earlier ones — and resolved ones — as an index: a row each with id, severity, age and title. That is the default because a project with hundreds of remembered findings otherwise produces a document nobody opens, in which the handful the run actually made are buried; on one real project it was 1.75 MB against 113 KB, and nearly half of it was findings already fixed. Nothing is lost: the ids are there, and `scout_report {history:'full'}` prints every one as before — use it when handing the document to someone who cannot read the project's memory. Age is what decides whether an unverified old finding is worth re-testing, so it is on every row.
114
145
 
146
+ The report opens with a plain-language section (the tickets' criteria, each passed, failed or not tested, when the run was given tickets; then each problem's impact, steps, what was expected and what happened, and its picture) before the technical one. Pass `scout_report {report:'qa'}` when the reader is a tester or anyone not technical and wants that section alone, and `{report:'dev'}` for the technical report alone; leave the default, `both`, otherwise.
147
+
115
148
  `scout_report` (satisfy the contract first) → `scout_close` → summarize in chat: worst findings first, coverage numbers, report path (`.scenescout/report.md`), and suggest promoting high findings to real Playwright regression tests (skeletons are in the report).