scenescout 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Calling the app's own API with the UI bypassed.
3
+ *
4
+ * A refusal shown by hiding or disabling a button is not a refusal. Confirming
5
+ * that the server refuses the same action is the single most valuable check a
6
+ * permission pass makes, and until now it could only be done outside the tool
7
+ * — in a shell, with curl and a hand-extracted token — so none of that
8
+ * evidence reached the report. A whole validation run's permission matrices
9
+ * lived in shell history and vanished with it.
10
+ *
11
+ * The request is made by the PAGE, not beside it. That matters twice over:
12
+ * it goes through the same interception the write policy is enforced on, so a
13
+ * safe-write session cannot delete a record it does not own by calling the
14
+ * endpoint instead of clicking; and it carries the session's own credentials,
15
+ * because it is the same origin with the same cookies.
16
+ *
17
+ * Bearer schemes are handled by replaying the Authorization header the app
18
+ * itself last sent, which the engine already sees on every intercepted
19
+ * request. Nothing here knows what a token looks like or where an app keeps
20
+ * one, so nothing here is tuned to any app.
21
+ *
22
+ * Everything in this file is pure so it can be table-tested; the one
23
+ * `page.evaluate` lives in browser.ts.
24
+ */
25
+ /** Methods a session may replay. Anything else is refused before it reaches the page. */
26
+ export const REPLAYABLE_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
27
+ /** Most of a response body that reaches the agent. A JSON list can be megabytes; the signature is in the first lines. */
28
+ export const BODY_MAX = 2000;
29
+ /** Response headers worth reporting. "Identical response" means status, body AND headers, so the ones that commonly differ are kept. */
30
+ export const REPORTED_HEADERS = ["content-type", "content-length", "location", "www-authenticate", "retry-after", "x-request-id", "cache-control"];
31
+ /**
32
+ * The path to call, resolved against the attached origin and fenced to it.
33
+ * Navigation is fenced the same way: a run attached to one app must not be
34
+ * able to make its browser talk to another host just because a path was
35
+ * spelled as a full URL.
36
+ */
37
+ export function resolveRequestUrl(baseUrl, path) {
38
+ const trimmed = path.trim();
39
+ if (!trimmed)
40
+ return { problem: "No path given. Pass a path such as /api/things, or a full URL on the attached origin." };
41
+ let target;
42
+ let base;
43
+ try {
44
+ base = new URL(baseUrl);
45
+ target = new URL(trimmed, base);
46
+ }
47
+ catch {
48
+ return { problem: `Could not read ${JSON.stringify(trimmed)} as a path or a URL.` };
49
+ }
50
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
51
+ return { problem: `Only http and https can be requested; ${target.protocol} cannot.` };
52
+ }
53
+ if (target.origin !== base.origin) {
54
+ return {
55
+ problem: `${target.origin} is not the origin this session is attached to (${base.origin}). A session talks to its own app only; attach another session to test another host.`,
56
+ };
57
+ }
58
+ return { url: target.toString() };
59
+ }
60
+ /** The method, upper-cased, or the reason it cannot be replayed. */
61
+ export function resolveMethod(method) {
62
+ const upper = (method ?? "GET").trim().toUpperCase();
63
+ if (!REPLAYABLE_METHODS.includes(upper)) {
64
+ return { problem: `${upper} is not a method this can replay. Use one of: ${REPLAYABLE_METHODS.join(", ")}.` };
65
+ }
66
+ return { method: upper };
67
+ }
68
+ /**
69
+ * The script the page runs. Built as one expression so it can be evaluated
70
+ * directly, with every value passed through JSON rather than interpolated as
71
+ * code: a header value or a body is data from the agent, and a quote in it
72
+ * must not be able to end the string it sits in.
73
+ *
74
+ * `credentials: "include"` so the session's cookies go with it, and the
75
+ * app's own Authorization header is replayed when one has been seen.
76
+ */
77
+ export function buildRequestScript(input) {
78
+ const { url, method, body, headers } = input;
79
+ const init = { method, credentials: "include", headers };
80
+ if (body !== undefined && method !== "GET" && method !== "HEAD")
81
+ init.body = body;
82
+ return (`(async () => { const started = Date.now();` +
83
+ ` const res = await fetch(${JSON.stringify(url)}, ${JSON.stringify(init)});` +
84
+ ` const text = await res.text();` +
85
+ ` const headers = {}; res.headers.forEach((v, k) => { headers[k] = v; });` +
86
+ ` return { status: res.status, statusText: res.statusText, headers, body: text.slice(0, ${BODY_MAX * 2}), full: text.length, ms: Date.now() - started, url: res.url }; })()`);
87
+ }
88
+ /** The headers to send: what the caller asked for, plus the app's own auth and a JSON content type when a body is present. */
89
+ export function requestHeaders(input) {
90
+ const out = {};
91
+ // The app's own header first, so an explicit one from the caller wins — that
92
+ // is how a session tests what happens with a different or absent credential.
93
+ if (input.auth)
94
+ out["authorization"] = input.auth;
95
+ if (input.body !== undefined)
96
+ out["content-type"] = "application/json";
97
+ for (const [name, value] of Object.entries(input.given ?? {}))
98
+ out[name.toLowerCase()] = value;
99
+ return out;
100
+ }
101
+ /** The raw result of the in-page fetch, reduced to what the agent and the report need. */
102
+ export function toReplayResult(raw) {
103
+ const kept = {};
104
+ for (const name of REPORTED_HEADERS) {
105
+ const value = raw.headers[name];
106
+ if (value !== undefined)
107
+ kept[name] = value;
108
+ }
109
+ return {
110
+ status: raw.status,
111
+ statusText: raw.statusText,
112
+ headers: kept,
113
+ body: raw.body.slice(0, BODY_MAX),
114
+ truncated: raw.full > BODY_MAX,
115
+ ms: raw.ms,
116
+ url: raw.url,
117
+ };
118
+ }
119
+ /** The signature a finding carries for this call: the line that dedups the same refusal across runs. */
120
+ export function replaySignature(method, url, status) {
121
+ let path = url;
122
+ try {
123
+ const parsed = new URL(url);
124
+ path = parsed.pathname + parsed.search;
125
+ }
126
+ catch {
127
+ // Not a URL we can shorten; the whole string is the signature.
128
+ }
129
+ return `${method} ${path} ${status}`;
130
+ }
131
+ /** What the agent reads back. Leads with the signature, because that is what a finding quotes. */
132
+ export function formatReplay(method, result) {
133
+ const lines = [replaySignature(method, result.url, result.status) + (result.statusText ? ` ${result.statusText}` : ""), `took ${result.ms} ms`];
134
+ const headers = Object.entries(result.headers);
135
+ if (headers.length > 0)
136
+ lines.push(headers.map(([k, v]) => `${k}: ${v}`).join(" · "));
137
+ if (result.body.length > 0) {
138
+ lines.push("", result.body + (result.truncated ? `\n… truncated at ${BODY_MAX} characters` : ""));
139
+ }
140
+ else {
141
+ lines.push("", "(empty body)");
142
+ }
143
+ return lines.join("\n");
144
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * How long to wait after an action before reading the page.
3
+ *
4
+ * This used to be a flat 400 ms sleep on every action, for a good reason:
5
+ * `waitForLoadState("networkidle")` latches once reached and then resolves
6
+ * instantly forever after, so it cannot be used to wait out the request an
7
+ * action just fired, and draining the oracles before that request lands would
8
+ * misattribute its violations to the next step.
9
+ *
10
+ * The floor was still the wrong shape. Measured against a demo app, a snapshot
11
+ * cost 740 ms of which 400 ms was the sleep — 54% of the wall time — and a run
12
+ * of 203 actions spent 81 seconds asleep. Meanwhile a page slower than 400 ms
13
+ * got cut off exactly when it mattered.
14
+ *
15
+ * The engine already intercepts every request, so it knows what is in flight.
16
+ * Waiting on THAT, with a quiet window after the last one starts, is both
17
+ * faster on a page that answers quickly and more patient with one that does
18
+ * not. The old constant survives as a ceiling rather than a floor.
19
+ *
20
+ * The same rule carries the deliberate slow-down: a run being watched by a
21
+ * person taking notes wants a fixed pace, not the fastest one. `paceMs` is a
22
+ * floor a session asks for; unset, a session goes as fast as its page allows.
23
+ */
24
+ /** No request has started for this long and none is in flight: the page has settled. */
25
+ export const QUIET_MS = 120;
26
+ /** Longest to wait for requests to drain, however busy the page is. A page that never goes quiet must not stall the run. */
27
+ export const SETTLE_CAP_MS = 2000;
28
+ /** How often the wait re-checks. */
29
+ export const SETTLE_TICK_MS = 20;
30
+ /**
31
+ * Whether to keep waiting. Split out from the browser so the rule can be
32
+ * table-tested: it is the difference between a run that is fast and one that
33
+ * reads a page before it has finished changing.
34
+ */
35
+ export function shouldKeepWaiting(state) {
36
+ // A pace a session asked for is a floor on the whole wait, and it is the
37
+ // only reason to wait once everything else says the page is ready.
38
+ if (state.elapsedMs < state.paceMs)
39
+ return true;
40
+ // Never longer than the cap, whatever the page is doing.
41
+ if (state.elapsedMs >= SETTLE_CAP_MS)
42
+ return false;
43
+ // Something is still out: wait for it, up to the cap.
44
+ if (state.inFlight > 0)
45
+ return true;
46
+ // Nothing in flight and nothing recent. The action itself is the last event
47
+ // that could still produce a request, so the quiet window runs from whichever
48
+ // of the two is later. Without this, an action whose request comes back
49
+ // through a hop — a click that posts to a service worker, which then fetches —
50
+ // is read while the page is between the two, and the request lands during the
51
+ // NEXT action, which is then blamed for it.
52
+ return Math.min(state.sinceLastStartMs, state.elapsedMs) < QUIET_MS;
53
+ }
54
+ /** A pace a session asked for, clamped. Negative or absurd values are a typo, not an instruction. */
55
+ export const PACE_MAX_MS = 60_000;
56
+ export function normalizePace(paceMs) {
57
+ if (typeof paceMs !== "number" || !Number.isFinite(paceMs) || paceMs <= 0)
58
+ return 0;
59
+ return Math.min(Math.round(paceMs), PACE_MAX_MS);
60
+ }
61
+ /** What the attach result says about a deliberate pace, or nothing when the session runs at full speed. */
62
+ export function describePace(paceMs) {
63
+ if (paceMs <= 0)
64
+ return "";
65
+ return `\n⏱ PACE: at least ${paceMs} ms between actions, so a person can follow along. Unset it with paceMs: 0 to go as fast as the page allows.`;
66
+ }
67
+ /**
68
+ * Waiting for a client-side redirect that has not happened yet.
69
+ *
70
+ * A guard that redirects on a timer after hydration issues no request until it
71
+ * fires, so there is nothing for the request-based settle above to wait on:
72
+ * it goes quiet, the URL is read, and the page reports the route it was asked
73
+ * for rather than the login page it actually bounced to. The old flat 400 ms
74
+ * sleep covered this by accident, and removing it made the bounce invisible
75
+ * whenever a loaded machine delayed the timer past the quiet window.
76
+ *
77
+ * So where a BOUNCE VERDICT is about to be made — attach judging a storage
78
+ * state, navigate judging coverage — the URL is watched until it has held
79
+ * still, rather than read once. This is paid per navigation, not per action.
80
+ */
81
+ /**
82
+ * How long the URL must hold still before a bounce verdict is believed.
83
+ *
84
+ * This is a window, not a guarantee: a guard slower than it still lands after
85
+ * the verdict. What it buys is the realistic case — a guard written to fire
86
+ * promptly, delayed by a loaded machine — which is the one that made this
87
+ * project's own CI fail intermittently on a 40 ms timer.
88
+ */
89
+ export const URL_QUIET_MS = 400;
90
+ /** Longest to watch. A page redirecting in a loop must not hold up the run. */
91
+ export const URL_CAP_MS = 3000;
92
+ /** Whether to keep watching the URL before judging where the page landed. */
93
+ export function keepWatchingUrl(w) {
94
+ if (w.elapsedMs >= URL_CAP_MS)
95
+ return false;
96
+ return w.sinceChangeMs < URL_QUIET_MS;
97
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Re-testing findings a previous run left open.
3
+ *
4
+ * The report has always carried two kinds of finding and been honest that they
5
+ * are not the same thing: what this run saw, and what some earlier run saw.
6
+ * The second kind is labelled historical and unverified, which is accurate and
7
+ * almost useless — a reader cannot tell a bug fixed three weeks ago from one
8
+ * that is still costing users money today, and neither can the next run.
9
+ *
10
+ * Closing that gap by hand is possible and nobody does it. It means opening
11
+ * the report, copying each finding's route and evidence, re-walking them one
12
+ * at a time, and calling `scout_resolve` on the ones that are gone — per
13
+ * finding, for as many as the history holds. One project's history held over
14
+ * three hundred.
15
+ *
16
+ * So the campaign is a tool. Asked for a worklist, it hands back the open
17
+ * findings in the order a run should re-test them: by severity, grouped so a
18
+ * route is walked once rather than once per finding, each with the evidence
19
+ * that identifies it and the steps that produced it. Asked to record a
20
+ * verdict, it stamps what was seen and when, so the next report can say
21
+ * "confirmed still present" with a date instead of "unverified".
22
+ *
23
+ * Pure, so the ordering and the wording can be table-tested.
24
+ */
25
+ import { normalizePath } from "./fingerprint.js";
26
+ /** What a re-test found. */
27
+ export const VERDICTS = ["gone", "present", "changed"];
28
+ export const VERDICT_MEANING = {
29
+ gone: "re-tested and the evidence no longer reproduces — resolved",
30
+ present: "re-tested and still reproduces exactly as filed",
31
+ changed: "re-tested and something is different — still broken, but not as described",
32
+ };
33
+ /** Most findings a single campaign hands over at once. A worklist longer than this is a plan nobody follows. */
34
+ export const MAX_WORKLIST = 25;
35
+ const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 };
36
+ function item(f) {
37
+ return {
38
+ id: f.id,
39
+ severity: f.severity,
40
+ category: f.category,
41
+ title: f.title,
42
+ route: normalizePath(f.url),
43
+ url: f.url,
44
+ evidence: f.evidence ?? "",
45
+ repro: f.repro ?? [],
46
+ foundAt: f.foundAt,
47
+ runs: f.runs ?? 1,
48
+ ...(f.verdict ? { lastVerdict: f.verdict } : {}),
49
+ ...(f.verifiedAt ? { lastVerifiedAt: f.verifiedAt } : {}),
50
+ };
51
+ }
52
+ /**
53
+ * The findings to re-test, in the order to do it.
54
+ *
55
+ * Severity decides what matters; route groups what is cheap, because walking
56
+ * one route and checking four findings on it costs a fraction of walking four
57
+ * routes. Within a route, a finding never verified comes before one verified
58
+ * before — re-checking the same finding twice while another has never been
59
+ * looked at is the failure mode this ordering exists to prevent.
60
+ */
61
+ export function verifyWorklist(findings, ids) {
62
+ const wanted = ids && ids.length > 0 ? new Set(ids) : null;
63
+ const open = findings.filter((f) => (f.status ?? "open") !== "resolved").filter((f) => (wanted ? wanted.has(f.id) : true));
64
+ // The severity of a route is its worst finding: a route carrying a high is
65
+ // walked before one carrying three mediums.
66
+ const worst = new Map();
67
+ for (const f of open) {
68
+ const route = normalizePath(f.url);
69
+ const rank = SEVERITY_ORDER[f.severity] ?? 3;
70
+ worst.set(route, Math.min(worst.get(route) ?? 9, rank));
71
+ }
72
+ return open
73
+ .map(item)
74
+ .sort((a, b) => (worst.get(a.route) ?? 9) - (worst.get(b.route) ?? 9) ||
75
+ a.route.localeCompare(b.route) ||
76
+ (SEVERITY_ORDER[a.severity] ?? 3) - (SEVERITY_ORDER[b.severity] ?? 3) ||
77
+ Number(Boolean(a.lastVerifiedAt)) - Number(Boolean(b.lastVerifiedAt)) ||
78
+ (a.lastVerifiedAt ?? "").localeCompare(b.lastVerifiedAt ?? "") ||
79
+ a.id.localeCompare(b.id))
80
+ .slice(0, MAX_WORKLIST);
81
+ }
82
+ /** Ids the caller asked for that no open finding matches — a silently short worklist is worse than a named miss. */
83
+ export function unknownIds(findings, ids) {
84
+ const open = new Set(findings.filter((f) => (f.status ?? "open") !== "resolved").map((f) => f.id));
85
+ return ids.filter((id) => !open.has(id));
86
+ }
87
+ /** The campaign brief the agent works down. */
88
+ export function formatWorklist(items, missing = []) {
89
+ if (items.length === 0) {
90
+ return missing.length > 0
91
+ ? `Nothing to verify. No OPEN finding matches: ${missing.join(", ")}. They may already be resolved, or belong to another project's memory.`
92
+ : "Nothing to verify — no open findings in this project's memory.";
93
+ }
94
+ const lines = [
95
+ `VERIFY CAMPAIGN — ${items.length} open finding(s) to re-test, worst route first.`,
96
+ `Re-test each one, then record what you saw: scout_verify { id, verdict: "gone" | "present" | "changed", note }.`,
97
+ `"gone" resolves it. "present" stamps it confirmed, so the report stops calling it unverified. "changed" keeps it open and says what differs.`,
98
+ ``,
99
+ ];
100
+ let route = "";
101
+ for (const it of items) {
102
+ if (it.route !== route) {
103
+ route = it.route;
104
+ lines.push(`── ${route} ──`);
105
+ }
106
+ const seen = it.lastVerifiedAt ? ` · last re-tested ${it.lastVerifiedAt.slice(0, 10)} (${it.lastVerdict})` : " · never re-tested";
107
+ lines.push(`[${it.severity}] ${it.id} ${it.title}`);
108
+ lines.push(` ${it.category} · found ${it.foundAt.slice(0, 10)} · ${it.runs} run(s)${seen}`);
109
+ if (it.evidence)
110
+ lines.push(` evidence: ${it.evidence}`);
111
+ if (it.repro.length > 0)
112
+ lines.push(` repro: ${it.repro.slice(0, 6).join(" → ")}`);
113
+ lines.push(` at: ${it.url}`);
114
+ }
115
+ if (missing.length > 0)
116
+ lines.push(``, `No OPEN finding matches: ${missing.join(", ")}.`);
117
+ return lines.join("\n");
118
+ }
119
+ /** Whether a verdict resolves the finding it is recorded against. */
120
+ export function resolvesFinding(verdict) {
121
+ return verdict === "gone";
122
+ }
123
+ /** What the agent reads back after recording one. */
124
+ export function describeVerdict(f, verdict, note) {
125
+ const tail = note ? `\n ${note}` : "";
126
+ if (verdict === "gone")
127
+ return `Resolved by re-test: [${f.severity}] ${f.title} (${f.id}).${tail}`;
128
+ if (verdict === "present")
129
+ return `Confirmed still present: [${f.severity}] ${f.title} (${f.id}). It stays open, and the report now dates the confirmation instead of calling it unverified.${tail}`;
130
+ return `Still open and changed: [${f.severity}] ${f.title} (${f.id}). Re-file the difference as its own finding if the behaviour is now a different bug.${tail}`;
131
+ }
132
+ /** How a verified finding reads in the report, or nothing when it was never re-tested. */
133
+ export function sayVerification(f) {
134
+ if (!f.verdict || !f.verifiedAt)
135
+ return "";
136
+ const on = f.verifiedAt.slice(0, 10);
137
+ if (f.verdict === "present")
138
+ return ` · confirmed still present on ${on}`;
139
+ if (f.verdict === "changed")
140
+ return ` · re-tested ${on}, behaviour has changed since it was filed`;
141
+ return ` · re-tested ${on}`;
142
+ }
@@ -38,9 +38,12 @@ import { FINDING_CATEGORIES, MemoryStore, redactSecrets } from "./engine/memory.
38
38
  import { LANE_NAME_MAX, laneReportInstruction, parseLaneReport, summarizeLaneReport } from "./engine/lane.js";
39
39
  import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
40
40
  import { FIXTURE_KINDS } from "./engine/fixtures.js";
41
- import { feedForSession, LIVE_ENV, writeStatusFile, LIVE_TOKEN_FILE, LiveServer, StatusBoard, } from "./engine/live.js";
41
+ import { feedForSession, LIVE_ENV, writeStatusFile, LIVE_TOKEN_FILE, liveEngines, liveTokenFileName, pidAlive, statusFileName, LiveServer, StatusBoard, } from "./engine/live.js";
42
+ import { formatBriefs, MAX_LANES, planLanes } from "./engine/brief.js";
42
43
  import { computeGaps, formatRouteCoverage, generateReport, replayDocument, reportEvidence } from "./engine/report.js";
44
+ import { describeVerdict, formatWorklist, unknownIds, VERDICTS, verifyWorklist } from "./engine/verify.js";
43
45
  import { RECORD_MAX_FRAMES, resolveFrame } from "./engine/replay.js";
46
+ import { describePace, normalizePace } from "./engine/settle.js";
44
47
  import { needsTask, taskRefusal, TASK_MAX } from "./engine/task.js";
45
48
  import { EXPLORE_PROMPT_ARGUMENTS, explorePrompt, loadPlaybook, PLAYBOOK_PROMPT, PLAYBOOK_TOOL, SERVER_INSTRUCTIONS } from "./playbook.js";
46
49
  import { formatScan, scanProject } from "./scan.js";
@@ -241,15 +244,20 @@ function reportExtras(eng) {
241
244
  mode: eng.mode,
242
245
  policyAttributed: eng.oracleLog.policyAttributed,
243
246
  version: PKG_VERSION,
247
+ attachedSessions: [...engines.keys()],
244
248
  };
245
249
  }
246
250
  /** Hand the live view's token to `scenescout watch` through a file only the owner can read. */
247
251
  function publishLiveToken(dir) {
248
252
  if (!liveAddress || liveDirs.has(dir) || liveTokenWrites.has(dir))
249
253
  return;
250
- const file = path.join(dir, LIVE_TOKEN_FILE);
254
+ // Named for this process: two engines on one project would otherwise hand
255
+ // `watch` one token for two ports, and whichever wrote last would win.
256
+ const file = path.join(dir, liveTokenFileName(process.pid));
251
257
  const write = fs.promises
252
258
  .writeFile(file, liveAddress.token, { mode: 0o600 })
259
+ // The shared name stays too, for a `watch` from before per-pid files.
260
+ .then(() => fs.promises.writeFile(path.join(dir, LIVE_TOKEN_FILE), liveAddress.token, { mode: 0o600 }))
253
261
  // `mode` applies only when the file is created; a leftover one keeps its old bits.
254
262
  .then(() => fs.promises.chmod(file, 0o600))
255
263
  .then(() => {
@@ -487,6 +495,27 @@ server.server.setRequestHandler(GetPromptRequestSchema, (request) => {
487
495
  // The lane report: how a parallel agent hands its results back to the planner
488
496
  // as typed decisions. One tool for both halves, so the instruction a lane is
489
497
  // given and the parser its reply meets are the same code.
498
+ // Splitting the app between lanes: the other half of the parallel protocol.
499
+ // scout_lane_report is how a lane hands its answers back; this is what the
500
+ // planner hands it in the first place.
501
+ server.registerTool("scout_lane_brief", {
502
+ 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.",
503
+ inputSchema: {
504
+ lanes: z.number().int().min(1).max(MAX_LANES).describe(`How many lanes to split across (1–${MAX_LANES})`),
505
+ goal: z.string().max(200).optional().describe("What the whole run is for; each lane's objective is written against it"),
506
+ routes: z.array(z.string()).max(500).optional().describe("Routes to split. Omit to split every route this project knows about."),
507
+ session: sessionParam,
508
+ },
509
+ }, serializedPerSession("scout_lane_brief", async ({ lanes, goal, routes }, session) => {
510
+ try {
511
+ const eng = engineFor(session);
512
+ const all = routes && routes.length > 0 ? routes : eng.allKnownRoutes();
513
+ return text(formatBriefs(planLanes(all, lanes, { goal, mode: eng.mode, role: eng.role }), { goal, mode: eng.mode, role: eng.role }), session);
514
+ }
515
+ catch (err) {
516
+ return errorText(err);
517
+ }
518
+ }));
490
519
  server.registerTool("scout_lane_report", {
491
520
  description: "For a run split across parallel agents (lanes). Without `reply`: returns the paragraph to put in a lane's prompt, telling it to hand back ONE typed JSON object (verdicts, severities and categories from closed sets, a calibrated confidence per decision, routes covered, what blocked it). " +
492
521
  "With `reply`: parses what the lane handed back and returns the one-line fold (defects, highs, unsure, mean confidence, routes) or the reason it was refused, to relay to the lane once. Touches no browser.",
@@ -543,7 +572,18 @@ server.registerTool("scout_attach", {
543
572
  .describe('This session\'s objective: the whole remit you were given, in one sentence ("Admin lane: §2 registers, §7 plan gating", ' +
544
573
  '"Approve and reject orders as a manager"). It sits above the task, which is what the session is doing at any moment. ' +
545
574
  "Shown to whoever is watching the run; worth setting whenever more than one session is live."),
546
- task: z.string().max(300).optional().describe("Old name for `objective` (2.0). Prefer `objective`."),
575
+ task: z
576
+ .string()
577
+ .max(300)
578
+ .optional()
579
+ .describe("What this session is doing right now, shown under its objective from the moment it appears, e.g. Signing in and taking stock. Passed alone it is read as the 2.0 spelling of `objective`. Defaults to a placeholder so a fresh card never reads as idle."),
580
+ paceMs: z
581
+ .number()
582
+ .int()
583
+ .min(0)
584
+ .max(60000)
585
+ .optional()
586
+ .describe("A floor between actions, in milliseconds, for when a person is watching and needs to keep up — following a flow, taking notes, demonstrating. Default 0: as fast as the page allows, which is what a run wants otherwise. Changeable mid-run with scout_session {paceMs}."),
547
587
  record: z
548
588
  .boolean()
549
589
  .default(false)
@@ -555,7 +595,7 @@ server.registerTool("scout_attach", {
555
595
  .optional()
556
596
  .describe("Session name for multi-role runs (e.g. 'admin', 'qa'). Creates/replaces that session's browser and makes it the default. Default: 'default'."),
557
597
  },
558
- }, serializedControl(async ({ url, projectPath, storageStatePath, mode, headed, browser, viewportWidth, viewportHeight, objective, task, record, session, }) => {
598
+ }, serializedControl(async ({ url, projectPath, storageStatePath, mode, headed, browser, viewportWidth, viewportHeight, objective, task, record, paceMs, session, }) => {
559
599
  try {
560
600
  const target = session ?? activeName;
561
601
  if (session) {
@@ -624,8 +664,13 @@ server.registerTool("scout_attach", {
624
664
  headed,
625
665
  browser,
626
666
  viewport,
627
- // `task` is what this was called in 2.0; it named the session's whole remit, which is the objective.
667
+ // `task` is what this was called in 2.0, where it named the session's
668
+ // whole remit. Alone it still means that. Given BESIDE an objective it
669
+ // means what it means everywhere else — what this session is doing
670
+ // right now — so the card says something from the moment it appears.
628
671
  objective: objective ?? task,
672
+ paceMs,
673
+ task: objective ? task : undefined,
629
674
  record,
630
675
  memoryStore: store,
631
676
  });
@@ -643,7 +688,7 @@ server.registerTool("scout_attach", {
643
688
  const recordNote = record && eng.memory?.dir
644
689
  ? `\n\n📸 RECORDING: a frame of the page after each action, under ${path.join(eng.memory.dir, "recordings", target)}/ (at most ${RECORD_MAX_FRAMES}). scout_report writes them into report.html beside report.md.`
645
690
  : "";
646
- return text(out + conflictNote + recordNote + (engines.size > 1 ? `\n${sessionLines()}` : "") + liveLine(), target);
691
+ return text(out + conflictNote + recordNote + describePace(eng.pace) + (engines.size > 1 ? `\n${sessionLines()}` : "") + liveLine(), target);
647
692
  }
648
693
  catch (err) {
649
694
  return errorText(err);
@@ -664,10 +709,32 @@ server.registerTool("scout_session", {
664
709
  // costs nothing and removes a guaranteed first-try rejection, since all
665
710
  // schemas are additionalProperties:false and reject the near-miss hard.
666
711
  session: z.string().max(40).optional().describe("Alias for `name`."),
712
+ paceMs: z
713
+ .number()
714
+ .int()
715
+ .min(0)
716
+ .max(60000)
717
+ .optional()
718
+ .describe("Change how fast this session acts, mid-run: a floor between actions in milliseconds, for when a person is watching and needs to keep up. 0 restores full speed. With `name`, applies to that session; without, to every live session — which is what 'slow everything down so I can follow' means."),
667
719
  },
668
- }, serializedControl(async ({ name, session }) => {
720
+ }, serializedControl(async ({ name, session, paceMs }) => {
669
721
  try {
670
722
  name = name ?? session;
723
+ // A pace with no session named is meant for the whole run: somebody is
724
+ // watching and wants to keep up with all of it, not one lane.
725
+ if (paceMs !== undefined && !name) {
726
+ const applied = [...engines.values()].map((e) => e.setPace(paceMs));
727
+ const at = applied[0] ?? normalizePace(paceMs);
728
+ return text((at > 0
729
+ ? `Every live session now waits at least ${at} ms between actions, so a person can follow along.`
730
+ : `Every live session is back to full speed: as fast as its page allows.`) + `\n${sessionLines()}`, activeName);
731
+ }
732
+ if (paceMs !== undefined && name) {
733
+ if (!engines.has(name))
734
+ return text(`No session named "${name}" yet — create it with scout_attach { session: "${name}", … }.\n${sessionLines()}`, activeName);
735
+ const at = engines.get(name).setPace(paceMs);
736
+ return text((at > 0 ? `${name} now waits at least ${at} ms between actions.` : `${name} is back to full speed.`) + `\n${sessionLines()}`, activeName);
737
+ }
671
738
  if (!name)
672
739
  return text(sessionLines() + liveLine(), activeName);
673
740
  if (!engines.has(name)) {
@@ -860,6 +927,28 @@ server.registerTool("scout_navigate", {
860
927
  return errorText(err);
861
928
  }
862
929
  }));
930
+ server.registerTool("scout_request", {
931
+ description: "Call the app's own API as this session, with the UI bypassed — the check that turns a hidden or disabled control into a proven refusal. A button that is not shown proves nothing; the same action refused by the server does. The fetch runs IN the page, so it carries the session's cookies and replays the Authorization header the app itself last sent, and it passes through the same interception the write policy is enforced on: in safe-write a mutation on a record this session did not create is refused here exactly as it would be for a click, and that refusal is the engine's safety net, not a finding. Returns the status line, the timing, the headers that decide whether two responses are truly identical (content-type, location, www-authenticate, retry-after, cache-control), and the body. Unlike a shell call, every request is recorded in the run's trail and its signature is what a finding should quote. Paths are fenced to the attached origin: use another session to reach another host.",
932
+ inputSchema: {
933
+ path: z.string().min(1).max(2000).describe("Path on the attached origin, e.g. /api/things/12, or a full URL on that same origin"),
934
+ method: z.enum(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]).optional().describe("Default GET"),
935
+ body: z.string().max(20000).optional().describe("Request body, sent as application/json unless a content-type header is given"),
936
+ headers: z
937
+ .record(z.string().max(2000))
938
+ .optional()
939
+ .describe("Extra headers. One given here wins over the app's own, which is how a session tests a different or absent credential."),
940
+ task: taskParam,
941
+ objective: legacyObjectiveParam,
942
+ session: sessionParam,
943
+ },
944
+ }, serializedPerSession("scout_request", async (args, session) => {
945
+ try {
946
+ return text(await engineFor(session).apiRequest({ method: args.method, path: args.path, body: args.body, headers: args.headers }), session);
947
+ }
948
+ catch (err) {
949
+ return errorText(err);
950
+ }
951
+ }));
863
952
  server.registerTool("scout_back", {
864
953
  description: "Go back in browser history (tests back-button resilience).",
865
954
  inputSchema: { task: taskParam, objective: legacyObjectiveParam, session: sessionParam },
@@ -936,11 +1025,11 @@ server.registerTool("scout_journey", {
936
1025
  }
937
1026
  }));
938
1027
  server.registerTool("scout_note", {
939
- description: "Cumulative WRITTEN knowledge about the tested app — .scenescout/ASSUMPTIONS.md, in prose a human can read and correct. memory.json stores coverage; this stores UNDERSTANDING, so every run starts smarter than the last. READ it at the start of every session ({action:'read'}). ADD durable learnings as you go ({action:'add', section, note}): what the app is for (app-model), who each role is and what they're FOR — infer the persona from what the role can see and do, e.g. 'qa-role = reviewer: approves orders, cannot administer' (roles), UI patterns the app follows (conventions), rules discovered the hard way like 'an order can only ship once approved' (constraints), fragile areas worth re-testing every run (risks), domain terms (glossary). Notes are dated, attributed to the acting role, and deduplicated. Do NOT record session-specific facts (ids, counts) — only durable knowledge.",
1028
+ description: "Cumulative WRITTEN knowledge about the tested app — .scenescout/ASSUMPTIONS.md, in prose a human can read and correct. memory.json stores coverage; this stores UNDERSTANDING, so every run starts smarter than the last. READ it at the start of every session ({action:'read'}). ADD durable learnings as you go ({action:'add', section, note}): what the app is for (app-model), who each role is and what they're FOR — infer the persona from what the role can see and do, e.g. 'qa-role = reviewer: approves orders, cannot administer' (roles), UI patterns the app follows (conventions), rules discovered the hard way like 'an order can only ship once approved' (constraints), fragile areas worth re-testing every run (risks), domain terms (glossary), and how to get the app testable at all — the command that regenerates an expired login state, what has to be running (setup), which the engine reads back to you the next time a storage state has expired. Notes are dated, attributed to the acting role, and deduplicated. Do NOT record session-specific facts (ids, counts) — only durable knowledge.",
940
1029
  inputSchema: {
941
1030
  action: z.enum(["read", "add"]).describe("'read' the accumulated knowledge, or 'add' one durable learning"),
942
1031
  section: z
943
- .enum(["app-model", "roles", "conventions", "constraints", "risks", "glossary"])
1032
+ .enum(["app-model", "roles", "conventions", "constraints", "risks", "glossary", "setup"])
944
1033
  .optional()
945
1034
  .describe("For add: which knowledge section this belongs to"),
946
1035
  note: z.string().max(500).optional().describe("For add: the learning, one or two sentences, written for a future reader with no context"),
@@ -1049,9 +1138,13 @@ server.registerTool("scout_report", {
1049
1138
  .enum(["minimal", "medium", "extensive"])
1050
1139
  .default("medium")
1051
1140
  .describe("Which completion contract to enforce — match the level the run was asked for"),
1141
+ history: z
1142
+ .enum(["index", "full"])
1143
+ .default("index")
1144
+ .describe("How much of the history to print. 'index' lists findings from earlier runs, and resolved ones, as a row each: id, severity, age, title. 'full' prints every one in full as before — on one project that was 1.75 MB against 113 KB, nearly half of it findings already fixed. Use 'full' when handing the document to someone who has no access to the memory."),
1052
1145
  session: sessionParam,
1053
1146
  },
1054
- }, serializedPerSession("scout_report", async ({ force, level }, session) => {
1147
+ }, serializedPerSession("scout_report", async ({ force, level, history }, session) => {
1055
1148
  try {
1056
1149
  const eng = engineFor(session);
1057
1150
  if (!eng.memory)
@@ -1100,6 +1193,7 @@ server.registerTool("scout_report", {
1100
1193
  `Then call scout_report again. Pass force=true ONLY if the user explicitly capped the budget.`, session);
1101
1194
  }
1102
1195
  const { path: p, summary } = generateReport(eng.memory, eng.oracleLog.all, {
1196
+ history,
1103
1197
  routesVisited: all.length - unvisited.length,
1104
1198
  routesTotal: all.length,
1105
1199
  designAudits: eng.designAuditCount,
@@ -1140,6 +1234,39 @@ server.registerTool("scout_resolve", {
1140
1234
  return errorText(err);
1141
1235
  }
1142
1236
  }));
1237
+ server.registerTool("scout_verify", {
1238
+ description: 'Re-test findings earlier runs left open. With no arguments, returns the open findings in the order to re-test them — worst route first, grouped so a route is walked once — each with its evidence and repro steps. Pass ids to narrow it to specific findings. After re-testing one, call again with id and verdict to record what you saw: "gone" resolves it, "present" stamps it confirmed so the report stops calling it unverified, "changed" keeps it open and says the behaviour differs. Use after a fix wave, or at the start of a run against an app this project has tested before.',
1239
+ inputSchema: {
1240
+ id: z.string().optional().describe("The finding being verified. Omit to get the worklist."),
1241
+ verdict: z.enum(VERDICTS).optional().describe('What the re-test found: "gone", "present" or "changed". Requires id.'),
1242
+ note: z.string().max(500).optional().describe("What you saw, in a sentence. Shown in the report beside the verdict."),
1243
+ ids: z.array(z.string()).max(50).optional().describe("Narrow the worklist to these finding ids."),
1244
+ session: sessionParam,
1245
+ },
1246
+ }, serializedPerSession("scout_verify", async ({ id, verdict, note, ids }, session) => {
1247
+ try {
1248
+ const eng = engineFor(session);
1249
+ if (!eng.memory)
1250
+ throw new Error("Not attached.");
1251
+ if (verdict && !id)
1252
+ return text(`Pass the finding the verdict is about: scout_verify { id: "a1b2c3d4e5", verdict: "${verdict}" }.`, session);
1253
+ if (id && !verdict) {
1254
+ return text(`Pass what the re-test found: scout_verify { id: "${id}", verdict: "gone" | "present" | "changed" }.`, session);
1255
+ }
1256
+ if (id && verdict) {
1257
+ const f = eng.memory.verifyFinding(id, verdict, note);
1258
+ if (!f)
1259
+ return text(`No finding with id ${id}.`, session);
1260
+ return text(describeVerdict(f, verdict, note), session);
1261
+ }
1262
+ const findings = eng.memory.findings;
1263
+ const missing = ids && ids.length > 0 ? unknownIds(findings, ids) : [];
1264
+ return text(formatWorklist(verifyWorklist(findings, ids), missing), session);
1265
+ }
1266
+ catch (err) {
1267
+ return errorText(err);
1268
+ }
1269
+ }));
1143
1270
  server.registerTool("scout_close", {
1144
1271
  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.",
1145
1272
  inputSchema: {
@@ -1218,7 +1345,13 @@ async function shutdown() {
1218
1345
  await Promise.allSettled(liveTokenWrites.values());
1219
1346
  for (const dir of liveDirs) {
1220
1347
  try {
1221
- fs.rmSync(path.join(dir, LIVE_TOKEN_FILE), { force: true });
1348
+ fs.rmSync(path.join(dir, liveTokenFileName(process.pid)), { force: true });
1349
+ fs.rmSync(path.join(dir, statusFileName(process.pid)), { force: true });
1350
+ // The shared names belong to whichever engine is still running, so they
1351
+ // are only removed when this process is the last one holding them.
1352
+ if (liveEngines(dir, pidAlive).filter((e) => e.pid !== process.pid).length === 0) {
1353
+ fs.rmSync(path.join(dir, LIVE_TOKEN_FILE), { force: true });
1354
+ }
1222
1355
  }
1223
1356
  catch (err) {
1224
1357
  console.error(`[scenescout] could not remove the live view's token file in ${dir}: ${err instanceof Error ? err.message : String(err)}`);