scenescout 1.5.0 → 3.0.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 +18 -0
- package/dist/engine/browser.js +38 -6
- package/dist/engine/live-page.js +45 -34
- package/dist/engine/live.js +24 -12
- package/dist/engine/memory.js +2 -0
- package/dist/engine/task.js +65 -0
- package/dist/mcp-server.js +73 -10
- package/package.json +1 -1
- package/skills/scenescout/SKILL.md +8 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# scenescout
|
|
2
2
|
|
|
3
|
+
## 3.0.0
|
|
4
|
+
|
|
5
|
+
### Major Changes
|
|
6
|
+
|
|
7
|
+
- c7c4811: **A session now says two things, and the second is required.** Its **objective** is the whole remit it was given, set once at `scout_attach {objective}` — "Admin lane: §2 registers, §7 plan gating". Its **task** is what it is doing right now, and every tool that acts on the app — `scout_navigate`, `scout_back`, `scout_click`, `scout_type`, `scout_select`, `scout_press`, `scout_upload`, `scout_run_plan` — takes one: a few words for the batch in front of it ("Filtering the documents register by status"). It stays set until a different one is passed, `scout_journey` sets it while a journey runs, and reading the page needs none. A call that acts with no task standing is refused, with what to pass and why.
|
|
8
|
+
|
|
9
|
+
This exists because both were optional and therefore usually absent: someone watching a run saw sessions working through their app with nothing to say why, which is the one thing the live view is for. Stating a task also marks the action log, so the close-up's feed groups and tints the actions that follow it, as it already did for a journey's.
|
|
10
|
+
|
|
11
|
+
Breaking, in two ways. An agent that never states a task now gets a refusal instead of a click. And the two names swapped to match what they mean: `scout_attach {task}` is now `scout_attach {objective}`, and the per-call `objective` of 2.0 is now `task`. Both old names are still accepted, so a caller written against 2.0 keeps working.
|
|
12
|
+
|
|
13
|
+
## 2.0.0
|
|
14
|
+
|
|
15
|
+
### Major Changes
|
|
16
|
+
|
|
17
|
+
- 74a45d6: **A tool that acts on the app now needs an objective.** `scout_navigate`, `scout_back`, `scout_click`, `scout_type`, `scout_select`, `scout_press`, `scout_upload` and `scout_run_plan` take an `objective`: one short sentence naming what the current batch of actions is for. It stays set until a different one is passed, so a batch costs one sentence rather than one per call, and `scout_journey` still sets it (and outranks it) while a journey runs. A call that acts with none standing is refused with what to pass and why.
|
|
18
|
+
|
|
19
|
+
This is the breaking part: an agent that never states one now gets a refusal instead of a click. It exists because the objective was optional and therefore usually absent — someone watching a run saw sessions working through their app with nothing to say why, which is the one thing the live view is for. The objective now also appears on each card in the grid, not only in a session's close-up, and a session that has not said anything yet says so.
|
|
20
|
+
|
|
3
21
|
## 1.5.0
|
|
4
22
|
|
|
5
23
|
### Minor Changes
|
package/dist/engine/browser.js
CHANGED
|
@@ -2,7 +2,8 @@ import { chromium, firefox, webkit } from "playwright";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { elementKey, fingerprintState, isNonPageRoute, normalizePath } from "./fingerprint.js";
|
|
5
|
-
import { AUTH_LOSS_PREFIX, JOURNEY_END, JOURNEY_START, MemoryStore } from "./memory.js";
|
|
5
|
+
import { AUTH_LOSS_PREFIX, JOURNEY_END, JOURNEY_START, MemoryStore, TASK_SET } from "./memory.js";
|
|
6
|
+
import { normalizeTask } from "./task.js";
|
|
6
7
|
import { describeInjection, newInjections, probeQueries, probeScript, probeShape, rememberProbe } from "./injection.js";
|
|
7
8
|
import { AuthLossTracker } from "./authloss.js";
|
|
8
9
|
import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, } from "./collector.js";
|
|
@@ -203,8 +204,33 @@ export class BrowserEngine {
|
|
|
203
204
|
designAuditCount = 0;
|
|
204
205
|
/** Active task-efficiency measurement (scout_journey), if any. */
|
|
205
206
|
journey = null;
|
|
206
|
-
/** The session's
|
|
207
|
-
|
|
207
|
+
/** The session's objective: the whole remit the agent was given at scout_attach. Empty when none was given. */
|
|
208
|
+
sessionObjective = "";
|
|
209
|
+
/**
|
|
210
|
+
* The batch of actions running right now. Required before a tool may act
|
|
211
|
+
* (task.ts), stated by the agent on the call or by a journey, and kept
|
|
212
|
+
* until it is replaced — a batch costs a few words, not one per click.
|
|
213
|
+
*/
|
|
214
|
+
currentTask = null;
|
|
215
|
+
/** Set what this session is doing now. An empty value clears it. */
|
|
216
|
+
setTask(text) {
|
|
217
|
+
const clean = normalizeTask(text);
|
|
218
|
+
if (!clean) {
|
|
219
|
+
this.currentTask = null;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (this.currentTask?.text === clean)
|
|
223
|
+
return;
|
|
224
|
+
this.currentTask = { text: clean, since: Date.now() };
|
|
225
|
+
// Logged so the feed can group the actions that follow under it, the way
|
|
226
|
+
// it groups a journey's — the trail is where a watcher reads what
|
|
227
|
+
// happened, and an ungrouped one says nothing about why.
|
|
228
|
+
this.logAction({ action: TASK_SET, target: clean, url: this.page?.url() ?? "" });
|
|
229
|
+
}
|
|
230
|
+
/** Whether anything is standing that the live view could show as the task. */
|
|
231
|
+
get hasTask() {
|
|
232
|
+
return this.journey !== null || this.currentTask !== null;
|
|
233
|
+
}
|
|
208
234
|
/**
|
|
209
235
|
* Begin measuring a user JOURNEY — the interaction cost of completing one
|
|
210
236
|
* real task ("create an order", "approve a document"). E2E suites assert
|
|
@@ -325,7 +351,7 @@ export class BrowserEngine {
|
|
|
325
351
|
throw new Error(`storageStatePath does not exist: ${opts.storageStatePath}`);
|
|
326
352
|
}
|
|
327
353
|
this.mode = opts.mode ?? "read-only";
|
|
328
|
-
this.
|
|
354
|
+
this.sessionObjective = (opts.objective ?? "").trim().replace(/\s+/g, " ").slice(0, 300);
|
|
329
355
|
this.headed = opts.headed ?? false;
|
|
330
356
|
this.blockedRequests = [];
|
|
331
357
|
this.pendingCreations = new Set();
|
|
@@ -1999,8 +2025,14 @@ export class BrowserEngine {
|
|
|
1999
2025
|
mode: this.mode,
|
|
2000
2026
|
browser: this.engineName,
|
|
2001
2027
|
headed: this.headed,
|
|
2002
|
-
...(this.
|
|
2003
|
-
|
|
2028
|
+
...(this.sessionObjective ? { objective: this.sessionObjective } : {}),
|
|
2029
|
+
// A journey is a whole user task being measured, so its goal is what the
|
|
2030
|
+
// session is doing while it runs.
|
|
2031
|
+
...(this.journey
|
|
2032
|
+
? { task: this.journey.goal, taskSince: new Date(this.journey.startedAt).toISOString() }
|
|
2033
|
+
: this.currentTask
|
|
2034
|
+
? { task: this.currentTask.text, taskSince: new Date(this.currentTask.since).toISOString() }
|
|
2035
|
+
: {}),
|
|
2004
2036
|
};
|
|
2005
2037
|
}
|
|
2006
2038
|
/**
|
package/dist/engine/live-page.js
CHANGED
|
@@ -103,6 +103,9 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
103
103
|
#focus .brief .since { margin-top: -10px; font-size: 12px; color: #98a2b3; }
|
|
104
104
|
@media (max-width: 700px) { #focus .lower { flex-direction: column; height: 45vh; } }
|
|
105
105
|
.task { padding: 0 12px 2px; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
106
|
+
.doing { padding: 0 12px 4px; font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
107
|
+
.doing::before { content: "▸ "; color: var(--accent); }
|
|
108
|
+
.doing.unset { color: var(--stuck); }
|
|
106
109
|
#focus .feed .a { color: #e6e9ee; }
|
|
107
110
|
#focus .feed .t, #focus .feed .d, #focus .feed .none { color: #98a2b3; }
|
|
108
111
|
#focus .feed .bad { color: #fca5a5; }
|
|
@@ -177,11 +180,11 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
177
180
|
<div class="lower">
|
|
178
181
|
<div class="feed" id="focus-feed" data-testid="live-focus-feed"></div>
|
|
179
182
|
<aside class="brief" aria-label="What this session is doing" data-testid="live-focus-brief">
|
|
180
|
-
<h3>
|
|
181
|
-
<p id="focus-task" data-testid="live-focus-task"></p>
|
|
182
|
-
<h3 id="focus-objective-head">Current objective</h3>
|
|
183
|
+
<h3>Objective</h3>
|
|
183
184
|
<p id="focus-objective" data-testid="live-focus-objective"></p>
|
|
184
|
-
<
|
|
185
|
+
<h3 id="focus-task-head">Doing now</h3>
|
|
186
|
+
<p id="focus-task" data-testid="live-focus-task"></p>
|
|
187
|
+
<p class="since" id="focus-task-since"></p>
|
|
185
188
|
</aside>
|
|
186
189
|
</div>
|
|
187
190
|
</div>
|
|
@@ -197,7 +200,7 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
197
200
|
var events = null;
|
|
198
201
|
var eventsKey = '';
|
|
199
202
|
var frames = {};
|
|
200
|
-
var
|
|
203
|
+
var hoverTask = null;
|
|
201
204
|
var reportOpen = false;
|
|
202
205
|
var reportTimer = null;
|
|
203
206
|
var reportProblem = null;
|
|
@@ -303,7 +306,9 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
303
306
|
var badge = el('span', 'badge idle');
|
|
304
307
|
top.appendChild(nameEl); top.appendChild(role); top.appendChild(badge);
|
|
305
308
|
var task = el('div', 'task');
|
|
306
|
-
task.setAttribute('data-testid', 'live-card-
|
|
309
|
+
task.setAttribute('data-testid', 'live-card-objective-' + name);
|
|
310
|
+
var doing = el('div', 'doing');
|
|
311
|
+
doing.setAttribute('data-testid', 'live-card-task-' + name);
|
|
307
312
|
var tool = el('div', 'line');
|
|
308
313
|
var url = el('div', 'line');
|
|
309
314
|
var shot = el('button', 'shot');
|
|
@@ -326,9 +331,9 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
326
331
|
toggle.setAttribute('data-testid', 'live-card-toggle-' + name);
|
|
327
332
|
var spec = el('span', 'spec');
|
|
328
333
|
foot.appendChild(toggle); foot.appendChild(spec);
|
|
329
|
-
root.appendChild(top); root.appendChild(task); root.appendChild(tool); root.appendChild(url); root.appendChild(shot); root.appendChild(feed); root.appendChild(foot);
|
|
334
|
+
root.appendChild(top); root.appendChild(task); root.appendChild(doing); root.appendChild(tool); root.appendChild(url); root.appendChild(shot); root.appendChild(feed); root.appendChild(foot);
|
|
330
335
|
|
|
331
|
-
var card = { name: name, root: root, role: role, badge: badge, task: task, tool: tool, url: url, shot: shot, img: img, feed: feed, toggle: toggle, spec: spec, live: false };
|
|
336
|
+
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 };
|
|
332
337
|
toggle.addEventListener('click', function () { setLive(card, !card.live); });
|
|
333
338
|
shot.addEventListener('click', function () { openFocus(name); });
|
|
334
339
|
img.src = shotUrl(name);
|
|
@@ -514,18 +519,18 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
514
519
|
node.textContent = '';
|
|
515
520
|
if (!lines || !lines.length) { node.appendChild(el('div', 'none', 'nothing recorded yet')); return; }
|
|
516
521
|
var group = null;
|
|
517
|
-
var
|
|
522
|
+
var groupTask = null;
|
|
518
523
|
var groups = 0;
|
|
519
524
|
lines.forEach(function (line) {
|
|
520
|
-
var
|
|
521
|
-
if (!group ||
|
|
522
|
-
group = el('div', 'group' + (
|
|
523
|
-
if (
|
|
525
|
+
var task = line.task || '';
|
|
526
|
+
if (!group || task !== groupTask) {
|
|
527
|
+
group = el('div', 'group' + (task ? ' g' + (groups % 4) : ''));
|
|
528
|
+
if (task) { groups += 1; group.title = task; }
|
|
524
529
|
if (onGroup) {
|
|
525
|
-
group.addEventListener('mouseenter', function () { onGroup(
|
|
530
|
+
group.addEventListener('mouseenter', function () { onGroup(task); });
|
|
526
531
|
group.addEventListener('mouseleave', function () { onGroup(null); });
|
|
527
532
|
}
|
|
528
|
-
|
|
533
|
+
groupTask = task;
|
|
529
534
|
node.appendChild(group);
|
|
530
535
|
}
|
|
531
536
|
var row = el('div', 'row');
|
|
@@ -545,9 +550,15 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
545
550
|
var d = describe(s);
|
|
546
551
|
card.root.className = 'card' + (s.state === 'stuck' ? ' stuck' : '');
|
|
547
552
|
card.role.textContent = s.role === 'anonymous' ? '' : s.role;
|
|
548
|
-
card.task.textContent = s.
|
|
549
|
-
card.task.title = s.
|
|
550
|
-
card.task.hidden = !s.
|
|
553
|
+
card.task.textContent = s.objective || '';
|
|
554
|
+
card.task.title = s.objective || '';
|
|
555
|
+
card.task.hidden = !s.objective;
|
|
556
|
+
// What it is doing right now, in the agent's words. A session that acts
|
|
557
|
+
// without saying is refused by the engine, so a blank one here is a
|
|
558
|
+
// session that has not acted yet — say that rather than showing nothing.
|
|
559
|
+
card.doing.textContent = s.task || 'not said yet';
|
|
560
|
+
card.doing.title = s.task || '';
|
|
561
|
+
card.doing.className = 'doing' + (s.task ? '' : ' unset');
|
|
551
562
|
card.badge.className = 'badge ' + s.state;
|
|
552
563
|
card.badge.textContent = d.badge;
|
|
553
564
|
card.tool.textContent = d.tool;
|
|
@@ -563,8 +574,8 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
563
574
|
document.getElementById('focus-img').alt = 'Live view of ' + name;
|
|
564
575
|
document.getElementById('focus-img').src = frames[name] || shotUrl(name);
|
|
565
576
|
document.getElementById('focus').classList.add('open');
|
|
566
|
-
|
|
567
|
-
renderFeed(document.getElementById('focus-feed'), (latest[name] || {}).feed,
|
|
577
|
+
hoverTask = null;
|
|
578
|
+
renderFeed(document.getElementById('focus-feed'), (latest[name] || {}).feed, showTask);
|
|
568
579
|
syncEvents();
|
|
569
580
|
loadFullFeed(name);
|
|
570
581
|
paintFocus();
|
|
@@ -581,7 +592,7 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
581
592
|
function loadFullFeed(name) {
|
|
582
593
|
fetch('api/activity?session=' + encodeURIComponent(name), { cache: 'no-store' })
|
|
583
594
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
584
|
-
.then(function (d) { if (d && focused === d.session) renderFeed(document.getElementById('focus-feed'), d.feed,
|
|
595
|
+
.then(function (d) { if (d && focused === d.session) renderFeed(document.getElementById('focus-feed'), d.feed, showTask); })
|
|
585
596
|
.catch(function () { /* the short feed from the last poll stays on screen */ });
|
|
586
597
|
}
|
|
587
598
|
|
|
@@ -593,24 +604,24 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
593
604
|
|
|
594
605
|
// The pointer is over a group of the close-up's feed: the brief shows the
|
|
595
606
|
// objective those actions served. It goes back to the current one on leaving.
|
|
596
|
-
function
|
|
597
|
-
|
|
607
|
+
function showTask(task) {
|
|
608
|
+
hoverTask = task;
|
|
598
609
|
paintBrief();
|
|
599
610
|
}
|
|
600
611
|
function paintBrief() {
|
|
601
612
|
var s = focused && latest[focused];
|
|
602
613
|
// The engine sees tool calls, not reasoning: both of these are the agent's own words, or nothing.
|
|
603
|
-
setBrief('focus-
|
|
604
|
-
var head = document.getElementById('focus-
|
|
605
|
-
if (
|
|
606
|
-
head.textContent = '
|
|
607
|
-
setBrief('focus-
|
|
608
|
-
document.getElementById('focus-
|
|
614
|
+
setBrief('focus-objective', s && s.objective, 'Not given. An agent sets it when it attaches the session.');
|
|
615
|
+
var head = document.getElementById('focus-task-head');
|
|
616
|
+
if (hoverTask !== null) {
|
|
617
|
+
head.textContent = 'Doing, for these actions';
|
|
618
|
+
setBrief('focus-task', hoverTask, 'Nothing was stated for these.');
|
|
619
|
+
document.getElementById('focus-task-since').textContent = '';
|
|
609
620
|
} else {
|
|
610
|
-
head.textContent = '
|
|
611
|
-
setBrief('focus-
|
|
612
|
-
document.getElementById('focus-
|
|
613
|
-
s && s.
|
|
621
|
+
head.textContent = 'Doing now';
|
|
622
|
+
setBrief('focus-task', s && s.task, 'Nothing stated yet.');
|
|
623
|
+
document.getElementById('focus-task-since').textContent =
|
|
624
|
+
s && s.task && s.taskSince ? 'for ' + held(Date.now() + skew - Date.parse(s.taskSince)) : '';
|
|
614
625
|
}
|
|
615
626
|
}
|
|
616
627
|
// Once a second, from the status poll: the bar, the brief, and every third time the long feed.
|
|
@@ -689,7 +700,7 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
689
700
|
});
|
|
690
701
|
document.getElementById('focus-close').addEventListener('click', closeFocus);
|
|
691
702
|
// A re-rendered feed replaces the group under the pointer without a mouseleave; leaving the feed itself still resets.
|
|
692
|
-
document.getElementById('focus-feed').addEventListener('mouseleave', function () {
|
|
703
|
+
document.getElementById('focus-feed').addEventListener('mouseleave', function () { showTask(null); });
|
|
693
704
|
document.getElementById('focus').addEventListener('click', function (e) { if (e.target === this) closeFocus(); });
|
|
694
705
|
document.getElementById('report-open').addEventListener('click', openReport);
|
|
695
706
|
document.getElementById('finished-report').addEventListener('click', openReport);
|
package/dist/engine/live.js
CHANGED
|
@@ -27,7 +27,7 @@ import fs from "node:fs";
|
|
|
27
27
|
import http from "node:http";
|
|
28
28
|
import path from "node:path";
|
|
29
29
|
import { LIVE_PAGE } from "./live-page.js";
|
|
30
|
-
import { JOURNEY_END, JOURNEY_START } from "./memory.js";
|
|
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
33
|
/** `SCENESCOUT_LIVE=off` keeps the engine from opening the live view's port at all. */
|
|
@@ -47,32 +47,44 @@ export function feedForSession(log, session, limit, redact = (s) => s) {
|
|
|
47
47
|
if (e && (e.session ?? session) === session)
|
|
48
48
|
mine.push(e);
|
|
49
49
|
}
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
// Two things can be standing when the window opens: a journey, and the
|
|
51
|
+
// stated task. Walk back until both are known — a journey is what the
|
|
52
|
+
// session is doing while it runs, exactly as the live view shows it.
|
|
53
|
+
let goal;
|
|
54
|
+
let batch;
|
|
55
|
+
let knowJourney = false;
|
|
56
|
+
let knowBatch = false;
|
|
57
|
+
for (; i >= 0 && !(knowJourney && knowBatch); i -= 1) {
|
|
52
58
|
const e = log[i];
|
|
53
59
|
if (!e || (e.session ?? session) !== session)
|
|
54
60
|
continue;
|
|
55
|
-
if (e.action === JOURNEY_END)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
if (!knowJourney && (e.action === JOURNEY_END || e.action === JOURNEY_START)) {
|
|
62
|
+
if (e.action === JOURNEY_START)
|
|
63
|
+
goal = e.target;
|
|
64
|
+
knowJourney = true;
|
|
65
|
+
}
|
|
66
|
+
if (!knowBatch && e.action === TASK_SET) {
|
|
67
|
+
batch = e.target;
|
|
68
|
+
knowBatch = true;
|
|
60
69
|
}
|
|
61
70
|
}
|
|
62
71
|
const lines = [];
|
|
63
72
|
for (const e of mine.reverse()) {
|
|
64
73
|
if (e.action === JOURNEY_START)
|
|
65
|
-
|
|
74
|
+
goal = e.target;
|
|
75
|
+
if (e.action === TASK_SET)
|
|
76
|
+
batch = e.target;
|
|
77
|
+
const task = goal ?? batch;
|
|
66
78
|
const line = { at: e.at, action: e.action, url: redact(e.url) };
|
|
67
79
|
if (e.target !== undefined)
|
|
68
80
|
line.target = e.target;
|
|
69
81
|
if (e.result !== undefined)
|
|
70
82
|
line.result = e.result;
|
|
71
|
-
if (
|
|
72
|
-
line.
|
|
83
|
+
if (task !== undefined)
|
|
84
|
+
line.task = task;
|
|
73
85
|
lines.push(line);
|
|
74
86
|
if (e.action === JOURNEY_END)
|
|
75
|
-
|
|
87
|
+
goal = undefined;
|
|
76
88
|
}
|
|
77
89
|
return lines;
|
|
78
90
|
}
|
package/dist/engine/memory.js
CHANGED
|
@@ -92,6 +92,8 @@ export function redactSecrets(text) {
|
|
|
92
92
|
/** The action-log lines that open and close a journey (scout_journey). The feed reads them to tell which goal an action served. */
|
|
93
93
|
export const JOURNEY_START = "journey:start";
|
|
94
94
|
export const JOURNEY_END = "journey:end";
|
|
95
|
+
/** Logged when a session states the task it is starting, so the feed can group the actions that follow under it. */
|
|
96
|
+
export const TASK_SET = "task";
|
|
95
97
|
const EMPTY = { version: 1, states: {}, findings: [] };
|
|
96
98
|
/**
|
|
97
99
|
* Fold another process's memory into ours, losing nothing from either side.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The task: a few words saying what a session is DOING right now — the batch
|
|
3
|
+
* of actions it is performing on the page in front of it.
|
|
4
|
+
*
|
|
5
|
+
* It sits under the session's objective, which is the whole remit the agent
|
|
6
|
+
* was given at attach ("Admin lane: §2 registers, §7 plan gating"). The
|
|
7
|
+
* objective barely changes; the task changes every time the session moves on
|
|
8
|
+
* to something else, and it is what tells a person watching what is happening
|
|
9
|
+
* now.
|
|
10
|
+
*
|
|
11
|
+
* The engine sees tool calls, never the reasoning behind them, so it can only
|
|
12
|
+
* show a task the agent states. Left optional it was usually absent, so a tool
|
|
13
|
+
* that acts on the app requires one: passed on the call, or already standing
|
|
14
|
+
* from an earlier call or a journey. It stays set until it is replaced, so a
|
|
15
|
+
* batch costs a few words, not one per click.
|
|
16
|
+
*
|
|
17
|
+
* Reading the page (snapshot, hover, scroll, coverage, the audits) needs none:
|
|
18
|
+
* orienting is what an agent does before it can say what it is about to do.
|
|
19
|
+
*/
|
|
20
|
+
/** Longer than this is a paragraph, not a task; the live view shows one line. */
|
|
21
|
+
export const TASK_MAX = 120;
|
|
22
|
+
/**
|
|
23
|
+
* The tools that act on the app under test. Each changes what the person
|
|
24
|
+
* watching is looking at, so each has to be able to say why.
|
|
25
|
+
*/
|
|
26
|
+
export const NEEDS_TASK = new Set([
|
|
27
|
+
"scout_navigate",
|
|
28
|
+
"scout_back",
|
|
29
|
+
"scout_click",
|
|
30
|
+
"scout_type",
|
|
31
|
+
"scout_select",
|
|
32
|
+
"scout_press",
|
|
33
|
+
"scout_upload",
|
|
34
|
+
"scout_run_plan",
|
|
35
|
+
]);
|
|
36
|
+
export function needsTask(tool) {
|
|
37
|
+
return NEEDS_TASK.has(tool);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* One line, whitespace collapsed, bounded. Empty input clears the task. A
|
|
41
|
+
* longer one is cut at a word where it can be and ends in an ellipsis, so a
|
|
42
|
+
* truncated task reads as truncated rather than as a sentence that stops
|
|
43
|
+
* mid-word.
|
|
44
|
+
*/
|
|
45
|
+
export function normalizeTask(text) {
|
|
46
|
+
const line = (text ?? "").replace(/\s+/g, " ").trim();
|
|
47
|
+
if (line.length <= TASK_MAX)
|
|
48
|
+
return line;
|
|
49
|
+
const cut = line.slice(0, TASK_MAX - 1);
|
|
50
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
51
|
+
return `${(lastSpace > TASK_MAX - 24 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* What a session is told when it acts with no task standing. It names the
|
|
55
|
+
* parameter, gives the shape of a good task and says the rule it satisfies —
|
|
56
|
+
* an agent that reads this once should not need it again.
|
|
57
|
+
*/
|
|
58
|
+
export function taskRefusal(tool) {
|
|
59
|
+
return (`${tool} needs a task: this session has none, so the live view cannot say what it is doing.\n` +
|
|
60
|
+
`Pass task:"…" on this call — a few words saying what you are DOING, not what you are checking. ` +
|
|
61
|
+
`"Filtering the documents register by status", "Filling the deviation form with invalid dates", "Signing in as QA_Team". ` +
|
|
62
|
+
`Not the acceptance criteria ("…narrows the set and is reflected in the URL"): that is the result you will judge, not the batch you are running. ` +
|
|
63
|
+
`It stays set until you pass a different one, so a batch costs a few words, not one per call. ` +
|
|
64
|
+
`scout_journey {action:"start", goal:"…"} sets it too, and is what to use when you are measuring a whole user task.`);
|
|
65
|
+
}
|
package/dist/mcp-server.js
CHANGED
|
@@ -39,6 +39,7 @@ import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
|
|
|
39
39
|
import { FIXTURE_KINDS } from "./engine/fixtures.js";
|
|
40
40
|
import { feedForSession, LIVE_ENV, writeStatusFile, LIVE_TOKEN_FILE, LiveServer, StatusBoard, } from "./engine/live.js";
|
|
41
41
|
import { computeGaps, formatRouteCoverage, generateReport } from "./engine/report.js";
|
|
42
|
+
import { needsTask, taskRefusal, TASK_MAX } from "./engine/task.js";
|
|
42
43
|
import { EXPLORE_PROMPT_ARGUMENTS, explorePrompt, loadPlaybook, PLAYBOOK_PROMPT, PLAYBOOK_TOOL, SERVER_INSTRUCTIONS } from "./playbook.js";
|
|
43
44
|
import { formatScan, scanProject } from "./scan.js";
|
|
44
45
|
/** Live sessions: each name owns an independent BrowserEngine (browser + auth). */
|
|
@@ -277,7 +278,7 @@ function writeStatus(session, phase, tool, budgetMs) {
|
|
|
277
278
|
if (!eng || !dir)
|
|
278
279
|
return;
|
|
279
280
|
// status.json is a poll target that gets pasted into bug reports.
|
|
280
|
-
const {
|
|
281
|
+
const { objective, task, ...described } = eng.liveDescription;
|
|
281
282
|
lastWriter = board.update(session, {
|
|
282
283
|
role: eng.role,
|
|
283
284
|
phase,
|
|
@@ -285,8 +286,8 @@ function writeStatus(session, phase, tool, budgetMs) {
|
|
|
285
286
|
url: redactSecrets(eng.currentUrl),
|
|
286
287
|
...(budgetMs ? { budgetMs } : {}),
|
|
287
288
|
...described,
|
|
288
|
-
...(task ? { task: redactSecrets(task) } : {}),
|
|
289
289
|
...(objective ? { objective: redactSecrets(objective) } : {}),
|
|
290
|
+
...(task ? { task: redactSecrets(task) } : {}),
|
|
290
291
|
});
|
|
291
292
|
void ensureLive(dir);
|
|
292
293
|
flushStatus(dir);
|
|
@@ -309,6 +310,18 @@ const sessionQueue = new SessionQueue();
|
|
|
309
310
|
function serializedPerSession(label, fn, timeoutMs = 60_000) {
|
|
310
311
|
return (args) => {
|
|
311
312
|
const session = args.session ?? activeName;
|
|
313
|
+
// Every acting tool passes through here, so the task is required in one
|
|
314
|
+
// place rather than eight. A call that states one sets it for the batch;
|
|
315
|
+
// a call that acts with none standing is told what to pass. `objective`
|
|
316
|
+
// is the name this parameter had in 2.0 and still works.
|
|
317
|
+
const eng = engines.get(session);
|
|
318
|
+
if (eng) {
|
|
319
|
+
const stated = args.task ?? args.objective;
|
|
320
|
+
if (stated !== undefined)
|
|
321
|
+
eng.setTask(stated);
|
|
322
|
+
if (needsTask(label) && !eng.hasTask)
|
|
323
|
+
return Promise.resolve(text(taskRefusal(label), session));
|
|
324
|
+
}
|
|
312
325
|
const exec = async () => {
|
|
313
326
|
writeStatus(session, "running", label, timeoutMs);
|
|
314
327
|
try {
|
|
@@ -348,6 +361,23 @@ const sessionParam = z
|
|
|
348
361
|
.max(40)
|
|
349
362
|
.optional()
|
|
350
363
|
.describe("Target this session directly instead of the active one — pass it explicitly when dispatching to MULTIPLE sessions in one turn (e.g. two scout_click calls with different `session`), which then run CONCURRENTLY rather than queueing. Omit for single-session sequential use.");
|
|
364
|
+
/**
|
|
365
|
+
* What the session is DOING right now. Required by the tools that act
|
|
366
|
+
* (task.ts) unless one is already standing; shown to whoever is watching the
|
|
367
|
+
* run, under the session's objective.
|
|
368
|
+
*/
|
|
369
|
+
const taskParam = z
|
|
370
|
+
.string()
|
|
371
|
+
.max(TASK_MAX)
|
|
372
|
+
.optional()
|
|
373
|
+
.describe("What you are DOING right now, in a few words: the action, not the acceptance criteria. " +
|
|
374
|
+
'"Filtering the documents register by status", "Filling the deviation form with invalid dates", "Signing in as QA_Team" — ' +
|
|
375
|
+
'NOT "§2.4 filtering narrows the set and the filter is reflected in the URL", which is what you are CHECKING, not what you are doing. ' +
|
|
376
|
+
'Naming the item you are on is fine ("§2.4: filtering the documents register"); keep the rest to what a colleague would see over your shoulder. ' +
|
|
377
|
+
"It stays set until you pass a different one, so a batch costs a few words, not one per call. " +
|
|
378
|
+
"Required on the tools that act unless a journey or an earlier call already set one.");
|
|
379
|
+
/** The name `task` had in 2.0. Still accepted, so a caller written against that release keeps working. */
|
|
380
|
+
const legacyObjectiveParam = z.string().max(TASK_MAX).optional().describe("Old name for `task` (2.0). Prefer `task`.");
|
|
351
381
|
// The method, for every client that has no skill loader. It is read per call,
|
|
352
382
|
// not cached: a source checkout's skill file can change under a running server.
|
|
353
383
|
server.registerTool(PLAYBOOK_TOOL, {
|
|
@@ -418,18 +448,21 @@ server.registerTool("scout_attach", {
|
|
|
418
448
|
.describe("Browser to drive. Default: the SCENESCOUT_BROWSER environment variable, else chromium. firefox and webkit must be downloaded first (scenescout install --browser-only --browsers firefox). Use them for a cross-browser pass; stay on chromium otherwise."),
|
|
419
449
|
viewportWidth: z.number().int().min(320).max(3840).optional().describe("Viewport width (default 1280); use e.g. 390 for a mobile pass"),
|
|
420
450
|
viewportHeight: z.number().int().min(480).max(2400).optional().describe("Viewport height (default 900)"),
|
|
421
|
-
|
|
451
|
+
objective: z
|
|
422
452
|
.string()
|
|
423
453
|
.max(300)
|
|
424
454
|
.optional()
|
|
425
|
-
.describe(
|
|
455
|
+
.describe('This session\'s objective: the whole remit you were given, in one sentence ("Admin lane: §2 registers, §7 plan gating", ' +
|
|
456
|
+
'"Approve and reject orders as a manager"). It sits above the task, which is what the session is doing at any moment. ' +
|
|
457
|
+
"Shown to whoever is watching the run; worth setting whenever more than one session is live."),
|
|
458
|
+
task: z.string().max(300).optional().describe("Old name for `objective` (2.0). Prefer `objective`."),
|
|
426
459
|
session: z
|
|
427
460
|
.string()
|
|
428
461
|
.max(40)
|
|
429
462
|
.optional()
|
|
430
463
|
.describe("Session name for multi-role runs (e.g. 'admin', 'qa'). Creates/replaces that session's browser and makes it the default. Default: 'default'."),
|
|
431
464
|
},
|
|
432
|
-
}, serializedControl(async ({ url, projectPath, storageStatePath, mode, headed, browser, viewportWidth, viewportHeight, task, session, }) => {
|
|
465
|
+
}, serializedControl(async ({ url, projectPath, storageStatePath, mode, headed, browser, viewportWidth, viewportHeight, objective, task, session, }) => {
|
|
433
466
|
try {
|
|
434
467
|
const target = session ?? activeName;
|
|
435
468
|
if (session) {
|
|
@@ -490,7 +523,18 @@ server.registerTool("scout_attach", {
|
|
|
490
523
|
/* conflict detection is best-effort */
|
|
491
524
|
}
|
|
492
525
|
const viewport = viewportWidth && viewportHeight ? { width: viewportWidth, height: viewportHeight } : undefined;
|
|
493
|
-
const out = await eng.attach({
|
|
526
|
+
const out = await eng.attach({
|
|
527
|
+
url,
|
|
528
|
+
projectDir: projectPath,
|
|
529
|
+
storageStatePath,
|
|
530
|
+
mode,
|
|
531
|
+
headed,
|
|
532
|
+
browser,
|
|
533
|
+
viewport,
|
|
534
|
+
// `task` is what this was called in 2.0; it named the session's whole remit, which is the objective.
|
|
535
|
+
objective: objective ?? task,
|
|
536
|
+
memoryStore: store,
|
|
537
|
+
});
|
|
494
538
|
eng.role = storageStatePath ? path.basename(storageStatePath).replace(/\.json$/i, "") : "anonymous";
|
|
495
539
|
// Put the session on the board now, so the live view shows it before its
|
|
496
540
|
// first tool call. liveLine() needs the port, so the server is awaited
|
|
@@ -585,6 +629,8 @@ server.registerTool("scout_run_plan", {
|
|
|
585
629
|
}))
|
|
586
630
|
.min(1)
|
|
587
631
|
.max(20),
|
|
632
|
+
task: taskParam,
|
|
633
|
+
objective: legacyObjectiveParam,
|
|
588
634
|
session: sessionParam,
|
|
589
635
|
},
|
|
590
636
|
}, serializedPerSession("scout_run_plan", async ({ steps }, session) => {
|
|
@@ -600,6 +646,8 @@ server.registerTool("scout_click", {
|
|
|
600
646
|
inputSchema: {
|
|
601
647
|
ref: z.string().describe("Element ref, e.g. e12"),
|
|
602
648
|
clicks: z.number().int().min(1).max(3).default(1).describe("1 = normal; 2-3 = rapid repeated clicks (double-submit probe)"),
|
|
649
|
+
task: taskParam,
|
|
650
|
+
objective: legacyObjectiveParam,
|
|
603
651
|
session: sessionParam,
|
|
604
652
|
},
|
|
605
653
|
}, serializedPerSession("scout_click", async ({ ref, clicks }, session) => {
|
|
@@ -620,6 +668,8 @@ server.registerTool("scout_type", {
|
|
|
620
668
|
value: z.string().optional().describe("Alias for `textValue`."),
|
|
621
669
|
pressEnter: z.boolean().default(false).describe("Press Enter after typing"),
|
|
622
670
|
replace: z.boolean().default(false).describe("Clear the field before typing instead of appending to existing content"),
|
|
671
|
+
task: taskParam,
|
|
672
|
+
objective: legacyObjectiveParam,
|
|
623
673
|
session: sessionParam,
|
|
624
674
|
},
|
|
625
675
|
}, serializedPerSession("scout_type", async ({ ref, textValue, value, pressEnter, replace }, session) => {
|
|
@@ -654,6 +704,8 @@ server.registerTool("scout_upload", {
|
|
|
654
704
|
.optional()
|
|
655
705
|
.describe("Generated fixture kind; default: inferred from the input's accept attribute (pdf when there is none, or none we can generate)"),
|
|
656
706
|
name: z.string().min(1).max(512).optional().describe("Filename override (default scenescout-fixture.<kind>, or the disk file's own name)"),
|
|
707
|
+
task: taskParam,
|
|
708
|
+
objective: legacyObjectiveParam,
|
|
657
709
|
session: sessionParam,
|
|
658
710
|
},
|
|
659
711
|
}, serializedPerSession("scout_upload", async ({ ref, filePath, fixture, name }, session) => {
|
|
@@ -677,7 +729,13 @@ server.registerTool("scout_hover", {
|
|
|
677
729
|
}));
|
|
678
730
|
server.registerTool("scout_select", {
|
|
679
731
|
description: "Select an option in a <select> by ref.",
|
|
680
|
-
inputSchema: {
|
|
732
|
+
inputSchema: {
|
|
733
|
+
ref: z.string(),
|
|
734
|
+
value: z.string().describe("Option value or label"),
|
|
735
|
+
task: taskParam,
|
|
736
|
+
objective: legacyObjectiveParam,
|
|
737
|
+
session: sessionParam,
|
|
738
|
+
},
|
|
681
739
|
}, serializedPerSession("scout_select", async ({ ref, value }, session) => {
|
|
682
740
|
try {
|
|
683
741
|
return text(await engineFor(session).select(ref, value), session);
|
|
@@ -688,7 +746,12 @@ server.registerTool("scout_select", {
|
|
|
688
746
|
}));
|
|
689
747
|
server.registerTool("scout_navigate", {
|
|
690
748
|
description: "Navigate to a URL or a path relative to the attached base URL (e.g. '/orders'). Also supports 'back' via scout_back.",
|
|
691
|
-
inputSchema: {
|
|
749
|
+
inputSchema: {
|
|
750
|
+
target: z.string().describe("Absolute URL or path like /settings"),
|
|
751
|
+
task: taskParam,
|
|
752
|
+
objective: legacyObjectiveParam,
|
|
753
|
+
session: sessionParam,
|
|
754
|
+
},
|
|
692
755
|
}, serializedPerSession("scout_navigate", async ({ target }, session) => {
|
|
693
756
|
try {
|
|
694
757
|
return text(await engineFor(session).navigate(target), session);
|
|
@@ -699,7 +762,7 @@ server.registerTool("scout_navigate", {
|
|
|
699
762
|
}));
|
|
700
763
|
server.registerTool("scout_back", {
|
|
701
764
|
description: "Go back in browser history (tests back-button resilience).",
|
|
702
|
-
inputSchema: { session: sessionParam },
|
|
765
|
+
inputSchema: { task: taskParam, objective: legacyObjectiveParam, session: sessionParam },
|
|
703
766
|
}, serializedPerSession("scout_back", async (_args, session) => {
|
|
704
767
|
try {
|
|
705
768
|
return text(await engineFor(session).goBack(), session);
|
|
@@ -729,7 +792,7 @@ server.registerTool("scout_scroll", {
|
|
|
729
792
|
}));
|
|
730
793
|
server.registerTool("scout_press", {
|
|
731
794
|
description: "Press a keyboard key (e.g. Escape, Tab, Enter) — useful for closing modals and testing keyboard navigation.",
|
|
732
|
-
inputSchema: { key: z.string(), session: sessionParam },
|
|
795
|
+
inputSchema: { key: z.string(), task: taskParam, objective: legacyObjectiveParam, session: sessionParam },
|
|
733
796
|
}, serializedPerSession("scout_press", async ({ key }, session) => {
|
|
734
797
|
try {
|
|
735
798
|
return text(await engineFor(session).press(key), session);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scenescout",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.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",
|
|
@@ -20,7 +20,7 @@ You are the brain of an exploratory UI tester. The SceneScout MCP server gives y
|
|
|
20
20
|
- **Source available?** Use it beyond the scan: when you file a finding, read the component or handler behind it and name the file and the likely fix — that is the difference between "the save button does nothing" and a finding a developer can act on in one step.
|
|
21
21
|
3. **Ensure the app is running.** Full-stack markers in the scan → do NOT launch the stack yourself; confirm the URL responds (you may curl it) or ask the user. Auto-launch only simple single-package frontends.
|
|
22
22
|
4. **Pick auth.** `--role X` → pass that storage-state file. Unspecified → least-privileged role; say so.
|
|
23
|
-
5. **`scout_attach`** with url, projectPath, storageStatePath, and the write **mode** — the DB behind the app may be live, so the engine enforces this at the network layer:
|
|
23
|
+
5. **`scout_attach`** with url, projectPath, storageStatePath, an `objective` (this session's whole remit, shown live), and the write **mode** — the DB behind the app may be live, so the engine enforces this at the network layer:
|
|
24
24
|
- `observe` (`--observe`): NOTHING but GET requests leaves the page (login and token refresh excepted) — no form submission reaches the server. **This is your default for a remote URL with no source** (see step 2): such a target is far more likely to hold real data, and in `read-only` every form you submit creates a record somebody has to clean up. Forms you fill cannot be submitted; the gap ledger says so and that is the honest result. Move to `read-only` only when the user says form submissions are acceptable there.
|
|
25
25
|
- `read-only` (default when the source is here, i.e. a local or dev app): destructive labels blocked in the UI AND all PUT/PATCH/DELETE + destructive POSTs blocked on the wire. Ordinary form POSTs still go through. Use unless told otherwise.
|
|
26
26
|
- `safe-write` (`--safe-write`, or the user asks to test creating/editing things): create freely — **prioritize testing CREATE flows** — then edit/delete ONLY the records you created (the engine tracks your creations and blocks mutations on anything else). Never attempt to clean up or modify pre-existing data.
|
|
@@ -37,10 +37,12 @@ You are the brain of an exploratory UI tester. The SceneScout MCP server gives y
|
|
|
37
37
|
4. **Snapshot economics:** `scout_snapshot` after landing somewhere new; re-snapshots of the same route return *diffs* with stable refs — "No element changes" costs you almost nothing. `scout_screenshot` ONLY for suspected pixel-native issues (a canvas, a rendering glitch); geometry problems (overlap, off-screen, a covered control) are already in the snapshot as GEOMETRY issues, and images that failed to load are listed under BROKEN IMAGES — file those, quoting the line.
|
|
38
38
|
5. **Native-user behaviours.** `scout_type {ref, textValue}` (or its alias `value`, matching `scout_select` and a plan step) APPENDS when a field already has content (menu clicks often insert @-mention chips or commands into composers — appending preserves them; the result reports what was already there); pass `replace=true` only to deliberately clear, and `pressEnter=true` to submit from the field the way a user would. Before concluding a badge, icon, or "N errors" indicator *does nothing*, `scout_hover` it — tooltips and hover cards are invisible to snapshots and clicks, and hover output includes what appeared. In HEADED mode (`scout_attach {headed:true}`, which the user asks for when they want to watch) the user's physical mouse competes with the synthetic pointer: if a hover reveals nothing and the finding matters, ask the user to move their mouse off the browser window and retry before filing. **Scroll long pages with `scout_scroll`** — the design audit and snapshot measure at the current scroll position, so judge deep sections by scrolling then re-auditing; it refuses to scroll where a real user couldn't and reports SCROLL LOCKED (the leaked modal scroll-lock that silently amputates everything below the fold — snapshots also flag it passively as an OVERLAY line), and scrolling triggers lazy-loaded content whose failures surface as fresh oracle violations. Elements fully clipped inside an overflow-hidden container are flagged UNREACHABLE in GEOMETRY issues — no amount of scrolling reveals them; that's a high-value layout bug, distinct from merely below-the-fold content. **A page can hold SEVERAL independent scroll regions** and plain `scout_scroll` moves the largest one, so a sidebar nav beside a taller main pane never budges: pass `scout_scroll {target:"testid=…"}` to scroll one region. Never report a nav item, tab or list row as missing/truncated until you have scrolled ITS container — content scrolled out of a secondary pane looks exactly like content that was cut off.
|
|
39
39
|
6. **The rest of the input vocabulary.** `scout_select` sets a `<select>` option by value or visible label — use it rather than clicking a native dropdown open, which does not render as page DOM. `scout_press` sends a real key to the focused element (`Escape` to dismiss a modal, `Tab` to walk focus order, `Enter` to submit from a field); it is also how the keyboard-only pass at `extensive` is performed, and it vets the focused control first so a destructive action cannot be triggered blind in read-only mode. **`scout_upload {ref}` attaches a file the way a user does** — `ref` is a visible `<input type=file>` (snapshots list these with role `file`; `scout_type` on one redirects here) OR the button/label/dropzone that opens the file chooser (the chooser is intercepted and answered — that is how the hidden input behind a styled "Choose file" control is reached); omit `ref` when the page has exactly one file input, hidden or not (snapshots disclose hidden ones on a FILE INPUTS line). Nothing needs to exist on disk: a small VALID fixture is generated in memory, its kind inferred from the input's `accept` attribute or chosen with `fixture` (`pdf`, `png`, `txt`, `csv`, `json`); `filePath` uploads a real file but must live inside the attached project (fenced, like navigation is fenced to the origin); `name` overrides the filename. The result flags a file that violates `accept` (a mismatch the app then ACCEPTS is a validation finding), warns when the app cleared the input after selection, and says whether a state-changing request fired on selection — if none did, either click the form's submit or read the next snapshot for a client-side rejection. Plans take `{action:"upload", target, value:"pdf"}` steps (`target` required). When the input or its trigger was addressed by `ref`, the gap ledger counts an attached-but-unsent file as filled-never-submitted; the ref-less path has no listed element to mark.
|
|
40
|
-
7. **
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
7. **Say what you are doing: `task` is required before a tool acts.** A session shows two lines to whoever is watching. Its **objective** is the whole remit you were given, set once at `scout_attach {objective}` ("Admin lane: §2 registers, §7 plan gating", "Approve and reject orders as a manager"). Its **task** is what you are doing *right now*, and every tool that changes the app or the page — `scout_navigate`, `scout_back`, `scout_click`, `scout_type`, `scout_select`, `scout_press`, `scout_upload`, `scout_run_plan` — takes it: a few words for the batch in front of you ("Filtering the documents register by status", "Filling the deviation form with invalid dates", "Signing in as QA_Team"). Say what you are DOING, not what you are checking — "§2.4 filtering narrows the set and is reflected in the URL" is the acceptance criteria, which is the result you will judge, not the batch you are running; naming the item is fine ("§2.4: filtering the documents register"). The task STAYS SET until you pass a different one, so a batch costs a few words, not one per call — pass a fresh one whenever you move on. Acting with none standing is refused: the person watching would otherwise see a session clicking through their app with nothing to say why. `scout_journey {action:"start", goal:…}` sets the task too while it runs — use a journey when you are MEASURING a whole user task, the parameter for everything else.
|
|
41
|
+
|
|
42
|
+
8. **Design-connoisseur pass without pixels: `scout_design_audit`.** Run it once per representative page (dashboard, a form, a detail view, a data table). Its output has two tiers: **⚠ measurable defects** (WCAG contrast, tiny targets, clipped text, aspect-distorted images, horizontal overflow, keyboard tab stops with no visible focus indicator — sampled with real Tab presses) and **→ craft suggestions** (line measure and line-height rhythm, spacing-grid adherence, typography entropy, gray census and accent-hue count, pure-#000 body text, elevation/control consistency, heading structure, indistinguishable links, AI-slop tells like gradient text/glassmorphism/side-stripe borders/identical card grids), closing with a SYSTEM SUMMARY of design-system coherence. Judge every line with product context (dense tables legitimately have small targets; a chart page legitimately uses many hues). File ⚠ defects as `visual`/`a11y`, and genuine → opportunities as `ux-polish` findings **quoting the concrete numbers** — "~142 characters per line (65–75 ideal)" beats "text feels wide". Every audit ends with a **PAGE SCORE** (0–100 overall + a11y/craft/consistency/task-clarity subscores) persisted per route — the report ranks pages worst-first, so re-runs show whether pages got better or worse. Separately, every `scout_snapshot` runs an **overlay/modal probe** automatically: an empty dialog over a grayed page, a backdrop with no dialog, a far-off-centre dialog leaving a blank band, or a dialog extending unreachably below the viewport appear as OVERLAY lines in GEOMETRY issues — treat these as high-value findings (the user is visually stuck). This is where "how could this page be better" gets answered, not just "is it broken".
|
|
43
|
+
9. **Measure task EASE with `scout_journey`, not just correctness.** Wrap each module's primary task (`{action:"start", goal:"Create an order"}` → do it → `{action:"end", completed:…}`). Navigate by CLICKING like a first-time user — typing a known deep URL shortcuts the very thing being measured (a route you can only reach by editing the address bar is itself a finding). The result gives interaction count, distinct screens, the path taken, and BACKTRACKS — returning to a screen already left is the clearest evidence the next step wasn't discoverable. An abandoned journey (`completed:false`) is a high-severity finding: the task is blocked or undiscoverable, which no passing e2e suite would ever reveal.
|
|
44
|
+
10. **Walk the auth surface too — anonymously.** Attach a second session WITHOUT a storage-state file (a fresh logged-out profile) and exercise signup, login failure states, and forgot/reset-password **as far as they physically go**. The mailbox wall is expected — reaching "check your email" IS the success condition; everything before it is what you're testing: does submit actually fire (a dead signup button is a high finding), are errors specific and actionable, can the user resend or recover from a typo, does the flow dead-end. Use plausible synthetic identities only (invent `qa-<runid>@example.com`-style addresses, never a real person's), submit each form valid AND invalid, and judge the feedback. Two classic findings live here: a forgot-password that answers "no account with that email" is an **account-enumeration leak** (file as security; "if an account exists, we sent a link" is the correct shape), and a signup that accepts the form then lands on a blank or logged-out page with no guidance is a **journey dead-end**. Signup creates a record, so what this pass may do depends on the mode. In `observe`, fill and submit the auth forms for their CLIENT-SIDE behaviour only: the engine blocks signup, password change and reset, and lets only a login itself go out. Disclose the server-side half as a gap. Actually creating an account needs the user's explicit okay and safe-write mode; the engine tracks the created account like any other creation.
|
|
45
|
+
11. **`scout_coverage` decides what's next** — it lists unvisited routes and unexercised elements. Trust it over your memory. Prefer reaching routes by clicking real navigation; fall back to direct URLs for coverage completeness and re-verification, and say which you used when it affects the finding (see the provenance rule below).
|
|
44
46
|
|
|
45
47
|
## Levels (completion contracts — the engine ENFORCES them via `scout_report {level}`)
|
|
46
48
|
|
|
@@ -68,7 +70,7 @@ Some flows need a TEAM — a document one role submits and another approves, a r
|
|
|
68
70
|
- While roles are NOT collaborating, use each one productively where its permissions matter (admin in /admin surfaces, low-privilege probing for permission leaks) — same coverage contract, different vantage points.
|
|
69
71
|
- **Infer the PERSONA behind each role, and write it down.** From what a role can see and do (its nav, its dashboard, the capability matrix in the report), state what this person is FOR: "qa = reviewer — approves orders, assigns reviewers, no admin" / "user = front-line user — reads documents, completes reviews, raises orders". Record it with `scout_note {section:'roles'}`. Then test the persona's WORLD, not just the permissions: does the operator's landing page serve an operator? Is anything they need N clicks deep? The capability matrix's divergent rows are questions, not verdicts — each is either a correct boundary or a gap ("should this role be able to do this?"); say which you believe it is and why.
|
|
70
72
|
- `scout_close {all: true}` at the end of a multi-role run; `scout_close {session}` to drop one role early.
|
|
71
|
-
- **Several agents in parallel** (subagents or a workflow, each driving its own session): each agent attaches its session when it STARTS and closes it by name when it FINISHES. Never open sessions ahead for agents that have not started, and never hand an open session from one agent to the next: an agent waiting for its turn should hold no browser. Give each session
|
|
73
|
+
- **Several agents in parallel** (subagents or a workflow, each driving its own session): each agent attaches its session when it STARTS and closes it by name when it FINISHES. Never open sessions ahead for agents that have not started, and never hand an open session from one agent to the next: an agent waiting for its turn should hold no browser. Give each session an `objective` when you attach it (`scout_attach {session, objective:"Approve and reject orders as a manager"}`) and wrap each goal in `scout_journey`: the live view shows that objective and the task it is on beside the session's feed, which is how the person watching knows what every agent is for. Keep that goal TRUE: one journey per goal, one goal per thing you are checking ("Save a settings change as the auditor", not "Check every page"), ended the moment it is decided and the next one started before you move on. A journey that outlives its goal shows the viewer an objective the session left behind minutes ago. Run roughly as many agents at once as the machine has cores, less two, since each drives a real browser; beyond that they only queue. Exploring one area is well within a mid-tier model, so run these agents on one (Sonnet or its equivalent in your client) unless the user names a model; keep the larger model for the agent that plans the split and writes the report. No agent may call `scout_close {all: true}` while others run — only the last step, once every agent has finished.
|
|
72
74
|
|
|
73
75
|
## The impatient-user pass (extensive)
|
|
74
76
|
|