scenescout 3.3.0 → 3.5.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.
@@ -90,12 +90,21 @@ export const LaneReport = z
90
90
  seen.add(d.observation);
91
91
  });
92
92
  });
93
- const FENCE_OPEN = /^```[a-z]*\s*\n/i;
94
- const FENCE_CLOSE = /\n?```\s*$/;
93
+ const FENCE_OPEN = /^```[a-z]*\s*\r?\n/i;
94
+ const FENCE_CLOSE = /\r?\n?```\s*$/;
95
+ /** Every fenced block in a reply, with its contents. */
96
+ const FENCED_BLOCK = /```[a-z]*[ \t]*\r?\n([\s\S]*?)\r?\n?```/gi;
95
97
  /**
96
98
  * 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
+ * tolerated, because every model has been trained to add one.
100
+ *
101
+ * Prose around ONE fenced JSON block is tolerated too, and dropped unread. The
102
+ * rule used to refuse it, and in a measured run six of eight lanes did it
103
+ * anyway: each refusal cost the planner a round trip or a hand-unwrap, to
104
+ * recover an object that was already unambiguous. The point — nobody has to
105
+ * READ the reply — survives, because the prose is never looked at. Unfenced
106
+ * prose is still refused, because where the object starts and ends is then a
107
+ * guess, and so is more than one fenced object.
99
108
  * The reason names what was wrong, since the planner relays it to the lane.
100
109
  *
101
110
  * With `expectedLane`, a report that names another lane is refused: the
@@ -104,13 +113,19 @@ const FENCE_CLOSE = /\n?```\s*$/;
104
113
  */
105
114
  export function parseLaneReport(text, expectedLane) {
106
115
  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" };
116
+ let aroundIgnored = false;
117
+ if (FENCE_OPEN.test(body) && FENCE_CLOSE.test(body) && [...body.matchAll(FENCED_BLOCK)].length === 1) {
110
118
  body = body.replace(FENCE_OPEN, "").replace(FENCE_CLOSE, "").trim();
111
119
  }
112
- if (!body.startsWith("{"))
113
- return { ok: false, reason: "the reply must be one JSON object, with no text before it" };
120
+ else if (!body.startsWith("{")) {
121
+ const objects = [...body.matchAll(FENCED_BLOCK)].map((m) => m[1].trim()).filter((b) => b.startsWith("{"));
122
+ if (objects.length > 1)
123
+ return { ok: false, reason: `the reply holds ${objects.length} fenced JSON blocks; hand back exactly one object` };
124
+ if (objects.length === 0)
125
+ return { ok: false, reason: "the reply must be one JSON object, with no text before it" };
126
+ body = objects[0];
127
+ aroundIgnored = true;
128
+ }
114
129
  let raw;
115
130
  try {
116
131
  raw = JSON.parse(body);
@@ -129,7 +144,7 @@ export function parseLaneReport(text, expectedLane) {
129
144
  if (expectedLane !== undefined && result.data.lane !== expectedLane) {
130
145
  return { ok: false, reason: `the report names lane "${result.data.lane}", but this reply was asked of lane "${expectedLane}"` };
131
146
  }
132
- return { ok: true, report: result.data };
147
+ return { ok: true, report: result.data, aroundIgnored };
133
148
  }
134
149
  /**
135
150
  * The paragraph the planner puts in every lane's prompt. It is generated from
@@ -138,14 +153,28 @@ export function parseLaneReport(text, expectedLane) {
138
153
  */
139
154
  export function laneReportInstruction(lane) {
140
155
  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.",
156
+ ...LANE_RUBRIC,
157
+ // The ONLY lane-specific sentence, and it comes last on purpose. Every
158
+ // lane in a wave is given the same rubric, so keeping it byte-identical up
159
+ // to here makes it one shared prompt prefix: the cache hits from the
160
+ // second lane onward instead of diverging at the first sentence, which is
161
+ // what putting the name in the shape line used to do.
162
+ `Your lane name is ${JSON.stringify(lane)}; put exactly that in "lane".`,
147
163
  ].join(" ");
148
164
  }
165
+ /**
166
+ * Everything every lane is told, identical for all of them. Built once from
167
+ * the same constants the parser enforces, because a limit a lane is not told
168
+ * refuses good replies.
169
+ */
170
+ const LANE_RUBRIC = [
171
+ "Reply with ONE JSON object and nothing else — no prose before or after it, no explanation, no headings. A fenced ```json block is fine; anything outside it is discarded unread, so put nothing there you want kept.",
172
+ `Shape: {"lane":<your lane name>,"status":<${quoteAll(LANE_STATUSES)}>,"decisions":[…],"routes":[…],"blocked_by":<string or null>}.`,
173
+ `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>}.`,
174
+ `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.`,
175
+ `"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.`,
176
+ "The object IS your final report: whatever hands it back must hand back the object verbatim, not a summary of it.",
177
+ ];
149
178
  function quoteAll(values) {
150
179
  return values.map((v) => `"${v}"`).join("|");
151
180
  }
@@ -250,8 +250,39 @@ export function mergeMemory(mine, theirs) {
250
250
  }
251
251
  if (Object.keys(out.roleAccess).length === 0)
252
252
  delete out.roleAccess;
253
+ // Lanes commonly run in SEPARATE processes — that is the point of a lane —
254
+ // so without this the spread at the top would keep one process's decisions
255
+ // and drop every other lane's, which is the exact bug this merge exists to
256
+ // prevent for findings. Keyed so merging the same foreign document twice is
257
+ // idempotent.
258
+ const byDecision = new Map();
259
+ for (const d of theirs.laneDecisions ?? [])
260
+ byDecision.set(decisionKey(d), d);
261
+ for (const d of mine.laneDecisions ?? [])
262
+ byDecision.set(decisionKey(d), d);
263
+ out.laneDecisions = [...byDecision.values()].sort((a, b) => a.at.localeCompare(b.at)).slice(-MAX_LANE_DECISIONS);
264
+ if (out.laneDecisions.length === 0)
265
+ delete out.laneDecisions;
253
266
  return out;
254
267
  }
268
+ /**
269
+ * What makes two stored decisions the same judgement.
270
+ *
271
+ * Deliberately NOT the timestamp. `at` is stamped when the planner folds the
272
+ * reply, not when the lane judged, so relaying one reply twice — which the
273
+ * protocol invites, since a refused report is asked for again — wrote every
274
+ * decision a second time and doubled the lane's weight in the calibration.
275
+ * Identity is what was decided, so re-folding the same reply is a no-op.
276
+ */
277
+ function decisionKey(d) {
278
+ return [d.lane, d.observation, d.verdict, d.severity ?? "", d.category ?? "", d.confidence, d.evidence ?? ""].join("|");
279
+ }
280
+ /**
281
+ * Most lane decisions kept. Calibration wants a few dozen; a long-lived
282
+ * project would otherwise accumulate every decision ever made and re-serialise
283
+ * them on each save, which is what made an old history slow to open.
284
+ */
285
+ export const MAX_LANE_DECISIONS = 1000;
255
286
  const MAX_DISCOVERED_ROUTES = 300;
256
287
  /** Shared finding-similarity helpers (used by live dedup and retro-merge). */
257
288
  function findingTokens(s) {
@@ -295,7 +326,22 @@ function findingLiterals(...texts) {
295
326
  * (`/api/users/7` and `/api/users/9` are one endpoint) so a per-record repro
296
327
  * does not read as a per-record bug.
297
328
  */
298
- function endpointSignatures(evidence) {
329
+ /**
330
+ * The signatures that identify a BUG: only those carrying a failure status.
331
+ *
332
+ * A bare `POST /api/orders` says which endpoint was involved, not what went
333
+ * wrong — a double submit and an accepted negative quantity both name it, and
334
+ * are two bugs. Exported so calibration applies the store's own rule instead
335
+ * of a second copy of this regex, which is what it had.
336
+ */
337
+ export function failingSignatures(evidence) {
338
+ return new Set([...endpointSignatures(evidence)].filter((sig) => /\s[45]\d{2}$/.test(sig)));
339
+ }
340
+ /**
341
+ * The endpoint signatures a piece of evidence names: method + normalised path,
342
+ * with the failure status when one follows.
343
+ */
344
+ export function endpointSignatures(evidence) {
299
345
  const out = new Set();
300
346
  if (!evidence)
301
347
  return out;
@@ -338,11 +384,10 @@ function sharesEndpointSignature(a, b) {
338
384
  // `POST /api/orders` (the endpoint answered 2xx, or no status was named)
339
385
  // says which endpoint was involved, not what went wrong: a double submit
340
386
  // and an accepted negative quantity both name it, and are two bugs.
341
- const failing = (evidence) => new Set([...endpointSignatures(evidence)].filter((sig) => /\s[45]\d{2}$/.test(sig)));
342
- const aSigs = failing(a.evidence);
387
+ const aSigs = failingSignatures(a.evidence);
343
388
  if (aSigs.size === 0)
344
389
  return false;
345
- for (const sig of failing(b.evidence))
390
+ for (const sig of failingSignatures(b.evidence))
346
391
  if (aSigs.has(sig))
347
392
  return true;
348
393
  return false;
@@ -522,6 +567,35 @@ export class MemoryStore {
522
567
  ownedIds = new Map();
523
568
  /** Human-readable creation log for this run — becomes the report's cleanup list. */
524
569
  createdResources = [];
570
+ /**
571
+ * Markup-shaped values any session of this run typed, and the injections
572
+ * already reported. Shared for the same reason ownership is: a parallel run
573
+ * splits the app by route, so the lane that types a payload into a create
574
+ * form is rarely the lane that opens the list rendering it, and a probe kept
575
+ * per session could only ever catch a reflected injection. Not persisted: a
576
+ * payload typed in an earlier run is not this run's evidence.
577
+ */
578
+ probes = [];
579
+ injectionsReported = new Set();
580
+ /**
581
+ * Design audits any session of this run performed. The report's gate asked
582
+ * the session that happened to call scout_report, so a run whose lanes
583
+ * audited every page was refused because the planner's own session had not.
584
+ * Not persisted: an audit from an earlier run does not satisfy this one.
585
+ */
586
+ auditsThisRun = 0;
587
+ /**
588
+ * End the run: forget what only this run's sessions know. Called when the
589
+ * last session on this project closes. The store itself outlives it — the
590
+ * server keeps one per project for the life of the process — so without
591
+ * this, a second run in the same process passed the audit gate on the first
592
+ * run's audit and stayed silent about an injection the first run reported.
593
+ */
594
+ endRun() {
595
+ this.probes = [];
596
+ this.injectionsReported.clear();
597
+ this.auditsThisRun = 0;
598
+ }
525
599
  constructor(projectDir) {
526
600
  this.dir = path.join(projectDir, MEMORY_DIRNAME);
527
601
  this.legacyDirNote = adoptLegacyMemoryDir(projectDir);
@@ -782,6 +856,45 @@ export class MemoryStore {
782
856
  fs.writeFileSync(this.assumptionsPath, content);
783
857
  return true;
784
858
  }
859
+ /** What the lanes decided, oldest first. Empty on a project that has never run one. */
860
+ get laneDecisions() {
861
+ return this.data.laneDecisions ?? [];
862
+ }
863
+ /**
864
+ * Record what a lane decided. Called once per accepted lane report, so the
865
+ * confidence it stated can be checked later against what the run filed.
866
+ * Free text from the lane is redacted like every other stored string: an
867
+ * observation is written by a model reading the app under test.
868
+ */
869
+ addLaneDecisions(lane, decisions) {
870
+ if (decisions.length === 0)
871
+ return 0;
872
+ const list = this.data.laneDecisions ?? [];
873
+ const seen = new Set(list.map(decisionKey));
874
+ let added = 0;
875
+ for (const d of decisions) {
876
+ const record = {
877
+ ...d,
878
+ lane,
879
+ observation: redactSecrets(d.observation).slice(0, 200),
880
+ evidence: d.evidence === null ? null : redactSecrets(d.evidence).slice(0, 200),
881
+ };
882
+ if (seen.has(decisionKey(record)))
883
+ continue;
884
+ seen.add(decisionKey(record));
885
+ list.push(record);
886
+ added += 1;
887
+ }
888
+ this.data.laneDecisions = list.slice(-MAX_LANE_DECISIONS);
889
+ if (added > 0)
890
+ this.flush();
891
+ // What survives the cap. Appends go to the tail and the cap keeps the
892
+ // tail, so all of `added` survives unless the call itself exceeds the cap.
893
+ // Measuring it as growth instead looked right and was not: `list` aliases
894
+ // the stored array, so the "before" length was read after the appends and
895
+ // every call after the first reported nothing kept — while storing fine.
896
+ return Math.min(added, MAX_LANE_DECISIONS);
897
+ }
785
898
  /** Mark a finding resolved; returns it or null. */
786
899
  resolveFinding(id) {
787
900
  const f = this.data.findings.find((x) => x.id === id);
@@ -17,7 +17,8 @@ export const POLICY_BLOCK_WINDOW_MS = 2000;
17
17
  * block rather than something the app did?
18
18
  *
19
19
  * Aborting a request makes the browser print a console error, and — when the
20
- * app does not catch the rejection — raise a page error. Left in, a report
20
+ * app does not catch the rejection — raise a page error. Answering one with the
21
+ * policy's stand-in 403 makes the browser print its "status of 403" line. Left in, a report
21
22
  * lists the tool's own safety net as defects of the app under test. (The
22
23
  * failed REQUEST itself is matched exactly, by request identity, in the
23
24
  * monitor; these two carry no request to match on, so they are attributed by
@@ -32,7 +33,7 @@ export function isPolicyInduced(v, msSincePolicyBlock) {
32
33
  return false;
33
34
  if (v.kind !== "page_error" && v.kind !== "console_error")
34
35
  return false;
35
- return /ERR_BLOCKED_BY_CLIENT|Failed to fetch|NetworkError when attempting to fetch|Load failed/i.test(v.detail);
36
+ return /ERR_BLOCKED_BY_CLIENT|Failed to fetch|NetworkError when attempting to fetch|Load failed|the server responded with a status of 403\b/i.test(v.detail);
36
37
  }
37
38
  /**
38
39
  * Invariant oracles: passive listeners that record violations regardless of
@@ -74,7 +75,7 @@ export class OracleMonitor {
74
75
  return;
75
76
  if (BENIGN_URL_RE.test(req.url()))
76
77
  return;
77
- if (this.abortedByPolicy(req)) {
78
+ if (this.refusedByPolicy(req)) {
78
79
  this.policyAttributed += 1;
79
80
  return;
80
81
  }
@@ -93,6 +94,12 @@ export class OracleMonitor {
93
94
  // reported here on every page load teaches the driver to ignore http_error.
94
95
  if (BENIGN_URL_RE.test(res.url()))
95
96
  return;
97
+ // The write policy's own stand-in refusal, matched by request identity
98
+ // like a dropped one: the server never said this.
99
+ if (this.refusedByPolicy(res.request())) {
100
+ this.policyAttributed += 1;
101
+ return;
102
+ }
96
103
  // 401/403 are often expected (auth probes); still report, but as medium.
97
104
  this.record({
98
105
  kind: "http_error",
@@ -113,7 +120,7 @@ export class OracleMonitor {
113
120
  */
114
121
  static MAX_REPORTED_SIGS = 5000;
115
122
  lastPolicyBlockAt = null;
116
- abortedByPolicy = () => false;
123
+ refusedByPolicy = () => false;
117
124
  /**
118
125
  * Errors attributed to the write policy's own blocks and therefore not
119
126
  * recorded as violations. Counted, so a report can say how many there were:
@@ -121,11 +128,11 @@ export class OracleMonitor {
121
128
  * errors look the same.
122
129
  */
123
130
  policyAttributed = 0;
124
- /** The engine knows exactly which requests it aborted; failed requests are matched against that, not against wording. */
125
- setPolicyAbortCheck(check) {
126
- this.abortedByPolicy = check;
131
+ /** The engine knows exactly which requests its policy stopped; failed requests and stand-in refusals are matched against that, not against wording. */
132
+ setPolicyRefusalCheck(check) {
133
+ this.refusedByPolicy = check;
127
134
  }
128
- /** Called by the engine when the write policy aborts a request, so the errors that abort causes are not held against the app. */
135
+ /** Called by the engine when the write policy stops a request (dropped or answered), so the errors that causes are not held against the app. */
129
136
  notePolicyBlock() {
130
137
  this.lastPolicyBlockAt = Date.now();
131
138
  }
@@ -4,7 +4,7 @@
4
4
  * counting them inflates the action count and drags the median gap toward
5
5
  * zero. One real run logged 325 entries of which 107 were stated tasks.
6
6
  */
7
- const MARKER_ACTIONS = new Set(["task", "attach", "created-resource", "journey:start", "journey:end", "record:full", "record:failed"]);
7
+ const MARKER_ACTIONS = new Set(["task", "attach", "close", "created-resource", "journey:start", "journey:end", "record:full", "record:failed"]);
8
8
  /** Whether a log entry represents work the browser actually did. */
9
9
  export function isActing(action) {
10
10
  return !MARKER_ACTIONS.has(action);
@@ -27,23 +27,37 @@ function median(sorted) {
27
27
  export function measurePace(log, nowMs, attached = []) {
28
28
  const stillOpen = new Set(attached);
29
29
  const bySession = new Map();
30
+ const lifecycle = new Map();
30
31
  for (const entry of log) {
31
- if (!isActing(entry.action))
32
- continue;
33
32
  const name = entry.session ?? "default";
34
- const list = bySession.get(name);
33
+ const into = isActing(entry.action) ? bySession : entry.action === "attach" || entry.action === "close" ? lifecycle : null;
34
+ if (!into)
35
+ continue;
36
+ const list = into.get(name);
35
37
  if (list)
36
38
  list.push(entry);
37
39
  else
38
- bySession.set(name, [entry]);
40
+ into.set(name, [entry]);
39
41
  }
42
+ // A session that attached and never acted still held a browser.
43
+ for (const name of lifecycle.keys())
44
+ if (!bySession.has(name))
45
+ bySession.set(name, []);
40
46
  const sessions = [];
41
47
  let first = Number.POSITIVE_INFINITY;
42
48
  let last = 0;
43
49
  for (const [session, entries] of bySession) {
44
- const times = entries.map((e) => Date.parse(e.at)).filter((t) => Number.isFinite(t));
45
- if (times.length === 0)
50
+ const times = entries
51
+ .map((e) => Date.parse(e.at))
52
+ .filter((t) => Number.isFinite(t))
53
+ .sort((a, b) => a - b);
54
+ const heldIdleMs = heldIdle(lifecycle.get(session) ?? [], times, nowMs, stillOpen.has(session));
55
+ if (times.length === 0) {
56
+ if (heldIdleMs > 0) {
57
+ sessions.push({ session, actions: 0, spanMs: 0, medianGapMs: 0, maxGapMs: 0, idleShare: 0, quietMs: 0, framed: 0, heldIdleMs });
58
+ }
46
59
  continue;
60
+ }
47
61
  times.sort((a, b) => a - b);
48
62
  first = Math.min(first, times[0]);
49
63
  last = Math.max(last, times[times.length - 1]);
@@ -61,6 +75,7 @@ export function measurePace(log, nowMs, attached = []) {
61
75
  idleShare: spanMs > 0 ? idleMs / spanMs : 0,
62
76
  quietMs: Math.max(0, nowMs - times[times.length - 1]),
63
77
  framed: entries.filter((e) => e.frame).length,
78
+ heldIdleMs,
64
79
  });
65
80
  }
66
81
  sessions.sort((a, b) => b.actions - a.actions);
@@ -75,6 +90,37 @@ export function measurePace(log, nowMs, attached = []) {
75
90
  quiet: sessions.filter((s) => s.quietMs > STALE_SESSION_MS && stillOpen.has(s.session)).map((s) => s.session),
76
91
  };
77
92
  }
93
+ /**
94
+ * Idle time at the edges of each attach: attach to first action, last action
95
+ * to close. An attach with no close after it ends at the next attach, or at
96
+ * now while the session is still open. A log written before close was
97
+ * recorded has no close marker; its closed sessions count no trailing idle,
98
+ * because when they closed is unknown.
99
+ */
100
+ function heldIdle(markers, actionTimes, nowMs, stillOpen) {
101
+ const events = markers
102
+ .map((m) => ({ kind: m.action, at: Date.parse(m.at) }))
103
+ .filter((e) => Number.isFinite(e.at))
104
+ .sort((a, b) => a.at - b.at);
105
+ let idle = 0;
106
+ for (let i = 0; i < events.length; i += 1) {
107
+ if (events[i].kind !== "attach")
108
+ continue;
109
+ const start = events[i].at;
110
+ const next = events.slice(i + 1).find((e) => e.kind === "attach" || e.kind === "close");
111
+ const end = next ? next.at : stillOpen ? nowMs : null;
112
+ const inside = actionTimes.filter((t) => t >= start && (end === null || t <= end));
113
+ if (inside.length === 0) {
114
+ if (end !== null)
115
+ idle += end - start;
116
+ continue;
117
+ }
118
+ idle += inside[0] - start;
119
+ if (end !== null)
120
+ idle += end - inside[inside.length - 1];
121
+ }
122
+ return Math.max(0, idle);
123
+ }
78
124
  /** Milliseconds as a person says them: 45s, 4m12s, 1h03m. */
79
125
  export function sayDuration(ms) {
80
126
  const total = Math.max(0, Math.round(ms / 1000));
@@ -96,13 +142,13 @@ export function formatPace(pace) {
96
142
  const lines = [
97
143
  `## How the run was paced`,
98
144
  ``,
99
- `${pace.actions} action(s) over ${sayDuration(pace.spanMs)}. Stated tasks and attaches are not counted: they take no time. Idle share is time the browser stood still waiting for the agent, not time the engine spent working.`,
145
+ `${pace.actions} action(s) over ${sayDuration(pace.spanMs)}. Stated tasks and attaches are not counted: they take no time. Idle share is time the browser stood still waiting for the agent, not time the engine spent working. Held idle is time a browser was open before a session's first action or after its last one — a lane waiting to be folded, or attached and never used.`,
100
146
  ``,
101
- `| Session | Actions | Span | Median gap | Longest gap | Idle | Frames |`,
102
- `|---|---:|---:|---:|---:|---:|---:|`,
147
+ `| Session | Actions | Span | Median gap | Longest gap | Idle | Held idle | Frames |`,
148
+ `|---|---:|---:|---:|---:|---:|---:|---:|`,
103
149
  ];
104
150
  for (const s of pace.sessions) {
105
- lines.push(`| ${s.session} | ${s.actions} | ${sayDuration(s.spanMs)} | ${sayDuration(s.medianGapMs)} | ${sayDuration(s.maxGapMs)} | ${Math.round(s.idleShare * 100)}% | ${s.framed} |`);
151
+ lines.push(`| ${s.session} | ${s.actions} | ${sayDuration(s.spanMs)} | ${sayDuration(s.medianGapMs)} | ${sayDuration(s.maxGapMs)} | ${Math.round(s.idleShare * 100)}% | ${sayDuration(s.heldIdleMs)} | ${s.framed} |`);
106
152
  }
107
153
  lines.push(``);
108
154
  if (pace.quiet.length > 0) {
@@ -185,3 +185,43 @@ export function allowsWrite(mode, method, destructiveWire, owned) {
185
185
  // PUT/PATCH/DELETE: only in safe-write, only on this run's own records.
186
186
  return mode === "safe-write" && owned;
187
187
  }
188
+ /**
189
+ * Which blocked requests the write policy answers rather than drops.
190
+ *
191
+ * A dropped request tells the page nothing it would ever meet in production:
192
+ * `fetch` rejects with a network error, and the code that handles a refusal —
193
+ * the branch that should say "couldn't save" — never runs. Answering a script's
194
+ * request with a refusal keeps the server untouched and exercises that branch,
195
+ * so a page that reports a refused save as saved is caught. A navigation (a
196
+ * native form post) is still dropped: answering it would replace the page the
197
+ * user was on with the stand-in body.
198
+ */
199
+ export function answersWithRefusal(resourceType) {
200
+ return resourceType === "fetch" || resourceType === "xhr";
201
+ }
202
+ /** Header on every refusal the policy writes, so the stand-in can be told from the server's own answer. */
203
+ export const POLICY_REFUSAL_HEADER = "x-scenescout-policy";
204
+ /**
205
+ * The response the write policy sends in the server's place. 403, because the
206
+ * request is forbidden, not failed: a 5xx invites retries, and a 401 is what
207
+ * many apps read as "signed out". The CORS headers let a cross-origin API
208
+ * call read the refusal instead of failing as a network error, which would
209
+ * drop it all over again. `origin` is the request's own Origin header, echoed
210
+ * only when there is one.
211
+ */
212
+ export function policyRefusal(mode, method, pathname, origin) {
213
+ const headers = { "content-type": "application/json", [POLICY_REFUSAL_HEADER]: `refused; mode=${mode}` };
214
+ if (origin) {
215
+ headers["access-control-allow-origin"] = origin;
216
+ headers["access-control-allow-credentials"] = "true";
217
+ headers["access-control-expose-headers"] = POLICY_REFUSAL_HEADER;
218
+ }
219
+ return {
220
+ status: 403,
221
+ headers,
222
+ body: JSON.stringify({
223
+ error: "Forbidden",
224
+ message: `${method} ${pathname} was refused by the tester's ${mode} write policy. The server never received it.`,
225
+ }),
226
+ };
227
+ }
@@ -4,6 +4,7 @@ import { SHARED_CHROME_ROUTE } from "./memory.js";
4
4
  import { sayVerification } from "./verify.js";
5
5
  import { feedForSession } from "./live.js";
6
6
  import { buildReplayHtml, evidenceFor } from "./replay.js";
7
+ import { calibrate, formatCalibration } from "./calibration.js";
7
8
  import { formatPace, measurePace } from "./pace.js";
8
9
  function playwrightSkeleton(f) {
9
10
  const routeClass = f.state.split("#")[0].split("?")[0];
@@ -414,7 +415,7 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
414
415
  lines.push(`| Route coverage | ${extras.routesVisited}/${extras.routesTotal} |`);
415
416
  lines.push(`| States explored | ${cov.states} |`);
416
417
  if (extras)
417
- lines.push(`| Design audits this session | ${extras.designAudits} |`);
418
+ lines.push(`| Design audits this run (all sessions) | ${extras.designAudits} |`);
418
419
  lines.push(`| Oracle violations this session | ${oracleLog.length} |`);
419
420
  if (extras?.policyAttributed) {
420
421
  lines.push(`| Errors caused by the tester's own write-policy blocks (not counted above) | ${extras.policyAttributed} |`);
@@ -616,6 +617,9 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
616
617
  if (extras?.pace !== false) {
617
618
  lines.push(...formatPace(measurePace(memory.actionLog, Date.now(), extras?.attachedSessions ?? [])));
618
619
  }
620
+ // Only on a run that used lanes, and only once enough of them have been
621
+ // judged for the number to mean anything; formatCalibration decides both.
622
+ lines.push(...formatCalibration(calibrate(memory.laneDecisions, memory.findings)));
619
623
  const markdown = lines.join("\n");
620
624
  const outPath = path.join(memory.dir, "report.md");
621
625
  const htmlPath = path.join(memory.dir, "report.html");
@@ -22,6 +22,7 @@
22
22
  * Everything in this file is pure so it can be table-tested; the one
23
23
  * `page.evaluate` lives in browser.ts.
24
24
  */
25
+ import { POLICY_REFUSAL_HEADER } from "./policy.js";
25
26
  /** Methods a session may replay. Anything else is refused before it reaches the page. */
26
27
  export const REPLAYABLE_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
27
28
  /** Most of a response body that reaches the agent. A JSON list can be megabytes; the signature is in the first lines. */
@@ -114,6 +115,7 @@ export function toReplayResult(raw) {
114
115
  truncated: raw.full > BODY_MAX,
115
116
  ms: raw.ms,
116
117
  url: raw.url,
118
+ refusedByPolicy: raw.headers[POLICY_REFUSAL_HEADER] ?? null,
117
119
  };
118
120
  }
119
121
  /** The signature a finding carries for this call: the line that dedups the same refusal across runs. */
@@ -128,8 +130,19 @@ export function replaySignature(method, url, status) {
128
130
  }
129
131
  return `${method} ${path} ${status}`;
130
132
  }
131
- /** What the agent reads back. Leads with the signature, because that is what a finding quotes. */
133
+ /**
134
+ * What the agent reads back. Leads with the signature, because that is what a
135
+ * finding quotes — except when the write policy answered, where there is no
136
+ * server signature to quote. Printing the stand-in's 403 as one would read as
137
+ * the server enforcing a permission it was never asked about.
138
+ */
132
139
  export function formatReplay(method, result) {
140
+ if (result.refusedByPolicy) {
141
+ const path = replaySignature(method, result.url, result.status).replace(/ \d+$/, "");
142
+ return (`REFUSED by the write policy (${result.refusedByPolicy}): ${path} never reached the server.\n` +
143
+ `This is the engine's safety net, not the server's answer, so it says nothing about whether the server enforces this rule. ` +
144
+ `To test that, re-attach in a mode that allows the request on a record this session owns, or leave it out of the finding.`);
145
+ }
133
146
  const lines = [replaySignature(method, result.url, result.status) + (result.statusText ? ` ${result.statusText}` : ""), `took ${result.ms} ms`];
134
147
  const headers = Object.entries(result.headers);
135
148
  if (headers.length > 0)