scenescout 3.1.1 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The lane report: what an agent exploring one area in parallel hands back to
3
+ * the agent that split the work, as typed decisions rather than prose.
4
+ *
5
+ * A parallel run has one planning agent and several lanes, each driving its
6
+ * own browser. A lane used to come back with a page of markdown, which the
7
+ * planner then had to read, re-judge and re-type into findings: a model pass
8
+ * over every lane's output, with nothing stopping a lane from inventing a
9
+ * category or writing "Low-Medium" as a severity.
10
+ *
11
+ * This module is the alternative, borrowed from decision-only models: every
12
+ * answer is a value from a closed set, carries a calibrated confidence, and
13
+ * is checked by a schema on arrival. The planner folds a report with a
14
+ * function call, not a reading.
15
+ *
16
+ * Both halves are reached through the scout_lane_report tool: with no reply
17
+ * it returns the instruction the planner puts in a lane's prompt, with one it
18
+ * parses it. The instruction is generated from the same constants the parser
19
+ * checks, and a table test asserts every value and every cap the parser
20
+ * enforces is in the text, because a limit the lane is not told refuses good
21
+ * replies: three of the first ten real replies were lost that way.
22
+ *
23
+ * Pure logic, no browser, so every rule here is table-tested.
24
+ */
25
+ import { z } from "zod";
26
+ import { FINDING_CATEGORIES } from "./memory.js";
27
+ /** Upper bound on the decisions and routes arrays, so a report is always small enough to hold whole. */
28
+ export const LANE_MAX_ITEMS = 255;
29
+ /** A signature such as "GET /api/things 500", not a sentence. */
30
+ export const LANE_EVIDENCE_MAX = 160;
31
+ /** A lane names its own observations, and a name that says what was seen needs more than forty characters. */
32
+ export const LANE_OBSERVATION_MAX = 64;
33
+ /** One line saying what blocked the lane; the detail belongs in a finding. */
34
+ export const LANE_BLOCKED_BY_MAX = 200;
35
+ /** The planner chooses the lane name, so this cap is on the planner's side; it is stated all the same. */
36
+ export const LANE_NAME_MAX = 40;
37
+ /** A route as the lane saw it, query string included. */
38
+ export const LANE_ROUTE_MAX = 200;
39
+ export const LANE_SEVERITIES = ["high", "medium", "low"];
40
+ /** The same set scout_finding accepts, so a lane can report every finding it filed. */
41
+ export const LANE_CATEGORIES = FINDING_CATEGORIES;
42
+ export const LANE_VERDICTS = ["defect", "not_a_defect", "unsure"];
43
+ export const LANE_STATUSES = ["complete", "partial", "blocked"];
44
+ const Confidence = z.number().min(0).max(1);
45
+ /**
46
+ * One observation judged: is it a defect, how bad, of what kind, and how sure
47
+ * the lane is. A non-defect may still carry a category and a signature: an
48
+ * "unsure" with a tentative category tells the planner where to look.
49
+ */
50
+ export const LaneDecision = z
51
+ .object({
52
+ /** A short id the lane assigns, unique within the report, so a decision can be traced back to what the lane saw. */
53
+ observation: z.string().min(1).max(LANE_OBSERVATION_MAX),
54
+ verdict: z.enum(LANE_VERDICTS),
55
+ severity: z.enum(LANE_SEVERITIES).nullable(),
56
+ category: z.enum(LANE_CATEGORIES).nullable(),
57
+ confidence: Confidence,
58
+ /** The same machine signature scout_finding takes as its cross-run dedup key. */
59
+ evidence: z.string().min(1).max(LANE_EVIDENCE_MAX).nullable(),
60
+ })
61
+ .strict()
62
+ .superRefine((d, ctx) => {
63
+ if (d.verdict === "defect" && (d.severity === null || d.category === null)) {
64
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "a defect needs a severity and a category" });
65
+ }
66
+ });
67
+ export const LaneReport = z
68
+ .object({
69
+ lane: z.string().min(1).max(LANE_NAME_MAX),
70
+ status: z.enum(LANE_STATUSES),
71
+ decisions: z.array(LaneDecision).max(LANE_MAX_ITEMS),
72
+ /** The routes the lane covered, as it saw them; the planner normalises when it compares them with the split. */
73
+ routes: z.array(z.string().min(1).max(LANE_ROUTE_MAX)).max(LANE_MAX_ITEMS),
74
+ /** Required when the status is "blocked"; a "partial" lane may use it to say what cut it short; a "complete" one has nothing to put there. */
75
+ blocked_by: z.string().min(1).max(LANE_BLOCKED_BY_MAX).nullable(),
76
+ })
77
+ .strict()
78
+ .superRefine((r, ctx) => {
79
+ if (r.status === "blocked" && r.blocked_by === null) {
80
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "a blocked lane must say what blocked it", path: ["blocked_by"] });
81
+ }
82
+ if (r.status === "complete" && r.blocked_by !== null) {
83
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "a complete lane cannot also have been blocked", path: ["blocked_by"] });
84
+ }
85
+ const seen = new Set();
86
+ r.decisions.forEach((d, i) => {
87
+ if (seen.has(d.observation)) {
88
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: `observation "${d.observation}" is judged twice`, path: ["decisions", i, "observation"] });
89
+ }
90
+ seen.add(d.observation);
91
+ });
92
+ });
93
+ const FENCE_OPEN = /^```[a-z]*\s*\n/i;
94
+ const FENCE_CLOSE = /\n?```\s*$/;
95
+ /**
96
+ * The reply is one JSON object and nothing else. A fenced block around it is
97
+ * tolerated, because every model has been trained to add one; text before or
98
+ * after it is not, because the point is that no one has to read the reply.
99
+ * The reason names what was wrong, since the planner relays it to the lane.
100
+ *
101
+ * With `expectedLane`, a report that names another lane is refused: the
102
+ * planner joins reports to the split by name, and a reply that carries the
103
+ * wrong one would be folded into the wrong lane.
104
+ */
105
+ export function parseLaneReport(text, expectedLane) {
106
+ let body = text.trim();
107
+ if (FENCE_OPEN.test(body)) {
108
+ if (!FENCE_CLOSE.test(body))
109
+ return { ok: false, reason: "the closing ``` must end the reply, with no text after it" };
110
+ body = body.replace(FENCE_OPEN, "").replace(FENCE_CLOSE, "").trim();
111
+ }
112
+ if (!body.startsWith("{"))
113
+ return { ok: false, reason: "the reply must be one JSON object, with no text before it" };
114
+ let raw;
115
+ try {
116
+ raw = JSON.parse(body);
117
+ }
118
+ catch (e) {
119
+ const message = e.message;
120
+ const trailing = /after JSON|Unexpected non-whitespace/i.test(message);
121
+ return { ok: false, reason: trailing ? "the reply must be one JSON object, with no text after it" : `not valid JSON: ${message}` };
122
+ }
123
+ const result = LaneReport.safeParse(raw);
124
+ if (!result.success) {
125
+ const issue = result.error.issues[0];
126
+ const at = issue.path.length ? ` at ${issue.path.join(".")}` : "";
127
+ return { ok: false, reason: `${issue.message}${at}` };
128
+ }
129
+ if (expectedLane !== undefined && result.data.lane !== expectedLane) {
130
+ return { ok: false, reason: `the report names lane "${result.data.lane}", but this reply was asked of lane "${expectedLane}"` };
131
+ }
132
+ return { ok: true, report: result.data };
133
+ }
134
+ /**
135
+ * The paragraph the planner puts in every lane's prompt. It is generated from
136
+ * the same constants the parser checks, so it can never list a value the
137
+ * parser would refuse, and it states every cap the parser enforces.
138
+ */
139
+ export function laneReportInstruction(lane) {
140
+ return [
141
+ "Reply with ONE JSON object and nothing else — no prose before or after it, no explanation, no headings. A fenced ```json block is fine.",
142
+ `Shape: {"lane":${JSON.stringify(lane)},"status":<${quoteAll(LANE_STATUSES)}>,"decisions":[…],"routes":[…],"blocked_by":<string or null>}.`,
143
+ `Each decision: {"observation":<a short id for what was observed, unique in the report, at most ${LANE_OBSERVATION_MAX} characters>,"verdict":<${quoteAll(LANE_VERDICTS)}>,"severity":<${quoteAll(LANE_SEVERITIES)} or null>,"category":<${quoteAll(LANE_CATEGORIES)} or null>,"confidence":<0..1>,"evidence":<machine signature such as "GET /api/things 500", or null>}.`,
144
+ `A "defect" must carry a severity and a category. "evidence" is a signature, not a sentence: at most ${LANE_EVIDENCE_MAX} characters. "confidence" is how sure you are of the verdict, calibrated: 0.5 means a coin flip, 0.95 means you would bet on it.`,
145
+ `"routes" lists the routes you covered, each at most ${LANE_ROUTE_MAX} characters. "blocked_by" is one line of at most ${LANE_BLOCKED_BY_MAX} characters saying what stopped you: required when the status is "blocked", allowed with "partial", null with "complete"; the detail belongs in a finding. The lane name is at most ${LANE_NAME_MAX} characters. At most ${LANE_MAX_ITEMS} decisions and ${LANE_MAX_ITEMS} routes. Unknown keys are refused.`,
146
+ "The object IS your final report: whatever hands it back must hand back the object verbatim, not a summary of it.",
147
+ ].join(" ");
148
+ }
149
+ function quoteAll(values) {
150
+ return values.map((v) => `"${v}"`).join("|");
151
+ }
152
+ /** The planner's fold: one line per lane, with the numbers it decides on. */
153
+ export function summarizeLaneReport(r) {
154
+ const defects = r.decisions.filter((d) => d.verdict === "defect");
155
+ const high = defects.filter((d) => d.severity === "high").length;
156
+ const unsure = r.decisions.filter((d) => d.verdict === "unsure").length;
157
+ const mean = r.decisions.length ? r.decisions.reduce((s, d) => s + d.confidence, 0) / r.decisions.length : 0;
158
+ const parts = [
159
+ r.status,
160
+ `${r.decisions.length} judged`,
161
+ `${defects.length} defects (${high} high)`,
162
+ `${unsure} unsure`,
163
+ `mean confidence ${mean.toFixed(2)}`,
164
+ `${r.routes.length} routes`,
165
+ ];
166
+ if (r.blocked_by)
167
+ parts.push(`blocked by ${r.blocked_by}`);
168
+ return `${r.lane}: ${parts.join(", ")}`;
169
+ }
@@ -58,6 +58,10 @@ export const LIVE_PAGE = `<!doctype html>
58
58
  color: #fff; background: var(--accent); border-radius: 6px; }
59
59
  main { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); gap: 12px; padding: 16px; }
60
60
  .card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; }
61
+ /* Every rule here that sets display beats the browser's own [hidden] rule,
62
+ so a hidden card stayed on screen with the "nothing matches" notice above
63
+ it. Say it once, for everything. */
64
+ [hidden] { display: none !important; }
61
65
  .card.stuck { border-color: var(--stuck); }
62
66
  .top { display: flex; align-items: center; gap: 8px; padding: 10px 12px 6px; }
63
67
  .name { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -118,6 +122,10 @@ export const LIVE_PAGE = `<!doctype html>
118
122
  .doing { padding: 0 12px 4px; font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
119
123
  .doing::before { content: "▸ "; color: var(--accent); }
120
124
  .doing.unset { color: var(--stuck); }
125
+ .pace { padding: 0 12px 4px; font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
126
+ .pace .bad { color: var(--stuck); }
127
+ header input#filter { font: inherit; font-size: 12px; padding: 4px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--text); min-width: 150px; }
128
+ #no-match { padding: 16px; color: var(--muted); }
121
129
  #focus .feed .a { color: #e6e9ee; }
122
130
  #focus .feed .t, #focus .feed .d, #focus .feed .none { color: #98a2b3; }
123
131
  #focus .feed .bad { color: #fca5a5; }
@@ -188,12 +196,14 @@ export const LIVE_PAGE = `<!doctype html>
188
196
  <span class="spacer"></span>
189
197
  <span class="meta" id="counts" data-testid="live-session-counts"></span>
190
198
  <span class="actions">
199
+ <input type="search" id="filter" placeholder="Filter sessions" aria-label="Filter sessions by name, role, objective, task or page" data-testid="live-filter" />
191
200
  <button type="button" id="report-open" data-testid="live-report-toggle">Report</button>
192
201
  <button type="button" id="all" aria-pressed="false" data-testid="live-all-toggle">Stream all</button>
193
202
  </span>
194
203
  </header>
195
204
  <div id="banner" role="alert" data-testid="live-unreachable-banner">The engine is not answering. It may have exited; this page will pick up again if it comes back.</div>
196
205
  <div id="empty" data-testid="live-empty-state">No session is attached yet. Cards appear here as soon as one attaches.</div>
206
+ <div id="no-match" data-testid="live-no-match" hidden></div>
197
207
  <div id="finished" data-testid="live-finished-state">
198
208
  <h2>The run has finished</h2>
199
209
  <p>Its browsers are closed, so there is nothing left to watch. What it found is in the report.</p>
@@ -240,6 +250,8 @@ export const LIVE_PAGE = `<!doctype html>
240
250
  var THUMB_EVERY_MS = 3000;
241
251
  var cards = {};
242
252
  var streamAll = false;
253
+ /** The header filter, lower-cased. Hides cards; never stops a session running. */
254
+ var filter = '';
243
255
  var focused = null;
244
256
  var skew = 0;
245
257
  var latest = {};
@@ -364,6 +376,14 @@ export const LIVE_PAGE = `<!doctype html>
364
376
  task.setAttribute('data-testid', 'live-card-objective-' + name);
365
377
  var doing = el('div', 'doing');
366
378
  doing.setAttribute('data-testid', 'live-card-task-' + name);
379
+ // How long it has been on this task, and how many of its recent steps went
380
+ // wrong. Both were already in the status payload and reached nobody: the
381
+ // first only inside the close-up, the second only as a red word in a feed
382
+ // somebody had to read. On a board of eleven cards, "which one is stuck on
383
+ // the same thing, and which one is having trouble" is the question being
384
+ // asked, and it was the one thing the board could not answer.
385
+ var pace = el('div', 'pace');
386
+ pace.setAttribute('data-testid', 'live-card-pace-' + name);
367
387
  var tool = el('div', 'line');
368
388
  var url = el('div', 'line');
369
389
  var shot = el('button', 'shot');
@@ -386,9 +406,9 @@ export const LIVE_PAGE = `<!doctype html>
386
406
  toggle.setAttribute('data-testid', 'live-card-toggle-' + name);
387
407
  var spec = el('span', 'spec');
388
408
  foot.appendChild(toggle); foot.appendChild(spec);
389
- root.appendChild(top); root.appendChild(task); root.appendChild(doing); root.appendChild(tool); root.appendChild(url); root.appendChild(shot); root.appendChild(feed); root.appendChild(foot);
409
+ root.appendChild(top); root.appendChild(task); root.appendChild(doing); root.appendChild(pace); root.appendChild(tool); root.appendChild(url); root.appendChild(shot); root.appendChild(feed); root.appendChild(foot);
390
410
 
391
- var card = { name: name, root: root, role: role, badge: badge, task: task, doing: doing, tool: tool, url: url, shot: shot, img: img, feed: feed, toggle: toggle, spec: spec, live: false };
411
+ var card = { name: name, root: root, role: role, badge: badge, task: task, doing: doing, pace: pace, tool: tool, url: url, shot: shot, img: img, feed: feed, toggle: toggle, spec: spec, live: false };
392
412
  toggle.addEventListener('click', function () { setLive(card, !card.live); });
393
413
  shot.addEventListener('click', function () { openFocus(name); });
394
414
  img.src = shotUrl(name);
@@ -735,9 +755,31 @@ export const LIVE_PAGE = `<!doctype html>
735
755
  card.url.textContent = s.url || '(no page yet)';
736
756
  card.url.title = s.url || '';
737
757
  card.spec.textContent = [s.mode, s.browser, s.headed ? 'headed' : 'headless'].filter(Boolean).join(' · ');
758
+ paintPace(card, s);
738
759
  renderFeed(card.feed, s.feed);
739
760
  }
740
761
 
762
+ /** Steps in the visible feed whose result reads as trouble. */
763
+ function troubled(feed) {
764
+ var n = 0;
765
+ (feed || []).forEach(function (line) { if (BAD_RESULT.test(line.result || '')) n += 1; });
766
+ return n;
767
+ }
768
+
769
+ function paintPace(card, s) {
770
+ var on = s.task && s.taskSince ? 'on this for ' + held(Date.now() + skew - Date.parse(s.taskSince)) : '';
771
+ var bad = troubled(s.feed);
772
+ card.pace.textContent = '';
773
+ if (on) card.pace.appendChild(el('span', '', on));
774
+ if (bad > 0) {
775
+ if (on) card.pace.appendChild(el('span', '', ' · '));
776
+ // Only the trouble is red. Reddening the whole line made "on this for
777
+ // 9s" look like the complaint.
778
+ card.pace.appendChild(el('span', 'bad', bad + ' of the last ' + s.feed.length + ' steps went wrong'));
779
+ }
780
+ card.pace.hidden = !on && bad === 0;
781
+ }
782
+
741
783
  function openFocus(name) {
742
784
  focused = name;
743
785
  scrubbed = null;
@@ -827,6 +869,32 @@ export const LIVE_PAGE = `<!doctype html>
827
869
  focusTick += 1;
828
870
  }
829
871
 
872
+ /**
873
+ * Whether a session survives the header's filter. Matched against everything
874
+ * the card already shows, because on a board of eleven the reader is looking
875
+ * for "the one on the orders register" as often as for a session by name.
876
+ * Filtering hides cards; it never stops them being polled or streamed, so a
877
+ * hidden session is still running and still counted in the header.
878
+ */
879
+ function matches(s) {
880
+ if (!filter) return true;
881
+ var hay = [s.session, s.role, s.objective, s.task, s.url, s.tool].join(' ').toLowerCase();
882
+ return hay.indexOf(filter) !== -1;
883
+ }
884
+
885
+ function applyFilter() {
886
+ var shown = 0;
887
+ Object.keys(cards).forEach(function (name) {
888
+ var s = latest[name];
889
+ var keep = !s || matches(s);
890
+ cards[name].root.hidden = !keep;
891
+ if (keep) shown += 1;
892
+ });
893
+ var none = document.getElementById('no-match');
894
+ none.hidden = !filter || shown > 0 || Object.keys(cards).length === 0;
895
+ none.textContent = 'No session matches ' + JSON.stringify(filter) + '.';
896
+ }
897
+
830
898
  function apply(snap) {
831
899
  skew = Date.parse(snap.at) - Date.now();
832
900
  var grid = document.getElementById('grid');
@@ -884,6 +952,7 @@ export const LIVE_PAGE = `<!doctype html>
884
952
  document.getElementById('engine').textContent = 'engine pid ' + snap.pid + ' · v' + snap.version;
885
953
  document.getElementById('counts').textContent = snap.sessions.length + ' session' + (snap.sessions.length === 1 ? '' : 's') +
886
954
  ' · ' + counts.running + ' running · ' + counts.idle + ' idle' + (counts.stuck ? ' · ' + counts.stuck + ' stuck' : '');
955
+ applyFilter();
887
956
  paintFocus();
888
957
  }
889
958
 
@@ -927,7 +996,37 @@ export const LIVE_PAGE = `<!doctype html>
927
996
  e.returnValue = '';
928
997
  });
929
998
  document.getElementById('report-close').addEventListener('click', closeReport);
999
+ document.getElementById('filter').addEventListener('input', function (e) {
1000
+ filter = String(e.target.value || '').trim().toLowerCase();
1001
+ applyFilter();
1002
+ });
1003
+
1004
+ /**
1005
+ * Step through the timeline from the keyboard.
1006
+ *
1007
+ * Scrubbing a long run by clicking 16px ticks is the kind of thing a mouse
1008
+ * is bad at, and the run being examined is usually the one with hundreds of
1009
+ * steps. Arrow keys move one step, Home and End jump to the ends, and Space
1010
+ * returns to the live picture — only while the close-up is open, and never
1011
+ * while the reader is typing in the filter.
1012
+ */
1013
+ function stepBy(delta) {
1014
+ if (timelineLines.length === 0) return;
1015
+ var at = scrubbed
1016
+ ? timelineLines.findIndex(function (l) { return l.at === scrubbed.at && l.action === scrubbed.action; })
1017
+ : timelineLines.length - 1;
1018
+ var next = Math.max(0, Math.min(timelineLines.length - 1, (at === -1 ? timelineLines.length - 1 : at) + delta));
1019
+ showStep(timelineLines[next]);
1020
+ }
1021
+
930
1022
  document.addEventListener('keydown', function (e) {
1023
+ if (focused && !reportOpen && e.target !== document.getElementById('filter')) {
1024
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); stepBy(e.key === 'ArrowLeft' ? -1 : 1); return; }
1025
+ if (e.key === 'Home' || e.key === 'End') { e.preventDefault(); stepBy(e.key === 'Home' ? -1e9 : 1e9); return; }
1026
+ // Back to what the session is showing NOW, which is otherwise a click
1027
+ // on a button the reader has to find.
1028
+ if (e.key === ' ' && scrubbed) { e.preventDefault(); backToLive(); return; }
1029
+ }
931
1030
  if (e.key !== 'Escape') return;
932
1031
  if (reportOpen) closeReport();
933
1032
  else if (focused) closeFocus();
@@ -30,6 +30,10 @@ import { LIVE_PAGE } from "./live-page.js";
30
30
  import { JOURNEY_END, JOURNEY_START, TASK_SET } from "./memory.js";
31
31
  /** Holds the live view's token, next to status.json. Written owner-only; removed when the engine shuts down. */
32
32
  export const LIVE_TOKEN_FILE = "live-token";
33
+ /** This engine's own token file. Shares the directory with other engines, so it carries the pid. */
34
+ export function liveTokenFileName(pid) {
35
+ return `${LIVE_TOKEN_FILE}.${pid}`;
36
+ }
33
37
  /** `SCENESCOUT_LIVE=off` keeps the engine from opening the live view's port at all. */
34
38
  export const LIVE_ENV = "SCENESCOUT_LIVE";
35
39
  /**
@@ -178,12 +182,68 @@ const statusWrites = new Map();
178
182
  * longer one after it (which is what overlapping `writeFile`s produced).
179
183
  * Best-effort: a failed write is dropped and the next one lands.
180
184
  */
185
+ /**
186
+ * Whether a process is still there. EPERM means it EXISTS but belongs to
187
+ * another user; only ESRCH means no such process, and treating both as dead
188
+ * reported a live engine as stale.
189
+ */
190
+ export function pidAlive(pid) {
191
+ if (!pid)
192
+ return false;
193
+ try {
194
+ process.kill(pid, 0);
195
+ return true;
196
+ }
197
+ catch (err) {
198
+ return err?.code === "EPERM";
199
+ }
200
+ }
201
+ export function statusFileName(pid) {
202
+ return `status.${pid}.json`;
203
+ }
204
+ /**
205
+ * Every engine that has written a status file here, newest first, one entry
206
+ * per pid. Two engines on one project used to overwrite each other in a single
207
+ * status.json, so `watch` could only ever find whichever attached last and the
208
+ * other run was reachable only from the transcript that started it.
209
+ * status.json is still written for readers from before this, and is used only
210
+ * when no per-pid file exists.
211
+ */
212
+ export function liveEngines(dir, isAlive) {
213
+ let names = [];
214
+ try {
215
+ names = fs.readdirSync(dir);
216
+ }
217
+ catch {
218
+ return [];
219
+ }
220
+ const perPid = names.filter((n) => /^status\.\d+\.json$/.test(n));
221
+ const out = [];
222
+ for (const name of perPid.length > 0 ? perPid : names.filter((n) => n === "status.json")) {
223
+ let parsed;
224
+ try {
225
+ parsed = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
226
+ }
227
+ catch {
228
+ continue;
229
+ }
230
+ const pid = typeof parsed.pid === "number" ? parsed.pid : NaN;
231
+ if (!Number.isInteger(pid) || !isAlive(pid))
232
+ continue;
233
+ out.push({ pid, status: parsed, file: name });
234
+ }
235
+ return out.sort((a, b) => (b.status.at ?? "").localeCompare(a.status.at ?? ""));
236
+ }
181
237
  export function writeStatusFile(dir, body) {
182
238
  const file = path.join(dir, "status.json");
183
239
  const tmp = `${file}.${process.pid}.tmp`;
240
+ // Both: status.json for readers from before per-pid files, and one named for
241
+ // this process so a second engine on the same project does not erase it.
242
+ const mine = path.join(dir, statusFileName(process.pid));
184
243
  const next = (statusWrites.get(dir) ?? Promise.resolve())
185
244
  .then(() => fs.promises.writeFile(tmp, body))
186
245
  .then(() => fs.promises.rename(tmp, file))
246
+ .then(() => fs.promises.writeFile(mine, body))
187
247
  .catch(() => fs.promises.rm(tmp, { force: true }).catch(() => { }));
188
248
  statusWrites.set(dir, next);
189
249
  return next;
@@ -1,6 +1,71 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { normalizePath, shortHash } from "./fingerprint.js";
4
+ /**
5
+ * The kinds a finding can be. One list: scout_finding's input schema, the lane
6
+ * report a parallel agent hands back, and the skill text all read it from
7
+ * here, so a category cannot exist in one and be refused by another.
8
+ */
9
+ export const FINDING_CATEGORIES = [
10
+ "console-error",
11
+ "page-error",
12
+ "http-error",
13
+ "network",
14
+ "dead-end",
15
+ "ux-confusing",
16
+ "ux-polish",
17
+ "visual",
18
+ "a11y",
19
+ "permission-leak",
20
+ "data-inconsistency",
21
+ "stale-state",
22
+ "data-loss",
23
+ "performance",
24
+ "security",
25
+ "missing-testid",
26
+ "other",
27
+ ];
28
+ /**
29
+ * Most states a route may keep. A route accumulates one state per distinct
30
+ * element set, so a register with filters, tabs and paging produces dozens;
31
+ * one project reached 6,075 states across 48 routes, a 36 MB history parsed
32
+ * and re-serialised on every save. Coverage is asked per ROUTE, so keeping the
33
+ * most recent states of each route preserves every answer the gap ledger
34
+ * needs while dropping the long tail nothing will ask about again.
35
+ */
36
+ export const MAX_STATES_PER_ROUTE = 40;
37
+ /**
38
+ * The states to keep. Never drops one a finding points at — a finding's repro
39
+ * trace and its route identity are read back from it — and never drops the
40
+ * newest of a route, so a route that was visited stays visited.
41
+ *
42
+ * Returns the pruned map rather than mutating, so the rule can be table-tested.
43
+ */
44
+ export function pruneStates(states, findings, perRoute = MAX_STATES_PER_ROUTE) {
45
+ const pinned = new Set(findings.map((f) => f.state));
46
+ const byRoute = new Map();
47
+ for (const entry of Object.entries(states)) {
48
+ const route = entry[1].route;
49
+ const list = byRoute.get(route);
50
+ if (list)
51
+ list.push(entry);
52
+ else
53
+ byRoute.set(route, [entry]);
54
+ }
55
+ const kept = {};
56
+ let dropped = 0;
57
+ for (const list of byRoute.values()) {
58
+ // Newest first, so the survivors are the ones a next run will meet again.
59
+ list.sort((a, b) => (b[1].lastSeen ?? b[1].firstSeen).localeCompare(a[1].lastSeen ?? a[1].firstSeen));
60
+ list.forEach(([fp, rec], index) => {
61
+ if (index < perRoute || pinned.has(fp))
62
+ kept[fp] = rec;
63
+ else
64
+ dropped += 1;
65
+ });
66
+ }
67
+ return { kept, dropped };
68
+ }
4
69
  /** An element class must appear on this many routes at minimum before it can count as shared chrome. */
5
70
  const CHROME_MIN_ROUTES = 4;
6
71
  /** …and on at least this share of all visited routes (a majority — "it's on every page"). */
@@ -660,9 +725,34 @@ export class MemoryStore {
660
725
  constraints: "Constraints — rules discovered the hard way (gates, preconditions, limits)",
661
726
  risks: "Risks & watchpoints — fragile areas worth re-testing every run",
662
727
  glossary: "Glossary — domain terms and what they mean here",
728
+ setup: "Setup — how to get this app into a testable state (how a login state is regenerated, what has to be running)",
663
729
  };
664
730
  return `## ${titles[section] ?? section}`;
665
731
  }
732
+ /**
733
+ * What earlier runs recorded about getting this app testable, if anything.
734
+ *
735
+ * Read back by the auth-failure message. A storage state expires on a timer
736
+ * nobody remembers, and "regenerate it" is advice the reader already had;
737
+ * the command that worked last time is the part worth keeping, and it is
738
+ * exactly the kind of thing a run pays to find out and then forgets.
739
+ */
740
+ setupRecipe() {
741
+ if (!fs.existsSync(this.assumptionsPath))
742
+ return [];
743
+ const text = fs.readFileSync(this.assumptionsPath, "utf8");
744
+ const heading = MemoryStore.sectionHeading("setup");
745
+ const start = text.indexOf(heading);
746
+ if (start === -1)
747
+ return [];
748
+ const rest = text.slice(start + heading.length);
749
+ const end = rest.indexOf("\n## ");
750
+ return (end === -1 ? rest : rest.slice(0, end))
751
+ .split("\n")
752
+ .map((l) => l.trim())
753
+ .filter((l) => l.startsWith("- "))
754
+ .map((l) => l.slice(2).trim());
755
+ }
666
756
  readAssumptions() {
667
757
  if (!fs.existsSync(this.assumptionsPath))
668
758
  return "(no ASSUMPTIONS.md yet — record what you learn with scout_note as you explore)";
@@ -701,13 +791,43 @@ export class MemoryStore {
701
791
  this.flush();
702
792
  return f;
703
793
  }
794
+ /**
795
+ * Record what a re-test of this finding found. "gone" resolves it; the other
796
+ * two leave it open and stamp the confirmation, which is what lets the report
797
+ * stop describing a finding somebody checked yesterday as unverified.
798
+ */
799
+ verifyFinding(id, verdict, note) {
800
+ const f = this.data.findings.find((x) => x.id === id);
801
+ if (!f)
802
+ return null;
803
+ f.verdict = verdict;
804
+ f.verifiedAt = new Date().toISOString();
805
+ if (note)
806
+ f.verifyNote = redactSecrets(note).slice(0, 500);
807
+ else
808
+ delete f.verifyNote;
809
+ if (verdict === "gone")
810
+ f.status = "resolved";
811
+ this.flush();
812
+ return f;
813
+ }
704
814
  load() {
705
815
  if (!fs.existsSync(this.memoryPath))
706
816
  return structuredClone(EMPTY);
707
817
  try {
708
818
  const raw = JSON.parse(fs.readFileSync(this.memoryPath, "utf8"));
709
- if (raw.version === 1)
819
+ if (raw.version === 1) {
820
+ // Prune once, on open: the long tail of per-route states is what makes
821
+ // an old history slow to parse and re-serialise, and it answers no
822
+ // question the gap ledger asks. Findings and the newest states of
823
+ // every route are kept, so coverage does not regress.
824
+ const { kept, dropped } = pruneStates(raw.states ?? {}, raw.findings ?? []);
825
+ if (dropped > 0) {
826
+ raw.states = kept;
827
+ this.prunedStates = dropped;
828
+ }
710
829
  return raw;
830
+ }
711
831
  this.loadWarning = `memory.json has unknown version ${String(raw.version)} — starting fresh.`;
712
832
  }
713
833
  catch (err) {
@@ -759,6 +879,8 @@ export class MemoryStore {
759
879
  // Deliberately NOT unref'd: a pending coverage write briefly holds the
760
880
  // process open so an exit without scout_close still lands the last save.
761
881
  }
882
+ /** How many states the last open pruned. Reported once, so a shrinking history is never silent. */
883
+ prunedStates = 0;
762
884
  /** Set when a debounced background write failed — cleared on the next successful write. Surfaced by scout_coverage/scout_close so a broken persistence path is never silently invisible. */
763
885
  lastSaveError = null;
764
886
  /**
@@ -883,6 +1005,7 @@ export class MemoryStore {
883
1005
  this.data.states[fingerprint] = rec;
884
1006
  }
885
1007
  rec.visits += 1;
1008
+ rec.lastSeen = new Date().toISOString();
886
1009
  const present = new Set(elementKeys);
887
1010
  for (const key of elementKeys) {
888
1011
  if (!rec.elements[key])
@@ -137,6 +137,15 @@ export class OracleMonitor {
137
137
  noteInjection(detail, url) {
138
138
  this.record({ kind: "dom_injection", severity: "high", detail, url });
139
139
  }
140
+ /**
141
+ * The page contradicted a request that was refused during the same action:
142
+ * a refused list rendered as an empty state, or a refused save reported as a
143
+ * success. Found by the engine's DOM scan (claims.ts), like injections, so
144
+ * it is reported through here rather than by a page event.
145
+ */
146
+ noteContradiction(c, url) {
147
+ this.record({ kind: c.kind, severity: "high", detail: c.detail, url });
148
+ }
140
149
  record(v) {
141
150
  if (isPolicyInduced(v, this.lastPolicyBlockAt === null ? null : Date.now() - this.lastPolicyBlockAt)) {
142
151
  this.policyAttributed += 1;