scenescout 3.15.0 → 3.17.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +70 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +191 -14
  5. package/dist/ci-run.js +268 -52
  6. package/dist/cli.js +107 -47
  7. package/dist/commands.js +3 -2
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1147 -286
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +244 -42
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +136 -18
  16. package/dist/engine/claims.js +159 -3
  17. package/dist/engine/collector.js +561 -30
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/design.js +281 -38
  20. package/dist/engine/export.js +877 -0
  21. package/dist/engine/fingerprint.js +92 -4
  22. package/dist/engine/flow.js +18 -6
  23. package/dist/engine/forms.js +181 -18
  24. package/dist/engine/journey.js +29 -1
  25. package/dist/engine/lane.js +13 -3
  26. package/dist/engine/launch.js +45 -6
  27. package/dist/engine/limits.js +7 -0
  28. package/dist/engine/live-page.js +49 -2
  29. package/dist/engine/live.js +4 -1
  30. package/dist/engine/memory.js +501 -47
  31. package/dist/engine/open.js +118 -0
  32. package/dist/engine/oracles.js +41 -1
  33. package/dist/engine/plain.js +268 -0
  34. package/dist/engine/png.js +127 -0
  35. package/dist/engine/policy.js +379 -9
  36. package/dist/engine/probes.js +3 -2
  37. package/dist/engine/profiles.js +45 -9
  38. package/dist/engine/project-folder.js +191 -0
  39. package/dist/engine/refresh.js +68 -3
  40. package/dist/engine/replay.js +63 -10
  41. package/dist/engine/report.js +241 -40
  42. package/dist/engine/request.js +317 -23
  43. package/dist/engine/sarif.js +120 -0
  44. package/dist/engine/settle.js +67 -0
  45. package/dist/engine/signed-in.js +256 -0
  46. package/dist/engine/status-pane-page.js +441 -0
  47. package/dist/engine/status-pane.js +128 -0
  48. package/dist/engine/tickets.js +671 -0
  49. package/dist/engine/unload.js +3 -2
  50. package/dist/export-run.js +633 -0
  51. package/dist/first-run.js +5 -0
  52. package/dist/installer.js +378 -8
  53. package/dist/intake.js +104 -0
  54. package/dist/login-run.js +250 -36
  55. package/dist/mcp-server.js +660 -65
  56. package/dist/playbook.js +5 -0
  57. package/dist/prompts.js +106 -0
  58. package/package.json +8 -5
  59. package/skills/scenescout/SKILL.md +49 -16
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Visual baselines for `scenescout check --baseline compare|update`, as data.
3
+ *
4
+ * A baseline is an approved picture of a page (its viewport, from the top) or
5
+ * of one element on it, kept per route and per browser engine, beside a small
6
+ * JSON saying how it was taken. `compare` takes the same picture again and
7
+ * diffs it with the baseline (png.ts); `update` writes new baselines, and only
8
+ * when it is asked to. What has a baseline is the project's choice, listed in
9
+ * the baselines folder's targets.json; nothing is baselined by default.
10
+ *
11
+ * Everything here is pure: the targets file and its errors, the file layout,
12
+ * what a baseline records, whether two pictures were taken alike, the verdict,
13
+ * and the evidence an unmet baseline is filed with. The browser half is
14
+ * BrowserEngine.captureForBaseline, which only takes the picture; check-run.ts
15
+ * reads and writes the files. Table-tested in check-test.
16
+ */
17
+ import { createHash } from "node:crypto";
18
+ import path from "node:path";
19
+ import { z } from "zod";
20
+ import { CAPTURE_MARGIN } from "./capture.js";
21
+ import { ARIA_ROLES, fieldPath, issueMessage, parseJsonFile, parseTarget, TARGET_HELP } from "./flow.js";
22
+ import { MEMORY_DIRNAME, redactRoute } from "./memory.js";
23
+ import { decodePng, diffImages } from "./png.js";
24
+ export const BASELINE_MODES = ["off", "compare", "update"];
25
+ /** The targets file, at the top of the baselines folder. */
26
+ export const TARGETS_FILE = "targets.json";
27
+ /** The most targets one check takes pictures of: each one is a page load. */
28
+ export const MAX_BASELINE_TARGETS = 100;
29
+ /**
30
+ * The share of pixels, in percent, that may change before a baseline counts
31
+ * as not met. Not 0: two pictures of an unchanged page taken by one browser
32
+ * build on one machine compare at 0%, but a run on another machine, or after
33
+ * a browser or font update, can anti-alias text and curved edges a pixel
34
+ * differently, and a gate that fails on that noise teaches a team to ignore
35
+ * it. 0.1% is 1,152 pixels of a 1280×900 page and 64 of a 320×200 picture, so
36
+ * a smaller change passes unless the project lowers the threshold. A change of
37
+ * size always counts.
38
+ */
39
+ export const DEFAULT_BASELINE_THRESHOLD = 0.1;
40
+ /** The element a target names when it names none: the page's viewport, from the top. */
41
+ const PAGE_ELEMENT = "page";
42
+ /** Where the pictures of an unmet baseline go, beside report.md. */
43
+ export const VISUAL_DIRNAME = "visual";
44
+ /** The baselines folder when --baselines names none: inside .scenescout/, which git ignores, so nothing is committed unless the project chooses a folder of its own. */
45
+ export function defaultBaselinesDir(projectDir) {
46
+ return path.join(projectDir, MEMORY_DIRNAME, "baselines");
47
+ }
48
+ /**
49
+ * The settings every baseline is taken with, in one copy: the check attaches
50
+ * with its window and scale, the browser takes the picture by the rest, and
51
+ * the JSON records them all, the window and scale as the browser reports them.
52
+ */
53
+ export const BASELINE_CAPTURE = {
54
+ viewport: { width: 1280, height: 900 },
55
+ deviceScaleFactor: 1,
56
+ margin: CAPTURE_MARGIN,
57
+ reducedMotion: "reduce",
58
+ animations: "disabled",
59
+ caret: "hide",
60
+ };
61
+ /**
62
+ * Page-side source that stops every animation and transition before an
63
+ * element is measured, not only while the screenshot is taken: a target that
64
+ * itself moves would otherwise be cropped wherever it was mid-movement. Finite
65
+ * ones are finished, infinite ones cancelled, as the screenshot's own setting
66
+ * does. A string, like the collector, so no build step can wrap it in a
67
+ * helper the page does not have.
68
+ */
69
+ export const STOP_ANIMATIONS_SCRIPT = `(() => {
70
+ for (const a of document.getAnimations()) {
71
+ const end = a.effect ? a.effect.getComputedTiming().endTime : Infinity;
72
+ if (end === Infinity) a.cancel();
73
+ else a.finish();
74
+ }
75
+ return true;
76
+ })()`;
77
+ const ELEMENT_HELP = `${PAGE_ELEMENT} or ${TARGET_HELP}`;
78
+ /** What an element names: null for the page itself, else the target a saved flow would use. Undefined when it is neither. */
79
+ export function elementTarget(element) {
80
+ if (element === PAGE_ELEMENT)
81
+ return null;
82
+ return parseTarget(element) ?? undefined;
83
+ }
84
+ const elementSchema = z.string().superRefine((e, ctx) => {
85
+ const target = elementTarget(e);
86
+ if (target === undefined)
87
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: `must be ${ELEMENT_HELP}` });
88
+ else if (target?.by === "role" && !ARIA_ROLES.has(target.role)) {
89
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: `names the role "${target.role}", which is not an ARIA role (e.g. button, link, region, img)` });
90
+ }
91
+ });
92
+ const targetsSchema = z
93
+ .object({
94
+ targets: z
95
+ .array(z
96
+ .object({
97
+ path: z.string().regex(/^\/\S*$/, "must be a path on the app, starting with / and with no spaces"),
98
+ element: elementSchema.optional(),
99
+ })
100
+ .strict())
101
+ .min(1, "lists nothing to keep a baseline of")
102
+ .max(MAX_BASELINE_TARGETS, `holds at most ${MAX_BASELINE_TARGETS} targets`),
103
+ })
104
+ .strict()
105
+ .superRefine((file, ctx) => {
106
+ // Two entries for one picture would write one file twice and compare it twice.
107
+ const seen = new Map();
108
+ file.targets.forEach((t, i) => {
109
+ const key = `${t.path}\u0000${t.element ?? PAGE_ELEMENT}`;
110
+ const first = seen.get(key);
111
+ if (first === undefined)
112
+ seen.set(key, i);
113
+ else
114
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["targets", i], message: `repeats targets[${first}]` });
115
+ });
116
+ });
117
+ /** Validate a targets file's text. Every mistake names the file and the field, as a flow's do. */
118
+ export function parseBaselineTargets(text, file) {
119
+ const parsed = parseJsonFile(text, file, targetsSchema);
120
+ if (!parsed.ok)
121
+ return parsed;
122
+ return { ok: true, targets: parsed.data.targets.map((t) => ({ path: t.path, element: t.element ?? PAGE_ELEMENT })) };
123
+ }
124
+ /** What to tell someone who asked for baselines in a folder with no targets file. */
125
+ export function missingTargetsMessage(file) {
126
+ return (`there is no ${file}: list the pages and elements to keep baselines of there, e.g. ` +
127
+ `{"targets": [{"path": "/", "element": "page"}, {"path": "/settings", "element": "testid=profile-card"}]}`);
128
+ }
129
+ // ---------------------------------------------------------------------------
130
+ // Where the files are
131
+ // ---------------------------------------------------------------------------
132
+ /**
133
+ * Text as a file name: lower-case letters, digits and dashes, then a hash of
134
+ * the exact text, so two that read alike never share a file. The readable part
135
+ * is taken from the text redacted as a route is, so a token in a target's path
136
+ * never becomes part of a file name, the report or the JSON.
137
+ */
138
+ function fileKey(text, fallback) {
139
+ const slug = redactRoute(text)
140
+ .toLowerCase()
141
+ .replace(/[^a-z0-9]+/g, "-")
142
+ .replace(/^-+|-+$/g, "")
143
+ .slice(0, 48)
144
+ .replace(/-+$/, "") || fallback;
145
+ return `${slug}-${createHash("sha256").update(text).digest("hex").slice(0, 8)}`;
146
+ }
147
+ /** The folder of one route's baselines: `/settings/profile` → `settings-profile-<hash>`, `/` → `index-<hash>`. */
148
+ export function routeFolder(routePath) {
149
+ return fileKey(routePath, "index");
150
+ }
151
+ /** The file name of one element's baseline, without its extension: `page`, or e.g. `testid-profile-card-<hash>`. */
152
+ export function elementFile(element) {
153
+ return element === PAGE_ELEMENT ? PAGE_ELEMENT : fileKey(element, "element");
154
+ }
155
+ /**
156
+ * A target's baseline, relative to the baselines folder and with forward
157
+ * slashes: one folder per engine (a picture from one browser is not another's
158
+ * baseline), one per route, then the PNG and the JSON beside it.
159
+ */
160
+ export function baselineFiles(engine, target) {
161
+ const stem = `${engine}/${routeFolder(target.path)}/${elementFile(target.element)}`;
162
+ return { png: `${stem}.png`, json: `${stem}.json` };
163
+ }
164
+ /** The pictures of an unmet baseline, relative to the check's output folder: what was expected, what it is now, and the changed pixels. */
165
+ export function visualFiles(target) {
166
+ const stem = `${VISUAL_DIRNAME}/${routeFolder(target.path)}/${elementFile(target.element)}`;
167
+ return { expected: `${stem}.expected.png`, actual: `${stem}.actual.png`, diff: `${stem}.diff.png` };
168
+ }
169
+ /**
170
+ * Whether a file under the output folder's visual/ is one visualFiles names:
171
+ * a route folder and a picture named exactly as a check names them. Only
172
+ * these are cleared before a run writes its own, so nothing else that happens
173
+ * to sit there is ever deleted.
174
+ */
175
+ export function isVisualPicture(folder, file) {
176
+ return /^[a-z0-9-]+-[0-9a-f]{8}$/.test(folder) && /^(page|[a-z0-9-]+-[0-9a-f]{8})\.(expected|actual|diff)\.png$/.test(file);
177
+ }
178
+ const size = z.object({ width: z.number().int().positive(), height: z.number().int().positive() });
179
+ const metaSchema = z.object({
180
+ path: z.string(),
181
+ element: z.string(),
182
+ engine: z.string(),
183
+ platform: z.string(),
184
+ // Read as strings, not the values this version writes, so a baseline taken another way is reported as different, not as unreadable.
185
+ capture: z.object({
186
+ viewport: size,
187
+ deviceScaleFactor: z.number().positive(),
188
+ margin: z.number().int().min(0),
189
+ reducedMotion: z.string(),
190
+ animations: z.string(),
191
+ caret: z.string(),
192
+ }),
193
+ size,
194
+ capturedAt: z.string(),
195
+ });
196
+ export function baselineMeta(o) {
197
+ return {
198
+ path: o.target.path,
199
+ element: o.target.element,
200
+ engine: o.engine,
201
+ platform: o.platform,
202
+ capture: o.capture,
203
+ size: o.size,
204
+ capturedAt: o.capturedAt,
205
+ };
206
+ }
207
+ /** Read a baseline's JSON. An error says why it cannot be used, for the report. */
208
+ export function parseBaselineMeta(text) {
209
+ let json;
210
+ try {
211
+ json = JSON.parse(text);
212
+ }
213
+ catch (err) {
214
+ return { ok: false, error: `its JSON is not valid (${err instanceof Error ? err.message : String(err)})` };
215
+ }
216
+ const parsed = metaSchema.safeParse(json);
217
+ if (!parsed.success) {
218
+ const first = parsed.error.issues[0];
219
+ return { ok: false, error: `its JSON is not a baseline's: ${fieldPath(first.path)} ${issueMessage(first)}` };
220
+ }
221
+ return { ok: true, meta: parsed.data };
222
+ }
223
+ /** How each setting is named and shown. Typed by CaptureSettings, so a setting added there must be added here, and so compared. */
224
+ const SETTING_SHOWN = {
225
+ viewport: { label: "viewport", value: (v) => `${v.width}×${v.height}` },
226
+ deviceScaleFactor: { label: "device scale", value: (v) => String(v) },
227
+ margin: { label: "margin", value: (v) => `${v}px` },
228
+ reducedMotion: { label: "reducedMotion", value: (v) => v },
229
+ animations: { label: "animations", value: (v) => v },
230
+ caret: { label: "caret", value: (v) => v },
231
+ };
232
+ /** How two pictures' settings differ, one phrase each; empty when they were taken alike. */
233
+ export function captureDifferences(stored, now) {
234
+ return Object.keys(SETTING_SHOWN).flatMap((k) => {
235
+ const { label, value } = SETTING_SHOWN[k];
236
+ const was = value(stored[k]);
237
+ const is = value(now[k]);
238
+ return was === is ? [] : [`${label} ${was} (now ${is})`];
239
+ });
240
+ }
241
+ /**
242
+ * Make sense of a target's two baseline files as found on disk (null for one
243
+ * that is not there). Half a baseline, a JSON that describes another target or
244
+ * browser, and a PNG that cannot be read or is not the size its JSON says are
245
+ * each unusable, with the reason: never silently compared, never silently
246
+ * treated as absent.
247
+ */
248
+ export function readStoredBaseline(target, engine, png, json) {
249
+ if (png === null && json === null)
250
+ return null;
251
+ if (png === null)
252
+ return { problem: "its PNG is missing; only the JSON beside it is there" };
253
+ if (json === null)
254
+ return { problem: "the JSON beside its PNG is missing" };
255
+ const parsed = parseBaselineMeta(json);
256
+ if (!parsed.ok)
257
+ return { problem: parsed.error };
258
+ const m = parsed.meta;
259
+ if (m.path !== target.path || m.element !== target.element || m.engine !== engine) {
260
+ return { problem: `its JSON describes ${m.element} on ${m.path} in ${m.engine}, not this target` };
261
+ }
262
+ let image;
263
+ try {
264
+ image = decodePng(png);
265
+ }
266
+ catch (err) {
267
+ return { problem: `its PNG cannot be read: ${err instanceof Error ? err.message : String(err)}` };
268
+ }
269
+ if (image.width !== m.size.width || image.height !== m.size.height) {
270
+ return { problem: `its PNG is ${image.width}×${image.height} where its JSON says ${m.size.width}×${m.size.height}` };
271
+ }
272
+ return { meta: m, image };
273
+ }
274
+ /**
275
+ * Far below the smallest step a share of pixels can take (100 / MAX_PIXELS),
276
+ * so a share exactly at the threshold is never read as past it because of
277
+ * floating point: 7 of 100 is 7.000000000000001%.
278
+ */
279
+ const SHARE_EPSILON = 1e-9;
280
+ /** Whether a comparison is a change: any change of size, or more of the pixels than the threshold allows. */
281
+ export function isChange(diff, threshold) {
282
+ if (diff.sizeChanged)
283
+ return true;
284
+ return diff.total > 0 && (diff.changed / diff.total) * 100 > threshold + SHARE_EPSILON;
285
+ }
286
+ function diffSummary(d) {
287
+ return { percent: d.percent, changedPixels: d.changed, totalPixels: d.total, sizeChanged: d.sizeChanged, baseline: d.before, now: d.after };
288
+ }
289
+ /**
290
+ * Decide what becomes of one captured target, given its stored baseline and
291
+ * the picture just taken. A status of `updated` is the one that writes the new
292
+ * picture as its baseline; `diffImage` comes with `changed`, for the pictures
293
+ * kept beside the report. update rewrites what compare would not accept and
294
+ * leaves alone a baseline compare would (none of its pixels changed, or no
295
+ * more than the threshold allows), so an update that changes nothing changes
296
+ * no file and noise under the threshold does not churn the folder. A baseline
297
+ * taken on another operating system is rewritten however close it came, so
298
+ * retaking baselines where the check runs replaces every one of them.
299
+ */
300
+ export function judgeBaseline(o) {
301
+ const { mode, stored, now } = o;
302
+ if (stored === null)
303
+ return mode === "update" ? { status: "updated", detail: "it had no baseline" } : { status: "no-baseline" };
304
+ const cannotUse = (why) => mode === "update" ? { status: "updated", detail: `replaced one that could not be used: ${why}` } : { status: "unusable", detail: why };
305
+ if ("problem" in stored)
306
+ return cannotUse(stored.problem);
307
+ const differs = captureDifferences(stored.meta.capture, now.capture);
308
+ if (differs.length > 0)
309
+ return cannotUse(`it was taken with other settings: ${differs.join(", ")}`);
310
+ const { platform } = stored.meta;
311
+ const otherSystem = platform !== now.platform;
312
+ const d = diffImages(stored.image, now.image);
313
+ const diff = diffSummary(d);
314
+ if (mode === "update") {
315
+ if (otherSystem)
316
+ return { status: "updated", detail: `replaced one taken on ${platform}`, diff };
317
+ if (!isChange(d, o.threshold))
318
+ return { status: "matches", diff };
319
+ // With the count, as describeChange gives it.
320
+ return { status: "updated", detail: `it was ${d.percent}% different (${d.changed} of ${d.total} pixels)`, diff };
321
+ }
322
+ const note = otherSystem ? { platformNote: `its baseline was taken on ${platform} and this check ran on ${now.platform}` } : {};
323
+ if (!isChange(d, o.threshold))
324
+ return { status: "matches", diff, ...note };
325
+ return { status: "changed", diff, diffImage: d.image, ...note };
326
+ }
327
+ /** The issue rule an unmet baseline is filed under (check.ts). */
328
+ export const VISUAL_RULE = "visual-change";
329
+ /**
330
+ * The evidence an unmet baseline is filed with, or null when the result is
331
+ * not an issue. A change and an unusable baseline (compare), and a target that
332
+ * could not be pictured at all (either mode), are issues: targets.json says
333
+ * the project expects to see it, and a comparison that compared nothing must
334
+ * not pass. No baseline yet is never one.
335
+ */
336
+ export function baselineEvidence(r, run) {
337
+ const what = `${r.element} on ${r.path}`;
338
+ switch (r.status) {
339
+ case "not-captured":
340
+ return `${what} could not be captured: ${r.detail ?? "no reason given"}${run.mode === "update" ? ", so its baseline was not written" : ""}`;
341
+ case "unusable":
342
+ return `${what}: its baseline cannot be used (${r.detail ?? "no reason given"}); run the check with --baseline update to take it again`;
343
+ case "changed":
344
+ return `${what}: ${r.diff ? describeChange(r.diff, run.threshold) : "it no longer matches its baseline"}${r.files ? ` — diff: ${r.files.diff}` : ""}`;
345
+ default:
346
+ return null;
347
+ }
348
+ }
349
+ /** A change in words. A change of size always counts, whatever the threshold, so it is named first. */
350
+ export function describeChange(d, threshold) {
351
+ if (d.sizeChanged) {
352
+ return `its size changed from ${d.baseline.width}×${d.baseline.height} to ${d.now.width}×${d.now.height}, which always counts (${d.percent}% of its pixels differ)`;
353
+ }
354
+ // The count beside the rounded share, so 0.504% past a 0.5% threshold does not read as 0.5% (allowed: 0.5%).
355
+ return `${d.percent}% of its pixels changed (${d.changedPixels} of ${d.totalPixels}; allowed: ${threshold}%)`;
356
+ }
357
+ /** The fingerprint of an unmet baseline: the same target in the same browser is the same alert, whatever its percentage this time. */
358
+ export function baselineFingerprint(engine, r) {
359
+ return createHash("sha256").update(`${VISUAL_RULE}\u0000${engine}\u0000${r.path}\u0000${r.element}`).digest("hex").slice(0, 32);
360
+ }
361
+ /** How many results there are of each status, for the report's summary line. */
362
+ export function countStatuses(results) {
363
+ const counts = { matches: 0, changed: 0, "no-baseline": 0, unusable: 0, "not-captured": 0, updated: 0 };
364
+ for (const r of results)
365
+ counts[r.status] += 1;
366
+ return counts;
367
+ }
368
+ /** Parse --baseline-threshold: a percentage from 0 to 100. */
369
+ export function parseThreshold(raw) {
370
+ if (raw === undefined)
371
+ return { ok: true, value: DEFAULT_BASELINE_THRESHOLD };
372
+ const value = Number(raw.trim());
373
+ if (raw.trim() === "" || !Number.isFinite(value) || value < 0 || value > 100) {
374
+ return { ok: false, error: "--baseline-threshold must be a percentage from 0 to 100, e.g. 0.5" };
375
+ }
376
+ return { ok: true, value };
377
+ }
@@ -114,6 +114,21 @@ function laneObjective(modules, goal) {
114
114
  const what = `Own ${owned}`;
115
115
  return goal ? `${what} — ${goal}` : what;
116
116
  }
117
+ /**
118
+ * How every lane works, whoever briefs it: formatBriefs passes these to a
119
+ * planner's lane agents, and an unattended run's lanes (`scenescout ci
120
+ * --lanes`) are given the same lines in their prompt. One list, so the two
121
+ * kinds of lane cannot drift apart.
122
+ */
123
+ export const LANE_RULES = [
124
+ `A lane works ITS routes only. Two lanes auditing the same register while a third module is never opened is the failure this plan exists to prevent — and route coverage will look complete either way. A defect on a page it does not own is that page's lane's to file.`,
125
+ `Every acting tool takes a \`task\`. A lane that acts with none is refused, and the person watching the live view would otherwise see a browser clicking through their app with nothing to say why.`,
126
+ `File each defect with scout_finding the moment it is judged, before writing the report. A defect only in the report is not in the run's report.`,
127
+ `Before calling a list empty, stale or stuck, read the status of the request behind it: a correct empty result and a failed one can render identically.`,
128
+ `On a create form, submit one markup-shaped value, then open where that record is listed. Any lane's session catches it rendering as markup, so the list may belong to another lane.`,
129
+ `When a control is withheld from this role, call the endpoint behind it with scout_request: hiding a button is not enforcing a rule. When a sort or filter runs, check the result is what it claims.`,
130
+ `Check scout_coverage before finishing.`,
131
+ ];
117
132
  /**
118
133
  * The briefing the planner gives the lanes.
119
134
  *
@@ -133,13 +148,7 @@ export function formatBriefs(briefs, opts = {}) {
133
148
  ` scout_attach { session: "<lane>", url: "<origin><landing>", projectPath, mode: "${mode}"${signInArgument(opts)}, objective: "<objective>" }`,
134
149
  ``,
135
150
  `Rules to pass on, which a hand-written brief tends to drop:`,
136
- ` · A lane works ITS routes only. Two lanes auditing the same register while a third module is never opened is the failure this plan exists to prevent — and route coverage will look complete either way. A defect on a page it does not own is that page's lane's to file.`,
137
- ` · Every acting tool takes a \`task\`. A lane that acts with none is refused, and the person watching the live view would otherwise see a browser clicking through their app with nothing to say why.`,
138
- ` · File each defect with scout_finding the moment it is judged, before writing the report. A defect only in the report is not in the run's report.`,
139
- ` · Before calling a list empty, stale or stuck, read the status of the request behind it: a correct empty result and a failed one can render identically.`,
140
- ` · On a create form, submit one markup-shaped value, then open where that record is listed. Any lane's session catches it rendering as markup, so the list may belong to another lane.`,
141
- ` · When a control is withheld from this role, call the endpoint behind it with scout_request: hiding a button is not enforcing a rule. When a sort or filter runs, check the result is what it claims.`,
142
- ` · Check scout_coverage before finishing.`,
151
+ ...LANE_RULES.map((rule) => ` · ${rule}`),
143
152
  ` · A lane reports back with scout_lane_report, not prose, and does NOT close its session: the planner folds the report, which checks each judged defect was filed, then closes it. Call scout_lane_report with no reply to get the instruction to put in its prompt.`,
144
153
  ``,
145
154
  ];