scenescout 3.5.0 โ†’ 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # scenescout
2
2
 
3
+ ## 3.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6fbd659: The pace section now separates a lane's working time from the time it holds its browser after finishing. "Idle while working" is the share of each lane's working time spent in gaps over 30 seconds; "after finishing" is time waiting to be collected and closed, split at the moment the lane's report was folded. The latter grows with the number of lanes and the slowest one, so it is reported as planner overhead rather than summed into the lanes' idle time. `scout_lane_report` now logs the fold so the split can be made.
8
+
3
9
  ## 3.5.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -335,7 +335,7 @@ A `๐Ÿ›ก WRITE-POLICY blocked` notice is the safety net doing its job, not an app
335
335
  - ๐Ÿ’ฏ **Page scores** (0โ€“100: a11y ยท craft ยท consistency ยท task-clarity), ranked worst-first, with stale scores from old runs marked as such.
336
336
  - ๐Ÿ‘ฅ **A role capability matrix** โ€” what each role could and couldn't reach.
337
337
  - ๐Ÿงพ **A gap ledger** โ€” everything *not* done, so the report is honest about its own coverage.
338
- - โฑ๏ธ **How the run was paced** โ€” actions, median gap, idle share and held-idle time per session, so a browser held open for nothing is visible.
338
+ - โฑ๏ธ **How the run was paced** โ€” how closely each session kept working, and apart from that, how long finished lanes held their browsers waiting to be collected, so neither hides the other.
339
339
  - ๐ŸŽฏ **How well the lanes judged** โ€” on a parallel run, whether the confidence each lane stated matched what the project went on to file, beside what later re-tests found ([ADR 10](docs/adr/0010-a-confidence-is-checked-not-trusted.md)).
340
340
 
341
341
  `.scenescout/report.html` โ€” the same report as one self-contained page, with every session's trail beside it, and on a [recorded run](#-recording-a-run-and-reading-it-back) the screenshots under each finding.
@@ -1212,7 +1212,10 @@ export class MemoryStore {
1212
1212
  return url;
1213
1213
  }
1214
1214
  };
1215
- const log = this.actionLog;
1215
+ // The planner's fold marker is not a step anyone took on a page. Left in,
1216
+ // its empty URL read as a route change and cut the trace of a finding filed
1217
+ // right after a fold โ€” which is when the fold asks lanes to file.
1218
+ const log = this.actionLog.filter((a) => a.action !== "lane-report");
1216
1219
  let start = Math.max(0, log.length - 12);
1217
1220
  for (let i = log.length - 1; i >= 0 && i >= log.length - 12; i--) {
1218
1221
  if (routeOf(log[i].url) !== routeOf(f.url)) {
@@ -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", "close", "created-resource", "journey:start", "journey:end", "record:full", "record:failed"]);
7
+ const MARKER_ACTIONS = new Set(["task", "attach", "close", "lane-report", "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);
@@ -30,7 +30,11 @@ export function measurePace(log, nowMs, attached = []) {
30
30
  const lifecycle = new Map();
31
31
  for (const entry of log) {
32
32
  const name = entry.session ?? "default";
33
- const into = isActing(entry.action) ? bySession : entry.action === "attach" || entry.action === "close" ? lifecycle : null;
33
+ const into = isActing(entry.action)
34
+ ? bySession
35
+ : entry.action === "attach" || entry.action === "close" || entry.action === "lane-report"
36
+ ? lifecycle
37
+ : null;
34
38
  if (!into)
35
39
  continue;
36
40
  const list = into.get(name);
@@ -51,10 +55,10 @@ export function measurePace(log, nowMs, attached = []) {
51
55
  .map((e) => Date.parse(e.at))
52
56
  .filter((t) => Number.isFinite(t))
53
57
  .sort((a, b) => a - b);
54
- const heldIdleMs = heldIdle(lifecycle.get(session) ?? [], times, nowMs, stillOpen.has(session));
58
+ const edge = edges(lifecycle.get(session) ?? [], times, nowMs, stillOpen.has(session));
55
59
  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 });
60
+ if (edge.leadInMs > 0) {
61
+ sessions.push({ session, actions: 0, spanMs: 0, medianGapMs: 0, maxGapMs: 0, idleShare: 0, quietMs: 0, framed: 0, ...edge });
58
62
  }
59
63
  continue;
60
64
  }
@@ -75,11 +79,20 @@ export function measurePace(log, nowMs, attached = []) {
75
79
  idleShare: spanMs > 0 ? idleMs / spanMs : 0,
76
80
  quietMs: Math.max(0, nowMs - times[times.length - 1]),
77
81
  framed: entries.filter((e) => e.frame).length,
78
- heldIdleMs,
82
+ ...edge,
79
83
  });
80
84
  }
81
85
  sessions.sort((a, b) => b.actions - a.actions);
86
+ const folds = sessions.filter((s) => s.afterFoldMs !== null);
87
+ const waiting = {
88
+ workingMs: sessions.reduce((sum, s) => sum + s.spanMs, 0),
89
+ workingIdleMs: sessions.reduce((sum, s) => sum + s.idleShare * s.spanMs, 0),
90
+ leadInMs: sessions.reduce((sum, s) => sum + s.leadInMs, 0),
91
+ afterFinishMs: sessions.reduce((sum, s) => sum + s.afterFinishMs, 0),
92
+ afterFoldMs: folds.length > 0 ? folds.reduce((sum, s) => sum + (s.afterFoldMs ?? 0), 0) : null,
93
+ };
82
94
  return {
95
+ waiting,
83
96
  sessions,
84
97
  spanMs: sessions.length > 0 ? last - first : 0,
85
98
  actions: sessions.reduce((sum, x) => sum + x.actions, 0),
@@ -91,18 +104,22 @@ export function measurePace(log, nowMs, attached = []) {
91
104
  };
92
105
  }
93
106
  /**
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.
107
+ * Time at the edges of each attach: attach to first action (lead-in), and last
108
+ * action to close (after finishing). An attach with no close after it ends at
109
+ * the next attach, or at now while the session is still open. A log written
110
+ * before close was recorded has no close marker; its closed sessions count no
111
+ * trailing time, because when they closed is unknown. A fold marker after the
112
+ * last action splits the trailing time at the moment the browser stopped being
113
+ * needed.
99
114
  */
100
- function heldIdle(markers, actionTimes, nowMs, stillOpen) {
115
+ function edges(markers, actionTimes, nowMs, stillOpen) {
101
116
  const events = markers
102
117
  .map((m) => ({ kind: m.action, at: Date.parse(m.at) }))
103
118
  .filter((e) => Number.isFinite(e.at))
104
119
  .sort((a, b) => a.at - b.at);
105
- let idle = 0;
120
+ let leadInMs = 0;
121
+ let afterFinishMs = 0;
122
+ let afterFoldMs = null;
106
123
  for (let i = 0; i < events.length; i += 1) {
107
124
  if (events[i].kind !== "attach")
108
125
  continue;
@@ -112,14 +129,19 @@ function heldIdle(markers, actionTimes, nowMs, stillOpen) {
112
129
  const inside = actionTimes.filter((t) => t >= start && (end === null || t <= end));
113
130
  if (inside.length === 0) {
114
131
  if (end !== null)
115
- idle += end - start;
132
+ leadInMs += end - start;
116
133
  continue;
117
134
  }
118
- idle += inside[0] - start;
119
- if (end !== null)
120
- idle += end - inside[inside.length - 1];
135
+ leadInMs += inside[0] - start;
136
+ if (end === null)
137
+ continue;
138
+ const lastAction = inside[inside.length - 1];
139
+ afterFinishMs += end - lastAction;
140
+ const fold = events.filter((e) => e.kind === "lane-report" && e.at >= lastAction && e.at <= end).at(-1);
141
+ if (fold)
142
+ afterFoldMs = (afterFoldMs ?? 0) + (end - fold.at);
121
143
  }
122
- return Math.max(0, idle);
144
+ return { leadInMs: Math.max(0, leadInMs), afterFinishMs: Math.max(0, afterFinishMs), afterFoldMs };
123
145
  }
124
146
  /** Milliseconds as a person says them: 45s, 4m12s, 1h03m. */
125
147
  export function sayDuration(ms) {
@@ -139,16 +161,28 @@ export function sayDuration(ms) {
139
161
  export function formatPace(pace) {
140
162
  if (pace.sessions.length === 0)
141
163
  return [];
164
+ const w = pace.waiting;
165
+ const pct = (part, whole) => (whole > 0 ? `${Math.round((part / whole) * 100)}%` : "0%");
142
166
  const lines = [
143
167
  `## How the run was paced`,
144
168
  ``,
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.`,
169
+ `${pace.actions} action(s) over ${sayDuration(pace.spanMs)}. Stated tasks and attaches are not counted: they take no time.`,
170
+ ``,
171
+ `**While working** โ€” first action to last, ${sayDuration(w.workingMs)} across sessions โ€” ${pct(w.workingIdleMs, w.workingMs)} was spent in gaps over ${sayDuration(IDLE_GAP_MS)}, the agent thinking at length. That is how closely the sessions kept working.`,
172
+ ``,
173
+ `**After finishing**, sessions held their browsers for a further ${sayDuration(w.afterFinishMs)}` +
174
+ (w.afterFoldMs !== null ? `, ${sayDuration(w.afterFoldMs)} of it after their report was already folded` : ``) +
175
+ (w.afterFoldMs !== null
176
+ ? `. That is time waiting to be collected and closed: it grows with the number of lanes and with the slowest one, not with how well any lane worked. Closing each lane as soon as its report is folded removes it.`
177
+ : `: time from the last action to close.`) +
178
+ ` (Before the first action: ${sayDuration(w.leadInMs)}.)`,
146
179
  ``,
147
- `| Session | Actions | Span | Median gap | Longest gap | Idle | Held idle | Frames |`,
180
+ `| Session | Actions | Working | Median gap | Longest gap | Idle while working | After finishing | Frames |`,
148
181
  `|---|---:|---:|---:|---:|---:|---:|---:|`,
149
182
  ];
150
183
  for (const s of pace.sessions) {
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} |`);
184
+ const after = sayDuration(s.afterFinishMs) + (s.afterFoldMs !== null ? ` (${sayDuration(s.afterFoldMs)} after fold)` : ``);
185
+ lines.push(`| ${s.session} | ${s.actions} | ${sayDuration(s.spanMs)} | ${sayDuration(s.medianGapMs)} | ${sayDuration(s.maxGapMs)} | ${Math.round(s.idleShare * 100)}% | ${after} | ${s.framed} |`);
152
186
  }
153
187
  lines.push(``);
154
188
  if (pace.quiet.length > 0) {
@@ -546,6 +546,9 @@ server.registerTool("scout_lane_report", {
546
546
  // already closed makes that the ordinary case, not an edge one.
547
547
  const owner = engines.get(lane)?.memory;
548
548
  const at = new Date().toISOString();
549
+ // Marks the moment the lane's browser stopped being needed, so the pace
550
+ // section can tell a lane still reporting from one waiting to be closed.
551
+ owner?.logAction({ action: "lane-report", url: engines.get(lane)?.currentUrl ?? "", session: lane });
549
552
  const kept = owner
550
553
  ? owner.addLaneDecisions(lane, parsed.report.decisions.map((d) => ({ ...d, lane, at })))
551
554
  : 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenescout",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
4
4
  "description": "SceneScout โ€” exploratory UI testing for AI coding agents. An MCP server that gives any agent (Claude Code, Cursor, VS Code Copilot, Codex, Gemini CLI and others) a structured view of a running web app, always-on oracles, a network-level write policy, memory across runs and a gap-checked report.",
5
5
  "license": "MIT",
6
6
  "author": "brunoboto96",