scenescout 3.14.1 → 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.
Files changed (59) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +90 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +211 -24
  5. package/dist/ci-run.js +374 -62
  6. package/dist/cli.js +221 -47
  7. package/dist/commands.js +42 -4
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1660 -386
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +258 -68
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +233 -18
  16. package/dist/engine/claims.js +234 -12
  17. package/dist/engine/collector.js +851 -78
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/dedup.js +329 -9
  20. package/dist/engine/design.js +355 -43
  21. package/dist/engine/export.js +877 -0
  22. package/dist/engine/fingerprint.js +92 -4
  23. package/dist/engine/flow.js +18 -6
  24. package/dist/engine/forms.js +198 -18
  25. package/dist/engine/journey.js +29 -1
  26. package/dist/engine/lane.js +13 -3
  27. package/dist/engine/launch.js +45 -6
  28. package/dist/engine/limits.js +7 -0
  29. package/dist/engine/live-page.js +49 -2
  30. package/dist/engine/live.js +4 -1
  31. package/dist/engine/memory.js +668 -70
  32. package/dist/engine/open.js +118 -0
  33. package/dist/engine/oracles.js +167 -2
  34. package/dist/engine/plain.js +268 -0
  35. package/dist/engine/png.js +127 -0
  36. package/dist/engine/policy.js +379 -9
  37. package/dist/engine/probes.js +3 -2
  38. package/dist/engine/profiles.js +45 -9
  39. package/dist/engine/project-folder.js +191 -0
  40. package/dist/engine/provider.js +4 -3
  41. package/dist/engine/refresh.js +338 -25
  42. package/dist/engine/replay.js +63 -10
  43. package/dist/engine/report.js +248 -40
  44. package/dist/engine/request.js +317 -23
  45. package/dist/engine/sarif.js +120 -0
  46. package/dist/engine/scripted-login.js +342 -62
  47. package/dist/engine/settle.js +67 -0
  48. package/dist/engine/signed-in.js +256 -0
  49. package/dist/engine/tickets.js +671 -0
  50. package/dist/engine/unload.js +3 -2
  51. package/dist/export-run.js +633 -0
  52. package/dist/first-run.js +581 -0
  53. package/dist/installer.js +267 -3
  54. package/dist/intake.js +104 -0
  55. package/dist/login-run.js +386 -71
  56. package/dist/mcp-server.js +637 -68
  57. package/dist/playbook.js +5 -0
  58. package/package.json +6 -3
  59. package/skills/scenescout/SKILL.md +51 -18
@@ -22,6 +22,7 @@ import path from "node:path";
22
22
  import { BROWSER_ENGINES } from "../browsers.js";
23
23
  import { MEMORY_DIRNAME, writeSelfIgnore } from "./memory.js";
24
24
  import { SCRIPT_FLAGS } from "./scripted-login.js";
25
+ import { SAVE_MODES } from "./signed-in.js";
25
26
  /** The directory under .scenescout/ that holds one file per role. */
26
27
  export const AUTH_DIRNAME = "auth";
27
28
  /** Owner read/write only: a profile is a live session. */
@@ -279,14 +280,36 @@ export function permissionNote(file, mode, platform = process.platform) {
279
280
  return null;
280
281
  return `The profile at ${file} can be read by other accounts on this machine (mode ${(mode & 0o777).toString(8)}); it holds a live session. Tighten it: chmod 600 "${file}"`;
281
282
  }
282
- /** The command that records a role's profile, with the URL when it is known. */
283
- export function loginCommand(role, url) {
284
- return `scenescout login ${url ?? "<url>"} --role ${role}`;
283
+ /** One argument quoted for the platform's usual shell: double quotes on Windows, single quotes elsewhere. */
284
+ export function shellQuote(value, platform = process.platform) {
285
+ if (platform === "win32")
286
+ return `"${value.replace(/"/g, '""')}"`;
287
+ return `'${value.replace(/'/g, "'\\''")}'`;
288
+ }
289
+ /**
290
+ * The command that records a role's profile, with the URL when it is known,
291
+ * and `--project` when the profile must go somewhere other than the folder
292
+ * the command is run from (a project folder SceneScout chose itself).
293
+ */
294
+ export function loginCommand(role, url, project, platform = process.platform) {
295
+ return `scenescout login ${url ?? "<url>"} --role ${role}${project ? ` --project ${shellQuote(project, platform)}` : ""}`;
296
+ }
297
+ /**
298
+ * The command that records a role's profile again for a session: with
299
+ * `--project` only when SceneScout chose the session's folder itself
300
+ * (`projectChosen`), since the command otherwise saves into the folder it is
301
+ * run from and the next attach would not find it. Used by a refusal for a
302
+ * missing role and by every hint for an expired one.
303
+ */
304
+ export function reloginCommand(opts) {
305
+ return loginCommand(opts.role, opts.url, opts.projectChosen ? opts.projectDir : undefined, opts.platform);
285
306
  }
286
307
  /**
287
308
  * Turn scout_attach's `role` and `storageStatePath` into the one storage state
288
309
  * the session loads. Both at once is refused rather than letting one win
289
- * silently. A missing profile names the command that records it.
310
+ * silently. A missing profile names the command that records it, with
311
+ * `--project` when SceneScout chose the folder (`projectChosen`), so the
312
+ * command saves where the next attach looks.
290
313
  */
291
314
  export function resolveAttachAuth(opts, exists = fs.existsSync, listRoles = listProfiles) {
292
315
  if (opts.role !== undefined && opts.storageStatePath !== undefined) {
@@ -305,7 +328,7 @@ export function resolveAttachAuth(opts, exists = fs.existsSync, listRoles = list
305
328
  catch {
306
329
  /* the list is a hint in the message; the refusal stands without it */
307
330
  }
308
- throw new Error(`no sign-in is saved for role "${checked.role}" in this project. Run \`${loginCommand(checked.role, opts.url)}\`, sign in in the window it opens, then attach again.` +
331
+ throw new Error(`no sign-in is saved for role "${checked.role}" in this project. Run \`${reloginCommand({ role: checked.role, url: opts.url, projectDir: opts.projectDir, projectChosen: opts.projectChosen, platform: opts.platform })}\` (or, from a conversation, scout_login { role: "${checked.role}" }), sign in in the window it opens, then attach again.` +
309
332
  (saved.length > 0 ? ` Saved roles: ${saved.join(", ")}.` : ""));
310
333
  }
311
334
  return { kind: "role", role: checked.role, storageStatePath: file };
@@ -322,11 +345,14 @@ export function roleLabel(auth) {
322
345
  return path.basename(auth.storageStatePath).replace(/\.json$/i, "");
323
346
  return "anonymous";
324
347
  }
325
- export const LOGIN_OPTION_NAMES = ["role", "project", "browser"];
348
+ export const LOGIN_OPTION_NAMES = ["role", "project", "browser", "save"];
349
+ /** Of the `--script` flags, those the window reads too. */
350
+ const WINDOW_SCRIPT_FLAGS = new Set(["success-url"]);
326
351
  /**
327
352
  * Parse `scenescout login <url> --role <name> [--project dir] [--browser engine]
328
- * [--script [--success-url …] [--timeout s] …]`. `--script` takes no value; the
329
- * flags only it reads are refused without it.
353
+ * [--save auto|enter] [--success-url …] [--script [--timeout s] …]`. `--script`
354
+ * takes no value; the flags only it reads are refused without it, and `--save`,
355
+ * which only the window reads, is refused with it.
330
356
  */
331
357
  export function parseLoginArgs(args, cwd) {
332
358
  const positional = [];
@@ -354,8 +380,10 @@ export function parseLoginArgs(args, cwd) {
354
380
  const known = new Set(LOGIN_OPTION_NAMES);
355
381
  const scriptOnly = new Set(SCRIPT_FLAGS);
356
382
  for (const name of flags.keys()) {
357
- if (scriptOnly.has(name) && !script)
383
+ if (scriptOnly.has(name) && !script && !WINDOW_SCRIPT_FLAGS.has(name))
358
384
  return { ok: false, error: `--${name} only applies with --script` };
385
+ if (name === "save" && script)
386
+ return { ok: false, error: "--save applies to the window, not to --script, which saves once its sign-in succeeds" };
359
387
  if (!known.has(name) && !scriptOnly.has(name))
360
388
  return { ok: false, error: `unknown option --${name}` };
361
389
  }
@@ -379,6 +407,12 @@ export function parseLoginArgs(args, cwd) {
379
407
  if (browser !== undefined && !BROWSER_ENGINES.includes(browser)) {
380
408
  return { ok: false, error: `--browser must be one of ${BROWSER_ENGINES.join(", ")}` };
381
409
  }
410
+ const save = flags.get("save");
411
+ if (save !== undefined && !SAVE_MODES.includes(save))
412
+ return { ok: false, error: `--save must be one of ${SAVE_MODES.join(", ")}` };
413
+ const successUrl = flags.get("success-url");
414
+ if (successUrl !== undefined && successUrl.trim() === "")
415
+ return { ok: false, error: "--success-url needs a value" };
382
416
  return {
383
417
  ok: true,
384
418
  options: {
@@ -387,6 +421,8 @@ export function parseLoginArgs(args, cwd) {
387
421
  projectDir: path.resolve(cwd, flags.get("project") ?? "."),
388
422
  ...(browser ? { browser: browser } : {}),
389
423
  ...(script ? { script: new Map([...flags].filter(([name]) => scriptOnly.has(name))) } : {}),
424
+ ...(save ? { save: save } : {}),
425
+ ...(!script && successUrl !== undefined ? { successUrl } : {}),
390
426
  },
391
427
  };
392
428
  }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Where a run keeps its notes, memory and report when the agent names no
3
+ * project folder (scout_attach `projectPath`).
4
+ *
5
+ * A client with no workspace (a desktop chat app, say) has no folder to offer,
6
+ * and a person testing a site should not be asked to invent one. So each tested
7
+ * site gets its own folder under the user's documents folder, such as
8
+ * `Documents/SceneScout/localhost-3000/`, created on first use.
9
+ *
10
+ * What wins, first to last:
11
+ * 1. the `projectPath` the attach names, always;
12
+ * 2. the client's workspace folder, when it offers one (MCP roots);
13
+ * 3. the per-site default, unless PROJECTS_DIR_ENV is `off`.
14
+ * The default is never placed inside a git repository below the home folder: a
15
+ * folder of recordings and saved logins does not belong in somebody's commits
16
+ * unless they chose it. A home folder that is itself a repository (dotfiles)
17
+ * does not count.
18
+ *
19
+ * Everything here is pure (the filesystem is passed in), so it is table-tested
20
+ * in memory-test.
21
+ */
22
+ import path from "node:path";
23
+ import { domainToUnicode, fileURLToPath } from "node:url";
24
+ import { MEMORY_DIRNAME } from "./memory.js";
25
+ /** The setting: an absolute folder that holds one folder per tested site, or `off`. */
26
+ export const PROJECTS_DIR_ENV = "SCENESCOUT_PROJECTS_DIR";
27
+ /** The folder made under the documents folder when the setting is unset. */
28
+ export const DEFAULT_PROJECTS_DIRNAME = "SceneScout";
29
+ /**
30
+ * The user's documents folder. Windows: `%USERPROFILE%\Documents`; Linux and
31
+ * others: XDG_DOCUMENTS_DIR from the environment or user-dirs.dirs (which the
32
+ * server reads on Linux only), else
33
+ * `~/Documents`; macOS: `~/Documents`.
34
+ */
35
+ export function documentsDir(home) {
36
+ if (home.platform === "win32") {
37
+ const profile = home.env.USERPROFILE && path.win32.isAbsolute(home.env.USERPROFILE) ? home.env.USERPROFILE : home.homedir;
38
+ return path.win32.join(profile, "Documents");
39
+ }
40
+ if (home.platform !== "darwin") {
41
+ const fromEnv = home.env.XDG_DOCUMENTS_DIR;
42
+ const fromFile = home.userDirs?.match(/^\s*XDG_DOCUMENTS_DIR\s*=\s*"([^"\n]*)"/m)?.[1];
43
+ for (const raw of [fromEnv, fromFile]) {
44
+ if (!raw)
45
+ continue;
46
+ const expanded = raw.replace(/^\$HOME(?=\/|$)/, home.homedir);
47
+ if (!path.posix.isAbsolute(expanded))
48
+ continue;
49
+ // resolve() drops a trailing slash, so "$HOME/" compares equal to the home folder.
50
+ const dir = path.posix.resolve(expanded);
51
+ // XDG says a documents folder equal to $HOME means "none": fall through to ~/Documents.
52
+ if (dir !== path.posix.resolve(home.homedir))
53
+ return dir;
54
+ }
55
+ }
56
+ return path.posix.join(home.homedir, "Documents");
57
+ }
58
+ /** Names Windows reserves, which no folder may take, whatever its extension. */
59
+ const RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\.|$)/i;
60
+ /**
61
+ * The folder name for a tested site: its host as a person would write it, with
62
+ * the port when there is one. `http://localhost:3000` → `localhost-3000`,
63
+ * `https://xn--bcher-kva.example/` → `bücher.example`, `http://[::1]:8080` →
64
+ * `ipv6-__1-8080`. Two addresses of one site (http and https, any path) share
65
+ * a folder; a different port is a different site. Throws for an address with
66
+ * no host, which has no site to name.
67
+ */
68
+ export function siteFolderName(url) {
69
+ let parsed;
70
+ try {
71
+ parsed = new URL(url);
72
+ }
73
+ catch {
74
+ throw new Error(`"${url}" is not a full address, so there is no site to name a folder after. Pass projectPath.`);
75
+ }
76
+ let host = parsed.hostname.toLowerCase().replace(/\.+$/, "");
77
+ if (!host)
78
+ throw new Error(`"${url}" has no host, so there is no site to name a folder after. Pass projectPath.`);
79
+ if (host.startsWith("["))
80
+ host = `ipv6-${host.slice(1, -1).replace(/:/g, "_")}`;
81
+ else
82
+ host = domainToUnicode(host).normalize("NFC") || host;
83
+ // A hostname holds only letters, digits, hyphens and dots, but say so here
84
+ // rather than trust it: this becomes one path segment.
85
+ host = host.replace(/[^\p{L}\p{M}\p{N}._-]/gu, "_").replace(/^\.+/, "_");
86
+ if (RESERVED.test(host))
87
+ host = `site-${host}`;
88
+ return parsed.port ? `${host}-${parsed.port}` : host;
89
+ }
90
+ /**
91
+ * The folder holding one folder per site: the setting when it names an
92
+ * absolute folder, `null` when it is `off`, else `<documents>/SceneScout`.
93
+ * A setting that is neither throws, naming the variable.
94
+ */
95
+ export function projectsRoot(home) {
96
+ const setting = home.env[PROJECTS_DIR_ENV]?.trim();
97
+ const p = home.platform === "win32" ? path.win32 : path.posix;
98
+ if (setting) {
99
+ if (setting.toLowerCase() === "off")
100
+ return null;
101
+ if (!p.isAbsolute(setting))
102
+ throw new Error(`${PROJECTS_DIR_ENV} must be an absolute folder or "off", not "${setting}".`);
103
+ return p.normalize(setting);
104
+ }
105
+ return p.join(documentsDir(home), DEFAULT_PROJECTS_DIRNAME);
106
+ }
107
+ /**
108
+ * The git repository a folder would sit inside: the nearest folder, the folder
109
+ * itself included, holding a `.git` (a directory, or a file in a worktree).
110
+ * `exists` is fs.existsSync in use; the folder need not exist yet.
111
+ *
112
+ * With `stopAt` (the home folder), the walk ends below it: a home folder that
113
+ * is itself a repository, as a dotfiles setup makes it, does not count, since
114
+ * refusing every default folder there is worse than one untracked folder in
115
+ * it. A folder outside `stopAt` is walked to the root.
116
+ */
117
+ export function enclosingRepo(dir, exists, platform = process.platform, stopAt) {
118
+ const p = platform === "win32" ? path.win32 : path.posix;
119
+ const same = (a, b) => (platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b);
120
+ const stop = stopAt === undefined ? undefined : p.resolve(stopAt);
121
+ for (let at = p.resolve(dir);;) {
122
+ if (stop !== undefined && same(at, stop))
123
+ return null;
124
+ if (exists(p.join(at, ".git")))
125
+ return at;
126
+ const up = p.dirname(at);
127
+ if (up === at)
128
+ return null;
129
+ at = up;
130
+ }
131
+ }
132
+ /**
133
+ * The first workspace folder a client offers as MCP roots, or null. Only `file:`
134
+ * roots name a folder, read as `platform` reads them: `file:///C:/work/app` is
135
+ * `C:\\work\\app` on Windows, and a root with no drive letter is no folder there.
136
+ */
137
+ export function workspaceFromRoots(roots, platform = process.platform) {
138
+ for (const root of roots ?? []) {
139
+ if (!root.uri.startsWith("file:"))
140
+ continue;
141
+ try {
142
+ return fileURLToPath(root.uri, { windows: platform === "win32" });
143
+ }
144
+ catch {
145
+ continue; // a file: URI naming another host, or no absolute path on this platform: not a folder here
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+ /**
151
+ * Which folder a run keeps its files in, and the plain line that says so.
152
+ * `given` is the attach's projectPath; `workspace` the client's folder, if any.
153
+ */
154
+ export function chooseProjectFolder(input) {
155
+ if (input.given !== undefined)
156
+ return { dir: input.given, source: "given", note: "" };
157
+ if (input.workspace) {
158
+ return {
159
+ dir: input.workspace,
160
+ source: "workspace",
161
+ note: `📁 FILES: no projectPath was given, so this run's notes, memory, sign-ins and report are kept in your workspace folder, under ${input.workspace}.`,
162
+ };
163
+ }
164
+ let root;
165
+ let name;
166
+ try {
167
+ root = projectsRoot(input.home);
168
+ name = siteFolderName(input.url);
169
+ }
170
+ catch (err) {
171
+ return { refused: `No projectPath was given and no default folder could be chosen: ${err.message}` };
172
+ }
173
+ if (root === null)
174
+ return {
175
+ refused: `No projectPath was given, and ${PROJECTS_DIR_ENV} is "off", so there is no default folder. Pass projectPath: the folder this run's notes, memory and report go in.`,
176
+ };
177
+ const p = input.home.platform === "win32" ? path.win32 : path.posix;
178
+ const dir = p.join(root, name);
179
+ const repo = enclosingRepo(dir, input.exists, input.home.platform, input.home.homedir);
180
+ if (repo)
181
+ return {
182
+ refused: `No projectPath was given, and the default folder ${dir} would be inside the git repository ${repo}, where a run's recordings and saved logins could be committed. ` +
183
+ `Pass projectPath to choose a folder (inside that repository too, if that is what you want), or set ${PROJECTS_DIR_ENV} to a folder outside it.`,
184
+ };
185
+ return {
186
+ dir,
187
+ source: "default",
188
+ note: `📁 FILES: this site's notes, memory, sign-ins and report are kept in ${dir} — a folder SceneScout made for this site, since none was given. ` +
189
+ `The report will be ${p.join(dir, MEMORY_DIRNAME, "report.md")}. Pass projectPath, or set ${PROJECTS_DIR_ENV}, to keep them elsewhere.`,
190
+ };
191
+ }
@@ -3,7 +3,8 @@ export class MalformedReply extends Error {
3
3
  }
4
4
  /** The most output one model call may produce. Large enough for a plan of steps; small enough that one reply cannot spend the budget. */
5
5
  export const MAX_OUTPUT_TOKENS = 16_000;
6
- const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : null);
6
+ /** A plain object as a record, or null for anything else (an array, null, a string). */
7
+ export const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : null);
7
8
  const num = (v) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0);
8
9
  function parseArgs(raw) {
9
10
  if (raw === undefined || raw === null || raw === "")
@@ -31,7 +32,7 @@ export class AnthropicConversation {
31
32
  headers: { "content-type": "application/json", "x-api-key": key, "anthropic-version": "2023-06-01" },
32
33
  body: {
33
34
  model: this.o.model,
34
- max_tokens: MAX_OUTPUT_TOKENS,
35
+ max_tokens: this.o.maxOutputTokens ?? MAX_OUTPUT_TOKENS,
35
36
  // The method and the tools are the same on every call: cached once, read back at a tenth of the price.
36
37
  system: [{ type: "text", text: this.o.system, cache_control: { type: "ephemeral" } }],
37
38
  tools: this.o.tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters })),
@@ -102,7 +103,7 @@ export class OpenAIConversation {
102
103
  input: this.input,
103
104
  tools: this.o.tools.map((t) => ({ type: "function", name: t.name, description: t.description, parameters: t.parameters, strict: false })),
104
105
  reasoning: { effort: this.o.effort },
105
- max_output_tokens: MAX_OUTPUT_TOKENS,
106
+ max_output_tokens: this.o.maxOutputTokens ?? MAX_OUTPUT_TOKENS,
106
107
  // Stateless: nothing of the tested app is kept on the provider's side
107
108
  // between calls, and the reasoning comes back encrypted so it can be
108
109
  // handed back with the rest of the turn.