scenescout 3.1.1 → 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.
- package/CHANGELOG.md +89 -0
- package/README.md +3 -2
- package/dist/cli.js +39 -23
- package/dist/engine/brief.js +134 -0
- package/dist/engine/browser.js +262 -19
- package/dist/engine/claims.js +194 -0
- package/dist/engine/lane.js +169 -0
- package/dist/engine/live-page.js +101 -2
- package/dist/engine/live.js +60 -0
- package/dist/engine/memory.js +124 -1
- package/dist/engine/oracles.js +9 -0
- package/dist/engine/pace.js +112 -0
- package/dist/engine/policy.js +53 -2
- package/dist/engine/report.js +70 -6
- package/dist/engine/request.js +144 -0
- package/dist/engine/settle.js +97 -0
- package/dist/engine/verify.js +142 -0
- package/dist/mcp-server.js +171 -33
- package/dist/scan.js +47 -1
- package/package.json +10 -3
- package/skills/scenescout/SKILL.md +11 -2
|
@@ -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
|
+
}
|
package/dist/mcp-server.js
CHANGED
|
@@ -34,12 +34,16 @@ import { ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, McpError }
|
|
|
34
34
|
import { z } from "zod";
|
|
35
35
|
import { BrowserEngine } from "./engine/browser.js";
|
|
36
36
|
import { reapOrphanBrowsers } from "./engine/reaper.js";
|
|
37
|
-
import { MemoryStore, redactSecrets } from "./engine/memory.js";
|
|
37
|
+
import { FINDING_CATEGORIES, MemoryStore, redactSecrets } from "./engine/memory.js";
|
|
38
|
+
import { LANE_NAME_MAX, laneReportInstruction, parseLaneReport, summarizeLaneReport } from "./engine/lane.js";
|
|
38
39
|
import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
|
|
39
40
|
import { FIXTURE_KINDS } from "./engine/fixtures.js";
|
|
40
|
-
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";
|
|
41
43
|
import { computeGaps, formatRouteCoverage, generateReport, replayDocument, reportEvidence } from "./engine/report.js";
|
|
44
|
+
import { describeVerdict, formatWorklist, unknownIds, VERDICTS, verifyWorklist } from "./engine/verify.js";
|
|
42
45
|
import { RECORD_MAX_FRAMES, resolveFrame } from "./engine/replay.js";
|
|
46
|
+
import { describePace, normalizePace } from "./engine/settle.js";
|
|
43
47
|
import { needsTask, taskRefusal, TASK_MAX } from "./engine/task.js";
|
|
44
48
|
import { EXPLORE_PROMPT_ARGUMENTS, explorePrompt, loadPlaybook, PLAYBOOK_PROMPT, PLAYBOOK_TOOL, SERVER_INSTRUCTIONS } from "./playbook.js";
|
|
45
49
|
import { formatScan, scanProject } from "./scan.js";
|
|
@@ -240,15 +244,20 @@ function reportExtras(eng) {
|
|
|
240
244
|
mode: eng.mode,
|
|
241
245
|
policyAttributed: eng.oracleLog.policyAttributed,
|
|
242
246
|
version: PKG_VERSION,
|
|
247
|
+
attachedSessions: [...engines.keys()],
|
|
243
248
|
};
|
|
244
249
|
}
|
|
245
250
|
/** Hand the live view's token to `scenescout watch` through a file only the owner can read. */
|
|
246
251
|
function publishLiveToken(dir) {
|
|
247
252
|
if (!liveAddress || liveDirs.has(dir) || liveTokenWrites.has(dir))
|
|
248
253
|
return;
|
|
249
|
-
|
|
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));
|
|
250
257
|
const write = fs.promises
|
|
251
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 }))
|
|
252
261
|
// `mode` applies only when the file is created; a leftover one keeps its old bits.
|
|
253
262
|
.then(() => fs.promises.chmod(file, 0o600))
|
|
254
263
|
.then(() => {
|
|
@@ -483,6 +492,51 @@ server.server.setRequestHandler(GetPromptRequestSchema, (request) => {
|
|
|
483
492
|
}
|
|
484
493
|
return { messages: [{ role: "user", content: { type: "text", text: message } }] };
|
|
485
494
|
});
|
|
495
|
+
// The lane report: how a parallel agent hands its results back to the planner
|
|
496
|
+
// as typed decisions. One tool for both halves, so the instruction a lane is
|
|
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
|
+
}));
|
|
519
|
+
server.registerTool("scout_lane_report", {
|
|
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). " +
|
|
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.",
|
|
522
|
+
inputSchema: {
|
|
523
|
+
lane: z.string().min(1).max(LANE_NAME_MAX).describe("The lane's name, as used in its session"),
|
|
524
|
+
reply: z.string().optional().describe("The text the lane handed back; omit to get the instruction instead"),
|
|
525
|
+
},
|
|
526
|
+
}, async ({ lane, reply }) => {
|
|
527
|
+
try {
|
|
528
|
+
if (reply === undefined)
|
|
529
|
+
return { content: [{ type: "text", text: laneReportInstruction(lane) }] };
|
|
530
|
+
const parsed = parseLaneReport(reply, lane);
|
|
531
|
+
const out = parsed.ok
|
|
532
|
+
? `Lane report accepted — ${summarizeLaneReport(parsed.report)}`
|
|
533
|
+
: `Lane report REFUSED: ${parsed.reason}. Ask the lane once for the corrected object; do not re-judge its prose.`;
|
|
534
|
+
return { content: [{ type: "text", text: out }] };
|
|
535
|
+
}
|
|
536
|
+
catch (err) {
|
|
537
|
+
return errorText(err);
|
|
538
|
+
}
|
|
539
|
+
});
|
|
486
540
|
server.registerTool("scout_scan", {
|
|
487
541
|
description: "Scan a project directory to discover the frontend workspace, framework, routes, dev command, Playwright auth storage states, and testid conventions. Run this first.",
|
|
488
542
|
inputSchema: { projectPath: z.string().describe("Absolute path to the project root") },
|
|
@@ -518,7 +572,18 @@ server.registerTool("scout_attach", {
|
|
|
518
572
|
.describe('This session\'s objective: the whole remit you were given, in one sentence ("Admin lane: §2 registers, §7 plan gating", ' +
|
|
519
573
|
'"Approve and reject orders as a manager"). It sits above the task, which is what the session is doing at any moment. ' +
|
|
520
574
|
"Shown to whoever is watching the run; worth setting whenever more than one session is live."),
|
|
521
|
-
task: z
|
|
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}."),
|
|
522
587
|
record: z
|
|
523
588
|
.boolean()
|
|
524
589
|
.default(false)
|
|
@@ -530,7 +595,7 @@ server.registerTool("scout_attach", {
|
|
|
530
595
|
.optional()
|
|
531
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'."),
|
|
532
597
|
},
|
|
533
|
-
}, 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, }) => {
|
|
534
599
|
try {
|
|
535
600
|
const target = session ?? activeName;
|
|
536
601
|
if (session) {
|
|
@@ -599,8 +664,13 @@ server.registerTool("scout_attach", {
|
|
|
599
664
|
headed,
|
|
600
665
|
browser,
|
|
601
666
|
viewport,
|
|
602
|
-
// `task` is what this was called in 2.0
|
|
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.
|
|
603
671
|
objective: objective ?? task,
|
|
672
|
+
paceMs,
|
|
673
|
+
task: objective ? task : undefined,
|
|
604
674
|
record,
|
|
605
675
|
memoryStore: store,
|
|
606
676
|
});
|
|
@@ -618,7 +688,7 @@ server.registerTool("scout_attach", {
|
|
|
618
688
|
const recordNote = record && eng.memory?.dir
|
|
619
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.`
|
|
620
690
|
: "";
|
|
621
|
-
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);
|
|
622
692
|
}
|
|
623
693
|
catch (err) {
|
|
624
694
|
return errorText(err);
|
|
@@ -639,10 +709,32 @@ server.registerTool("scout_session", {
|
|
|
639
709
|
// costs nothing and removes a guaranteed first-try rejection, since all
|
|
640
710
|
// schemas are additionalProperties:false and reject the near-miss hard.
|
|
641
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."),
|
|
642
719
|
},
|
|
643
|
-
}, serializedControl(async ({ name, session }) => {
|
|
720
|
+
}, serializedControl(async ({ name, session, paceMs }) => {
|
|
644
721
|
try {
|
|
645
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
|
+
}
|
|
646
738
|
if (!name)
|
|
647
739
|
return text(sessionLines() + liveLine(), activeName);
|
|
648
740
|
if (!engines.has(name)) {
|
|
@@ -835,6 +927,28 @@ server.registerTool("scout_navigate", {
|
|
|
835
927
|
return errorText(err);
|
|
836
928
|
}
|
|
837
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
|
+
}));
|
|
838
952
|
server.registerTool("scout_back", {
|
|
839
953
|
description: "Go back in browser history (tests back-button resilience).",
|
|
840
954
|
inputSchema: { task: taskParam, objective: legacyObjectiveParam, session: sessionParam },
|
|
@@ -911,11 +1025,11 @@ server.registerTool("scout_journey", {
|
|
|
911
1025
|
}
|
|
912
1026
|
}));
|
|
913
1027
|
server.registerTool("scout_note", {
|
|
914
|
-
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.",
|
|
915
1029
|
inputSchema: {
|
|
916
1030
|
action: z.enum(["read", "add"]).describe("'read' the accumulated knowledge, or 'add' one durable learning"),
|
|
917
1031
|
section: z
|
|
918
|
-
.enum(["app-model", "roles", "conventions", "constraints", "risks", "glossary"])
|
|
1032
|
+
.enum(["app-model", "roles", "conventions", "constraints", "risks", "glossary", "setup"])
|
|
919
1033
|
.optional()
|
|
920
1034
|
.describe("For add: which knowledge section this belongs to"),
|
|
921
1035
|
note: z.string().max(500).optional().describe("For add: the learning, one or two sentences, written for a future reader with no context"),
|
|
@@ -955,27 +1069,7 @@ server.registerTool("scout_finding", {
|
|
|
955
1069
|
description: "Record a structured finding (bug, UX issue, or improvement). Deduplicates across runs; automatically captures the recent action trace as the repro. Use for anything worth reporting: crashes, oracle violations you confirmed, dead ends, confusing UX, permission leaks, missing testids — and design-audit improvement opportunities (ux-polish) with their concrete measurements.",
|
|
956
1070
|
inputSchema: {
|
|
957
1071
|
severity: z.enum(["high", "medium", "low"]),
|
|
958
|
-
category: z
|
|
959
|
-
.enum([
|
|
960
|
-
"console-error",
|
|
961
|
-
"page-error",
|
|
962
|
-
"http-error",
|
|
963
|
-
"network",
|
|
964
|
-
"dead-end",
|
|
965
|
-
"ux-confusing",
|
|
966
|
-
"ux-polish",
|
|
967
|
-
"visual",
|
|
968
|
-
"a11y",
|
|
969
|
-
"permission-leak",
|
|
970
|
-
"data-inconsistency",
|
|
971
|
-
"stale-state",
|
|
972
|
-
"data-loss",
|
|
973
|
-
"performance",
|
|
974
|
-
"security",
|
|
975
|
-
"missing-testid",
|
|
976
|
-
"other",
|
|
977
|
-
])
|
|
978
|
-
.describe("Pick the closest — use 'other' only when nothing fits"),
|
|
1072
|
+
category: z.enum(FINDING_CATEGORIES).describe("Pick the closest — use 'other' only when nothing fits"),
|
|
979
1073
|
title: z.string().describe("One-line summary of the defect"),
|
|
980
1074
|
detail: z.string().describe("What happened, what was expected, and the evidence"),
|
|
981
1075
|
evidence: z
|
|
@@ -1044,9 +1138,13 @@ server.registerTool("scout_report", {
|
|
|
1044
1138
|
.enum(["minimal", "medium", "extensive"])
|
|
1045
1139
|
.default("medium")
|
|
1046
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."),
|
|
1047
1145
|
session: sessionParam,
|
|
1048
1146
|
},
|
|
1049
|
-
}, serializedPerSession("scout_report", async ({ force, level }, session) => {
|
|
1147
|
+
}, serializedPerSession("scout_report", async ({ force, level, history }, session) => {
|
|
1050
1148
|
try {
|
|
1051
1149
|
const eng = engineFor(session);
|
|
1052
1150
|
if (!eng.memory)
|
|
@@ -1095,6 +1193,7 @@ server.registerTool("scout_report", {
|
|
|
1095
1193
|
`Then call scout_report again. Pass force=true ONLY if the user explicitly capped the budget.`, session);
|
|
1096
1194
|
}
|
|
1097
1195
|
const { path: p, summary } = generateReport(eng.memory, eng.oracleLog.all, {
|
|
1196
|
+
history,
|
|
1098
1197
|
routesVisited: all.length - unvisited.length,
|
|
1099
1198
|
routesTotal: all.length,
|
|
1100
1199
|
designAudits: eng.designAuditCount,
|
|
@@ -1135,6 +1234,39 @@ server.registerTool("scout_resolve", {
|
|
|
1135
1234
|
return errorText(err);
|
|
1136
1235
|
}
|
|
1137
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
|
+
}));
|
|
1138
1270
|
server.registerTool("scout_close", {
|
|
1139
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.",
|
|
1140
1272
|
inputSchema: {
|
|
@@ -1213,7 +1345,13 @@ async function shutdown() {
|
|
|
1213
1345
|
await Promise.allSettled(liveTokenWrites.values());
|
|
1214
1346
|
for (const dir of liveDirs) {
|
|
1215
1347
|
try {
|
|
1216
|
-
fs.rmSync(path.join(dir,
|
|
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
|
+
}
|
|
1217
1355
|
}
|
|
1218
1356
|
catch (err) {
|
|
1219
1357
|
console.error(`[scenescout] could not remove the live view's token file in ${dir}: ${err instanceof Error ? err.message : String(err)}`);
|
package/dist/scan.js
CHANGED
|
@@ -340,6 +340,49 @@ export function scanProject(projectDir) {
|
|
|
340
340
|
notes,
|
|
341
341
|
};
|
|
342
342
|
}
|
|
343
|
+
/**
|
|
344
|
+
* Whether a saved login is still good, read from the expiry inside its own
|
|
345
|
+
* token. A stale storage state is otherwise discovered only by attaching and
|
|
346
|
+
* being told AUTH FAILED, after a role has been chosen and a browser started —
|
|
347
|
+
* and in one project every one of 168 saved logins had expired.
|
|
348
|
+
*
|
|
349
|
+
* Best-effort by design: a state whose token cannot be read is reported as
|
|
350
|
+
* nothing rather than as a problem, because plenty of apps do not use JWTs.
|
|
351
|
+
*/
|
|
352
|
+
export function describeAuthAge(file, nowMs = Date.now()) {
|
|
353
|
+
let raw;
|
|
354
|
+
try {
|
|
355
|
+
raw = fs.readFileSync(file, "utf8");
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return "";
|
|
359
|
+
}
|
|
360
|
+
const exp = jwtExpiry(raw);
|
|
361
|
+
if (exp === null)
|
|
362
|
+
return "";
|
|
363
|
+
const left = Math.round((exp - nowMs) / 60_000);
|
|
364
|
+
if (left <= 0)
|
|
365
|
+
return " (EXPIRED)";
|
|
366
|
+
return left < 30 ? ` (${left}m left)` : "";
|
|
367
|
+
}
|
|
368
|
+
/** The soonest `exp` of any JWT-looking value in the file, as milliseconds. */
|
|
369
|
+
function jwtExpiry(raw) {
|
|
370
|
+
let soonest = null;
|
|
371
|
+
for (const match of raw.matchAll(/eyJ[A-Za-z0-9_-]{4,}\.([A-Za-z0-9_-]{4,})\.[A-Za-z0-9_-]{4,}/g)) {
|
|
372
|
+
try {
|
|
373
|
+
const body = Buffer.from(match[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
|
|
374
|
+
const exp = JSON.parse(body).exp;
|
|
375
|
+
if (typeof exp !== "number")
|
|
376
|
+
continue;
|
|
377
|
+
const ms = exp * 1000;
|
|
378
|
+
soonest = soonest === null ? ms : Math.min(soonest, ms);
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
// Not a token we can read; the next match may be.
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return soonest;
|
|
385
|
+
}
|
|
343
386
|
export function formatScan(result) {
|
|
344
387
|
const lines = [
|
|
345
388
|
`Project: ${result.projectDir}`,
|
|
@@ -348,8 +391,11 @@ export function formatScan(result) {
|
|
|
348
391
|
`Playwright config: ${result.hasPlaywright ? "yes" : "no"} · data-testid convention: ${result.usesTestids ? "yes" : "not detected"}`,
|
|
349
392
|
`Auth storage states (${result.authStates.length}): ${result.authStates
|
|
350
393
|
.slice(0, 8)
|
|
351
|
-
.map((p) => path.basename(p))
|
|
394
|
+
.map((p) => `${path.basename(p)}${describeAuthAge(p)}`)
|
|
352
395
|
.join(", ") || "none"}`,
|
|
396
|
+
...(result.authStates.length > 0 && result.authStates.every((p) => describeAuthAge(p) === " (EXPIRED)")
|
|
397
|
+
? [` ⚠ Every saved login listed here has expired. scout_attach would land on a login page; regenerate them before attaching.`]
|
|
398
|
+
: []),
|
|
353
399
|
`Routes (${result.routes.length}):`,
|
|
354
400
|
...result.routes.slice(0, 60).map((r) => ` ${r}`),
|
|
355
401
|
...(result.routes.length > 60 ? [` … and ${result.routes.length - 60} more`] : []),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scenescout",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "SceneScout — exploratory UI testing for AI coding agents. An MCP server that gives any agent (Claude Code, Cursor, VS Code Copilot, Codex, Gemini CLI and others) a structured view of a running web app, always-on oracles, a network-level write policy, memory across runs and a gap-checked report.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "brunoboto96",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"mcp-check": "npm run build && npm run mcp-check:run",
|
|
72
72
|
"mcp-check:run": "tsx scripts/mcp-check.ts",
|
|
73
73
|
"test": "npm run build && npm run test:unit && npm run smoke:run && npm run mcp-check:run",
|
|
74
|
-
"test:unit": "npm run scan-test && npm run oracle-test && npm run policy-test && npm run fixture-test && npm run dispatch-test && npm run design-test && npm run contract-test && npm run memory-test && npm run install-test && npm run live-test && npm run demo-test && npm run hygiene-test",
|
|
74
|
+
"test:unit": "npm run scan-test && npm run oracle-test && npm run policy-test && npm run fixture-test && npm run dispatch-test && npm run design-test && npm run contract-test && npm run pace-test && npm run request-test && npm run settle-test && npm run claims-test && npm run verify-test && npm run brief-test && npm run lane-test && npm run memory-test && npm run install-test && npm run live-test && npm run demo-test && npm run hygiene-test",
|
|
75
75
|
"scan-test": "tsx scripts/scan-test.ts",
|
|
76
76
|
"oracle-test": "tsx --test scripts/oracle-test.ts",
|
|
77
77
|
"policy-test": "tsx --test scripts/policy-test.ts",
|
|
@@ -79,11 +79,18 @@
|
|
|
79
79
|
"dispatch-test": "tsx --test scripts/dispatch-test.ts",
|
|
80
80
|
"design-test": "tsx --test scripts/design-test.ts",
|
|
81
81
|
"contract-test": "tsx --test scripts/contract-test.ts",
|
|
82
|
+
"lane-test": "tsx --test scripts/lane-test.ts",
|
|
82
83
|
"memory-test": "tsx --test scripts/memory-test.ts",
|
|
83
84
|
"install-test": "tsx --test scripts/install-test.ts",
|
|
84
85
|
"live-test": "tsx --test scripts/live-test.ts",
|
|
85
86
|
"demo-test": "tsx --test scripts/demo-test.ts",
|
|
86
|
-
"hygiene-test": "tsx --test scripts/hygiene-test.ts"
|
|
87
|
+
"hygiene-test": "tsx --test scripts/hygiene-test.ts",
|
|
88
|
+
"pace-test": "tsx --test scripts/pace-test.ts",
|
|
89
|
+
"request-test": "tsx --test scripts/request-test.ts",
|
|
90
|
+
"settle-test": "tsx --test scripts/settle-test.ts",
|
|
91
|
+
"claims-test": "tsx --test scripts/claims-test.ts",
|
|
92
|
+
"verify-test": "tsx --test scripts/verify-test.ts",
|
|
93
|
+
"brief-test": "tsx --test scripts/brief-test.ts"
|
|
87
94
|
},
|
|
88
95
|
"dependencies": {
|
|
89
96
|
"@modelcontextprotocol/sdk": "^1.12.0",
|