scenescout 3.22.0 → 3.23.1
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 +12 -0
- package/README.md +88 -769
- package/dist/ci-run.js +259 -36
- package/dist/cli.js +1 -1
- package/dist/engine/brief.js +35 -3
- package/dist/engine/ci-lanes.js +28 -12
- package/dist/engine/ci.js +39 -1
- package/dist/engine/dispatch.js +70 -0
- package/dist/engine/from-run.js +649 -0
- package/dist/engine/memory.js +135 -0
- package/dist/engine/report.js +26 -0
- package/dist/mcp-server.js +108 -11
- package/package.json +2 -2
- package/skills/scenescout/SKILL.md +2 -2
package/dist/engine/ci.js
CHANGED
|
@@ -16,6 +16,7 @@ import { markdownCell } from "./check.js";
|
|
|
16
16
|
import { parseLimitFlag } from "./limits.js";
|
|
17
17
|
import { isWorthALook, redactSecrets } from "./memory.js";
|
|
18
18
|
import { SARIF_ANCHOR_FALLBACKS, checkSarifAnchor, sarifLocation } from "./sarif.js";
|
|
19
|
+
import { fromRunLine, resolveFromRun } from "./from-run.js";
|
|
19
20
|
// ── options ─────────────────────────────────────────────────────────────────
|
|
20
21
|
export const PROVIDERS = ["anthropic", "openai"];
|
|
21
22
|
/** Where each provider's key is read from. Nothing else is: no flag, no file, no input. */
|
|
@@ -93,6 +94,9 @@ export const CI_OPTION_NAMES = [
|
|
|
93
94
|
"show",
|
|
94
95
|
"compare-url",
|
|
95
96
|
"dedup",
|
|
97
|
+
"from-run",
|
|
98
|
+
"from-run-mode",
|
|
99
|
+
"from-run-turns-per-page",
|
|
96
100
|
"sarif-file-anchor",
|
|
97
101
|
];
|
|
98
102
|
/** The longest --show description: it becomes a line of the model's prompt. */
|
|
@@ -115,7 +119,8 @@ export function checkBaseUrl(raw) {
|
|
|
115
119
|
return { ok: true, url: u.toString().replace(/\/+$/, "") };
|
|
116
120
|
return { ok: false, error: "--base-url must be https (plain http only to 127.0.0.1 or localhost): the API key is sent to it" };
|
|
117
121
|
}
|
|
118
|
-
|
|
122
|
+
/** `env` is read for the earlier run alone (SCENESCOUT_FROM_RUN, SCENESCOUT_FROM_RUN_MODE), since a flag wins over it. */
|
|
123
|
+
export function parseCiArgs(args, cwd, env = {}) {
|
|
119
124
|
const positional = [];
|
|
120
125
|
const flags = new Map();
|
|
121
126
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -271,6 +276,13 @@ export function parseCiArgs(args, cwd) {
|
|
|
271
276
|
const anchor = flags.has("sarif-file-anchor") ? checkSarifAnchor(flags.get("sarif-file-anchor")) : undefined;
|
|
272
277
|
if (anchor && !anchor.ok)
|
|
273
278
|
return anchor;
|
|
279
|
+
const runFlags = { path: flags.get("from-run"), mode: flags.get("from-run-mode"), turnsPerPage: flags.get("from-run-turns-per-page") };
|
|
280
|
+
if (show && (runFlags.path !== undefined || runFlags.mode !== undefined || runFlags.turnsPerPage !== undefined))
|
|
281
|
+
return { ok: false, error: "--from-run starts an exploration from an earlier run, and --show explores nothing: give one or the other" };
|
|
282
|
+
// A run asked to show an element explores nothing, so an earlier run named in the environment does not apply to it.
|
|
283
|
+
const fromRun = show ? { ok: true } : resolveFromRun(runFlags, env);
|
|
284
|
+
if (!fromRun.ok)
|
|
285
|
+
return fromRun;
|
|
274
286
|
const resolve = (p) => (p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p) ? p : `${cwd.replace(/[\\/]$/, "")}/${p}`);
|
|
275
287
|
return {
|
|
276
288
|
ok: true,
|
|
@@ -296,6 +308,7 @@ export function parseCiArgs(args, cwd) {
|
|
|
296
308
|
...(navTimeout.value !== undefined ? { navTimeoutMs: navTimeout.value } : {}),
|
|
297
309
|
dedup: dedup,
|
|
298
310
|
...(anchor ? { sarifFileAnchor: anchor.value } : {}),
|
|
311
|
+
...(fromRun.fromRun ? { fromRun: { ...fromRun.fromRun, path: resolve(fromRun.fromRun.path), given: fromRun.fromRun.path } } : {}),
|
|
299
312
|
},
|
|
300
313
|
};
|
|
301
314
|
}
|
|
@@ -691,6 +704,7 @@ export function ciKickoff(o) {
|
|
|
691
704
|
return [
|
|
692
705
|
`Run an exploratory test session following the method.`,
|
|
693
706
|
`Target: ${o.url}`,
|
|
707
|
+
...(o.fromRunLines ?? []),
|
|
694
708
|
`Project directory (for scout_scan): ${o.projectDir}`,
|
|
695
709
|
`Level: ${o.level}`,
|
|
696
710
|
`Write mode: ${o.mode}`,
|
|
@@ -760,6 +774,7 @@ export function ciSummaryMarkdown(r, secrets = []) {
|
|
|
760
774
|
`| Model | ${r.provider} ${cell(r.model, secrets)}, effort ${r.effort} |`,
|
|
761
775
|
...(r.lanes ? [`| Lanes | ${lanesCell(r.lanes, secrets)} |`] : []),
|
|
762
776
|
...(r.dedup ? [`| Finding dedup | ${dedupLine(r.dedup)} |`] : []),
|
|
777
|
+
...(r.fromRun ? [`| Started from | ${cell(fromRunLine(r.fromRun), secrets)} |`] : []),
|
|
763
778
|
`| Usage | ${usageLine(r.spend, r.model, r.endedAt, r.price)} |`,
|
|
764
779
|
``,
|
|
765
780
|
];
|
|
@@ -785,6 +800,27 @@ export function ciSummaryMarkdown(r, secrets = []) {
|
|
|
785
800
|
lines.push(r.capture ? `The pictures are in shots/.` : `The full report, with repro steps and the gap ledger, is report.md.`, ``);
|
|
786
801
|
return lines.join("\n");
|
|
787
802
|
}
|
|
803
|
+
/** A run record with every text a page or a person wrote in it passed through `clean`, as everything else ci.json holds is. */
|
|
804
|
+
function cleanRecord(r, clean) {
|
|
805
|
+
const all = (xs) => xs.map(clean);
|
|
806
|
+
return {
|
|
807
|
+
...r,
|
|
808
|
+
knownRoutes: all(r.knownRoutes),
|
|
809
|
+
visited: all(r.visited),
|
|
810
|
+
lanes: r.lanes.map((l) => ({ session: l.session, routes: all(l.routes) })),
|
|
811
|
+
steps: r.steps.map((s) => ({ ...s, route: clean(s.route), ...(s.target !== undefined ? { target: clean(s.target) } : {}) })),
|
|
812
|
+
left: r.left.map((l) => ({
|
|
813
|
+
...l,
|
|
814
|
+
route: clean(l.route),
|
|
815
|
+
unexercised: all(l.unexercised),
|
|
816
|
+
forms: all(l.forms),
|
|
817
|
+
unchosen: l.unchosen.map((d) => ({ key: clean(d.key), options: all(d.options) })),
|
|
818
|
+
})),
|
|
819
|
+
gaps: all(r.gaps),
|
|
820
|
+
...(r.assigned ? { assigned: all(r.assigned) } : {}),
|
|
821
|
+
...(r.prefixes ? { prefixes: r.prefixes.map((n) => ({ ...n, target: clean(n.target), ...(n.why !== undefined ? { why: clean(n.why) } : {}) })) } : {}),
|
|
822
|
+
};
|
|
823
|
+
}
|
|
788
824
|
function lanesCell(l, secrets) {
|
|
789
825
|
if (l.oneLoop)
|
|
790
826
|
return `${l.asked} asked; explored in one loop: ${cell(l.oneLoop, secrets)}`;
|
|
@@ -876,6 +912,8 @@ export function ciSummaryJson(r, version, secrets = []) {
|
|
|
876
912
|
})),
|
|
877
913
|
...(r.capture ? { capture: cleanCapture(r.capture, clean) } : {}),
|
|
878
914
|
...(r.lanes ? { lanes: lanesJson(r.lanes, clean) } : {}),
|
|
915
|
+
...(r.fromRun ? { fromRun: { mode: r.fromRun.mode, source: clean(r.fromRun.source), runId: r.fromRun.runId, recordAt: r.fromRun.recordAt } } : {}),
|
|
916
|
+
...(r.record ? { record: cleanRecord(r.record, clean) } : {}),
|
|
879
917
|
};
|
|
880
918
|
}
|
|
881
919
|
/** The lanes as ci.json holds them. Module paths and lane names come from the app's routes: redacted like the rest. */
|
package/dist/engine/dispatch.js
CHANGED
|
@@ -98,3 +98,73 @@ export class SessionQueue {
|
|
|
98
98
|
this.forget(key);
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* The work of every tool call still running, per session.
|
|
103
|
+
*
|
|
104
|
+
* The watchdog answers a slow call, but it cannot stop the call: the work goes
|
|
105
|
+
* on in the background and can still write into the project (its action log,
|
|
106
|
+
* a frame, a debounced memory save). Left alone, those writes land after
|
|
107
|
+
* `scout_close` has answered, and after another session has attached to the
|
|
108
|
+
* same folder. So close waits for them: it closes the browser first, which
|
|
109
|
+
* makes every page-bound step of the abandoned work fail at once, then waits
|
|
110
|
+
* here for the work to finish unwinding. The wait is bounded, so work that is
|
|
111
|
+
* stuck on something other than the browser cannot hold a close forever; the
|
|
112
|
+
* caller is told how many calls were still running when it gave up.
|
|
113
|
+
*/
|
|
114
|
+
export class CallWork {
|
|
115
|
+
running = new Map();
|
|
116
|
+
/** Record `work` as running for `key` until it settles, whether it resolves or rejects. */
|
|
117
|
+
track(key, work) {
|
|
118
|
+
let set = this.running.get(key);
|
|
119
|
+
if (!set) {
|
|
120
|
+
set = new Set();
|
|
121
|
+
this.running.set(key, set);
|
|
122
|
+
}
|
|
123
|
+
set.add(work);
|
|
124
|
+
const done = () => {
|
|
125
|
+
const left = this.running.get(key);
|
|
126
|
+
if (!left)
|
|
127
|
+
return;
|
|
128
|
+
left.delete(work);
|
|
129
|
+
if (left.size === 0)
|
|
130
|
+
this.running.delete(key);
|
|
131
|
+
};
|
|
132
|
+
// The rejection is the call's own caller's to handle; here it only means "finished".
|
|
133
|
+
work.then(done, done);
|
|
134
|
+
}
|
|
135
|
+
/** Calls still running for `key`. */
|
|
136
|
+
count(key) {
|
|
137
|
+
return this.running.get(key)?.size ?? 0;
|
|
138
|
+
}
|
|
139
|
+
/** Wait up to `ms` for `key`'s running calls to finish; resolves with how many are still running. */
|
|
140
|
+
async settle(key, ms) {
|
|
141
|
+
return this.settleKeys([key], ms);
|
|
142
|
+
}
|
|
143
|
+
/** The same for every session (close-all). */
|
|
144
|
+
async settleAll(ms) {
|
|
145
|
+
return this.settleKeys([...this.running.keys()], ms);
|
|
146
|
+
}
|
|
147
|
+
async settleKeys(keys, ms) {
|
|
148
|
+
const work = keys.flatMap((k) => [...(this.running.get(k) ?? [])]);
|
|
149
|
+
if (work.length > 0) {
|
|
150
|
+
let timer;
|
|
151
|
+
await Promise.race([Promise.allSettled(work), new Promise((resolve) => (timer = setTimeout(resolve, ms)))]);
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
}
|
|
154
|
+
return keys.reduce((n, k) => n + this.count(k), 0);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The cap on every tool call's watchdog, from SCENESCOUT_WATCHDOG_MS. Unset
|
|
159
|
+
* means each tool keeps its own limit. Anything else must be a whole number
|
|
160
|
+
* of milliseconds of at least 100, or the server refuses to start: a typo
|
|
161
|
+
* here would otherwise run with limits nobody chose.
|
|
162
|
+
*/
|
|
163
|
+
export function watchdogCap(raw) {
|
|
164
|
+
if (raw === undefined || raw.trim() === "")
|
|
165
|
+
return undefined;
|
|
166
|
+
const ms = Number(raw);
|
|
167
|
+
if (!Number.isInteger(ms) || ms < 100)
|
|
168
|
+
throw new Error(`SCENESCOUT_WATCHDOG_MS must be a whole number of milliseconds, at least 100; got "${raw}"`);
|
|
169
|
+
return ms;
|
|
170
|
+
}
|