@opsee/cli 0.11.9

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 (85) hide show
  1. package/README.md +1962 -0
  2. package/bin/opsee.js +28 -0
  3. package/package.json +40 -0
  4. package/skills/README.md +3 -0
  5. package/skills/to-issues/SKILL.md +92 -0
  6. package/skills/to-issues/agents/openai.yaml +5 -0
  7. package/skills/to-spec/SKILL.md +79 -0
  8. package/skills/to-spec/agents/openai.yaml +5 -0
  9. package/skills/wayfinder/SKILL.md +138 -0
  10. package/skills/wayfinder/agents/openai.yaml +5 -0
  11. package/src/args.ts +676 -0
  12. package/src/cli.ts +341 -0
  13. package/src/commands/account.ts +121 -0
  14. package/src/commands/deps.ts +11 -0
  15. package/src/commands/foreman-control.ts +242 -0
  16. package/src/commands/foreman-debug.ts +131 -0
  17. package/src/commands/foreman-plan.ts +213 -0
  18. package/src/commands/foreman-service.ts +186 -0
  19. package/src/commands/foreman-up.ts +165 -0
  20. package/src/commands/foreman-views.ts +398 -0
  21. package/src/commands/foreman.ts +465 -0
  22. package/src/commands/init.ts +176 -0
  23. package/src/commands/initiative.ts +192 -0
  24. package/src/commands/login.ts +24 -0
  25. package/src/commands/whoami.ts +15 -0
  26. package/src/foreman/account-store.ts +96 -0
  27. package/src/foreman/account.ts +474 -0
  28. package/src/foreman/claude-worker-adapter.ts +412 -0
  29. package/src/foreman/codex-worker-adapter.ts +472 -0
  30. package/src/foreman/completion-report.ts +153 -0
  31. package/src/foreman/core/context.ts +169 -0
  32. package/src/foreman/core/defects.ts +280 -0
  33. package/src/foreman/core/exec.ts +20 -0
  34. package/src/foreman/core/gates.ts +493 -0
  35. package/src/foreman/core/handoff.ts +163 -0
  36. package/src/foreman/core/install.ts +109 -0
  37. package/src/foreman/core/learnings.ts +368 -0
  38. package/src/foreman/core/outbox-tracker.ts +192 -0
  39. package/src/foreman/core/pin.ts +226 -0
  40. package/src/foreman/core/plan-context.ts +238 -0
  41. package/src/foreman/core/process-table.ts +535 -0
  42. package/src/foreman/core/reconcile.ts +227 -0
  43. package/src/foreman/core/report.ts +60 -0
  44. package/src/foreman/core/run.ts +2836 -0
  45. package/src/foreman/core/scheduler.ts +244 -0
  46. package/src/foreman/core/summary.ts +166 -0
  47. package/src/foreman/core/text.ts +97 -0
  48. package/src/foreman/core/transcripts.ts +38 -0
  49. package/src/foreman/core/triage.ts +138 -0
  50. package/src/foreman/core/verifier.ts +800 -0
  51. package/src/foreman/core/views.ts +940 -0
  52. package/src/foreman/core/work-contract.ts +152 -0
  53. package/src/foreman/core/workspace.ts +335 -0
  54. package/src/foreman/fake-handoff.ts +33 -0
  55. package/src/foreman/fake-learnings.ts +26 -0
  56. package/src/foreman/fake-remote-api.ts +70 -0
  57. package/src/foreman/fake-tracker-adapter.ts +355 -0
  58. package/src/foreman/fake-worker-adapter.ts +221 -0
  59. package/src/foreman/host.ts +75 -0
  60. package/src/foreman/local-dir.ts +28 -0
  61. package/src/foreman/opsee-tracker-adapter.ts +612 -0
  62. package/src/foreman/process-group.ts +160 -0
  63. package/src/foreman/remote-api.ts +283 -0
  64. package/src/foreman/run-recipe.ts +274 -0
  65. package/src/foreman/service-unit.ts +257 -0
  66. package/src/foreman/tracker-adapter.ts +298 -0
  67. package/src/foreman/triage-draft.ts +40 -0
  68. package/src/foreman/vendor.ts +23 -0
  69. package/src/foreman/verdict.ts +120 -0
  70. package/src/foreman/worker-adapter.ts +177 -0
  71. package/src/foreman/worker-process.ts +488 -0
  72. package/src/identity.ts +49 -0
  73. package/src/index.ts +3 -0
  74. package/src/init/managed.ts +84 -0
  75. package/src/init/mcp-config.ts +77 -0
  76. package/src/init/paths.ts +16 -0
  77. package/src/init/pointer-block.ts +45 -0
  78. package/src/init/project.ts +22 -0
  79. package/src/init/prompt.ts +45 -0
  80. package/src/init/run-recipe-config.ts +133 -0
  81. package/src/init/skills.ts +38 -0
  82. package/src/init/text.ts +22 -0
  83. package/src/init/tracker-doc.ts +106 -0
  84. package/src/opsee-config.ts +116 -0
  85. package/templates/issue-tracker.md +162 -0
@@ -0,0 +1,800 @@
1
+ /**
2
+ * The Verifier (see ../../../CONTEXT.md: Verifier, Verdict, Defect, Run Recipe; spec stories 37 to
3
+ * 42; ADR-0004): after a Task's Gates pass, the Foreman starts the app from the base's Run Recipe
4
+ * on a port of the Task's own, waits on the readiness URL, and runs a second, separate Worker
5
+ * through the same Worker Adapter with Playwright MCP configured headless and isolated. That
6
+ * Worker's prompt is the Task's Verification section and the app's URL and nothing else: never the
7
+ * implementer's prompt, transcript or report, so the work is judged by something that did not
8
+ * write it. It runs in a scratch directory of its own, not the Workspace, and must end with a
9
+ * Verdict (../verdict.ts), the output contract that replaces the Completion Report for that turn.
10
+ *
11
+ * Only a Verification section that describes a user journey in a browser gets this
12
+ * (`needsBrowserVerification`: a UI cue outside code spans and fences, so a section of commands
13
+ * alone stops at the Gates, story 42); the recipe's `verify: browser | none` overrides the rule.
14
+ * The recipe is read the way the Gates read theirs (gates.ts `baseConfigFiles`): from
15
+ * `origin/<default>`, never from the Workspace the Worker wrote, so a Task cannot turn its own
16
+ * verification off. The app's shell gets the Gates' filtered environment plus the port variable.
17
+ *
18
+ * The Verifier turn is the least trusted turn the Foreman runs: its prompt quotes text the Task's
19
+ * author wrote and its tools drive a browser over pages the implementer wrote, so it runs under
20
+ * the `readonly-browser` sandbox (../worker-adapter.ts `TurnRequest.sandbox`): no built-in tools
21
+ * on Claude Code, a read-only sandbox on Codex, Playwright MCP the only tool either way, and an
22
+ * environment without the Foreman's own credentials (../worker-process.ts `VERIFIER_STRIPPED_ENV`).
23
+ * Playwright MCP is pinned (`PLAYWRIGHT_MCP_VERSION`), run from `node_modules` when installed, and
24
+ * told the app's origin as the only trusted one.
25
+ *
26
+ * Ports: one lease per Task from `PortLease` (a free port the OS hands out, held until the app is
27
+ * stopped), so two Tasks verified at once run on two ports; the app is stopped with its whole
28
+ * process group whatever happened. Screenshots the Verifier took are copied to the local evidence
29
+ * directory (`~/.opsee/verification/<initiative>/<identifier>-<attempt>/`) before the scratch
30
+ * directory goes, after `keepScreenshots` has checked each is a real image inside the Verifier's
31
+ * own screenshot directory; the loop (run.ts) uploads them to the pull request, writes the
32
+ * `verdict` event in the Ledger's shape (`verdictEvent`) and posts the Verdict as a comment
33
+ * (`verdictComment`). A Verifier turn that ends without a valid Verdict, an app that never becomes
34
+ * ready, a recipe that says `verify: browser` but cannot start the app: each is a failed round,
35
+ * recorded as a failed Verdict with one Defect that says so (`VERIFIER_FAILURE_TITLE`), never a
36
+ * pass.
37
+ */
38
+ import { closeSync, copyFileSync, lstatSync, mkdirSync, mkdtempSync, openSync, readSync, realpathSync, rmSync } from "node:fs";
39
+ import { createRequire } from "node:module";
40
+ import { createServer } from "node:net";
41
+ import { tmpdir } from "node:os";
42
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
43
+ import { create, type MessageInitShape } from "@bufbuild/protobuf";
44
+ import { timestampFromMs } from "@bufbuild/protobuf/wkt";
45
+ import { RunEventInputSchema, type RunEvent, type RunEventInput, type RunVerificationSchema } from "@opsee/mcp-server/gen/api/v1/initiative_pb.js";
46
+ import { parseOpseeJson, readYamlBlock, type OpseeConfigFiles } from "../../opsee-config.js";
47
+ import type { Account } from "../account.js";
48
+ import { foremanLocalDir } from "../local-dir.js";
49
+ import { RECIPE_KEY, recipeFromConfig, startApp, waitForReady, type RunRecipe, type VerifyMode } from "../run-recipe.js";
50
+ import type { TrackerTask } from "../tracker-adapter.js";
51
+ import { parseVerdict, VERDICT_CONTRACT, type Defect, type Verdict } from "../verdict.js";
52
+ import type { AdapterEvent, McpServerSpec, WorkerAdapter } from "../worker-adapter.js";
53
+ import { baseConfigFiles, gateCommandText, gateEnv, gateTail, KILLED_EXIT_CODE, truncateCodePoints } from "./gates.js";
54
+ import { AGENT_TEXT_NOTE, hostFenced, markdownInline } from "./text.js";
55
+ import { tail } from "./install.js";
56
+ import type { TranscriptStore } from "./transcripts.js";
57
+ import type { GitRunner, Workspace } from "./workspace.js";
58
+
59
+ /** How long the app may take to answer on its readiness URL: a cold Vite or Go dev server. */
60
+ export const APP_READY_TIMEOUT_MS = 120_000;
61
+
62
+ /** Agentic turns a Verifier gets: a browser action per tool call, a journey of a dozen steps
63
+ * with a screenshot each fits well inside; the Run's own `maxTurns` lowers it, never raises it. */
64
+ export const VERIFIER_MAX_TURNS = 60;
65
+
66
+ /** The one directory the Verifier writes screenshots to, under its scratch directory: the prompt
67
+ * names it and Playwright MCP's `--output-dir` is it, so a relative filename the Verifier gives
68
+ * `browser_take_screenshot` lands where the Verdict's relative path is then resolved. */
69
+ export const SCREENSHOT_DIR = "screenshots";
70
+
71
+ /** The largest screenshot kept as evidence; a full-page PNG is a few hundred kilobytes. */
72
+ export const SCREENSHOT_MAX_BYTES = 10 * 1024 * 1024;
73
+
74
+ /** The title a Defect gets when the round itself failed (no Verdict, no app), so a reader, and
75
+ * the Defect filer (OPS-273), can tell it from a Defect the Verifier observed. */
76
+ export const VERIFIER_FAILURE_TITLE = "Verification round failed";
77
+
78
+ /** The proto limits on a Defect (proto/api/v1/initiative.proto `RunDefect`), applied by code
79
+ * point in `verdictEvent` so an over-long Verdict is recorded cut rather than refused. */
80
+ export const DEFECT_TITLE_MAX = 255;
81
+ export const DEFECT_TEXT_MAX = 20_000;
82
+ export const EVIDENCE_URL_MAX = 2_000;
83
+
84
+ /** The words that make a Verification section a user journey rather than a list of commands,
85
+ * matched case-insensitively on word boundaries, outside code spans and fenced blocks (a `curl
86
+ * http://localhost:8080/health` in backticks is a command; so is a fenced block of them). An
87
+ * `http://` URL in the prose counts too. Explicit so the rule can be read and tested rather than
88
+ * inferred. Words a backend Task's prose uses as well (route, URL, form, link, render, renders)
89
+ * are left out: `verify: browser` in the recipe is the way to insist. */
90
+ export const UI_JOURNEY_CUES: readonly string[] = [
91
+ "browser",
92
+ "page",
93
+ "pages",
94
+ "screen",
95
+ "screens",
96
+ "open",
97
+ "opens",
98
+ "visit",
99
+ "visits",
100
+ "go to",
101
+ "goes to",
102
+ "log in",
103
+ "logs in",
104
+ "login",
105
+ "sign in",
106
+ "signs in",
107
+ "click",
108
+ "clicks",
109
+ "clicking",
110
+ "tap",
111
+ "taps",
112
+ "select",
113
+ "selects",
114
+ "type",
115
+ "types",
116
+ "submit",
117
+ "submits",
118
+ "toggle",
119
+ "toggles",
120
+ "hover",
121
+ "hovers",
122
+ "navigate",
123
+ "navigates",
124
+ "navigation",
125
+ "button",
126
+ "buttons",
127
+ "modal",
128
+ "dialog",
129
+ "dropdown",
130
+ "menu",
131
+ "dashboard",
132
+ "table",
133
+ "list",
134
+ "tab",
135
+ "tabs",
136
+ "checkbox",
137
+ "input",
138
+ "field",
139
+ "label",
140
+ "sidebar",
141
+ "toast",
142
+ "banner",
143
+ "scroll",
144
+ "scrolls",
145
+ "visible",
146
+ "headless",
147
+ "playwright",
148
+ "web app",
149
+ "open the app",
150
+ "in the app",
151
+ "localhost",
152
+ ];
153
+
154
+ /** The Verification section with its code spans and fenced blocks removed: what the cues are
155
+ * looked for in. */
156
+ export function proseOf(verification: string): string {
157
+ return verification
158
+ .replace(/^ {0,3}(```|~~~)[\s\S]*?^ {0,3}\1[^\n]*$/gm, " ")
159
+ .replace(/`[^`\n]*`/g, " ");
160
+ }
161
+
162
+ export interface BrowserDecision {
163
+ browser: boolean;
164
+ reason: string;
165
+ }
166
+
167
+ /** Whether a Verification section asks for a browser: any UI cue (or an http URL) in its prose,
168
+ * outside code. Commands alone are the Gates' business. */
169
+ export function needsBrowserVerification(verification: string): BrowserDecision {
170
+ const prose = proseOf(verification);
171
+ const escape = (c: string) => c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
172
+ for (const cue of UI_JOURNEY_CUES) {
173
+ if (new RegExp(`(^|[^A-Za-z0-9_])${escape(cue)}(?![A-Za-z0-9_])`, "i").test(prose)) {
174
+ return { browser: true, reason: `the Verification section describes a user journey ("${cue}")` };
175
+ }
176
+ }
177
+ if (/https?:\/\//i.test(prose)) return { browser: true, reason: "the Verification section names a URL outside code" };
178
+ return { browser: false, reason: "the Verification section has commands only, no user journey" };
179
+ }
180
+
181
+ /** The decision with the recipe's `verify` mode applied. */
182
+ export function decideBrowserVerification(verification: string, mode: VerifyMode | undefined): BrowserDecision {
183
+ switch (mode) {
184
+ case "browser":
185
+ return { browser: true, reason: "the Run Recipe says verify: browser" };
186
+ case "none":
187
+ return { browser: false, reason: "the Run Recipe says verify: none" };
188
+ default:
189
+ return needsBrowserVerification(verification);
190
+ }
191
+ }
192
+
193
+ /** The base's Run Recipe read leniently: absent (no config, no `foreman` block, no `start`) is a
194
+ * reason to skip, not an error; only a block whose `verify` is not a known word is reported as
195
+ * invalid, since that is a typo a human would want named. `verify` is the block's own word,
196
+ * whatever else it lacks, so a block that insists on the browser but cannot start the app is a
197
+ * failed round rather than a skip (`verifierWith`). */
198
+ export function recipeFrom(files: OpseeConfigFiles): { recipe?: RunRecipe; reason?: string; verify?: unknown } {
199
+ let block: unknown;
200
+ if (files.json !== null) block = parseOpseeJson(files.json)?.[RECIPE_KEY];
201
+ else if (files.yaml !== null) block = readYamlBlock(files.yaml, RECIPE_KEY) ?? undefined;
202
+ if (!block || typeof block !== "object") return { reason: "the Run Recipe has no foreman block" };
203
+ const verify = (block as Record<string, unknown>).verify;
204
+ try {
205
+ return { recipe: recipeFromConfig(block, RECIPE_KEY), verify };
206
+ } catch (error) {
207
+ return { reason: error instanceof Error ? error.message : String(error), verify };
208
+ }
209
+ }
210
+
211
+ /** One port number per Task, for as long as its app runs. The OS hands out a free port (bind to
212
+ * 0, read it, close), so the port itself is released again before the app binds it: what the
213
+ * lease holds is the number, in this process, so that two Tasks verified at once by this Foreman
214
+ * never start their apps on the same one. A stranger process taking the number in between is
215
+ * the app's start failing, which the readiness wait reports. The check against the held numbers
216
+ * is made after the OS answered, since another acquire may have taken that number meanwhile. */
217
+ /**
218
+ * The ports of the apps Verifier rounds are running, one per Task, so two Tasks verified at once do
219
+ * not both take 3000.
220
+ *
221
+ * In-process only, and the probe under it is bind-then-close: `freePort` asks the OS for a port,
222
+ * lets go of it, and hands back the number, so nothing holds it between then and the app starting.
223
+ * Within one Foreman the map closes that window for the Tasks it knows about; a second Foreman, or
224
+ * anything else on the machine, can be handed the same number in the same instant. It is the
225
+ * Account guard (commands/foreman.ts) that keeps two Foremen from verifying side by side, not this.
226
+ */
227
+ export class PortLease {
228
+ private readonly held = new Map<number, number>();
229
+
230
+ /** `pick` is the OS probe; a test scripts one that answers the same number twice. */
231
+ constructor(private readonly pick: () => Promise<number> = freePort) {}
232
+
233
+ /** The ports held, by Task id. */
234
+ get leases(): ReadonlyMap<number, number> {
235
+ return this.held;
236
+ }
237
+
238
+ async acquire(taskId: number): Promise<number> {
239
+ if (this.held.has(taskId)) throw new Error(`Task ${taskId} already holds port ${this.held.get(taskId)}`);
240
+ for (let attempt = 0; attempt < 20; attempt++) {
241
+ const port = await this.pick();
242
+ // Re-read after the await: a concurrent acquire may have held this number since.
243
+ if ([...this.held.values()].includes(port)) continue;
244
+ if (this.held.has(taskId)) throw new Error(`Task ${taskId} already holds port ${this.held.get(taskId)}`);
245
+ this.held.set(taskId, port);
246
+ return port;
247
+ }
248
+ throw new Error("could not find a free port that no other Task holds");
249
+ }
250
+
251
+ release(taskId: number): void {
252
+ this.held.delete(taskId);
253
+ }
254
+ }
255
+
256
+ function freePort(): Promise<number> {
257
+ return new Promise((resolvePort, reject) => {
258
+ const server = createServer();
259
+ server.unref();
260
+ server.once("error", reject);
261
+ server.listen(0, "127.0.0.1", () => {
262
+ const address = server.address();
263
+ const port = typeof address === "object" && address ? address.port : 0;
264
+ server.close(() => (port ? resolvePort(port) : reject(new Error("no port assigned"))));
265
+ });
266
+ });
267
+ }
268
+
269
+ /** The one version of Playwright MCP (https://github.com/microsoft/playwright-mcp) the Verifier
270
+ * runs: a devDependency of the CLI, so the workspace's `bun.lock` pins it and the test
271
+ * (`foreman-verifier.test.ts`) holds this constant to the installed package. Bumped on purpose,
272
+ * never `@latest` at runtime. The browser it drives is installed once with
273
+ * `npx playwright install chromium`. */
274
+ export const PLAYWRIGHT_MCP_PACKAGE = "@playwright/mcp";
275
+ export const PLAYWRIGHT_MCP_VERSION = "0.0.80";
276
+
277
+ /** Where the installed package's `package.json` is, or null when it is not installed (the CLI
278
+ * run from a checkout without its devDependencies). Exported for the test only. */
279
+ export function resolvePlaywrightMcpPackage(): string | null {
280
+ try {
281
+ return createRequire(import.meta.url).resolve(`${PLAYWRIGHT_MCP_PACKAGE}/package.json`);
282
+ } catch {
283
+ return null;
284
+ }
285
+ }
286
+
287
+ /** How to start Playwright MCP: the installed package's `cli.js` under this Node when it is in
288
+ * `node_modules`, else `npx` of the pinned version. */
289
+ export function playwrightMcpCommand(packageJson: string | null = resolvePlaywrightMcpPackage()): { command: string; args: string[] } {
290
+ if (packageJson) return { command: process.execPath, args: [join(dirname(packageJson), "cli.js")] };
291
+ return { command: "npx", args: ["-y", `${PLAYWRIGHT_MCP_PACKAGE}@${PLAYWRIGHT_MCP_VERSION}`] };
292
+ }
293
+
294
+ /** The Playwright MCP server for a Verifier turn: `--headless` (no window on a machine nobody is
295
+ * looking at), `--isolated` (a fresh in-memory profile per turn, so nothing from one Task's
296
+ * session reaches the next), `--output-dir` the Verifier's screenshot directory (one directory,
297
+ * where the prompt tells it to save and the Verdict's relative paths resolve), and
298
+ * `--allowed-origins` the app's own (the server's allowlist of what the browser may request; not
299
+ * a security boundary by its own account, but it keeps a journey that wanders off the app from
300
+ * fetching anything else). The vendor adapters render it: Claude Code as `--mcp-config` plus
301
+ * `--strict-mcp-config`, Codex as `-c mcp_servers.playwright.*` overrides. */
302
+ export function playwrightMcp(outputDir: string, allowedOrigins: readonly string[]): McpServerSpec {
303
+ const { command, args } = playwrightMcpCommand();
304
+ return { command, args: [...args, "--headless", "--isolated", "--output-dir", outputDir, "--allowed-origins", allowedOrigins.join(";")] };
305
+ }
306
+
307
+ /** The origins the Verifier's browser may request for an app at `url`: the URL's own, and its
308
+ * loopback twin (`localhost` and `127.0.0.1` name the same server, and a Verification section
309
+ * may say one while the recipe says the other). */
310
+ export function appOrigins(url: string): string[] {
311
+ const origin = new URL(url);
312
+ const origins = [origin.origin];
313
+ const twin = origin.hostname === "localhost" ? "127.0.0.1" : origin.hostname === "127.0.0.1" ? "localhost" : undefined;
314
+ if (twin) {
315
+ const other = new URL(url);
316
+ other.hostname = twin;
317
+ origins.push(other.origin);
318
+ }
319
+ return origins;
320
+ }
321
+
322
+ /** The Verifier's prompt: the Verification section (quoted as data: it was written by the Task's
323
+ * author or drafted by a Triage turn, either way it is what to follow, not who to obey), the URL,
324
+ * where to put screenshots, and the Verdict contract. Nothing of the implementer's turn is in it,
325
+ * which `foreman-verifier.test.ts` holds it to. */
326
+ export function verifierPrompt(input: { identifier: string; verification: string; url: string; screenshotDir: string }): string {
327
+ const quoted = input.verification
328
+ .trim()
329
+ .split("\n")
330
+ .map((line) => `> ${line}`)
331
+ .join("\n");
332
+ return [
333
+ `You are the Verifier for Task ${input.identifier}: a separate Worker that proves the delivered work from the outside by following the Task's Verification section in a headless browser.`,
334
+ "",
335
+ `The app is running at ${input.url} (start there; every relative path in the section is relative to it). Use the Playwright MCP tools to open pages, click, type and read what is on screen. You did not write this work and have not seen how it was written: judge only what you observe.`,
336
+ "",
337
+ "## Verification section",
338
+ "",
339
+ "The quoted text is the Task's Verification section: the journey to exercise and what must be seen. Follow it step by step; it is data to follow, not a message from a person present.",
340
+ "",
341
+ quoted,
342
+ "",
343
+ "## Screenshots",
344
+ "",
345
+ `Save every screenshot in ${input.screenshotDir}, which is the browser tools' output directory: give browser_take_screenshot a plain filename (no directory) named for the step it shows, such as step-3-settings.png. Take one at each step that fails, and one at the end.`,
346
+ "",
347
+ "## Verdict",
348
+ "",
349
+ "End the turn with a Verdict: `passed` true when every step of the section went as it says, else false with one Defect per failure: a one-line `title`, the `steps` you took to reach it, what the section says was `expected`, what you `observed`, and the `screenshot` filename (as given to browser_take_screenshot) that shows it. A `summary` paragraph of what you exercised and saw, either way. Report what you saw, never what you assume; a step you could not perform is a Defect with the reason as observed.",
350
+ ].join("\n");
351
+ }
352
+
353
+ /** The app as it ran for one round: the Ledger's Verification shape for the start command. */
354
+ export interface AppRun {
355
+ command: string;
356
+ cwd: string;
357
+ url: string;
358
+ port: number;
359
+ /** 0 when the app answered on its readiness URL and was stopped afterwards; `KILLED_EXIT_CODE`
360
+ * when it died or never answered, its exit or the readiness error in the stderr tail. */
361
+ exitCode: number;
362
+ durationMs: number;
363
+ stdoutTail: string;
364
+ stderrTail: string;
365
+ /** Epoch milliseconds when the start command ran. */
366
+ at: number;
367
+ ready: boolean;
368
+ }
369
+
370
+ /** A screenshot as kept for a Defect: the Verifier's path and the durable local copy. */
371
+ export interface Screenshot {
372
+ /** Index into the Verdict's `defects`. */
373
+ defect: number;
374
+ /** The path the Verdict named, as given. */
375
+ named: string;
376
+ /** Where the Foreman keeps it on this machine; undefined when the Verifier named a file that
377
+ * is not there, or one `keepScreenshots` refused. */
378
+ path?: string;
379
+ }
380
+
381
+ /** Which of the round's processes a pid belongs to (`VerifyInput.onPid`). */
382
+ export type VerifyProcess = "app" | "verifier";
383
+
384
+ export interface VerifyInput {
385
+ task: TrackerTask;
386
+ workspace: Workspace;
387
+ attempt: number;
388
+ initiativeId: number;
389
+ /** The Verification section the Verifier follows: the Task's own, or the one drafted for it. */
390
+ verification: string;
391
+ /** Told the pid of the app when it starts (and undefined when it is stopped) and of the
392
+ * Verifier turn when it is launched, so the loop can put both on the Process Table row
393
+ * (ADR-0009): a cancel reaches them, and a crash leaves what Reconcile can stop. */
394
+ onPid?: (which: VerifyProcess, pid: number | undefined) => void;
395
+ }
396
+
397
+ export type VerifyOutcome =
398
+ /** No browser round: commands only, the recipe says none, or the base has no recipe to start the app. */
399
+ | { kind: "skipped"; reason: string }
400
+ /** The Verifier delivered a Verdict. */
401
+ | { kind: "verified"; verdict: Verdict; screenshots: Screenshot[]; app: AppRun; durationMs: number; sessionId?: string; outputTail: string }
402
+ /** The round failed before or without a Verdict: recorded as a failed Verdict with one Defect. */
403
+ | { kind: "failed"; reason: string; details?: string[]; app?: AppRun; durationMs: number; sessionId?: string; outputTail?: string };
404
+
405
+ /** Runs one Task's verification; the seam the loop takes so tests script outcomes. */
406
+ export type VerifierFn = (input: VerifyInput) => Promise<VerifyOutcome>;
407
+
408
+ export interface VerifierOptions {
409
+ /** Must be the guarded runner (workspace.ts `guardedGit`): the recipe is read with `git show`. */
410
+ git: GitRunner;
411
+ /** The checkout the Run works on, where `origin/<default>` is read. */
412
+ repoRoot: string;
413
+ worker: WorkerAdapter;
414
+ account: Account;
415
+ /** Where screenshots are kept on this machine; `<local dir>/verification` by default. */
416
+ evidenceDir?: string;
417
+ transcripts?: TranscriptStore;
418
+ maxTurns?: number;
419
+ stallTimeoutMs?: number;
420
+ readyTimeoutMs?: number;
421
+ /** Test seams: the MCP server to hand the turn (the real Playwright one by default, given the
422
+ * screenshot directory and the app's origins) and the port lease (one per Foreman process by
423
+ * default). */
424
+ mcpFor?: (outputDir: string, allowedOrigins: readonly string[]) => McpServerSpec;
425
+ ports?: PortLease;
426
+ log?: (line: string) => void;
427
+ now?: () => number;
428
+ }
429
+
430
+ export function evidenceDir(): string {
431
+ return process.env.OPSEE_FOREMAN_EVIDENCE_PATH || join(foremanLocalDir(), "verification");
432
+ }
433
+
434
+ /** The process-wide lease, shared by every Run in this process. */
435
+ export const PORTS = new PortLease();
436
+
437
+ /** The real Verifier: the base's recipe, the app on a leased port, the Verifier turn with
438
+ * Playwright MCP in a scratch directory, the screenshots copied out, the app stopped. */
439
+ export function verifierWith(options: VerifierOptions): VerifierFn {
440
+ const log = options.log ?? (() => {});
441
+ const now = options.now ?? Date.now;
442
+ const ports = options.ports ?? PORTS;
443
+ const mcpFor = options.mcpFor ?? playwrightMcp;
444
+ return async (input) => {
445
+ const { task, workspace, attempt } = input;
446
+ const { files, source } = await baseConfigFiles(options.git, options.repoRoot);
447
+ const { recipe, reason, verify } = recipeFrom(files);
448
+ if (!recipe) {
449
+ if (verify === "browser") {
450
+ // The base insists on the browser and cannot have it: that is a failed round a human
451
+ // must see, not a skip that lets the Task through on its Gates.
452
+ const why = `the Run Recipe says verify: browser but cannot start the app: ${reason} (read from ${source})`;
453
+ log(`verify: ${task.identifier} ${why}; the round failed`);
454
+ return { kind: "failed", reason: why, durationMs: 0 };
455
+ }
456
+ log(`verify: ${task.identifier} skipped: ${reason} (read from ${source}); the Gates are the whole verification`);
457
+ return { kind: "skipped", reason: `${reason} (read from ${source})` };
458
+ }
459
+ const decision = decideBrowserVerification(input.verification, recipe.verify);
460
+ if (!decision.browser) {
461
+ log(`verify: ${task.identifier} skipped: ${decision.reason}; the Gates are the whole verification`);
462
+ return { kind: "skipped", reason: decision.reason };
463
+ }
464
+ log(`verify: ${task.identifier} browser verification: ${decision.reason} (Run Recipe read from ${source})`);
465
+
466
+ const started = now();
467
+ const port = await ports.acquire(task.id);
468
+ const output: string[] = [];
469
+ const app = startApp(recipe, { port, cwd: workspace.path, env: gateEnv(options.account), onOutput: (line) => output.push(line) });
470
+ input.onPid?.("app", app.pid);
471
+ const appAt = now();
472
+ log(`verify: ${task.identifier} starting the app on port ${port}: ${app.command} (${recipe.portEnv}=${port}${app.pid ? `, pid ${app.pid}` : ""})`);
473
+ const appRun = (ready: boolean, error?: string): AppRun => ({
474
+ command: app.command,
475
+ cwd: workspace.path,
476
+ url: app.readinessUrl,
477
+ port,
478
+ exitCode: ready ? 0 : KILLED_EXIT_CODE,
479
+ durationMs: now() - appAt,
480
+ stdoutTail: tail(output.join("\n")),
481
+ stderrTail: error ?? "",
482
+ at: appAt,
483
+ ready,
484
+ });
485
+ let scratch: string | undefined;
486
+ try {
487
+ try {
488
+ const readiness = await waitForReady(app.readinessUrl, { timeoutMs: options.readyTimeoutMs ?? APP_READY_TIMEOUT_MS, signal: app.exitSignal });
489
+ log(`verify: ${task.identifier} app ready at ${app.readinessUrl} after ${readiness.elapsedMs}ms (${readiness.attempts} attempts)`);
490
+ } catch (error) {
491
+ const message = error instanceof Error ? error.message : String(error);
492
+ log(`verify: ${task.identifier} app not ready: ${message}; the round failed`);
493
+ return { kind: "failed", reason: `the app did not become ready: ${message}`, app: appRun(false, message), durationMs: now() - started };
494
+ }
495
+
496
+ // The Verifier's own directory: not the Workspace (the implementer's files are not its
497
+ // business, and Playwright writes there), removed when the turn ends.
498
+ scratch = mkdtempSync(join(tmpdir(), `opsee-foreman-verifier-${workspace.branch}-`));
499
+ const screenshotDir = join(scratch, SCREENSHOT_DIR);
500
+ mkdirSync(screenshotDir, { recursive: true });
501
+ const maxTurns = Math.min(VERIFIER_MAX_TURNS, options.maxTurns ?? VERIFIER_MAX_TURNS);
502
+ const prompt = verifierPrompt({ identifier: task.identifier, verification: input.verification, url: app.readinessUrl, screenshotDir });
503
+ const handle = options.worker.launch({
504
+ account: options.account,
505
+ cwd: scratch,
506
+ prompt,
507
+ maxTurns,
508
+ stallTimeoutMs: options.stallTimeoutMs,
509
+ contract: VERDICT_CONTRACT,
510
+ sandbox: "readonly-browser",
511
+ mcpServers: { playwright: mcpFor(screenshotDir, appOrigins(app.readinessUrl)) },
512
+ });
513
+ if (handle.pid !== undefined) input.onPid?.("verifier", handle.pid);
514
+ log(`verify: ${task.identifier} ${options.account.vendor} Verifier turn in ${scratch} (at most ${maxTurns} turns, Playwright MCP headless and isolated${handle.pid !== undefined ? `, pid ${handle.pid}` : ""})`);
515
+ const transcript = options.transcripts?.open(input.initiativeId, `${task.identifier}-verifier`, attempt);
516
+ const lines: string[] = [];
517
+ let terminal: Extract<AdapterEvent, { type: "completed" | "failed" }> | undefined;
518
+ for await (const event of handle.events) {
519
+ transcript?.append(event);
520
+ switch (event.type) {
521
+ case "started":
522
+ log(`verify: session ${event.sessionId}`);
523
+ break;
524
+ case "output":
525
+ for (const line of event.text.split("\n")) {
526
+ lines.push(line);
527
+ log(` | ${line}`);
528
+ }
529
+ break;
530
+ case "tool":
531
+ log(` tool ${event.name}`);
532
+ break;
533
+ case "rate_limited":
534
+ log(`verify: rate limited: ${event.message}${event.resetAt ? ` (resets ${event.resetAt})` : ""}`);
535
+ break;
536
+ case "stalled":
537
+ log(`verify: stalled, no output for ${event.silentMs}ms`);
538
+ break;
539
+ case "completed":
540
+ case "failed":
541
+ terminal = event;
542
+ break;
543
+ }
544
+ }
545
+ if (!terminal) throw new Error("Worker Adapter contract violated: the event stream ended without a terminal event");
546
+ const outputTail = tail(lines.join("\n"));
547
+ const durationMs = now() - started;
548
+ if (terminal.type === "failed") {
549
+ log(`verify: ${task.identifier} Verifier turn ${terminal.reason}: ${terminal.message}; the round failed`);
550
+ return { kind: "failed", reason: `the Verifier turn ended ${terminal.reason}: ${terminal.message}`, details: terminal.details, app: appRun(true), durationMs, sessionId: terminal.sessionId, outputTail };
551
+ }
552
+ // The adapter validated it under the Verdict contract; parsed once more here so a fake or a
553
+ // future adapter that hands the object through unchecked cannot make a pass of a bad shape.
554
+ const parsed = parseVerdict(terminal.structured);
555
+ if (!parsed.ok) {
556
+ log(`verify: ${task.identifier} the Verifier's result is not a Verdict (${parsed.errors.join("; ")}); the round failed`);
557
+ return { kind: "failed", reason: "the Verifier's result is not a Verdict", details: parsed.errors, app: appRun(true), durationMs, sessionId: terminal.sessionId, outputTail };
558
+ }
559
+ const verdict = parsed.verdict;
560
+ const screenshots = keepScreenshots(verdict, screenshotDir, join(options.evidenceDir ?? evidenceDir(), String(input.initiativeId), `${task.identifier}-${attempt}`), log);
561
+ log(`verify: ${task.identifier} Verdict ${verdict.passed ? "passed" : `failed with ${verdict.defects.length} Defect${verdict.defects.length === 1 ? "" : "s"}`} after ${durationMs}ms`);
562
+ return { kind: "verified", verdict, screenshots, app: appRun(true), durationMs, sessionId: terminal.sessionId, outputTail };
563
+ } finally {
564
+ await app.stop();
565
+ input.onPid?.("app", undefined);
566
+ ports.release(task.id);
567
+ if (scratch) rmSync(scratch, { recursive: true, force: true });
568
+ log(`verify: ${task.identifier} app stopped, port ${port} released`);
569
+ }
570
+ };
571
+ }
572
+
573
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
574
+ const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
575
+
576
+ /** Why a file the Verdict names is not kept as evidence, or undefined when it is: the Verdict is
577
+ * written by an agent that has read the pages under test, so a path in it is data, and the
578
+ * only files that leave the scratch directory for the evidence directory (and from there the
579
+ * pull request) are real images the Verifier's own browser wrote into its screenshot directory.
580
+ * `realpath` of the file must be under `realpath` of the directory (so `..`, an absolute path
581
+ * elsewhere, or a symlinked directory inside it all fail); a symlink is refused before it is
582
+ * followed; it must be a regular file, at most `SCREENSHOT_MAX_BYTES`, and start with the PNG or
583
+ * JPEG magic bytes. Exported for the test. */
584
+ export function screenshotRefusal(candidate: string, screenshotDir: string): string | undefined {
585
+ let root: string;
586
+ try {
587
+ root = realpathSync(screenshotDir);
588
+ } catch {
589
+ return "the screenshot directory is gone";
590
+ }
591
+ let stat: ReturnType<typeof lstatSync>;
592
+ try {
593
+ stat = lstatSync(candidate);
594
+ } catch {
595
+ return "it was not written";
596
+ }
597
+ if (stat.isSymbolicLink()) return "it is a symbolic link";
598
+ if (!stat.isFile()) return "it is not a regular file";
599
+ let real: string;
600
+ try {
601
+ real = realpathSync(candidate);
602
+ } catch {
603
+ return "it was not written";
604
+ }
605
+ if (!real.startsWith(root + sep)) return "it is outside the Verifier's screenshot directory";
606
+ if (stat.size > SCREENSHOT_MAX_BYTES) return `it is ${stat.size} bytes, over the ${SCREENSHOT_MAX_BYTES} byte limit`;
607
+ const head = Buffer.alloc(PNG_MAGIC.length);
608
+ const fd = openSync(real, "r");
609
+ let read: number;
610
+ try {
611
+ read = readSync(fd, head, 0, head.length, 0);
612
+ } finally {
613
+ closeSync(fd);
614
+ }
615
+ const isPng = read >= PNG_MAGIC.length && head.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC);
616
+ const isJpeg = read >= JPEG_MAGIC.length && head.subarray(0, JPEG_MAGIC.length).equals(JPEG_MAGIC);
617
+ if (!isPng && !isJpeg) return "it is not a PNG or JPEG image";
618
+ return undefined;
619
+ }
620
+
621
+ /** Copies each Defect's screenshot out of the Verifier's screenshot directory into the evidence
622
+ * directory, under the file's own name (a counter breaks a clash). A relative name resolves
623
+ * against the screenshot directory; whatever it resolves to must pass `screenshotRefusal`, and a
624
+ * file that does not (not written, elsewhere, a symlink, not an image) is kept as a Screenshot
625
+ * with no path, which the comment then says, with the reason in the log. */
626
+ export function keepScreenshots(verdict: Verdict, screenshotDir: string, into: string, log: (line: string) => void = () => {}): Screenshot[] {
627
+ const kept: Screenshot[] = [];
628
+ const used = new Set<string>();
629
+ verdict.defects.forEach((defect, index) => {
630
+ const named = defect.screenshot.trim();
631
+ if (named === "") {
632
+ kept.push({ defect: index, named });
633
+ return;
634
+ }
635
+ const source = isAbsolute(named) ? named : resolve(screenshotDir, named);
636
+ const refusal = screenshotRefusal(source, screenshotDir);
637
+ if (refusal) {
638
+ log(refusal === "it was not written" ? `verify: Defect ${index + 1} names screenshot ${named}, which was not written` : `verify: Defect ${index + 1} names screenshot ${named}, which is not kept: ${refusal}`);
639
+ kept.push({ defect: index, named });
640
+ return;
641
+ }
642
+ mkdirSync(into, { recursive: true, mode: 0o700 });
643
+ let name = basename(source);
644
+ for (let n = 2; used.has(name); n++) name = `${n}-${basename(source)}`;
645
+ used.add(name);
646
+ const path = join(into, name);
647
+ copyFileSync(source, path);
648
+ kept.push({ defect: index, named, path });
649
+ });
650
+ return kept;
651
+ }
652
+
653
+ /** How a round's Verdict reads when the round failed: one Defect that says so, so the Run
654
+ * Record and the pull request carry the failure in the Verdict's own shape. */
655
+ export function failureVerdict(reason: string, details: string[] = []): Verdict {
656
+ return {
657
+ passed: false,
658
+ // The flag, not the title, is what tells the Defect filer this Verdict is the Foreman's own
659
+ // (verdict.ts `roundFailed`): a Verifier cannot set it, so it cannot suppress filing by title.
660
+ roundFailed: true,
661
+ summary: `The Foreman could not complete browser verification: ${reason}.`,
662
+ defects: [
663
+ {
664
+ title: `${VERIFIER_FAILURE_TITLE}: ${reason}`,
665
+ steps: "The Foreman started the app from the Run Recipe and launched the Verifier with the Task's Verification section.",
666
+ expected: "A Verdict from the Verifier.",
667
+ observed: [reason, ...details].join("\n"),
668
+ screenshot: "",
669
+ },
670
+ ],
671
+ };
672
+ }
673
+
674
+ /** The Verifier id of the app's Verification: `<identifier>-<attempt>-verify-app`. */
675
+ export function appEvidenceId(identifier: string, attempt: number): string {
676
+ return `${identifier}-${attempt}-verify-app`;
677
+ }
678
+
679
+ function appVerification(identifier: string, attempt: number, app: AppRun): MessageInitShape<typeof RunVerificationSchema> {
680
+ return {
681
+ id: appEvidenceId(identifier, attempt),
682
+ stage: "verify",
683
+ tool: "app",
684
+ command: gateCommandText(app.command),
685
+ cwd: app.cwd,
686
+ exitCode: app.exitCode,
687
+ durationMs: BigInt(Math.max(0, Math.round(app.durationMs))),
688
+ stdoutTail: gateTail(app.stdoutTail),
689
+ stderrTail: gateTail(app.stderrTail),
690
+ at: timestampFromMs(app.at),
691
+ };
692
+ }
693
+
694
+ /** The `verdict` Run Record event: the Ledger's Verdict shape (status, Defects with their
695
+ * evidence URL, the app's start as evidence), written before the comment and the board move it
696
+ * precedes (ADR-0009). `evidenceUrls` are per Defect: the upload on the pull request, else the
697
+ * local path, else empty. Every string is cut to the proto's limit by code point, so a Verifier
698
+ * that wrote at length is recorded cut rather than refused after the Gates passed.
699
+ *
700
+ * Attribution invariant (read back by `verdictStatusOf`): a `verdict` event belongs to the
701
+ * attempt whose app evidence it carries (`appEvidenceId`), and one written without an app (the
702
+ * round failed before the app started) belongs to the attempt of the last `dispatch` event
703
+ * before it on the Task's Run Record, since the loop writes it inside that attempt, after its
704
+ * dispatch event and before its attempt event. Every failed round that had an app attaches it. */
705
+ export function verdictEvent(task: Pick<TrackerTask, "id" | "identifier">, attempt: number, verdict: Verdict, evidenceUrls: string[], app: AppRun | undefined, at: number): RunEventInput {
706
+ const payload: MessageInitShape<typeof RunEventInputSchema>["payload"] = {
707
+ payload: {
708
+ case: "verdict",
709
+ value: {
710
+ status: verdict.passed ? "passed" : "failed",
711
+ defects: verdict.defects.map((d, i) => ({
712
+ title: truncateCodePoints(d.title, DEFECT_TITLE_MAX),
713
+ steps: truncateCodePoints(d.steps, DEFECT_TEXT_MAX),
714
+ expected: truncateCodePoints(d.expected, DEFECT_TEXT_MAX),
715
+ observed: truncateCodePoints(d.observed, DEFECT_TEXT_MAX),
716
+ evidenceUrl: truncateCodePoints(evidenceUrls[i] ?? "", EVIDENCE_URL_MAX),
717
+ })),
718
+ evidence: app ? [appVerification(task.identifier, attempt, app)] : [],
719
+ },
720
+ },
721
+ };
722
+ return create(RunEventInputSchema, { taskId: task.id, occurredAt: timestampFromMs(at), payload });
723
+ }
724
+
725
+ /** What the Run Record's `verdict` events say about one attempt: the last one's status, or
726
+ * `none` when the attempt has no Verdict (skipped, or before this slice). Reconcile settles from
727
+ * it beside the Gates' verdict. `events` are the Task's, in Run Record order, and must include
728
+ * its `dispatch` events beside the `verdict` ones for the attribution invariant on
729
+ * `verdictEvent` to hold for a Verdict written without an app. */
730
+ export function verdictStatusOf(events: readonly RunEvent[], attempt: number, identifier: string): "passed" | "failed" | "none" {
731
+ let last: "passed" | "failed" | "none" = "none";
732
+ let current: number | undefined;
733
+ for (const event of events) {
734
+ const payload = event.payload?.payload;
735
+ if (payload?.case === "dispatch") {
736
+ current = payload.value.attempt;
737
+ continue;
738
+ }
739
+ if (payload?.case !== "verdict") continue;
740
+ const app = payload.value.evidence.find((e) => e.id.endsWith("-verify-app"));
741
+ const ours = app ? app.id === appEvidenceId(identifier, attempt) : current === attempt;
742
+ if (!ours) continue;
743
+ last = payload.value.status === "passed" ? "passed" : "failed";
744
+ }
745
+ return last;
746
+ }
747
+
748
+ /** How the pull request comment shows each Defect's evidence: the upload's markdown, or where
749
+ * the file is kept when the host takes no upload, or that there is none. */
750
+ export interface DefectEvidence {
751
+ markdown?: string;
752
+ localPath?: string;
753
+ }
754
+
755
+ /** The comment on the pull request: the Verdict, the app, every Defect with its screenshot. A
756
+ * screenshot kept on this machine is named by its evidence directory and file, never by the
757
+ * full path (the comment is public to the pull request's readers; the Foreman's home is not). */
758
+ export function verdictComment(verdict: Verdict, evidence: DefectEvidence[], app: AppRun | undefined): string {
759
+ const lines: string[] = ["## Foreman Verdict", ""];
760
+ lines.push(verdict.passed ? "Verification passed: the Verifier followed the Task's Verification section in a headless browser and every step went as it says." : `Verification failed with ${verdict.defects.length} Defect${verdict.defects.length === 1 ? "" : "s"}. The Task is blocked for a human.`);
761
+ if (app) lines.push("", `App: \`${app.command}\` in ${app.cwd}, ${app.ready ? `ready at ${app.url}` : "never ready"}.`);
762
+ // Everything below came out of a Verifier that drove a browser over whatever the page rendered,
763
+ // and this comment is public on the pull request — the one surface a human reads on a code host.
764
+ // Free text is quoted the way the Defect filer quotes it; the title lands in a heading of ours,
765
+ // so it is flattened and its Markdown taken away, `@` included, so no Verdict can forge a mention.
766
+ if (verdict.summary.trim()) lines.push("", AGENT_TEXT_NOTE, "", hostFenced(verdict.summary));
767
+ verdict.defects.forEach((d, i) => {
768
+ lines.push("", `### Defect ${i + 1}: ${markdownInline(d.title, "(untitled Defect)")}`, "", "**Steps:**", "", hostFenced(d.steps), "", "**Expected:**", "", hostFenced(d.expected), "", "**Observed:**", "", hostFenced(d.observed));
769
+ const e = evidence[i];
770
+ if (e?.markdown) lines.push("", e.markdown);
771
+ else if (e?.localPath) lines.push("", `Screenshot ${basename(e.localPath)} is kept on the Foreman's machine, in its evidence directory ${basename(dirname(e.localPath))} (this host takes no upload for comments).`);
772
+ else lines.push("", `No screenshot${d.screenshot ? ` (the Verifier named ${markdownInline(d.screenshot, "a file")}, which was not written)` : ""}.`);
773
+ });
774
+ return lines.join("\n");
775
+ }
776
+
777
+ /** The Verification section of the attempt event's summary and of the Task comment's note. */
778
+ export function verifySummary(outcome: VerifyOutcome): string {
779
+ switch (outcome.kind) {
780
+ case "skipped":
781
+ return `skipped (${outcome.reason})`;
782
+ case "verified":
783
+ return outcome.verdict.passed ? `passed in the browser after ${outcome.durationMs}ms` : `${outcome.verdict.defects.length} Defect${outcome.verdict.defects.length === 1 ? "" : "s"} found in the browser: ${outcome.verdict.defects.map((d) => d.title).join("; ")}`;
784
+ case "failed":
785
+ return `round failed: ${outcome.reason}`;
786
+ }
787
+ }
788
+
789
+ /** Whether an outcome lets the Task reach review: a skip, or a passed Verdict. */
790
+ export function verificationPassed(outcome: VerifyOutcome | undefined): boolean {
791
+ return outcome === undefined || outcome.kind === "skipped" || (outcome.kind === "verified" && outcome.verdict.passed);
792
+ }
793
+
794
+ /** The Verdict a round produced, whatever happened to it: the Verifier's, or the failure's. */
795
+ export function verdictOf(outcome: Exclude<VerifyOutcome, { kind: "skipped" }>): Verdict {
796
+ return outcome.kind === "verified" ? outcome.verdict : failureVerdict(outcome.reason, outcome.details);
797
+ }
798
+
799
+ /** Exported for tests: the Defect shape the event carries. */
800
+ export type { Defect };