scenescout 3.15.0 → 3.16.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 +75 -0
- package/README.md +64 -15
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +190 -14
- package/dist/ci-run.js +268 -52
- package/dist/cli.js +104 -43
- package/dist/commands.js +3 -2
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1147 -286
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +182 -39
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +136 -18
- package/dist/engine/claims.js +159 -3
- package/dist/engine/collector.js +561 -30
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/design.js +281 -38
- package/dist/engine/export.js +877 -0
- package/dist/engine/fingerprint.js +92 -4
- package/dist/engine/flow.js +18 -6
- package/dist/engine/forms.js +181 -18
- package/dist/engine/journey.js +29 -1
- package/dist/engine/lane.js +13 -3
- package/dist/engine/launch.js +45 -6
- package/dist/engine/limits.js +7 -0
- package/dist/engine/live-page.js +49 -2
- package/dist/engine/live.js +4 -1
- package/dist/engine/memory.js +501 -47
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +41 -1
- package/dist/engine/plain.js +268 -0
- package/dist/engine/png.js +127 -0
- package/dist/engine/policy.js +379 -9
- package/dist/engine/probes.js +3 -2
- package/dist/engine/profiles.js +45 -9
- package/dist/engine/project-folder.js +191 -0
- package/dist/engine/refresh.js +68 -3
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +241 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/settle.js +67 -0
- package/dist/engine/signed-in.js +256 -0
- package/dist/engine/tickets.js +671 -0
- package/dist/engine/unload.js +3 -2
- package/dist/export-run.js +633 -0
- package/dist/first-run.js +4 -0
- package/dist/installer.js +267 -3
- package/dist/intake.js +104 -0
- package/dist/login-run.js +250 -36
- package/dist/mcp-server.js +567 -57
- package/dist/playbook.js +5 -0
- package/package.json +6 -3
- package/skills/scenescout/SKILL.md +49 -16
package/dist/mcp-server.js
CHANGED
|
@@ -29,7 +29,10 @@
|
|
|
29
29
|
* a loopback-only live view shows what each one is looking at
|
|
30
30
|
* (`scenescout watch <project>`, engine/live.ts, ADR 7).
|
|
31
31
|
*/
|
|
32
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
33
|
+
import { spawn } from "node:child_process";
|
|
32
34
|
import fs from "node:fs";
|
|
35
|
+
import os from "node:os";
|
|
33
36
|
import path from "node:path";
|
|
34
37
|
import { fileURLToPath } from "node:url";
|
|
35
38
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -37,8 +40,11 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
37
40
|
import { ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
38
41
|
import { z } from "zod";
|
|
39
42
|
import { BrowserEngine } from "./engine/browser.js";
|
|
43
|
+
import { readyBrowser } from "./engine/launch.js";
|
|
44
|
+
import { defaultEngine } from "./browsers.js";
|
|
45
|
+
import { downloadBrowsers, presentBrowsers } from "./installer.js";
|
|
40
46
|
import { reapOrphanBrowsers } from "./engine/reaper.js";
|
|
41
|
-
import { FINDING_CATEGORIES, isWorthALook, MemoryStore, mergeableCategories, redactSecrets } from "./engine/memory.js";
|
|
47
|
+
import { describeMerge, FINDING_CATEGORIES, isWorthALook, MemoryStore, mergeableCategories, redactSecrets } from "./engine/memory.js";
|
|
42
48
|
import { DEDUP_ENV, DEDUP_MODES, redactKeys, secretValues } from "./engine/ci.js";
|
|
43
49
|
import { DedupJudge, planDedup, samplingAsk } from "./engine/dedup.js";
|
|
44
50
|
import { httpJudgeAsk } from "./ci-run.js";
|
|
@@ -48,18 +54,25 @@ import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
|
|
|
48
54
|
import { FIXTURE_KINDS } from "./engine/fixtures.js";
|
|
49
55
|
import { feedForSession, LIVE_ENV, writeStatusFile, LIVE_TOKEN_FILE, liveEngines, liveTokenFileName, pidAlive, statusFileName, LiveServer, StatusBoard, } from "./engine/live.js";
|
|
50
56
|
import { formatBriefs, MAX_LANES, planLanes } from "./engine/brief.js";
|
|
57
|
+
import { decideOpen, OPEN_CHOICES, OPEN_ENV, openChoiceFromEnv, openInBrowser } from "./engine/open.js";
|
|
51
58
|
import { DEFAULT_EXPIRY_MARGIN_MINUTES, DEFAULT_RUN_MINUTES, judgeProfileFile } from "./engine/expiry.js";
|
|
52
|
-
import {
|
|
53
|
-
import {
|
|
54
|
-
import {
|
|
59
|
+
import { parseLoginArgs } from "./engine/profiles.js";
|
|
60
|
+
import { LOGIN_WINDOW_MAX_MS, savedLine, startLoginWindow } from "./login-run.js";
|
|
61
|
+
import { LoginWindows, WAIT_SAYS } from "./engine/signed-in.js";
|
|
62
|
+
import { computeGaps, coverageView, formatRouteCoverage, generateReport, replayDocument, reportEvidence } from "./engine/report.js";
|
|
63
|
+
import { DEFAULT_REPORT_AUDIENCE, REPORT_AUDIENCES } from "./engine/plain.js";
|
|
55
64
|
import { describeVerdict, formatWorklist, unknownIds, VERDICTS, verifyWorklist } from "./engine/verify.js";
|
|
65
|
+
import { CRITERION_VERDICTS, findCriterion, formatCriteriaForLanes, formatReading, isTicketFileName, judgeCriterion, MAX_CRITERION_FINDINGS, MAX_REASON, MAX_TICKET_FILE_BYTES, MAX_TICKET_TEXT, MAX_TICKETS, NOT_TESTED_REASONS, parseTickets, TICKET_FILE_EXTENSIONS, } from "./engine/tickets.js";
|
|
56
66
|
import { ACTION_TIMEOUT_ENV, DEFAULT_ACTION_TIMEOUT_MS, DEFAULT_CRAWL_NAV_TIMEOUT_MS, DEFAULT_NAV_TIMEOUT_MS, LIMIT_BOUNDS, NAV_TIMEOUT_ENV, watchdogFor, } from "./engine/limits.js";
|
|
67
|
+
import { MAX_READ_POSTS, READ_POSTS_ENV } from "./engine/policy.js";
|
|
68
|
+
import { chooseProjectFolder, PROJECTS_DIR_ENV, workspaceFromRoots } from "./engine/project-folder.js";
|
|
57
69
|
import { RECORD_MAX_FRAMES, resolveFrame } from "./engine/replay.js";
|
|
58
70
|
import { describePace, normalizePace } from "./engine/settle.js";
|
|
59
71
|
import { needsTask, taskRefusal, TASK_MAX } from "./engine/task.js";
|
|
60
72
|
import { EXPLORE_PROMPT_ARGUMENTS, explorePrompt, loadPlaybook, PLAYBOOK_PROMPT, PLAYBOOK_TOOL, SERVER_INSTRUCTIONS } from "./playbook.js";
|
|
61
73
|
import { formatScan, scanProject } from "./scan.js";
|
|
62
|
-
import { CAPTURE_MARGIN, CAPTURES_DIRNAME, captureFileName, captureResultText, MAX_CAPTURE_MARGIN } from "./engine/capture.js";
|
|
74
|
+
import { CAPTURE_MARGIN, CAPTURES_DIRNAME, captureFileName, captureResultText, describePicture, EVIDENCE_ENV, EVIDENCE_LIMITS, EVIDENCE_MARGIN, EVIDENCE_MODES, evidenceFrame, evidenceSettings, findingPicturePath, MAX_CAPTURE_MARGIN, RECORD_ENV, recordChoice, returnsInline, } from "./engine/capture.js";
|
|
75
|
+
import { decodePng, fitPicture } from "./engine/png.js";
|
|
63
76
|
/** Live sessions: each name owns an independent BrowserEngine (browser + auth). */
|
|
64
77
|
const engines = new Map();
|
|
65
78
|
/**
|
|
@@ -254,8 +267,10 @@ function reportExtras(eng) {
|
|
|
254
267
|
designAudits: eng.memory?.auditsThisRun ?? eng.designAuditCount,
|
|
255
268
|
createdResources: eng.createdResources,
|
|
256
269
|
unvisitedRoutes: unvisited,
|
|
270
|
+
knownRoutes: all,
|
|
257
271
|
mode: eng.mode,
|
|
258
272
|
trustedEmbeds: [...eng.trustedEmbeds],
|
|
273
|
+
readPosts: eng.readPosts.map((e) => e.entry),
|
|
259
274
|
policyAttributed: eng.oracleLog.policyAttributed,
|
|
260
275
|
version: PKG_VERSION,
|
|
261
276
|
attachedSessions: [...engines.keys()],
|
|
@@ -334,6 +349,28 @@ function liveLine() {
|
|
|
334
349
|
return (`\nLive view: http://127.0.0.1:${liveAddress.port}/${liveAddress.token}/ — give this address to the user so they can watch every session ` +
|
|
335
350
|
`(current tool, page thumbnail, optional live stream). It opens on this machine only and cannot act on the run.`);
|
|
336
351
|
}
|
|
352
|
+
/** What each session's attach decided to open (engine/open.ts). scout_report reads it for the report. */
|
|
353
|
+
const openDecisions = new Map();
|
|
354
|
+
/** The live view is one address for every session, so it is opened once per server. */
|
|
355
|
+
let liveOpened = false;
|
|
356
|
+
/** Whether a result has named the setting that turns opening off; named once. */
|
|
357
|
+
let openSettingNamed = false;
|
|
358
|
+
/** Whether an attach has said why the live view was not opened; said once. */
|
|
359
|
+
let openWhySaid = false;
|
|
360
|
+
/** Open a target in the default browser and say so in a line for the tool result; a failure is said, never thrown. */
|
|
361
|
+
function openForUser(what, target) {
|
|
362
|
+
const outcome = openInBrowser(target, {
|
|
363
|
+
platform: process.platform,
|
|
364
|
+
spawn: (command, args, options) => spawn(command, args, options),
|
|
365
|
+
onError: (why) => logLine(`could not open ${what}: ${why}`),
|
|
366
|
+
});
|
|
367
|
+
if (!outcome.ok)
|
|
368
|
+
return `\nCould not open ${what}: ${outcome.why}.`;
|
|
369
|
+
if (openSettingNamed)
|
|
370
|
+
return `\nOpened ${what} in the default browser.`;
|
|
371
|
+
openSettingNamed = true;
|
|
372
|
+
return `\nOpened ${what} in the default browser (${OPEN_ENV}=none, or scout_attach {open: "none"}, turns this off).`;
|
|
373
|
+
}
|
|
337
374
|
function flushStatus(dir) {
|
|
338
375
|
// Not awaited on a tool call's hot path: status is best-effort observability
|
|
339
376
|
// and must never add blocking filesystem latency there. The writer queues
|
|
@@ -451,11 +488,17 @@ function serializedPerSession(label, fn, timeoutMs = 60_000) {
|
|
|
451
488
|
return sessionQueue.run(session, exec);
|
|
452
489
|
};
|
|
453
490
|
}
|
|
491
|
+
const requestExtra = new AsyncLocalStorage();
|
|
492
|
+
function asRequestExtra(value) {
|
|
493
|
+
return value && typeof value.sendNotification === "function" ? value : undefined;
|
|
494
|
+
}
|
|
454
495
|
/** Control-plane tools (scout_scan/scout_session/scout_close-all) don't target one browser — their own tiny chain keeps them off session queues without racing each other. */
|
|
455
496
|
let controlChain = Promise.resolve();
|
|
456
497
|
function serializedControl(fn) {
|
|
457
498
|
return (...args) => {
|
|
458
|
-
|
|
499
|
+
// The SDK passes the request's extra (progress token, notifications) after the arguments; it is kept for the call, as requestExtra.
|
|
500
|
+
const call = () => requestExtra.run(asRequestExtra(args[1]), () => fn(...args));
|
|
501
|
+
const run = controlChain.then(call, call);
|
|
459
502
|
controlChain = run.catch(() => { });
|
|
460
503
|
return run;
|
|
461
504
|
};
|
|
@@ -568,7 +611,7 @@ server.registerTool("scout_lane_brief", {
|
|
|
568
611
|
runMs: (runMinutes ?? DEFAULT_RUN_MINUTES) * 60_000,
|
|
569
612
|
marginMs: (expiryMarginMinutes ?? DEFAULT_EXPIRY_MARGIN_MINUTES) * 60_000,
|
|
570
613
|
role: eng.auth.role,
|
|
571
|
-
rerun:
|
|
614
|
+
rerun: eng.reloginCommand(eng.auth.role),
|
|
572
615
|
});
|
|
573
616
|
if (verdict.kind === "refuse")
|
|
574
617
|
return errorText(new Error(verdict.message));
|
|
@@ -578,7 +621,11 @@ server.registerTool("scout_lane_brief", {
|
|
|
578
621
|
const all = routes && routes.length > 0 ? routes : eng.allKnownRoutes();
|
|
579
622
|
const briefs = planLanes(all, lanes, { goal, mode: eng.mode, role: eng.role });
|
|
580
623
|
laneLedger.nameBriefed(briefs.map((b) => b.lane), (s) => engines.has(s));
|
|
581
|
-
|
|
624
|
+
// A run given tickets tells every lane which criteria it answers.
|
|
625
|
+
const criteria = eng.memory ? formatCriteriaForLanes(eng.memory.ticketsThisRun().tickets) : "";
|
|
626
|
+
return text(expiryNote +
|
|
627
|
+
formatBriefs(briefs, { goal, mode: eng.mode, role: eng.role, roleProfile: eng.auth.kind === "role" }) +
|
|
628
|
+
(criteria ? `\n${criteria}` : ""), session);
|
|
582
629
|
}
|
|
583
630
|
catch (err) {
|
|
584
631
|
return errorText(err);
|
|
@@ -645,7 +692,7 @@ server.registerTool("scout_lane_report", {
|
|
|
645
692
|
.map((u) => ` · ${u}`)
|
|
646
693
|
.join("\n") +
|
|
647
694
|
(unfiled.length > MAX_UNFILED_NAMED ? `\n … +${unfiled.length - MAX_UNFILED_NAMED} more` : "") +
|
|
648
|
-
`\nFile each with scout_finding (the same evidence), or
|
|
695
|
+
`\nFile each with scout_finding (the same evidence), or have the lane name the finding's id in the decision's "finding", before closing the lane's session. A judged defect that is never filed is not in the report.`
|
|
649
696
|
: "";
|
|
650
697
|
laneLedger.fold(lane, engines.get(lane)?.attached === true);
|
|
651
698
|
// A lane that lost its sign-in and re-attached from its role's profile
|
|
@@ -681,10 +728,14 @@ server.registerTool("scout_scan", {
|
|
|
681
728
|
}
|
|
682
729
|
}));
|
|
683
730
|
server.registerTool("scout_attach", {
|
|
684
|
-
description: "Launch a browser and attach to a running web app. First attach in this conversation and you have read neither the SceneScout skill nor scout_playbook? Call scout_playbook before this. Write policy is enforced at the NETWORK layer: mode='observe' blocks EVERY request that is not a GET (login and token refresh excepted) — choose it for a target that holds real data, where even an ordinary form submission would create a record; mode='read-only' (default) blocks destructive-labeled elements AND all PUT/PATCH/DELETE + destructive POSTs, but lets ordinary form POSTs through; mode='safe-write' allows creating data and permits updates/deletes ONLY on resources this session created (use when the user wants create/edit flows tested); mode='destructive' allows everything — ONLY when the user explicitly confirmed a disposable/seeded environment. Pass `role` to sign in with a login the user saved by `scenescout login <url> --role <name>`, or a Playwright storage-state JSON as storageStatePath. Pass `session` to keep MULTIPLE roles alive at once (one browser each, genuinely concurrent) for collaboration testing — target each directly with every tool's `session` param, or use scout_session to set which one is the default; coverage and findings merge into one project memory.",
|
|
731
|
+
description: "Launch a browser and attach to a running web app. First attach in this conversation and you have read neither the SceneScout skill nor scout_playbook? Call scout_playbook before this. Write policy is enforced at the NETWORK layer: mode='observe' blocks EVERY request that is not a GET (login and token refresh excepted, and POSTs the user named in readPosts) — choose it for a target that holds real data, where even an ordinary form submission would create a record; mode='read-only' (default) blocks destructive-labeled elements AND all PUT/PATCH/DELETE + destructive POSTs, but lets ordinary form POSTs through; mode='safe-write' allows creating data and permits updates/deletes ONLY on resources this session created (use when the user wants create/edit flows tested); mode='destructive' allows everything — ONLY when the user explicitly confirmed a disposable/seeded environment. Pass `role` to sign in with a login the user saved by `scenescout login <url> --role <name>`, or a Playwright storage-state JSON as storageStatePath. Pass `session` to keep MULTIPLE roles alive at once (one browser each, genuinely concurrent) for collaboration testing — target each directly with every tool's `session` param, or use scout_session to set which one is the default; coverage and findings merge into one project memory.",
|
|
685
732
|
inputSchema: {
|
|
686
733
|
url: z.string().describe("Base URL of the running app, e.g. http://localhost:3000"),
|
|
687
|
-
projectPath: z
|
|
734
|
+
projectPath: z
|
|
735
|
+
.string()
|
|
736
|
+
.optional()
|
|
737
|
+
.describe("Absolute path to the project (memory + report live in .scenescout/ here). Pass it whenever you have a project or working folder. " +
|
|
738
|
+
`Omitted: the client's workspace folder, else a folder per tested site under the user's documents folder (Documents/SceneScout/<host>/, or ${PROJECTS_DIR_ENV}), which the result names — tell the user where it is.`),
|
|
688
739
|
storageStatePath: z.string().optional().describe("Optional Playwright storage-state JSON path for authenticated exploration. Not with `role`."),
|
|
689
740
|
role: z
|
|
690
741
|
.string()
|
|
@@ -701,7 +752,7 @@ server.registerTool("scout_attach", {
|
|
|
701
752
|
browser: z
|
|
702
753
|
.enum(["chromium", "firefox", "webkit"])
|
|
703
754
|
.optional()
|
|
704
|
-
.describe("Browser to drive. Default: the SCENESCOUT_BROWSER environment variable, else chromium.
|
|
755
|
+
.describe("Browser to drive. Default: the SCENESCOUT_BROWSER environment variable, else chromium. A build that is not on disk is downloaded on this attach, once, except in CI or with SCENESCOUT_BROWSER_DOWNLOAD=off, where the attach names the command to run. Use firefox or webkit for a cross-browser pass; stay on chromium otherwise."),
|
|
705
756
|
viewportWidth: z.number().int().min(320).max(3840).optional().describe("Viewport width (default 1280); use e.g. 390 for a mobile pass"),
|
|
706
757
|
viewportHeight: z.number().int().min(480).max(2400).optional().describe("Viewport height (default 900)"),
|
|
707
758
|
objective: z
|
|
@@ -729,11 +780,22 @@ server.registerTool("scout_attach", {
|
|
|
729
780
|
.optional()
|
|
730
781
|
.describe('Origins of embedded frames (e.g. "https://pay.example.com") whose writes out of the app may go out — ONLY when the user named them, typically a provider in test mode, and only in safe-write mode. ' +
|
|
731
782
|
"Never add one yourself. Hostile input, repeated-click probes and uploads stay refused in them."),
|
|
783
|
+
readPosts: z
|
|
784
|
+
.array(z.string().max(300))
|
|
785
|
+
.max(MAX_READ_POSTS)
|
|
786
|
+
.optional()
|
|
787
|
+
.describe(`POST endpoints that only read, e.g. ["POST /api/search", "POST https://api.example.com/reports/query"], which observe mode then lets out — ONLY when the user named them. Never add one yourself, even when the gap ledger lists a refused POST: ask the user. ` +
|
|
788
|
+
`Exact paths; * stands for one path segment. Still refused when the path or body looks destructive or the body is a GraphQL mutation. Observe mode only. Default: the ${READ_POSTS_ENV} environment variable, else none.`),
|
|
732
789
|
record: z
|
|
733
790
|
.boolean()
|
|
734
|
-
.
|
|
791
|
+
.optional()
|
|
735
792
|
.describe("Keep a frame of the page after every action, under .scenescout/recordings/, and show it beside that step in report.html. " +
|
|
736
|
-
|
|
793
|
+
`Default: the ${RECORD_ENV} environment variable (on or off), else off: a recording is pictures of the app under test sitting in the project folder. Turn it on for QA work, where the run is evidence and not only a report.`),
|
|
794
|
+
evidence: z
|
|
795
|
+
.enum(EVIDENCE_MODES)
|
|
796
|
+
.optional()
|
|
797
|
+
.describe("What happens to the picture each scout_finding takes of what it is about. 'inline': kept under .scenescout/recordings/, shown in report.html, and returned in the scout_finding result so the conversation shows it. 'file': kept and shown in the report only. 'off': none taken. " +
|
|
798
|
+
`Default: the ${EVIDENCE_ENV} environment variable, else 'inline', or 'file' in a CI job. Pictures are bounded in size and in how many one session returns (see the configuration reference).`),
|
|
737
799
|
actionTimeoutMs: z
|
|
738
800
|
.number()
|
|
739
801
|
.int()
|
|
@@ -760,9 +822,20 @@ server.registerTool("scout_attach", {
|
|
|
760
822
|
"'judge': the rule first, then, for a filing the rule keeps apart from everything recorded, a model is asked whether it is one of the open findings on its page, and merges it when it says so. " +
|
|
761
823
|
"It needs ANTHROPIC_API_KEY or OPENAI_API_KEY in the server's environment, and sends each pair's titles, categories and evidence, and the page's path, to that provider — ONLY when the user asked for it. " +
|
|
762
824
|
"Applies to every session of the project until the run ends."),
|
|
825
|
+
open: z
|
|
826
|
+
.enum(OPEN_CHOICES)
|
|
827
|
+
.optional()
|
|
828
|
+
.describe(`Open the live view (on this attach) and/or report.html (when scout_report writes it) in the user's default browser: 'live', 'report', 'both' or 'none'. ` +
|
|
829
|
+
`Default: the ${OPEN_ENV} environment variable, else 'both' on a local desktop session (headed or headless) and 'none' in CI, over SSH, or with no display. ` +
|
|
830
|
+
"Pass it only when the user asked for something other than the default."),
|
|
763
831
|
},
|
|
764
|
-
}, serializedControl(async ({ url, projectPath, storageStatePath, role, mode, headed, browser, viewportWidth, viewportHeight, objective, task, record, trustedEmbeds, paceMs, actionTimeoutMs, navTimeoutMs, session, dedup, }) => {
|
|
832
|
+
}, serializedControl(async ({ url, projectPath, storageStatePath, role, mode, headed, browser, viewportWidth, viewportHeight, objective, task, record, evidence, trustedEmbeds, readPosts, paceMs, actionTimeoutMs, navTimeoutMs, session, dedup, open, }) => {
|
|
765
833
|
try {
|
|
834
|
+
// Settled first: a refusal leaves the default session as it was.
|
|
835
|
+
const folder = await projectFolderFor(url, projectPath);
|
|
836
|
+
if ("refused" in folder)
|
|
837
|
+
return errorText(new Error(folder.refused));
|
|
838
|
+
projectPath = folder.dir;
|
|
766
839
|
const target = session ?? activeName;
|
|
767
840
|
if (session) {
|
|
768
841
|
activeName = session;
|
|
@@ -824,15 +897,25 @@ server.registerTool("scout_attach", {
|
|
|
824
897
|
// Moving this session to another project leaves its old one; if it was
|
|
825
898
|
// the last session there, that run is over. Re-attaching to the SAME
|
|
826
899
|
// project is the same run, and keeps what the run has learned.
|
|
900
|
+
// Before anything changes: a SCENESCOUT_OPEN the server cannot use refuses the attach.
|
|
901
|
+
const opening = decideOpen(open ?? openChoiceFromEnv(process.env), {
|
|
902
|
+
env: process.env,
|
|
903
|
+
platform: process.platform,
|
|
904
|
+
});
|
|
827
905
|
const previous = eng.memory;
|
|
828
906
|
if (previous && previous !== store && ![...engines.values()].some((e) => e !== eng && e.memory === previous))
|
|
829
907
|
previous.endRun();
|
|
830
908
|
// Before the browser starts: a value it cannot use refuses the attach rather than being replaced.
|
|
831
909
|
const dedupNote = configureDedup(store, dedup);
|
|
910
|
+
const recording = recordChoice(record, process.env);
|
|
911
|
+
const pictures = evidenceSettings(evidence, process.env);
|
|
832
912
|
const viewport = viewportWidth && viewportHeight ? { width: viewportWidth, height: viewportHeight } : undefined;
|
|
913
|
+
// The first attach on a machine without the browser downloads it here, once, rather than failing with a command to run.
|
|
914
|
+
const browserNote = await readyBrowserFor({ engine: browser ?? defaultEngine(process.env), headed: headed ?? false }, requestExtra.getStore());
|
|
833
915
|
const out = await eng.attach({
|
|
834
916
|
url,
|
|
835
917
|
projectDir: projectPath,
|
|
918
|
+
projectChosen: folder.source === "default",
|
|
836
919
|
storageStatePath,
|
|
837
920
|
role,
|
|
838
921
|
mode,
|
|
@@ -846,8 +929,9 @@ server.registerTool("scout_attach", {
|
|
|
846
929
|
objective: objective ?? task,
|
|
847
930
|
paceMs,
|
|
848
931
|
task: objective ? task : undefined,
|
|
849
|
-
record,
|
|
932
|
+
record: recording,
|
|
850
933
|
trustedEmbeds,
|
|
934
|
+
readPosts,
|
|
851
935
|
actionTimeoutMs,
|
|
852
936
|
navTimeoutMs,
|
|
853
937
|
memoryStore: store,
|
|
@@ -859,18 +943,109 @@ server.registerTool("scout_attach", {
|
|
|
859
943
|
await ensureLive(eng.memory.dir);
|
|
860
944
|
writeStatus(target, "idle", "scout_attach");
|
|
861
945
|
}
|
|
946
|
+
// A new attach is a new count of pictures returned.
|
|
947
|
+
evidenceFor.set(eng, { ...pictures, shown: 0 });
|
|
948
|
+
openDecisions.set(target, opening);
|
|
949
|
+
// The address holds the token, and goes only to this machine's opener: the live view's loopback and token rules are unchanged.
|
|
950
|
+
let openNote = "";
|
|
951
|
+
if (opening.live && liveAddress && !liveOpened) {
|
|
952
|
+
liveOpened = true;
|
|
953
|
+
openNote = openForUser("the live view", `http://127.0.0.1:${liveAddress.port}/${liveAddress.token}/`);
|
|
954
|
+
}
|
|
955
|
+
else if (!opening.live && liveAddress && !openWhySaid) {
|
|
956
|
+
// Once per server, so a person wondering why no window appeared is told how to change it.
|
|
957
|
+
openWhySaid = true;
|
|
958
|
+
openNote = `\nNot opened in a browser (${opening.why}); scout_attach {open} or ${OPEN_ENV} changes that.`;
|
|
959
|
+
}
|
|
862
960
|
// Recording writes pictures of the app under test into the project, so
|
|
863
961
|
// a run doing it says where they go rather than leaving the person to
|
|
864
962
|
// find a folder of screenshots later.
|
|
865
|
-
const recordNote =
|
|
963
|
+
const recordNote = recording && eng.memory?.dir
|
|
866
964
|
? `\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.`
|
|
867
965
|
: "";
|
|
868
|
-
|
|
966
|
+
const picturesNote = pictures.mode !== "off" && eng.memory?.dir
|
|
967
|
+
? `\n📷 FINDING PICTURES: ${pictures.mode} (${pictures.source}): each scout_finding keeps a picture of what it names under ${path.join(eng.memory.dir, "recordings")}/ for report.html${pictures.mode === "inline" ? `, and returns the first ${pictures.inlineMax} in its result` : ""}.`
|
|
968
|
+
: "";
|
|
969
|
+
return text(browserNote +
|
|
970
|
+
out +
|
|
971
|
+
(folder.note ? `\n\n${folder.note}` : "") +
|
|
972
|
+
conflictNote +
|
|
973
|
+
recordNote +
|
|
974
|
+
picturesNote +
|
|
975
|
+
dedupNote +
|
|
976
|
+
describePace(eng.pace) +
|
|
977
|
+
(engines.size > 1 ? `\n${sessionLines()}` : "") +
|
|
978
|
+
liveLine() +
|
|
979
|
+
openNote, target);
|
|
869
980
|
}
|
|
870
981
|
catch (err) {
|
|
871
982
|
return errorText(err);
|
|
872
983
|
}
|
|
873
984
|
}));
|
|
985
|
+
/**
|
|
986
|
+
* readyBrowser with the real download, returning the line that opens the
|
|
987
|
+
* attach's answer when it downloaded (empty when it did not). Each line goes
|
|
988
|
+
* to stderr and, when the client asked for progress, as a progress
|
|
989
|
+
* notification; the last one is repeated while the download runs, so a client
|
|
990
|
+
* that extends its timeout on progress keeps waiting.
|
|
991
|
+
*/
|
|
992
|
+
async function readyBrowserFor(need, extra) {
|
|
993
|
+
const token = extra?._meta?.progressToken;
|
|
994
|
+
let progress = 0;
|
|
995
|
+
let last = "";
|
|
996
|
+
const notify = (message) => {
|
|
997
|
+
if (token === undefined || !extra)
|
|
998
|
+
return;
|
|
999
|
+
extra.sendNotification({ method: "notifications/progress", params: { progressToken: token, progress: ++progress, message } }).catch((err) => {
|
|
1000
|
+
logLine(`progress notification failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1001
|
+
});
|
|
1002
|
+
};
|
|
1003
|
+
const say = (line) => {
|
|
1004
|
+
last = line;
|
|
1005
|
+
logLine(line);
|
|
1006
|
+
notify(line);
|
|
1007
|
+
};
|
|
1008
|
+
const note = await readyBrowser(need, {
|
|
1009
|
+
env: process.env,
|
|
1010
|
+
present: presentBrowsers,
|
|
1011
|
+
say,
|
|
1012
|
+
download: async (targets) => {
|
|
1013
|
+
const heartbeat = setInterval(() => notify(last), 10_000);
|
|
1014
|
+
try {
|
|
1015
|
+
return await downloadBrowsers(targets, "stderr");
|
|
1016
|
+
}
|
|
1017
|
+
finally {
|
|
1018
|
+
clearInterval(heartbeat);
|
|
1019
|
+
}
|
|
1020
|
+
},
|
|
1021
|
+
});
|
|
1022
|
+
return note ? `${note}\n\n` : "";
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* The folder an attach keeps its files in (engine/project-folder.ts): the
|
|
1026
|
+
* projectPath given, else the client's workspace folder, else a folder per
|
|
1027
|
+
* tested site. Only a client that offers roots is asked for them.
|
|
1028
|
+
*/
|
|
1029
|
+
async function projectFolderFor(url, given) {
|
|
1030
|
+
let workspace = null;
|
|
1031
|
+
if (given === undefined && server.server.getClientCapabilities()?.roots) {
|
|
1032
|
+
try {
|
|
1033
|
+
workspace = workspaceFromRoots((await server.server.listRoots(undefined, { timeout: 3000 })).roots);
|
|
1034
|
+
}
|
|
1035
|
+
catch (err) {
|
|
1036
|
+
logLine(`the client offers a workspace but did not list it (${err.message}); using the default folder`);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
const userDirsFile = path.join(os.homedir(), ".config", "user-dirs.dirs");
|
|
1040
|
+
const userDirs = process.platform === "linux" && fs.existsSync(userDirsFile) ? fs.readFileSync(userDirsFile, "utf8") : undefined;
|
|
1041
|
+
return chooseProjectFolder({
|
|
1042
|
+
given,
|
|
1043
|
+
workspace,
|
|
1044
|
+
url,
|
|
1045
|
+
home: { platform: process.platform, homedir: os.homedir(), env: process.env, userDirs },
|
|
1046
|
+
exists: fs.existsSync,
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
874
1049
|
/** Keys in this process's environment, and anything shaped like one, taken out of a line before it is shown. */
|
|
875
1050
|
const withoutKeys = (text) => redactKeys(text, secretValues(process.env));
|
|
876
1051
|
/** A line for the operator, on stderr. */
|
|
@@ -904,6 +1079,61 @@ function configureDedup(store, asked) {
|
|
|
904
1079
|
store.dedupOff = undefined;
|
|
905
1080
|
return plan.note;
|
|
906
1081
|
}
|
|
1082
|
+
/** Each session's finding-picture settings from its last attach, and how many pictures its results have carried since. */
|
|
1083
|
+
const evidenceFor = new WeakMap();
|
|
1084
|
+
/**
|
|
1085
|
+
* Take, bound and keep a filed finding's picture (capture.ts decides whether
|
|
1086
|
+
* and of what; png.ts fits it). Returns the line the result adds and, when the
|
|
1087
|
+
* result carries it, the picture. A picture that cannot be taken or kept never
|
|
1088
|
+
* fails the filing, which is already recorded: the line says why there is none.
|
|
1089
|
+
*/
|
|
1090
|
+
async function findingPicture(eng, filed, ref) {
|
|
1091
|
+
const settings = evidenceFor.get(eng);
|
|
1092
|
+
const dir = eng.memory?.dir;
|
|
1093
|
+
if (!settings || !dir)
|
|
1094
|
+
return { line: "" };
|
|
1095
|
+
const { finding, isNew } = filed;
|
|
1096
|
+
const plan = evidenceFrame({
|
|
1097
|
+
mode: settings.mode,
|
|
1098
|
+
ref,
|
|
1099
|
+
pageOpen: eng.alive,
|
|
1100
|
+
isNew,
|
|
1101
|
+
hasPicture: !!finding.picture,
|
|
1102
|
+
regressed: !isNew && !!finding.regressedAt && finding.regressedAt === finding.foundAt,
|
|
1103
|
+
});
|
|
1104
|
+
if (!plan.take)
|
|
1105
|
+
return { line: settings.mode === "off" ? "" : `\nNo picture taken: ${plan.why}.` };
|
|
1106
|
+
try {
|
|
1107
|
+
const shot = await eng.captureEvidence(plan.frame === "element" ? plan.ref : undefined, EVIDENCE_MARGIN);
|
|
1108
|
+
const fitted = fitPicture(decodePng(shot.png), settings.maxPx, settings.maxBytes);
|
|
1109
|
+
if (!fitted)
|
|
1110
|
+
return { line: `\nNo picture kept: none small enough to be readable fits in ${Math.round(settings.maxBytes / 1024)} KB.` };
|
|
1111
|
+
const rel = findingPicturePath(eng.sessionKey, finding.id);
|
|
1112
|
+
const file = path.join(dir, rel);
|
|
1113
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1114
|
+
fs.writeFileSync(file, fitted.png);
|
|
1115
|
+
const picture = {
|
|
1116
|
+
width: fitted.width,
|
|
1117
|
+
height: fitted.height,
|
|
1118
|
+
frame: shot.frame,
|
|
1119
|
+
...(shot.label ? { label: shot.label } : {}),
|
|
1120
|
+
at: new Date().toISOString(),
|
|
1121
|
+
};
|
|
1122
|
+
eng.memory?.setPicture(finding.id, rel, picture);
|
|
1123
|
+
const said = `\n📷 Picture (${describePicture(picture)}${fitted.shrunk ? ", shrunk to fit" : ""}): ${file}${shot.note ? ` — ${shot.note}` : ""}`;
|
|
1124
|
+
if (!returnsInline(settings.mode, settings.shown, settings.inlineMax)) {
|
|
1125
|
+
const capped = settings.mode === "inline"
|
|
1126
|
+
? ` Not shown here: this session has returned its ${settings.inlineMax} (${EVIDENCE_LIMITS.inline.env} sets how many); it is in report.html.`
|
|
1127
|
+
: "";
|
|
1128
|
+
return { line: said + capped };
|
|
1129
|
+
}
|
|
1130
|
+
settings.shown += 1;
|
|
1131
|
+
return { line: said, image: { type: "image", data: fitted.png.toString("base64"), mimeType: "image/png" } };
|
|
1132
|
+
}
|
|
1133
|
+
catch (err) {
|
|
1134
|
+
return { line: `\nNo picture kept: ${err instanceof Error ? err.message : String(err)}` };
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
907
1137
|
/** What scout_finding tells the agent about what filing did. */
|
|
908
1138
|
function filedText(filed, category) {
|
|
909
1139
|
const { finding, isNew, promoted } = filed;
|
|
@@ -918,7 +1148,7 @@ function filedText(filed, category) {
|
|
|
918
1148
|
return `Merged into finding ${finding.id}, which was worth a look, and promoted to a defect: [${finding.severity}] ${finding.title}. It now counts among the report's findings.`;
|
|
919
1149
|
if (finding.regressedAt)
|
|
920
1150
|
return `⟳ REOPENED as a REGRESSION: finding ${finding.id} was previously resolved but the evidence reproduces again (seen in ${finding.runs} runs). Worth calling out to the user.`;
|
|
921
|
-
return `Not recorded as new: merged into existing finding ${finding.id} — [${finding.severity}] ${finding.title}${finding.evidence ? ` (evidence: ${finding.evidence.slice(0, 160)})` : " (no evidence)"}, filed as ${finding.category}, seen in ${finding.runs} runs. If yours is a different bug, file it again: under the category that says what is wrong if it is another kind of defect (a finding filed as ${category} merges only with one filed as ${mergeableCategories(category).join(" or ")}), or with evidence naming the request that failed for you (method and path) — two findings are kept apart when both name requests and none is shared.`;
|
|
1151
|
+
return `Not recorded as new: merged into existing finding ${finding.id} — [${finding.severity}] ${finding.title}${finding.evidence ? ` (evidence: ${finding.evidence.slice(0, 160)})` : " (no evidence)"}, filed as ${finding.category}, seen in ${finding.runs} run${finding.runs === 1 ? "" : "s"}${filed.merge?.sameRun ? " (already filed this run, so the count did not change)" : ""}.${filed.merge ? describeMerge(filed.merge, finding.convention) : ""} If yours is a different bug, file it again: under the category that says what is wrong if it is another kind of defect (a finding filed as ${category} merges only with one filed as ${mergeableCategories(category).join(" or ")}), or with evidence naming the request that failed for you (method and path) — two findings are kept apart when both name requests and none is shared.`;
|
|
922
1152
|
}
|
|
923
1153
|
function sessionLines() {
|
|
924
1154
|
const lines = ["Live sessions:"];
|
|
@@ -927,6 +1157,114 @@ function sessionLines() {
|
|
|
927
1157
|
}
|
|
928
1158
|
return lines.join("\n");
|
|
929
1159
|
}
|
|
1160
|
+
// Signing in from the conversation: a window the person signs in in, watched
|
|
1161
|
+
// in the background, so a call need not last as long as the person takes and
|
|
1162
|
+
// a second call picks up the same window. One per project and role.
|
|
1163
|
+
const pendingLogins = new LoginWindows(LOGIN_WINDOW_MAX_MS);
|
|
1164
|
+
/** How long one scout_login call waits for the person, by default and at most, in seconds. */
|
|
1165
|
+
const LOGIN_WAIT_DEFAULT_S = 120;
|
|
1166
|
+
const LOGIN_WAIT_MAX_S = 600;
|
|
1167
|
+
/** How often a waiting call tells a client that asked for progress that it is still going, in ms. */
|
|
1168
|
+
const LOGIN_PROGRESS_EVERY_MS = 10_000;
|
|
1169
|
+
server.registerTool("scout_login", {
|
|
1170
|
+
description: "Open a visible browser window for the USER to sign in to the app as a role, and save that sign-in for scout_attach { role }. " +
|
|
1171
|
+
"Use it when an attach is refused because no sign-in is saved for the role, or the saved one has expired. " +
|
|
1172
|
+
'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." ' +
|
|
1173
|
+
"The window saves once the user is back on the app with a new session (a round trip through a single sign-on provider is followed, not taken for the end) and closes. " +
|
|
1174
|
+
"Never type credentials into it yourself. Returns once signed in and saved, or after waitSeconds with the window still open: then call scout_login again with the same role to keep waiting. " +
|
|
1175
|
+
"Closing the window saves nothing. Needs a desktop: on a machine with no display, ask the user to run `scenescout login <url> --role <name>` where they can see the window.",
|
|
1176
|
+
inputSchema: {
|
|
1177
|
+
url: z.string().describe("Where to sign in: the app's address or its sign-in page, e.g. http://localhost:3000/login"),
|
|
1178
|
+
role: z.string().max(40).describe("The name to save the sign-in under, e.g. admin; scout_attach { role } signs in with it"),
|
|
1179
|
+
projectPath: z
|
|
1180
|
+
.string()
|
|
1181
|
+
.optional()
|
|
1182
|
+
.describe("Absolute path to the project (the sign-in is saved in .scenescout/auth/ here), as for scout_attach. Omitted: the same folder an attach with no projectPath uses for this site, which the result names."),
|
|
1183
|
+
browser: z
|
|
1184
|
+
.enum(["chromium", "firefox", "webkit"])
|
|
1185
|
+
.optional()
|
|
1186
|
+
.describe("Browser to open. Default: the SCENESCOUT_BROWSER environment variable, else chromium"),
|
|
1187
|
+
successUrl: z
|
|
1188
|
+
.string()
|
|
1189
|
+
.max(500)
|
|
1190
|
+
.optional()
|
|
1191
|
+
.describe("Only when the user says how to tell: signed in once the URL's path contains this, or the URL starts with it (an absolute URL), instead of when a new session appears"),
|
|
1192
|
+
waitSeconds: z
|
|
1193
|
+
.number()
|
|
1194
|
+
.int()
|
|
1195
|
+
.min(1)
|
|
1196
|
+
.max(LOGIN_WAIT_MAX_S)
|
|
1197
|
+
.optional()
|
|
1198
|
+
.describe(`How long this call waits for the user before returning with the window still open (default ${LOGIN_WAIT_DEFAULT_S})`),
|
|
1199
|
+
},
|
|
1200
|
+
}, async (args, extra) => {
|
|
1201
|
+
try {
|
|
1202
|
+
// The folder an attach with no projectPath would use, so the attach after this finds the sign-in.
|
|
1203
|
+
const folder = await projectFolderFor(args.url, args.projectPath);
|
|
1204
|
+
if ("refused" in folder)
|
|
1205
|
+
return errorText(new Error(folder.refused));
|
|
1206
|
+
const projectDir = path.resolve(folder.dir);
|
|
1207
|
+
const where = folder.note ? `\n\n${folder.note}` : "";
|
|
1208
|
+
const parsed = parseLoginArgs([args.url, "--role", args.role, ...(args.browser ? ["--browser", args.browser] : []), ...(args.successUrl ? ["--success-url", args.successUrl] : [])], projectDir);
|
|
1209
|
+
if (!parsed.ok)
|
|
1210
|
+
return errorText(new Error(parsed.error));
|
|
1211
|
+
const options = { ...parsed.options, projectDir };
|
|
1212
|
+
const key = `${options.projectDir}\0${options.role}`;
|
|
1213
|
+
const { window: pending, resumed } = await pendingLogins.get(key, () => startLoginWindow(options));
|
|
1214
|
+
const waitMs = (args.waitSeconds ?? LOGIN_WAIT_DEFAULT_S) * 1000;
|
|
1215
|
+
const progressToken = extra._meta?.progressToken;
|
|
1216
|
+
let ticks = 0;
|
|
1217
|
+
const ticker = progressToken !== undefined
|
|
1218
|
+
? setInterval(() => {
|
|
1219
|
+
ticks += 1;
|
|
1220
|
+
const p = pending.progress();
|
|
1221
|
+
void extra
|
|
1222
|
+
.sendNotification({
|
|
1223
|
+
method: "notifications/progress",
|
|
1224
|
+
params: {
|
|
1225
|
+
progressToken,
|
|
1226
|
+
progress: ticks,
|
|
1227
|
+
message: `Waiting for the sign-in as "${options.role}": ${p.reason === "starting" ? "the window is opening" : WAIT_SAYS[p.reason]}`,
|
|
1228
|
+
},
|
|
1229
|
+
})
|
|
1230
|
+
.catch((err) => console.error(`[scenescout] scout_login progress: ${err instanceof Error ? err.message : String(err)}`));
|
|
1231
|
+
}, LOGIN_PROGRESS_EVERY_MS)
|
|
1232
|
+
: undefined;
|
|
1233
|
+
let timer;
|
|
1234
|
+
let onAbort;
|
|
1235
|
+
const outcome = await Promise.race([
|
|
1236
|
+
pending.done,
|
|
1237
|
+
new Promise((resolve) => {
|
|
1238
|
+
timer = setTimeout(() => resolve("waiting"), waitMs);
|
|
1239
|
+
}),
|
|
1240
|
+
new Promise((resolve) => {
|
|
1241
|
+
onAbort = () => resolve("cancelled");
|
|
1242
|
+
extra.signal.addEventListener("abort", onAbort, { once: true });
|
|
1243
|
+
}),
|
|
1244
|
+
]).finally(() => {
|
|
1245
|
+
clearTimeout(timer);
|
|
1246
|
+
if (ticker)
|
|
1247
|
+
clearInterval(ticker);
|
|
1248
|
+
if (onAbort)
|
|
1249
|
+
extra.signal.removeEventListener("abort", onAbort);
|
|
1250
|
+
});
|
|
1251
|
+
const differs = resumed && pending.url !== options.url ? ` (the window was opened at ${pending.url} by an earlier call; that one is still the one being watched)` : "";
|
|
1252
|
+
if (outcome === "waiting" || outcome === "cancelled") {
|
|
1253
|
+
const p = pending.progress();
|
|
1254
|
+
return text(`Still waiting for the user to sign in as "${options.role}"${differs}: ${p.reason === "starting" ? "the window is opening" : WAIT_SAYS[p.reason]}. ` +
|
|
1255
|
+
`The window stays open for up to ${LOGIN_WINDOW_MAX_MS / 60_000} minutes from when it opened. Call scout_login again with the same role to keep waiting, once the user says they are done or to check.` +
|
|
1256
|
+
where, activeName);
|
|
1257
|
+
}
|
|
1258
|
+
// This call reports the outcome; the next call for the role opens a new window.
|
|
1259
|
+
pendingLogins.reported(key, pending);
|
|
1260
|
+
if (!outcome.ok)
|
|
1261
|
+
return errorText(new Error(`nothing was saved for role "${options.role}": ${outcome.error}`));
|
|
1262
|
+
return text(`${outcome.detected}\n${savedLine(options, outcome.saved)}${where}`, activeName);
|
|
1263
|
+
}
|
|
1264
|
+
catch (err) {
|
|
1265
|
+
return errorText(err);
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
930
1268
|
server.registerTool("scout_session", {
|
|
931
1269
|
description: "List live sessions, or set which one is the DEFAULT (used by any tool call that omits `session`). Prefer passing `session` directly on each tool call for multi-role work — that's what lets concurrent dispatch happen; scout_session is for sequential convenience (skip repeating `session` on every call) and for checking what's live. Both browsers stay live and authenticated regardless of which is default — re-snapshot a session after a break to see what changed while it was away.",
|
|
932
1270
|
inputSchema: {
|
|
@@ -976,7 +1314,7 @@ server.registerTool("scout_session", {
|
|
|
976
1314
|
}
|
|
977
1315
|
}));
|
|
978
1316
|
server.registerTool("scout_snapshot", {
|
|
979
|
-
description: "Capture the current page state: URL, state fingerprint, a one-line summary of the main area's heading and text, interactable elements with refs (e1, e2, …) and their state (pressed, selected, checked, expanded, current), what the page announces (alert and status regions, by their text), geometry issues, coverage, and oracle violations since the last action. Re-snapshotting
|
|
1317
|
+
description: "Capture the current page state: URL, state fingerprint, a one-line summary of the main area's heading and text, interactable elements with refs (e1, e2, …) and their state (pressed, selected, checked, expanded, current), what the page announces (alert and status regions, by their text), geometry issues, coverage, and oracle violations since the last action. Re-snapshotting a route returns a DIFF against its last snapshot, even after visiting elsewhere, and another tab of the same screen diffs against that screen's last tab; refs stay stable, including across a search or filter that rewrites only the query string. On a dense page, says what past the element cap was cut. Cheap — prefer this over screenshots.",
|
|
980
1318
|
inputSchema: {
|
|
981
1319
|
full: z.boolean().default(false).describe("Force a full element list instead of a diff"),
|
|
982
1320
|
session: sessionParam,
|
|
@@ -990,9 +1328,13 @@ server.registerTool("scout_snapshot", {
|
|
|
990
1328
|
}
|
|
991
1329
|
}));
|
|
992
1330
|
server.registerTool("scout_crawl", {
|
|
993
|
-
description: "Engine-side route sweep in ONE call: visits each path (default: all known routes not yet visited), records states into coverage memory, and returns a per-route health summary (HTTP status, element count, oracle violations, dead-ends, auth-redirects). Navigation-only — safe in read-only mode. Use this FIRST for broad coverage; explore interactively only where it flags problems or where journeys matter.",
|
|
1331
|
+
description: "Engine-side route sweep in ONE call: visits each path (default: all known routes not yet visited), records states into coverage memory, and returns a per-route health summary (HTTP status, element count, what the main area holds, oracle violations, dead-ends, auth-redirects, and ERROR-VIEW or STILL-LOADING for a main area showing only an alert or a loading placeholder). Navigation-only — safe in read-only mode. Use this FIRST for broad coverage; explore interactively only where it flags problems or where journeys matter.",
|
|
994
1332
|
inputSchema: {
|
|
995
|
-
paths: z
|
|
1333
|
+
paths: z
|
|
1334
|
+
.array(z.string())
|
|
1335
|
+
.max(150)
|
|
1336
|
+
.optional()
|
|
1337
|
+
.describe("Paths to visit, e.g. ['/orders','/settings'], or full URLs on the attached origin. A path resolves from the origin's root, whatever page the session attached on. Omit to crawl all unvisited known routes."),
|
|
996
1338
|
session: sessionParam,
|
|
997
1339
|
},
|
|
998
1340
|
}, serializedPerSession("scout_crawl", async ({ paths }, session) => {
|
|
@@ -1004,7 +1346,7 @@ server.registerTool("scout_crawl", {
|
|
|
1004
1346
|
}
|
|
1005
1347
|
}, 600_000));
|
|
1006
1348
|
server.registerTool("scout_run_plan", {
|
|
1007
|
-
description: "Execute up to 20 actions in ONE call — use for mechanical sequences (fill a form, walk a wizard) so each step doesn't cost a round-trip. Targets resolve at execution time by semantic locator: 'testid=…', 'text=…', 'label=…' or 'role=button[name=Save]' (never snapshot refs). The same steps, saved to .scenescout/flows/<name>.json with expect-text / expect-url / expect-request steps added, are replayed by `scenescout check` on every pull request. An `upload` step attaches a file as scout_upload does (target required — the file input or the control that opens its chooser; value = a fixture kind or a project-relative path). The plan ABORTS at the first NEW oracle violation, policy refusal, or failed step, returning a transcript of how far it got; repeats of already-reported violations do not abort (they stay logged for the report).",
|
|
1349
|
+
description: "Execute up to 20 actions in ONE call — use for mechanical sequences (fill a form, walk a wizard) so each step doesn't cost a round-trip. Targets resolve at execution time by semantic locator: 'testid=…', 'text=…', 'label=…' or 'role=button[name=Save]' (never snapshot refs). The same steps, saved to .scenescout/flows/<name>.json with expect-text / expect-url / expect-request steps added, are replayed by `scenescout check` on every pull request. An `upload` step attaches a file as scout_upload does (target required — the file input or the control that opens its chooser; value = a fixture kind or a project-relative path). The plan ABORTS at the first NEW oracle violation, policy refusal, or failed step, returning a transcript of how far it got; repeats of already-reported violations do not abort (they stay logged for the report). For a sweep of independent steps (tabs, filters, pages) pass onViolation \"continue\": a new error status or its console echo is listed on its step's line and the plan goes on; a failed step, a policy refusal or any other violation still stops it. A select step whose value names no option fails at once, listing the options.",
|
|
1008
1350
|
inputSchema: {
|
|
1009
1351
|
steps: z
|
|
1010
1352
|
.array(z.object({
|
|
@@ -1022,30 +1364,39 @@ server.registerTool("scout_run_plan", {
|
|
|
1022
1364
|
}))
|
|
1023
1365
|
.min(1)
|
|
1024
1366
|
.max(20),
|
|
1367
|
+
onViolation: z
|
|
1368
|
+
.enum(["stop", "continue"])
|
|
1369
|
+
.default("stop")
|
|
1370
|
+
.describe("stop (default): end the plan at the first new oracle violation, right for a form flow whose steps depend on each other. continue: list a new http_error or console_error on its step's line and run the next step, for a sweep of independent steps"),
|
|
1025
1371
|
task: taskParam,
|
|
1026
1372
|
objective: legacyObjectiveParam,
|
|
1027
1373
|
session: sessionParam,
|
|
1028
1374
|
},
|
|
1029
|
-
}, serializedPerSession("scout_run_plan", async ({ steps }, session) => {
|
|
1375
|
+
}, serializedPerSession("scout_run_plan", async ({ steps, onViolation }, session) => {
|
|
1030
1376
|
try {
|
|
1031
|
-
return text(await engineFor(session).runPlan(steps), session);
|
|
1377
|
+
return text(await engineFor(session).runPlan(steps, onViolation ?? "stop"), session);
|
|
1032
1378
|
}
|
|
1033
1379
|
catch (err) {
|
|
1034
1380
|
return errorText(err);
|
|
1035
1381
|
}
|
|
1036
1382
|
}, 240_000));
|
|
1383
|
+
const leaveParam = z
|
|
1384
|
+
.boolean()
|
|
1385
|
+
.optional()
|
|
1386
|
+
.describe("How to answer if the page asks to confirm leaving (a beforeunload prompt over unsent input): true leaves and discards that input, false stays. Omitted, observe and read-only stay and other modes leave. The result says when the page asked.");
|
|
1037
1387
|
server.registerTool("scout_click", {
|
|
1038
1388
|
description: "Click an element by its ref from the latest scout_snapshot. Returns the outcome plus any oracle violations triggered. clicks=2 (or 3) probes IMPATIENT-USER behaviour: a rapid multi-click that fires the same state-changing request twice means the control is not guarded against double submission (button stays enabled, endpoint not idempotent) — use it on every important submit/create button once; the result says explicitly whether duplicates fired.",
|
|
1039
1389
|
inputSchema: {
|
|
1040
1390
|
ref: z.string().describe("Element ref, e.g. e12"),
|
|
1041
1391
|
clicks: z.number().int().min(1).max(3).default(1).describe("1 = normal; 2-3 = rapid repeated clicks (double-submit probe)"),
|
|
1392
|
+
leave: leaveParam,
|
|
1042
1393
|
task: taskParam,
|
|
1043
1394
|
objective: legacyObjectiveParam,
|
|
1044
1395
|
session: sessionParam,
|
|
1045
1396
|
},
|
|
1046
|
-
}, serializedPerSession("scout_click", async ({ ref, clicks }, session) => {
|
|
1397
|
+
}, serializedPerSession("scout_click", async ({ ref, clicks, leave }, session) => {
|
|
1047
1398
|
try {
|
|
1048
|
-
return text(await engineFor(session).click(ref, clicks ?? 1), session);
|
|
1399
|
+
return text(await engineFor(session).click(ref, clicks ?? 1, leave), session);
|
|
1049
1400
|
}
|
|
1050
1401
|
catch (err) {
|
|
1051
1402
|
return errorText(err);
|
|
@@ -1121,10 +1472,10 @@ server.registerTool("scout_hover", {
|
|
|
1121
1472
|
}
|
|
1122
1473
|
}));
|
|
1123
1474
|
server.registerTool("scout_select", {
|
|
1124
|
-
description: "Select an option in a <select> by ref.",
|
|
1475
|
+
description: "Select an option in a <select> by ref. The value is matched against the options before anything is picked: an exact value, an exact label, either ignoring case, then a label it starts with. A value matching no option, or several, is refused at once with the options listed.",
|
|
1125
1476
|
inputSchema: {
|
|
1126
1477
|
ref: z.string(),
|
|
1127
|
-
value: z.string().describe("Option value or label"),
|
|
1478
|
+
value: z.string().describe("Option value or label (or the start of a label, when only one option has it)"),
|
|
1128
1479
|
task: taskParam,
|
|
1129
1480
|
objective: legacyObjectiveParam,
|
|
1130
1481
|
session: sessionParam,
|
|
@@ -1138,23 +1489,24 @@ server.registerTool("scout_select", {
|
|
|
1138
1489
|
}
|
|
1139
1490
|
}));
|
|
1140
1491
|
server.registerTool("scout_navigate", {
|
|
1141
|
-
description: "Navigate to a
|
|
1492
|
+
description: "Navigate to a path on the attached origin (e.g. '/orders') or a full URL on it. A path resolves from the origin's root, whatever page the session attached on. Also supports 'back' via scout_back.",
|
|
1142
1493
|
inputSchema: {
|
|
1143
1494
|
target: z.string().describe("Absolute URL or path like /settings"),
|
|
1495
|
+
leave: leaveParam,
|
|
1144
1496
|
task: taskParam,
|
|
1145
1497
|
objective: legacyObjectiveParam,
|
|
1146
1498
|
session: sessionParam,
|
|
1147
1499
|
},
|
|
1148
|
-
}, serializedPerSession("scout_navigate", async ({ target }, session) => {
|
|
1500
|
+
}, serializedPerSession("scout_navigate", async ({ target, leave }, session) => {
|
|
1149
1501
|
try {
|
|
1150
|
-
return text(await engineFor(session).navigate(target), session);
|
|
1502
|
+
return text(await engineFor(session).navigate(target, leave), session);
|
|
1151
1503
|
}
|
|
1152
1504
|
catch (err) {
|
|
1153
1505
|
return errorText(err);
|
|
1154
1506
|
}
|
|
1155
1507
|
}));
|
|
1156
1508
|
server.registerTool("scout_request", {
|
|
1157
|
-
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.",
|
|
1509
|
+
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 — its first 2000 characters, or the part named by select (one JSON value by path) or offset/limit (a window of characters). 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.",
|
|
1158
1510
|
inputSchema: {
|
|
1159
1511
|
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"),
|
|
1160
1512
|
method: z.enum(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]).optional().describe("Default GET"),
|
|
@@ -1163,13 +1515,48 @@ server.registerTool("scout_request", {
|
|
|
1163
1515
|
.record(z.string().max(2000))
|
|
1164
1516
|
.optional()
|
|
1165
1517
|
.describe("Extra headers. One given here wins over the app's own, which is how a session tests a different or absent credential."),
|
|
1518
|
+
select: z
|
|
1519
|
+
.string()
|
|
1520
|
+
.max(500)
|
|
1521
|
+
.optional()
|
|
1522
|
+
.describe('Return one value of a JSON response body by its dotted path, e.g. "stats.open" or "items.0.name", pretty-printed and up to 8000 characters. A path that is not there says which keys are.'),
|
|
1523
|
+
offset: z
|
|
1524
|
+
.number()
|
|
1525
|
+
.int()
|
|
1526
|
+
.min(0)
|
|
1527
|
+
.max(1_000_000)
|
|
1528
|
+
.optional()
|
|
1529
|
+
.describe("Return the body (or the selected value) from this character on. The body is cut at 2000 characters by default; the result names the next offset."),
|
|
1530
|
+
limit: z
|
|
1531
|
+
.number()
|
|
1532
|
+
.int()
|
|
1533
|
+
.min(1)
|
|
1534
|
+
.max(8000)
|
|
1535
|
+
.optional()
|
|
1536
|
+
.describe("How many characters to return with offset or select. Default 2000, or 8000 for a select with no offset."),
|
|
1166
1537
|
task: taskParam,
|
|
1167
1538
|
objective: legacyObjectiveParam,
|
|
1168
1539
|
session: sessionParam,
|
|
1169
1540
|
},
|
|
1170
1541
|
}, serializedPerSession("scout_request", async (args, session) => {
|
|
1171
1542
|
try {
|
|
1172
|
-
|
|
1543
|
+
const view = { select: args.select, offset: args.offset, limit: args.limit };
|
|
1544
|
+
return text(await engineFor(session).apiRequest({ method: args.method, path: args.path, body: args.body, headers: args.headers, view }), session);
|
|
1545
|
+
}
|
|
1546
|
+
catch (err) {
|
|
1547
|
+
return errorText(err);
|
|
1548
|
+
}
|
|
1549
|
+
}));
|
|
1550
|
+
server.registerTool("scout_network", {
|
|
1551
|
+
description: "List the data requests (fetch and XHR) the current page made since its document loaded: method, path, status and time, oldest first, each marked with the route it was sent from when a client-side route change moved the page since. Use it when the page and the server seem to disagree — an empty list where the API has data, a stale value after a save — to tell a request that failed, one still pending and one that never ran apart. Read-only: it lists what the browser already saw and sends nothing. Credentials in query strings are redacted. A full page load starts a new list; scout_request's own calls are marked.",
|
|
1552
|
+
inputSchema: {
|
|
1553
|
+
contains: z.string().max(200).optional().describe("Only requests whose path contains this text, e.g. /api/things"),
|
|
1554
|
+
limit: z.number().int().min(1).max(200).optional().describe("How many of the newest to list. Default 40."),
|
|
1555
|
+
session: sessionParam,
|
|
1556
|
+
},
|
|
1557
|
+
}, serializedPerSession("scout_network", async (args, session) => {
|
|
1558
|
+
try {
|
|
1559
|
+
return text(engineFor(session).listPageRequests({ contains: args.contains, limit: args.limit }), session);
|
|
1173
1560
|
}
|
|
1174
1561
|
catch (err) {
|
|
1175
1562
|
return errorText(err);
|
|
@@ -1177,10 +1564,10 @@ server.registerTool("scout_request", {
|
|
|
1177
1564
|
}));
|
|
1178
1565
|
server.registerTool("scout_back", {
|
|
1179
1566
|
description: "Go back in browser history (tests back-button resilience).",
|
|
1180
|
-
inputSchema: { task: taskParam, objective: legacyObjectiveParam, session: sessionParam },
|
|
1181
|
-
}, serializedPerSession("scout_back", async (
|
|
1567
|
+
inputSchema: { leave: leaveParam, task: taskParam, objective: legacyObjectiveParam, session: sessionParam },
|
|
1568
|
+
}, serializedPerSession("scout_back", async ({ leave }, session) => {
|
|
1182
1569
|
try {
|
|
1183
|
-
return text(await engineFor(session).goBack(), session);
|
|
1570
|
+
return text(await engineFor(session).goBack(leave), session);
|
|
1184
1571
|
}
|
|
1185
1572
|
catch (err) {
|
|
1186
1573
|
return errorText(err);
|
|
@@ -1322,7 +1709,7 @@ server.registerTool("scout_capture", {
|
|
|
1322
1709
|
}
|
|
1323
1710
|
}));
|
|
1324
1711
|
server.registerTool("scout_finding", {
|
|
1325
|
-
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.",
|
|
1712
|
+
description: "Record a structured finding (bug, UX issue, or improvement). Deduplicates across runs; automatically captures the recent action trace as the repro, and a picture of what it is about (the element `ref` names, else the viewport), kept for report.html and returned in this result as an image. 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.",
|
|
1326
1713
|
inputSchema: {
|
|
1327
1714
|
severity: z.enum(["high", "medium", "low"]),
|
|
1328
1715
|
category: z.enum(FINDING_CATEGORIES).describe("Pick the closest — use 'other' only when nothing fits"),
|
|
@@ -1338,9 +1725,14 @@ server.registerTool("scout_finding", {
|
|
|
1338
1725
|
.max(LANE_CONVENTION_MAX)
|
|
1339
1726
|
.optional()
|
|
1340
1727
|
.describe("Only for a WORTH-A-LOOK finding: the observation is real, and it is a defect only under a convention of this project you cannot see. Name that convention, e.g. 'a 4px spacing scale' or 'test ids on every control'. The report lists it under \"Worth a look\", apart from the defects, and does not count it as one. Not for \"I could not tell\": leave that unfiled or look closer. Omit for a defect."),
|
|
1728
|
+
ref: z
|
|
1729
|
+
.string()
|
|
1730
|
+
.max(20)
|
|
1731
|
+
.optional()
|
|
1732
|
+
.describe("The ref, from the latest scout_snapshot, of the element the finding is about. Its picture (the element plus a margin) is kept with the finding and shown in report.html. Omit it and the picture is the viewport."),
|
|
1341
1733
|
session: sessionParam,
|
|
1342
1734
|
},
|
|
1343
|
-
}, serializedPerSession("scout_finding", async ({ severity, category, title, detail, evidence, convention, }, session) => {
|
|
1735
|
+
}, serializedPerSession("scout_finding", async ({ severity, category, title, detail, evidence, convention, ref, }, session) => {
|
|
1344
1736
|
try {
|
|
1345
1737
|
const eng = engineFor(session);
|
|
1346
1738
|
if (!eng.memory)
|
|
@@ -1358,34 +1750,40 @@ server.registerTool("scout_finding", {
|
|
|
1358
1750
|
});
|
|
1359
1751
|
if (filed.judgeError)
|
|
1360
1752
|
logLine(`dedup judge: ${filed.judgeError}; the rule decided`);
|
|
1361
|
-
|
|
1753
|
+
const picture = await findingPicture(eng, filed, ref);
|
|
1754
|
+
const result = text(filedText(filed, category) + picture.line, session);
|
|
1755
|
+
if (picture.image)
|
|
1756
|
+
result.content.push(picture.image);
|
|
1757
|
+
return result;
|
|
1362
1758
|
}
|
|
1363
1759
|
catch (err) {
|
|
1364
1760
|
return errorText(err);
|
|
1365
1761
|
}
|
|
1366
1762
|
}));
|
|
1367
1763
|
server.registerTool("scout_coverage", {
|
|
1368
|
-
description: "Show exploration coverage: states visited
|
|
1369
|
-
inputSchema: {
|
|
1370
|
-
|
|
1764
|
+
description: "Show exploration coverage: states visited, which elements remain unexercised, which options of a dropdown used this run no session has chosen yet, and which forms seen this run no session has submitted with every text field blank. Use to decide where to explore next and when the level's budget is satisfied. 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 one lane is not handed another's gaps; scope:\"project\" shows every session's, each form and route tagged with the sessions that saw it.",
|
|
1765
|
+
inputSchema: {
|
|
1766
|
+
scope: z
|
|
1767
|
+
.enum(["session", "project"])
|
|
1768
|
+
.optional()
|
|
1769
|
+
.describe("'session': only the routes this session reached this run and the forms it saw. 'project': every route in the memory, across runs and sessions. Default: 'session' when other sessions share this project, else 'project'."),
|
|
1770
|
+
session: sessionParam,
|
|
1771
|
+
},
|
|
1772
|
+
}, serializedPerSession("scout_coverage", async (args, session) => {
|
|
1371
1773
|
try {
|
|
1372
1774
|
const eng = engineFor(session);
|
|
1373
|
-
|
|
1775
|
+
const memory = eng.memory;
|
|
1776
|
+
if (!memory)
|
|
1374
1777
|
throw new Error("Not attached.");
|
|
1375
|
-
const
|
|
1778
|
+
const shared = [...engines.values()].some((e) => e !== eng && e.memory === memory);
|
|
1376
1779
|
const unvisited = eng.unvisitedKnownRoutes();
|
|
1377
1780
|
const lines = [
|
|
1378
|
-
...(
|
|
1781
|
+
...(memory.lastSaveError
|
|
1379
1782
|
? [
|
|
1380
|
-
`⚠ MEMORY WRITE FAILING: ${
|
|
1783
|
+
`⚠ MEMORY WRITE FAILING: ${memory.lastSaveError} — coverage/findings since the last successful write are NOT persisted to disk. If this doesn't clear on its own, check the project directory still exists and is writable.`,
|
|
1381
1784
|
]
|
|
1382
1785
|
: []),
|
|
1383
|
-
|
|
1384
|
-
formatRouteCoverage(eng.allKnownRoutes(), unvisited),
|
|
1385
|
-
`Unexercised elements by route:`,
|
|
1386
|
-
...cov.unexercised.slice(0, 25).map((u) => ` ${u.state}: ${u.keys.slice(0, 6).join(", ")}${u.keys.length > 6 ? ` … +${u.keys.length - 6}` : ""}`),
|
|
1387
|
-
...formatUnchosenOptions(eng.memory.unchosenOptions()),
|
|
1388
|
-
...formatNeverSubmittedEmpty(eng.memory.formsNeverSubmittedEmpty()),
|
|
1786
|
+
...coverageView(memory, eng.sessionKey, args.scope ?? (shared ? "session" : "project"), formatRouteCoverage(eng.allKnownRoutes(), unvisited)),
|
|
1389
1787
|
];
|
|
1390
1788
|
return text(lines.join("\n"), session);
|
|
1391
1789
|
}
|
|
@@ -1405,9 +1803,13 @@ server.registerTool("scout_report", {
|
|
|
1405
1803
|
.enum(["index", "full"])
|
|
1406
1804
|
.default("index")
|
|
1407
1805
|
.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."),
|
|
1806
|
+
report: z
|
|
1807
|
+
.enum(REPORT_AUDIENCES)
|
|
1808
|
+
.default(DEFAULT_REPORT_AUDIENCE)
|
|
1809
|
+
.describe("Which parts the report carries. 'both' (default): a plain-language section first — a short summary, then each problem with numbered steps, what was expected, what happened, its picture and its impact (blocks users, annoying, cosmetic), each with its technical detail folded beneath — followed by the technical report. 'qa': the plain section alone, for a tester or anyone not technical. 'dev': the technical report alone, as before the plain section existed. It applies to the files this call writes; the live view always shows both."),
|
|
1408
1810
|
session: sessionParam,
|
|
1409
1811
|
},
|
|
1410
|
-
}, serializedPerSession("scout_report", async ({ force, level, history }, session) => {
|
|
1812
|
+
}, serializedPerSession("scout_report", async ({ force, level, history, report }, session) => {
|
|
1411
1813
|
try {
|
|
1412
1814
|
const eng = engineFor(session);
|
|
1413
1815
|
if (!eng.memory)
|
|
@@ -1444,6 +1846,7 @@ server.registerTool("scout_report", {
|
|
|
1444
1846
|
routesTotal: all.length,
|
|
1445
1847
|
designAudits: auditsThisRun,
|
|
1446
1848
|
unvisitedRoutes: unvisited,
|
|
1849
|
+
knownRoutes: all,
|
|
1447
1850
|
mode: eng.mode,
|
|
1448
1851
|
});
|
|
1449
1852
|
if (lvl === "extensive" && gapList.length > 0) {
|
|
@@ -1458,21 +1861,25 @@ server.registerTool("scout_report", {
|
|
|
1458
1861
|
return text(`NOT GENERATED — the '${lvl}' completion contract is unmet:\n\n${gates.join("\n\n")}\n\n` +
|
|
1459
1862
|
`Then call scout_report again. Pass force=true ONLY if the user explicitly capped the budget.`, session);
|
|
1460
1863
|
}
|
|
1461
|
-
const { path: p, summary } = generateReport(eng.memory, eng.oracleLog.all, {
|
|
1864
|
+
const { path: p, summary, html, } = generateReport(eng.memory, eng.oracleLog.all, {
|
|
1462
1865
|
history,
|
|
1866
|
+
report,
|
|
1463
1867
|
routesVisited: all.length - unvisited.length,
|
|
1464
1868
|
routesTotal: all.length,
|
|
1465
1869
|
designAudits: auditsThisRun,
|
|
1466
1870
|
createdResources: eng.createdResources,
|
|
1467
1871
|
unvisitedRoutes: unvisited,
|
|
1872
|
+
knownRoutes: all,
|
|
1468
1873
|
mode: eng.mode,
|
|
1469
1874
|
trustedEmbeds: [...eng.trustedEmbeds],
|
|
1875
|
+
readPosts: eng.readPosts.map((e) => e.entry),
|
|
1470
1876
|
policyAttributed: eng.oracleLog.policyAttributed,
|
|
1471
1877
|
// Which sessions are still open decides whether a quiet one is holding a browser, and how long its trailing idle runs.
|
|
1472
1878
|
attachedSessions: [...engines.keys()],
|
|
1473
1879
|
});
|
|
1474
1880
|
void p;
|
|
1475
|
-
|
|
1881
|
+
const openNote = html && openDecisions.get(session)?.report ? openForUser("the report", html) : "";
|
|
1882
|
+
return text(summary + openNote, session);
|
|
1476
1883
|
}
|
|
1477
1884
|
catch (err) {
|
|
1478
1885
|
return errorText(err);
|
|
@@ -1536,6 +1943,108 @@ server.registerTool("scout_verify", {
|
|
|
1536
1943
|
return errorText(err);
|
|
1537
1944
|
}
|
|
1538
1945
|
}));
|
|
1946
|
+
// Answering the tickets: read their acceptance criteria, then record a verdict on each.
|
|
1947
|
+
server.registerTool("scout_tickets", {
|
|
1948
|
+
description: "Read the tickets or acceptance criteria the person gave this run, pasted (`text`) or from a file (`path`), and keep them so the report answers each criterion: passed, failed or not tested. " +
|
|
1949
|
+
'Recognises Given/When/Then scenarios, checklists, numbered and "AC1:" criteria, and lists under an "Acceptance criteria" heading; several tickets may be given at once. ' +
|
|
1950
|
+
"A ticket with no recognisable criteria is reported as such, never guessed at. Returns each criterion's id (AC1, AC2, …) to judge it by with scout_criterion. Touches no browser.",
|
|
1951
|
+
inputSchema: {
|
|
1952
|
+
text: z.string().max(MAX_TICKET_TEXT).optional().describe("The tickets as pasted. Pass this or `path`."),
|
|
1953
|
+
path: z
|
|
1954
|
+
.string()
|
|
1955
|
+
.optional()
|
|
1956
|
+
.describe(`A ticket file to read (${TICKET_FILE_EXTENSIONS.join(", ")}), absolute or relative to the project folder. Pass this or \`text\`.`),
|
|
1957
|
+
session: sessionParam,
|
|
1958
|
+
},
|
|
1959
|
+
}, serializedPerSession("scout_tickets", async ({ text: pasted, path: file }, session) => {
|
|
1960
|
+
try {
|
|
1961
|
+
const eng = engineFor(session);
|
|
1962
|
+
if (!eng.memory)
|
|
1963
|
+
throw new Error("Not attached — attach first, so the tickets are kept with the project.");
|
|
1964
|
+
if ((pasted === undefined) === (file === undefined))
|
|
1965
|
+
return text("Pass the tickets as `text`, or a file as `path`: one of the two.", session);
|
|
1966
|
+
let body = pasted ?? "";
|
|
1967
|
+
let source = "pasted text";
|
|
1968
|
+
if (file !== undefined) {
|
|
1969
|
+
// The file a link points at is what is read, so its name is what is checked: a "notes.md" link to a key file is refused.
|
|
1970
|
+
const full = fs.realpathSync(path.isAbsolute(file) ? file : path.resolve(path.dirname(eng.memory.dir), file));
|
|
1971
|
+
if (!isTicketFileName(full))
|
|
1972
|
+
return text(`Not read: a ticket file is one of ${TICKET_FILE_EXTENSIONS.join(", ")}. Paste anything else as \`text\`.`, session);
|
|
1973
|
+
const stat = fs.statSync(full);
|
|
1974
|
+
if (!stat.isFile())
|
|
1975
|
+
return text(`Not read: ${full} is not a file.`, session);
|
|
1976
|
+
if (stat.size > MAX_TICKET_FILE_BYTES)
|
|
1977
|
+
return text(`Not read: ${full} is larger than ${MAX_TICKET_FILE_BYTES} bytes.`, session);
|
|
1978
|
+
body = fs.readFileSync(full, "utf8");
|
|
1979
|
+
source = path.basename(full);
|
|
1980
|
+
}
|
|
1981
|
+
if (!body.trim())
|
|
1982
|
+
return text("Nothing to read: the tickets are empty.", session);
|
|
1983
|
+
const parsed = parseTickets(body, source);
|
|
1984
|
+
const kept = eng.memory.addTickets(parsed);
|
|
1985
|
+
// Say what a bound left out, so a long backlog is never answered in part without a word.
|
|
1986
|
+
const cuts = [
|
|
1987
|
+
...(body.length > MAX_TICKET_TEXT ? [`only the first ${MAX_TICKET_TEXT} characters were read`] : []),
|
|
1988
|
+
...(parsed.length >= MAX_TICKETS ? [`at most ${MAX_TICKETS} tickets are read at once`] : []),
|
|
1989
|
+
];
|
|
1990
|
+
return text(formatReading(kept) + (cuts.length ? `\n\n⚠ Not everything was read: ${cuts.join("; ")}. Read the rest in another call.` : ""), session);
|
|
1991
|
+
}
|
|
1992
|
+
catch (err) {
|
|
1993
|
+
return errorText(err);
|
|
1994
|
+
}
|
|
1995
|
+
}));
|
|
1996
|
+
server.registerTool("scout_criterion", {
|
|
1997
|
+
description: "Record whether one acceptance criterion of a ticket read with scout_tickets passed, failed or was not tested, with how sure you are. " +
|
|
1998
|
+
"The link from a criterion to the findings that show it is YOUR judgement, stated with a confidence — never matched on words. " +
|
|
1999
|
+
'A "fail" names the findings that show it (file them with scout_finding first); "not-tested" says why in untestedBecause. Recording the same criterion again from the same session replaces your earlier verdict. Touches no browser.',
|
|
2000
|
+
inputSchema: {
|
|
2001
|
+
ticket: z.string().min(1).describe('The ticket\'s id as scout_tickets gave it, e.g. "PROJ-12" or "T1"'),
|
|
2002
|
+
criterion: z.string().min(1).describe('The criterion\'s id, e.g. "AC2" (or just "2")'),
|
|
2003
|
+
verdict: z.enum(CRITERION_VERDICTS).describe('"pass", "fail" or "not-tested"'),
|
|
2004
|
+
findings: z
|
|
2005
|
+
.array(z.string())
|
|
2006
|
+
.max(MAX_CRITERION_FINDINGS)
|
|
2007
|
+
.optional()
|
|
2008
|
+
.describe("Ids of the findings that show this criterion failing (required for a fail; may be given for a pass, none for not-tested)"),
|
|
2009
|
+
confidence: z.number().min(0).max(1).describe("How sure you are of this verdict and of the findings linked to it, from 0 to 1. State it honestly"),
|
|
2010
|
+
reason: z.string().min(1).max(MAX_REASON).describe("What you saw, or why it could not be tried, in a sentence"),
|
|
2011
|
+
untestedBecause: z
|
|
2012
|
+
.enum(NOT_TESTED_REASONS)
|
|
2013
|
+
.optional()
|
|
2014
|
+
.describe('Only with verdict "not-tested": "no-access" (the role this run used could not reach it), "observe-blocked" (it needs a change sent and this session is in observe mode), "out-of-scope" (it is outside what this run could check, such as an email or another system)'),
|
|
2015
|
+
session: sessionParam,
|
|
2016
|
+
},
|
|
2017
|
+
}, serializedPerSession("scout_criterion", async (args, session) => {
|
|
2018
|
+
try {
|
|
2019
|
+
const eng = engineFor(session);
|
|
2020
|
+
const memory = eng.memory;
|
|
2021
|
+
if (!memory)
|
|
2022
|
+
throw new Error("Not attached.");
|
|
2023
|
+
const judged = judgeCriterion(args, {
|
|
2024
|
+
tickets: memory.tickets,
|
|
2025
|
+
findings: memory.findings,
|
|
2026
|
+
mode: eng.mode,
|
|
2027
|
+
session: eng.sessionKey,
|
|
2028
|
+
at: new Date().toISOString(),
|
|
2029
|
+
});
|
|
2030
|
+
if (!judged.ok)
|
|
2031
|
+
return text(`Not recorded: ${judged.reason}.`, session);
|
|
2032
|
+
memory.addCriterionVerdict(judged.record);
|
|
2033
|
+
const r = judged.record;
|
|
2034
|
+
const ticket = memory.tickets.find((t) => t.id === r.ticket);
|
|
2035
|
+
const criterion = ticket ? findCriterion(ticket, r.criterion) : undefined;
|
|
2036
|
+
const linked = r.findings.map((id) => memory.findings.find((f) => f.id === id)).filter((f) => f !== undefined);
|
|
2037
|
+
const said = r.verdict === "pass" ? "passes" : r.verdict === "fail" ? "fails" : `was not tested (${r.untestedBecause})`;
|
|
2038
|
+
return text([
|
|
2039
|
+
`Recorded: ${r.ticket} ${r.criterion} ${said}, confidence ${r.confidence.toFixed(2)}.`,
|
|
2040
|
+
...(criterion ? [` ${criterion.text}`] : []),
|
|
2041
|
+
...linked.map((f) => ` linked: [${f.severity}] ${f.title} (${f.id})`),
|
|
2042
|
+
].join("\n"), session);
|
|
2043
|
+
}
|
|
2044
|
+
catch (err) {
|
|
2045
|
+
return errorText(err);
|
|
2046
|
+
}
|
|
2047
|
+
}));
|
|
1539
2048
|
server.registerTool("scout_close", {
|
|
1540
2049
|
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. " +
|
|
1541
2050
|
"A session that is a lane of a parallel run (named by scout_lane_brief or scout_lane_report) is not closed until its lane report has been accepted by scout_lane_report, since folding needs the session attached; the refusal names every such lane.",
|
|
@@ -1588,6 +2097,7 @@ server.registerTool("scout_close", {
|
|
|
1588
2097
|
await eng.close();
|
|
1589
2098
|
const saveError = eng.memory?.lastSaveError;
|
|
1590
2099
|
engines.delete(name);
|
|
2100
|
+
openDecisions.delete(name);
|
|
1591
2101
|
// The last session on this project ends its run.
|
|
1592
2102
|
if (eng.memory && ![...engines.values()].some((e) => e.memory === eng.memory))
|
|
1593
2103
|
eng.memory.endRun();
|
|
@@ -1648,7 +2158,7 @@ async function shutdown() {
|
|
|
1648
2158
|
console.error(`[scenescout] could not remove the live view's token file in ${dir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1649
2159
|
}
|
|
1650
2160
|
}
|
|
1651
|
-
await Promise.allSettled([live?.stop(), ...[...engines.values()].map((e) => e.close())]);
|
|
2161
|
+
await Promise.allSettled([live?.stop(), ...[...engines.values()].map((e) => e.close()), ...pendingLogins.all().map((p) => p.cancel())]);
|
|
1652
2162
|
}
|
|
1653
2163
|
process.on("SIGINT", () => {
|
|
1654
2164
|
void shutdown().finally(() => process.exit(0));
|