ccqa 1.46.1 → 1.47.1

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.
@@ -0,0 +1,2222 @@
1
+ import { createRequire } from "node:module";
2
+ import { readFile } from "node:fs/promises";
3
+ import { parse } from "yaml";
4
+ import { ZodError, z } from "zod";
5
+ import { query } from "@anthropic-ai/claude-agent-sdk";
6
+ import { AsyncLocalStorage } from "node:async_hooks";
7
+ //#region src/runtime/env-vars.ts
8
+ const ENV_VAR_RE = /\$\{([A-Z_][A-Z0-9_]*)\}|\$([A-Z_][A-Z0-9_]*)/g;
9
+ const ANY_VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
10
+ const BRACED_VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
11
+ /**
12
+ * Replace every `$NAME` / `${NAME}` reference in `value` using `lookup`. When
13
+ * `lookup` returns `undefined`, the original reference text is preserved
14
+ * (callers that want empty-string substitution should wrap with `?? ""`).
15
+ */
16
+ function substituteVars(value, lookup) {
17
+ ANY_VAR_RE.lastIndex = 0;
18
+ return value.replace(ANY_VAR_RE, (match, braced, plain) => {
19
+ const replacement = lookup(braced ?? plain ?? "");
20
+ return replacement === void 0 ? match : replacement;
21
+ });
22
+ }
23
+ /**
24
+ * Iterate every `${NAME}` / `$NAME` reference name (case-insensitive form)
25
+ * appearing in `value`. Used by callers that want to enumerate refs without
26
+ * also substituting, e.g. the env-scrub map builder. The reference name
27
+ * grammar is the canonical one shared with `substituteVars`.
28
+ */
29
+ function* iterEnvRefNames(value) {
30
+ ANY_VAR_RE.lastIndex = 0;
31
+ let m;
32
+ while ((m = ANY_VAR_RE.exec(value)) !== null) {
33
+ const name = m[1] ?? m[2];
34
+ if (name) yield name;
35
+ }
36
+ }
37
+ /**
38
+ * Resolve every `$VAR` / `${VAR}` reference against `overrides`, then the
39
+ * current process env. `overrides` carries values an invoker injects into a
40
+ * child process (e.g. CCQA_RUN_ID), which beat the parent env there.
41
+ *
42
+ * Missing variables expand to the empty string, mirroring `sh` behaviour.
43
+ * Throwing would force ccqa to be invoked with every var set even for
44
+ * unused blocks, which is more user-hostile than letting the test fail
45
+ * downstream with a clearer message ("login form rejected: empty password").
46
+ */
47
+ function resolveEnvRefs(value, overrides = {}) {
48
+ return value.replace(ENV_VAR_RE, (_, braced, plain) => {
49
+ const name = braced ?? plain ?? "";
50
+ return overrides[name] ?? process.env[name] ?? "";
51
+ });
52
+ }
53
+ /**
54
+ * Embed `$VAR` / `${VAR}` as a JS template-literal expression that reads
55
+ * `process.env.VAR ?? ""` at runtime. Used by `ccqa generate` so the test
56
+ * script never bakes in the secret value.
57
+ *
58
+ * Returns a JavaScript string-literal expression (template literal when env
59
+ * refs are present, plain string literal otherwise).
60
+ *
61
+ * Examples:
62
+ * "${PASSWORD}" -> '`${process.env.PASSWORD ?? ""}`'
63
+ * "user-${SUFFIX}@x.com" -> '`user-${process.env.SUFFIX ?? ""}@x.com`'
64
+ * "literal value" -> '"literal value"'
65
+ */
66
+ function envRefsToJsExpression(value) {
67
+ return refsToJsExpression(value, () => null);
68
+ }
69
+ /**
70
+ * Generalised version of `envRefsToJsExpression`. Each `$NAME` / `${NAME}`
71
+ * reference in `value` is passed to `nameToExpr(name)` first:
72
+ *
73
+ * - If it returns a string, that string is interpolated as a JS expression
74
+ * (no quoting / no `?? ""` wrap — the caller decides the shape).
75
+ * - If it returns `null`, the reference is treated as a missing env var
76
+ * and expands to `process.env.<NAME> ?? ""` (the legacy behaviour).
77
+ *
78
+ * Used by the block codegen path: param names map to `params.<name>`,
79
+ * everything else falls through to `process.env.X ?? ""`.
80
+ */
81
+ function refsToJsExpression(value, nameToExpr, refRe = ANY_VAR_RE) {
82
+ refRe.lastIndex = 0;
83
+ if (!refRe.test(value)) return JSON.stringify(value);
84
+ const escaped = value.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, (_match, offset, source) => {
85
+ const probe = new RegExp(refRe.source, "g");
86
+ let m;
87
+ while ((m = probe.exec(source)) !== null) if (m.index === offset) return "${";
88
+ return "\\${";
89
+ });
90
+ refRe.lastIndex = 0;
91
+ return `\`${escaped.replace(refRe, (_match, braced, plain) => {
92
+ const name = braced ?? plain ?? "";
93
+ const expr = nameToExpr(name);
94
+ return expr !== null ? `\${${expr}}` : `\${process.env.${name} ?? ""}`;
95
+ })}\``;
96
+ }
97
+ /** `${VAR}` only. For prose, where a bare `$WORD` is a word and not a reference. */
98
+ function bracedRefsToJsExpression(value) {
99
+ return refsToJsExpression(value, () => null, BRACED_VAR_RE);
100
+ }
101
+ //#endregion
102
+ //#region src/spec/yaml-schema.ts
103
+ /**
104
+ * An action step: one user-facing browser interaction. `instruction` and
105
+ * `expected` are the natural-language description handed to Claude during
106
+ * `ccqa trace`. URLs live inside `instruction`, either verbatim or via
107
+ * `${ENV_VAR}` references (resolved at runtime).
108
+ */
109
+ const ActionStepSchema = z.object({
110
+ instruction: z.string().min(1),
111
+ expected: z.string().min(1)
112
+ }).strict();
113
+ /**
114
+ * An include step: invokes a reusable block (`.ccqa/blocks/<name>/spec.yaml`).
115
+ * `params` values are plain strings; env refs (`${VAR}`) inside them are
116
+ * resolved at expand time the same way step instructions are.
117
+ */
118
+ const IncludeStepSchema = z.object({
119
+ include: z.string().min(1),
120
+ params: z.record(z.string(), z.string()).optional()
121
+ }).strict();
122
+ /**
123
+ * A claim about the page decided by a model rather than by a selector match,
124
+ * for output a run cannot predict. `from` narrows what it reads to one
125
+ * element; omitted, the page's visible text. See docs/spec.md.
126
+ */
127
+ const JudgeByLlmStepSchema = z.object({
128
+ judgeByLlm: z.string().min(1),
129
+ from: z.string().min(1).optional()
130
+ }).strict();
131
+ /**
132
+ * A spec step is an action, an include, or a judge-by-LLM — discriminated by the
133
+ * presence of the `include` / `judgeByLlm` key (see the predicates below).
134
+ */
135
+ const StepSchema = z.union([
136
+ ActionStepSchema,
137
+ IncludeStepSchema,
138
+ JudgeByLlmStepSchema
139
+ ]);
140
+ /**
141
+ * Execution mode for `ccqa run`:
142
+ * - `deterministic` (default): vitest replays the recorded `test.spec.ts`.
143
+ * - `live`: Claude drives agent-browser per step (for fragile UIs where
144
+ * codegen is impractical). Cost ~$0.5 per spec.
145
+ */
146
+ const SpecModeSchema = z.enum(["deterministic", "live"]);
147
+ /**
148
+ * A name a spec chooses that ccqa resolves to a path or looks up in a
149
+ * registry. Restricted to a slug so it cannot escape a directory.
150
+ */
151
+ function slug(what) {
152
+ return z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, `${what} must be a slug (letters, digits, '.', '_', '-'; no path separators)`);
153
+ }
154
+ /**
155
+ * A saved browser session (cookies + localStorage) to restore before the spec
156
+ * runs, resolved to `.ccqa/sessions/<profile>/<name>.json` at run time.
157
+ */
158
+ const SessionNameSchema = slug("session name");
159
+ /**
160
+ * Sessions to restore before a `mode: live` spec runs: one name or a list,
161
+ * always read back as a list. Multiple names are merged (their cookies +
162
+ * localStorage are unioned) and restored together, so a spec can start
163
+ * signed-in to several providers at once.
164
+ */
165
+ const SessionFieldSchema = z.union([SessionNameSchema, z.array(SessionNameSchema).min(1)]).transform((v) => Array.isArray(v) ? v : [v]);
166
+ /**
167
+ * A generation-target id: which plugin turns this spec into runnable tests
168
+ * (e.g. "agent-browser", "playwright", "runn"). Whether the id names a
169
+ * registered target is the registry's responsibility, so new targets don't
170
+ * require a schema change.
171
+ */
172
+ const TargetIdSchema = slug("target");
173
+ /** The built-in recorder-backed target. `mode:` / `session:` only apply to it. */
174
+ const AGENT_BROWSER_TARGET = "agent-browser";
175
+ /**
176
+ * Top-level spec schema. `.strict()` rejects any unknown key.
177
+ *
178
+ * `mode:` and `session:` are agent-browser-only fields, enforced here when
179
+ * `target:` names another target. When `target:` is omitted the effective
180
+ * target comes from config (`defaultTarget`, falling back to agent-browser),
181
+ * which this schema can't see — so mode/session pass parsing and the
182
+ * post-resolution check is the target resolver's responsibility.
183
+ */
184
+ const TestSpecSchema = z.object({
185
+ title: z.string().min(1),
186
+ disabled: z.boolean().optional(),
187
+ target: TargetIdSchema.optional(),
188
+ mode: SpecModeSchema.optional(),
189
+ session: SessionFieldSchema.optional(),
190
+ steps: z.array(StepSchema).min(1)
191
+ }).strict().superRefine((spec, ctx) => {
192
+ if (spec.target === void 0 || spec.target === "agent-browser") return;
193
+ for (const key of ["mode", "session"]) if (spec[key] !== void 0) ctx.addIssue({
194
+ code: "custom",
195
+ path: [key],
196
+ message: `\`${key}\` only applies to the agent-browser target — remove it or drop \`target: ${spec.target}\``
197
+ });
198
+ });
199
+ /** Default mode when `mode:` is absent. */
200
+ const DEFAULT_SPEC_MODE = "deterministic";
201
+ /**
202
+ * A block param declaration. `required` defaults to true; only explicit
203
+ * `required: false` makes it optional. `secret: true` flags the value as
204
+ * sensitive — codegen renders such values as `process.env.<NAME> ?? ""`
205
+ * template literals so the secret never ends up baked into test.spec.ts.
206
+ */
207
+ const BlockParamSchema = z.object({
208
+ name: z.string().min(1),
209
+ required: z.boolean().optional(),
210
+ secret: z.boolean().optional()
211
+ }).strict();
212
+ /**
213
+ * Block schema. A block step is an action or a judge-by-LLM — nested blocks are
214
+ * forbidden, so including a block from inside another block fails parsing here
215
+ * (the parser maps the union's cryptic failure into a nested-block message).
216
+ */
217
+ const BlockSpecSchema = z.object({
218
+ title: z.string().min(1),
219
+ params: z.array(BlockParamSchema).optional(),
220
+ steps: z.array(z.union([ActionStepSchema, JudgeByLlmStepSchema])).min(1)
221
+ }).strict();
222
+ /** Runtime predicates for the StepSchema union. */
223
+ function isIncludeStep(step) {
224
+ return "include" in step;
225
+ }
226
+ function isJudgeByLlmStep(step) {
227
+ return "judgeByLlm" in step;
228
+ }
229
+ /** Returns true if a block param is required (default: true). */
230
+ function isParamRequired(param) {
231
+ return param.required !== false;
232
+ }
233
+ //#endregion
234
+ //#region src/spec/parser.ts
235
+ /** The spec/block root (an `unrecognized_keys` issue there has an empty path). */
236
+ const atRoot = (path) => path.length === 0;
237
+ /** A block param entry — the issue path is `params.<index>`. */
238
+ const atBlockParam = (path) => path.length === 2 && path[0] === "params" && typeof path[1] === "number";
239
+ const UNREAD_PARAM_FIELD = "nothing reads it (a block param reaches the prompts as its name, required and secret only). Delete the line.";
240
+ const REMOVED_FIELDS = {
241
+ relatedPaths: {
242
+ at: atRoot,
243
+ message: "which specs a change affects is now decided by `ccqa select-specs`, which reads the diff instead of a declared path list. Delete the field."
244
+ },
245
+ dummy: {
246
+ at: atBlockParam,
247
+ message: UNREAD_PARAM_FIELD
248
+ },
249
+ description: {
250
+ at: atBlockParam,
251
+ message: UNREAD_PARAM_FIELD
252
+ }
253
+ };
254
+ /** Parse a spec.yaml. Schema rejections are rewritten with actionable messages. */
255
+ function parseTestSpec(content, source = "spec.yaml") {
256
+ const raw = parseYamlOrThrow(content, source);
257
+ try {
258
+ return TestSpecSchema.parse(raw);
259
+ } catch (e) {
260
+ throw enrichZodError(e, source, false, raw);
261
+ }
262
+ }
263
+ /**
264
+ * Throw-suppressed sibling of `parseTestSpec`. Used by report-side helpers
265
+ * that derive cosmetic data (title, step descriptions) from spec.yaml and
266
+ * want a missing or malformed file to degrade silently rather than abort
267
+ * the report.
268
+ */
269
+ function tryParseTestSpec(yaml) {
270
+ if (!yaml) return null;
271
+ try {
272
+ return parseTestSpec(yaml);
273
+ } catch {
274
+ return null;
275
+ }
276
+ }
277
+ /**
278
+ * Parse a block's spec.yaml. Block-specific errors include the targeted
279
+ * nested-block message (the underlying zod failure on an `include` key
280
+ * inside a block step is hard to read).
281
+ */
282
+ function parseBlockSpec(content, source = "block spec.yaml") {
283
+ const raw = parseYamlOrThrow(content, source);
284
+ try {
285
+ return BlockSpecSchema.parse(raw);
286
+ } catch (e) {
287
+ throw enrichZodError(e, source, true, raw);
288
+ }
289
+ }
290
+ function parseYamlOrThrow(content, source) {
291
+ try {
292
+ return parse(content);
293
+ } catch (e) {
294
+ throw new Error(`Failed to parse YAML (${source}): ${e.message}`);
295
+ }
296
+ }
297
+ function enrichZodError(error, source, isBlock, raw) {
298
+ if (!(error instanceof ZodError)) return error;
299
+ const lines = [`Invalid ${source}:`];
300
+ for (const issue of error.issues) for (const [path, message] of explain(issue, isBlock, raw)) lines.push(` - ${path.join(".") || "(root)"}: ${message}`);
301
+ return new Error(lines.join("\n"));
302
+ }
303
+ const NESTED_BLOCK_MESSAGE = "Nested blocks are not supported — flatten by inlining the included block's steps into this block.";
304
+ function explain(issue, isBlock, raw) {
305
+ const asIs = [[issue.path, humanizeIssue(issue, isBlock, raw)]];
306
+ if (issue.code !== "invalid_union" || !issue.errors?.length) return asIs;
307
+ if (isBlock && stepKind(raw, issue.path) === "include") return asIs;
308
+ const best = pickBranch(issue.errors, stepKind(raw, issue.path));
309
+ if (!best?.length) return asIs;
310
+ return best.map((b) => {
311
+ const path = [...issue.path, ...b.path];
312
+ return [path, humanizeIssue({
313
+ ...b,
314
+ path
315
+ }, isBlock, raw)];
316
+ });
317
+ }
318
+ /** Which kind of step the author was writing, read from the key they used. */
319
+ function stepKind(raw, path) {
320
+ const node = nodeAt(raw, path);
321
+ if (!isRecord(node)) return null;
322
+ for (const key of ["include", "judgeByLlm"]) if (key in node) return key;
323
+ return "instruction";
324
+ }
325
+ /**
326
+ * The branch the author meant: the one that accepts the key they wrote. Any
327
+ * other branch reports that key as unrecognized, which would tell them the
328
+ * key they just used does not exist. Ties among the rest go to the branch
329
+ * that recognized the most of what they wrote.
330
+ */
331
+ function pickBranch(branches, kind) {
332
+ const accepts = kind === null ? branches : branches.filter((b) => !rejectsKey(b, kind));
333
+ return (accepts.length > 0 ? accepts : branches).reduce((a, b) => unknownKeyCount(a) <= unknownKeyCount(b) ? a : b);
334
+ }
335
+ function rejectsKey(issues, key) {
336
+ return issues.some((i) => Array.isArray(i.keys) && i.keys.includes(key));
337
+ }
338
+ function unknownKeyCount(issues) {
339
+ return issues.reduce((n, i) => n + (Array.isArray(i.keys) ? i.keys.length : 0), 0);
340
+ }
341
+ function humanizeIssue(issue, isBlock, raw) {
342
+ if (isBlock && issue.code === "invalid_union" && stepKind(raw, issue.path) === "include") return NESTED_BLOCK_MESSAGE;
343
+ if (issue.code === "unrecognized_keys") {
344
+ const keys = Array.isArray(issue.keys) ? issue.keys : [];
345
+ if (isBlock && keys.includes("include")) return NESTED_BLOCK_MESSAGE;
346
+ const removed = keys.filter((k) => REMOVED_FIELDS[k]?.at(issue.path));
347
+ const stillUnknown = keys.filter((k) => !REMOVED_FIELDS[k]?.at(issue.path));
348
+ const parts = removed.map((k) => `\`${k}\` is no longer part of the spec schema — ${REMOVED_FIELDS[k].message}`);
349
+ if (stillUnknown.length > 0) parts.push(`Unknown keys: ${stillUnknown.join(", ")}`);
350
+ return parts.join(" ");
351
+ }
352
+ return issue.message;
353
+ }
354
+ function nodeAt(raw, path) {
355
+ let node = raw;
356
+ for (const segment of path) {
357
+ if (!isRecord(node)) return void 0;
358
+ node = node[segment];
359
+ }
360
+ return node;
361
+ }
362
+ function isRecord(value) {
363
+ return typeof value === "object" && value !== null;
364
+ }
365
+ //#endregion
366
+ //#region src/spec/expand.ts
367
+ /** Runtime predicates for the ExpandedStep union. */
368
+ function isExpandedJudgeByLlmStep(step) {
369
+ return "judgeByLlm" in step;
370
+ }
371
+ function isExpandedActionStep(step) {
372
+ return !("judgeByLlm" in step);
373
+ }
374
+ function isJudgeBody(step) {
375
+ return "judgeByLlm" in step;
376
+ }
377
+ /**
378
+ * Walk the spec's top-level steps, inlining any `- include: <block>` reference
379
+ * as the block's own steps in order. The result is a flat `step-NN`-numbered
380
+ * sequence — block boundaries survive only as the `source` tag, so trace and
381
+ * codegen never need a separate block code path.
382
+ */
383
+ function expandSpec(spec, options) {
384
+ const out = [];
385
+ let counter = 0;
386
+ const allocId = () => {
387
+ counter += 1;
388
+ return `step-${String(counter).padStart(2, "0")}`;
389
+ };
390
+ for (const step of spec.steps) if (isIncludeStep(step)) {
391
+ const block = resolveBlock(step.include, step.params ?? {}, options.blocks);
392
+ const substitute = (text) => substituteVars(text, block.lookup);
393
+ for (const blockStep of block.steps) out.push(expandStep(blockStep, allocId(), step.include, substitute));
394
+ } else out.push(expandStep(step, allocId(), "spec", (text) => text));
395
+ return out;
396
+ }
397
+ /**
398
+ * `expandSpec` for a target that emits no judge call. A claim is refused by
399
+ * name rather than dropped: one that goes unjudged is a test that passes
400
+ * without testing. Blocks carry steps the spec does not name, which is why
401
+ * this runs after expansion rather than in the schema.
402
+ */
403
+ function expandActionSteps(spec, options, specKey, target) {
404
+ return expandSpec(spec, options).map((step) => {
405
+ if (isExpandedJudgeByLlmStep(step)) {
406
+ const from = step.source === "spec" ? "" : ` (from block \`${step.source}\`)`;
407
+ throw new Error(`${specKey}: step ${step.id}${from} uses \`judgeByLlm\`, but the "${target.id}" target cannot honour it — ${target.reason}`);
408
+ }
409
+ return step;
410
+ });
411
+ }
412
+ /** `substitute` resolves a block's `$param` refs; a spec's own steps have none, so it passes text through. */
413
+ function expandStep(step, id, source, substitute) {
414
+ if (isJudgeByLlmStep(step)) return {
415
+ id,
416
+ source,
417
+ judgeByLlm: substitute(step.judgeByLlm),
418
+ ...step.from !== void 0 ? { from: substitute(step.from) } : {}
419
+ };
420
+ return {
421
+ id,
422
+ source,
423
+ instruction: substitute(step.instruction),
424
+ expected: substitute(step.expected)
425
+ };
426
+ }
427
+ function resolveBlock(blockName, rawParams, blocks) {
428
+ const block = blocks.get(blockName);
429
+ if (!block) throw new Error(`Unknown block: "${blockName}". Define it under .ccqa/blocks/${blockName}/spec.yaml.`);
430
+ const declaredParams = new Map((block.params ?? []).map((p) => [p.name, p]));
431
+ for (const key of Object.keys(rawParams)) if (!declaredParams.has(key)) throw new Error(`Block "${blockName}" received unknown param "${key}". Declared params: ${[...declaredParams.keys()].join(", ") || "(none)"}.`);
432
+ for (const [pname, def] of declaredParams) if (isParamRequired(def) && !(pname in rawParams)) throw new Error(`Block "${blockName}" is missing required param "${pname}".`);
433
+ const lookup = (name) => {
434
+ if (Object.prototype.hasOwnProperty.call(rawParams, name)) return rawParams[name];
435
+ };
436
+ return {
437
+ steps: block.steps,
438
+ lookup
439
+ };
440
+ }
441
+ /**
442
+ * Collect every block name referenced by a spec (top-level only — blocks
443
+ * cannot nest). Used by the store / drift layers to know which blocks to
444
+ * load or invalidate.
445
+ */
446
+ function collectIncludedBlockNames(spec) {
447
+ const names = /* @__PURE__ */ new Set();
448
+ for (const step of spec.steps) if (isIncludeStep(step)) names.add(step.include);
449
+ return [...names];
450
+ }
451
+ //#endregion
452
+ //#region src/claude/env-keys.ts
453
+ /**
454
+ * Variables that carry a credential the Claude Code process can use on its
455
+ * own, with no login on the host: an API key, a gateway bearer token, or a
456
+ * subscription token from `claude setup-token`.
457
+ */
458
+ const CREDENTIAL_ENV_KEYS = [
459
+ "ANTHROPIC_API_KEY",
460
+ "ANTHROPIC_AUTH_TOKEN",
461
+ "CLAUDE_CODE_OAUTH_TOKEN"
462
+ ];
463
+ /**
464
+ * Standard Claude Code environment variables that select the API endpoint and
465
+ * credentials. ccqa forwards whichever of these are set to the underlying
466
+ * Claude Code process; it does not read or interpret their values.
467
+ *
468
+ * - `ANTHROPIC_BASE_URL` — the API endpoint to send requests to.
469
+ * - `ANTHROPIC_AUTH_TOKEN` — sent as `Authorization: Bearer <token>`.
470
+ * - `ANTHROPIC_API_KEY` — API key, when used instead of a token.
471
+ * - `ANTHROPIC_CUSTOM_HEADERS` — extra request headers.
472
+ * - `CLAUDE_CODE_OAUTH_TOKEN` — long-lived subscription token from
473
+ * `claude setup-token`, the headless-CI counterpart of a login.
474
+ */
475
+ const ENDPOINT_ENV_KEYS = [
476
+ "ANTHROPIC_BASE_URL",
477
+ "ANTHROPIC_CUSTOM_HEADERS",
478
+ ...CREDENTIAL_ENV_KEYS
479
+ ];
480
+ //#endregion
481
+ //#region src/cli/logger.ts
482
+ const STEP_ICONS = {
483
+ STEP_START: "▶",
484
+ STEP_DONE: "✓",
485
+ ASSERTION_FAILED: "✗",
486
+ STEP_SKIPPED: "⊘",
487
+ RUN_COMPLETED: "■"
488
+ };
489
+ /**
490
+ * When a `withBuffer` scope is active, every log line (stdout and stderr) is
491
+ * appended to its buffer instead of being written immediately. Parallel spec
492
+ * runs use this so each spec's narration — including logs emitted deep inside
493
+ * the live executor — flushes as one contiguous block, not interleaved.
494
+ */
495
+ const bufferStore = new AsyncLocalStorage();
496
+ const sinkStore = new AsyncLocalStorage();
497
+ /** True while inside a `withBuffer` scope: progress lines avoid TTY cursor tricks. */
498
+ function isBuffered() {
499
+ return bufferStore.getStore() !== void 0;
500
+ }
501
+ function emit(text, sink = process.stdout) {
502
+ const store = bufferStore.getStore();
503
+ if (store) {
504
+ store.out.push(text);
505
+ return;
506
+ }
507
+ const activeSink = sinkStore.getStore();
508
+ if (activeSink) {
509
+ activeSink.write(text);
510
+ return;
511
+ }
512
+ sink.write(text);
513
+ }
514
+ /**
515
+ * Write raw text to the active `withBuffer` scope, or straight to stdout when
516
+ * none is active. Lets a runner redirect sub-process output (e.g. a child's
517
+ * stdout) into the same buffer as its `log.*` lines so they flush together.
518
+ */
519
+ function emitRaw(text) {
520
+ emit(text);
521
+ }
522
+ /**
523
+ * Run `fn` with all its log output captured into a buffer, then flush the
524
+ * buffer in one shot under `label`. Used by parallel runners to keep each
525
+ * spec's output legible. Output is flushed even when `fn` throws.
526
+ *
527
+ * When `buffered` is false, `fn` runs with no buffer so its output streams
528
+ * live — this is the sequential (concurrency 1) path, unchanged from before.
529
+ */
530
+ async function withBuffer(label, buffered, fn) {
531
+ if (!buffered) return fn();
532
+ const store = { out: [] };
533
+ try {
534
+ return await bufferStore.run(store, fn);
535
+ } finally {
536
+ emit(`\n──── ${label} ────\n${store.out.join("")}`);
537
+ }
538
+ }
539
+ function header(command, target) {
540
+ emit(`\nccqa ${command}${target ? ` ${target}` : ""}\n\n`);
541
+ }
542
+ function write(scope, message, sink = process.stdout) {
543
+ emit(`[${scope}] ${message}\n`, sink);
544
+ }
545
+ function meta(key, value) {
546
+ write("meta", `${key}: ${value}`);
547
+ }
548
+ function blank() {
549
+ emit("\n");
550
+ }
551
+ function info(message) {
552
+ write("info", message);
553
+ }
554
+ function step(type, stepId, detail) {
555
+ emit(` ${STEP_ICONS[type]} [${stepId}] ${detail}\n`);
556
+ }
557
+ function bash(command) {
558
+ emit(` $ ${command.slice(0, 120)}\n`);
559
+ }
560
+ function error(message) {
561
+ write("error", message, process.stderr);
562
+ }
563
+ function warn(message) {
564
+ write("warn", message, process.stderr);
565
+ }
566
+ function hint(message) {
567
+ emit("\n");
568
+ write("hint", message);
569
+ }
570
+ function fix(message) {
571
+ write("fix", message);
572
+ }
573
+ function run(message) {
574
+ write("run", message);
575
+ }
576
+ /**
577
+ * Render a single-line progress indicator for a step-by-step loop.
578
+ *
579
+ * On a TTY the line is rewritten in place via `\r` so the terminal stays
580
+ * uncluttered. In a non-TTY environment (CI, piped runs) we fall back to
581
+ * a regular `[info]` line every PROGRESS_NONTTY_STRIDE steps to avoid
582
+ * spamming the log with one line per action.
583
+ *
584
+ * Callers MUST call `progressEnd()` when the loop finishes (or aborts) so
585
+ * the carriage-return line gets a final newline; otherwise the next log
586
+ * line lands on the same physical row.
587
+ */
588
+ const PROGRESS_NONTTY_STRIDE = 5;
589
+ let lastProgressNonTtyEmit = -1;
590
+ function progress(current, total, label) {
591
+ const text = `[info] ${current + 1}/${total} ${label}`;
592
+ if (process.stdout.isTTY && !isBuffered()) {
593
+ process.stdout.write(`\r${text}\x1b[K`);
594
+ return;
595
+ }
596
+ if (current === 0 || current - lastProgressNonTtyEmit >= PROGRESS_NONTTY_STRIDE) {
597
+ emit(`${text}\n`);
598
+ lastProgressNonTtyEmit = current;
599
+ }
600
+ }
601
+ function progressEnd() {
602
+ if (process.stdout.isTTY && !isBuffered()) process.stdout.write(`\r\x1b[K`);
603
+ lastProgressNonTtyEmit = -1;
604
+ }
605
+ /**
606
+ * Time a long-running step under the given scope, emitting `started` and
607
+ * `finished in N.Ns` markers. Scope must be a tag the user wants to grep
608
+ * for — typically "run" for vitest and "fix" for diagnose-loop steps.
609
+ */
610
+ async function timedPhase(label, fn, scope = "fix") {
611
+ const startedAt = Date.now();
612
+ write(scope, `${label} started`);
613
+ try {
614
+ const result = await fn();
615
+ write(scope, `${label} finished in ${((Date.now() - startedAt) / 1e3).toFixed(1)}s`);
616
+ return result;
617
+ } catch (err) {
618
+ write(scope, `${label} threw after ${((Date.now() - startedAt) / 1e3).toFixed(1)}s`);
619
+ throw err;
620
+ }
621
+ }
622
+ //#endregion
623
+ //#region src/ir/from-agent-browser.ts
624
+ /**
625
+ * Normalization from the agent-browser side of the recorder into the IR.
626
+ * The trace protocol emits one pipe-delimited `AB_ACTION|...` line per
627
+ * browser action (see `src/prompts/trace.ts` and
628
+ * `claude/invoke.ts:extractAbActionFromBashCommand`); `parseAbActionLine`
629
+ * turns each line into a `RecordedAction`.
630
+ *
631
+ * The mapping is a deterministic re-encoding: `to-agent-browser.ts` is its
632
+ * inverse, and the round-trip identity (ab argv → wire → IR → ab argv) is
633
+ * pinned by `roundtrip.test.ts`.
634
+ */
635
+ /**
636
+ * Semantic locator strategies exposed by `agent-browser find`. Used by the
637
+ * `find_*` wire commands when a target cannot be uniquely picked out by the
638
+ * ALLOWED CSS forms (e.g. repeated `aria-label='1 reply'` rows where only
639
+ * "the last one" is meaningful).
640
+ *
641
+ * `first` / `last` / `nth` are positional helpers whose value carries an
642
+ * inner CSS selector (`nth` additionally needs an index); they normalize to
643
+ * a `css` Locator plus `index`. The remaining strategies read the value as
644
+ * the human-visible text/id and normalize to the matching `Locator.by`.
645
+ */
646
+ const FIND_LOCATORS = [
647
+ "role",
648
+ "text",
649
+ "label",
650
+ "placeholder",
651
+ "alt",
652
+ "title",
653
+ "testid",
654
+ "first",
655
+ "last",
656
+ "nth"
657
+ ];
658
+ /**
659
+ * Actions reachable via `agent-browser find <locator> ... <action>`. Kept
660
+ * here next to the locator list so all `find` wire knowledge lives in one
661
+ * place — `claude/invoke.ts` imports these instead of redefining its own sets.
662
+ */
663
+ const FIND_ACTIONS = [
664
+ "click",
665
+ "dblclick",
666
+ "fill",
667
+ "type",
668
+ "hover",
669
+ "focus",
670
+ "check",
671
+ "uncheck"
672
+ ];
673
+ const css = (value) => ({
674
+ by: "css",
675
+ value
676
+ });
677
+ function parseAbActionLine(line) {
678
+ if (!line.startsWith("AB_ACTION|")) return null;
679
+ const parts = line.split("|");
680
+ const command = parts[1];
681
+ switch (command) {
682
+ case "cookies_clear": return { action: "cookies_clear" };
683
+ case "open": return {
684
+ action: "navigate",
685
+ value: (parts[2] ?? "").replace(/^["']|["']$/g, "")
686
+ };
687
+ case "press": return {
688
+ action: "press",
689
+ ...opt("value", parts[2])
690
+ };
691
+ case "scroll": return {
692
+ action: "scroll",
693
+ ...opt("direction", parts[2]),
694
+ ...opt("pixels", parts[3])
695
+ };
696
+ case "snapshot": return {
697
+ action: "snapshot",
698
+ ...opt("observation", parts[2])
699
+ };
700
+ case "assert": return {
701
+ action: "assert",
702
+ assert: parts[2],
703
+ ...parts[3] ? { locator: css(parts[3]) } : {},
704
+ ...parts[4] ? { value: parts[4] } : {},
705
+ ...parts[5] ? { observation: parts[5] } : {}
706
+ };
707
+ case "click":
708
+ case "dblclick":
709
+ case "check":
710
+ case "uncheck":
711
+ case "hover":
712
+ if (!parts[2]) return null;
713
+ return {
714
+ action: command,
715
+ locator: css(parts[2]),
716
+ ...opt("label", parts[3])
717
+ };
718
+ case "wait":
719
+ if (parts[2] === "--text") {
720
+ if (!parts[3]) return null;
721
+ return {
722
+ action: "wait",
723
+ locator: {
724
+ by: "text",
725
+ value: parts[3]
726
+ },
727
+ ...opt("label", parts[4])
728
+ };
729
+ }
730
+ if (!parts[2]) return null;
731
+ return {
732
+ action: "wait",
733
+ locator: css(parts[2]),
734
+ ...opt("label", parts[3])
735
+ };
736
+ case "fill":
737
+ case "type":
738
+ case "select":
739
+ if (!parts[2]) return null;
740
+ return {
741
+ action: command,
742
+ locator: css(parts[2]),
743
+ ...parts[3] !== void 0 ? { value: parts[3] } : {},
744
+ ...opt("label", parts[4])
745
+ };
746
+ case "drag":
747
+ if (!parts[2] || !parts[3]) return null;
748
+ return {
749
+ action: "drag",
750
+ locator: css(parts[2]),
751
+ target: css(parts[3]),
752
+ ...opt("label", parts[4])
753
+ };
754
+ case "upload": {
755
+ const selector = parts[2];
756
+ const files = parts.slice(3).filter((f) => f !== "");
757
+ if (!selector || files.length === 0) return null;
758
+ return {
759
+ action: "upload",
760
+ locator: css(selector),
761
+ files
762
+ };
763
+ }
764
+ case "find_click":
765
+ case "find_dblclick":
766
+ case "find_hover":
767
+ case "find_focus":
768
+ case "find_check":
769
+ case "find_uncheck": return parseFindAction(command.slice(5), parts, false);
770
+ case "find_fill":
771
+ case "find_type": return parseFindAction(command.slice(5), parts, true);
772
+ case "get_count":
773
+ case "get_url": return null;
774
+ default: return null;
775
+ }
776
+ }
777
+ /**
778
+ * Promote a `CCQA_ASSERT=<marker>` env marker on an agent-browser command
779
+ * into recorded assert action(s). The marker travels on the same channel as
780
+ * the command itself (see `claude/invoke.ts`), so a verification the model
781
+ * performs anyway (`wait --text`, `get count`) becomes a recorded assert
782
+ * without relying on the `AB_ACTION|assert|...` text protocol.
783
+ *
784
+ * `abAction` is the wire line for the marked command (null when the command
785
+ * has no wire form at all). Mapping — anything else returns null and the
786
+ * caller warns and records the command unpromoted:
787
+ *
788
+ * - `wait --text "X"` + `1` (or `text_visible`) → `assert text_visible X`,
789
+ * REPLACING the wait: the emitted abAssert is itself a timed wait, so
790
+ * keeping both would wait twice.
791
+ * - `get count "<sel>"` + `element_visible` / `element_not_visible`
792
+ * → `assert <marker> <sel>` (the probe records nothing by itself).
793
+ * - any command + `url_contains:<substring>` → the command's own action (if
794
+ * it records one) followed by `assert url_contains <substring>`.
795
+ */
796
+ function promoteMarkedAssert(abAction, marker) {
797
+ if (marker.startsWith("url_contains:")) {
798
+ const substring = marker.slice(13);
799
+ if (!substring) return null;
800
+ const assert = {
801
+ action: "assert",
802
+ assert: "url_contains",
803
+ value: substring
804
+ };
805
+ const base = abAction === null ? null : parseAbActionLine(abAction);
806
+ return base === null ? [assert] : [base, assert];
807
+ }
808
+ const parts = abAction === null ? [] : abAction.split("|");
809
+ if (marker === "1" || marker === "text_visible") {
810
+ if (parts[1] === "wait" && parts[2] === "--text" && parts[3]) return [{
811
+ action: "assert",
812
+ assert: "text_visible",
813
+ value: parts[3]
814
+ }];
815
+ return null;
816
+ }
817
+ if (marker === "element_visible" || marker === "element_not_visible") {
818
+ if (parts[1] === "get_count" && parts[2]) return [{
819
+ action: "assert",
820
+ assert: marker,
821
+ locator: css(parts[2])
822
+ }];
823
+ return null;
824
+ }
825
+ return null;
826
+ }
827
+ /**
828
+ * Common parser for the `find_*` wire family. `<extra>` carries `--name` for
829
+ * `role`, the integer index for `nth`, and is empty otherwise. We accept a
830
+ * literally empty `<extra>` (the LLM emits a placeholder `|` so the
831
+ * positional layout stays stable across locators).
832
+ */
833
+ function parseFindAction(action, parts, hasFillValue) {
834
+ const locatorToken = parts[2];
835
+ const findValue = parts[3];
836
+ const extra = parts[4] ?? "";
837
+ const exact = (parts[5] ?? "") === "exact";
838
+ if (!locatorToken || !FIND_LOCATORS.includes(locatorToken) || !findValue) return null;
839
+ let locator;
840
+ let index;
841
+ if (locatorToken === "first" || locatorToken === "last") {
842
+ locator = css(findValue);
843
+ index = locatorToken;
844
+ } else if (locatorToken === "nth") {
845
+ const parsed = extra ? Number.parseInt(extra, 10) : NaN;
846
+ if (Number.isNaN(parsed)) return null;
847
+ locator = css(findValue);
848
+ index = parsed;
849
+ } else if (locatorToken === "role") locator = {
850
+ by: "role",
851
+ value: findValue,
852
+ ...extra ? { name: extra } : {},
853
+ ...exact ? { exact: true } : {}
854
+ };
855
+ else locator = {
856
+ by: locatorToken,
857
+ value: findValue,
858
+ ...exact ? { exact: true } : {}
859
+ };
860
+ return {
861
+ action,
862
+ locator,
863
+ ...index !== void 0 ? { index } : {},
864
+ ...hasFillValue ? {
865
+ ...parts[6] !== void 0 ? { value: parts[6] } : {},
866
+ ...opt("label", parts[7])
867
+ } : opt("label", parts[6])
868
+ };
869
+ }
870
+ /** Include an optional string field only when it is non-empty. */
871
+ function opt(key, value) {
872
+ return value ? { [key]: value } : {};
873
+ }
874
+ //#endregion
875
+ //#region src/runtime/env-scrub.ts
876
+ /**
877
+ * Build a list of `[envValue, "${VAR}"]` pairs for every `${VAR}` reference
878
+ * mentioned in the spec OR in any of its expanded (block-inlined) steps.
879
+ * Used at trace time to scrub recorded Claude-text outputs so a value the
880
+ * spec author intentionally threaded through `process.env` is preserved as
881
+ * `${VAR}` in `ir.json` rather than baked in as the concrete
882
+ * trace-time value.
883
+ *
884
+ * Why we walk `spec.steps` AND `expanded`:
885
+ * - `spec.steps` carries the spec's own `instruction` / `expected` + each
886
+ * include's raw `params` (which may themselves be `${ENV}` refs).
887
+ * - `expanded` carries the inlined block-internal steps, whose
888
+ * `instruction` / `expected` may *also* contain `${ENV}` refs that
889
+ * don't go through include params.
890
+ *
891
+ * Each ref resolves against `overrides` first, then `process.env` —
892
+ * `overrides` carries values the invoker injects into the child process,
893
+ * which beat the parent env there. Only refs that resolve non-empty land in
894
+ * the map — scrubbing against an empty string would corrupt unrelated empty
895
+ * strings in the action stream; the rest are returned via `unresolved` so
896
+ * the caller can warn the user.
897
+ *
898
+ * Longer values sort first so a `${SHORT}` whose value is a substring of a
899
+ * `${LONG}` value doesn't clobber the longer one.
900
+ *
901
+ * `title` is deliberately NOT scanned — it never reaches the recorded action
902
+ * stream.
903
+ */
904
+ function buildSpecEnvScrub(spec, expanded, overrides = {}) {
905
+ const refNames = /* @__PURE__ */ new Set();
906
+ for (const step of spec.steps) if (isIncludeStep(step)) for (const v of Object.values(step.params ?? {})) collect(v, refNames);
907
+ else collectStepRefs(step, refNames);
908
+ for (const step of expanded) collectStepRefs(step, refNames);
909
+ const map = [];
910
+ const unresolved = [];
911
+ for (const name of refNames) {
912
+ const value = overrides[name] ?? process.env[name];
913
+ if (typeof value === "string" && value.length > 0) map.push([value, "${" + name + "}"]);
914
+ else unresolved.push(name);
915
+ }
916
+ map.sort((a, b) => b[0].length - a[0].length);
917
+ return {
918
+ map,
919
+ unresolved
920
+ };
921
+ }
922
+ function collect(value, into) {
923
+ for (const name of iterEnvRefNames(value)) into.add(name);
924
+ }
925
+ function collectStepRefs(step, into) {
926
+ if (isJudgeBody(step)) {
927
+ collect(step.judgeByLlm, into);
928
+ if (step.from !== void 0) collect(step.from, into);
929
+ return;
930
+ }
931
+ collect(step.instruction, into);
932
+ collect(step.expected, into);
933
+ }
934
+ /** Shorter than this, a value is no secret and matches inside ordinary words. */
935
+ const MIN_PROSE_SCRUB_LENGTH = 4;
936
+ /** Long enough to clear the length bar, still ordinary prose / JSON. */
937
+ const COMMON_PROSE_VALUES = new Set([
938
+ "true",
939
+ "false",
940
+ "null",
941
+ "none",
942
+ "undefined"
943
+ ]);
944
+ /**
945
+ * Scrub map for model output, built like {@link buildSpecEnvScrub} but
946
+ * without the values that read as ordinary text (`"1"`, `"true"`): prose
947
+ * runs to paragraphs, where replacing every occurrence of such a value
948
+ * costs more meaning than it protects. Record's own scrub keeps them for
949
+ * its single command lines; the live path reuses this one map for its Bash
950
+ * command log too, trading that short-value coverage for not building a
951
+ * second map.
952
+ */
953
+ function buildProseEnvScrubMap(spec, expanded, overrides = {}) {
954
+ return buildSpecEnvScrub(spec, expanded, overrides).map.filter(([value]) => value.length >= MIN_PROSE_SCRUB_LENGTH && !COMMON_PROSE_VALUES.has(value.toLowerCase()));
955
+ }
956
+ /**
957
+ * Replace every occurrence of an env value with its `${VAR}` placeholder in
958
+ * `text`. **Caller invariant**: the map must be sorted longest-value-first
959
+ * so a shorter value doesn't shadow a longer one that contains it as a
960
+ * substring. `buildSpecEnvScrub` upholds this; hand-built maps should too.
961
+ */
962
+ function scrubEnvValues(text, scrubMap) {
963
+ if (scrubMap.length === 0) return text;
964
+ let out = text;
965
+ for (const [value, placeholder] of scrubMap) if (out.includes(value)) out = out.replaceAll(value, placeholder);
966
+ return out;
967
+ }
968
+ //#endregion
969
+ //#region src/claude/native-binary.ts
970
+ const require = createRequire(import.meta.url);
971
+ /**
972
+ * The agent SDK launches Claude through a native `claude` binary that ships in
973
+ * a per-platform package (`@anthropic-ai/claude-agent-sdk-<platform>-<cpu>`),
974
+ * declared as an *optional* dependency of the SDK. Optional means a consumer's
975
+ * lockfile can omit it without any install-time error — and then every Claude
976
+ * call fails at runtime with a message that never reaches our logs. Resolving
977
+ * the package up front lets us say so once, in a line that names the fix.
978
+ *
979
+ * ccqa's own package.json repeats these packages in `optionalDependencies` for
980
+ * the same reason: a second declaration gives the resolver another chance to
981
+ * record them. Keep that list's version range in step with the SDK's.
982
+ */
983
+ function nativeBinaryPackage(platform = process.platform, arch = process.arch, musl = isMusl(platform)) {
984
+ return `@anthropic-ai/claude-agent-sdk-${platform}-${arch === "arm64" ? "arm64" : "x64"}${platform === "linux" && musl ? "-musl" : ""}`;
985
+ }
986
+ /**
987
+ * musl builds (Alpine and friends) need their own binary. Node doesn't expose
988
+ * the libc flavour directly; the absence of `glibcVersionRuntime` in the
989
+ * process report is the usual proxy.
990
+ */
991
+ function isMusl(platform) {
992
+ if (platform !== "linux") return false;
993
+ return !(process.report?.getReport?.())?.header?.glibcVersionRuntime;
994
+ }
995
+ /**
996
+ * Name of the platform package this host needs, or `null` when it resolves.
997
+ * The per-platform packages have no `exports`, so the manifest is reachable.
998
+ */
999
+ function missingNativeBinaryPackage(resolve = require.resolve) {
1000
+ const pkg = nativeBinaryPackage();
1001
+ try {
1002
+ resolve(`${pkg}/package.json`);
1003
+ return null;
1004
+ } catch {
1005
+ return pkg;
1006
+ }
1007
+ }
1008
+ /** Advice shown when the binary is absent — the package name plus how to fix it. */
1009
+ function missingNativeBinaryMessage(pkg) {
1010
+ return `${pkg} is not installed. The Claude Agent SDK needs it to start Claude on this platform, so every Claude-backed command (run in live mode, drift, diagnose) will fail. It ships as an optional dependency of the SDK, which a lockfile can drop silently: reinstall without omitting optional dependencies, or add it to your project as a direct dependency pinned to the same version as @anthropic-ai/claude-agent-sdk.`;
1011
+ }
1012
+ //#endregion
1013
+ //#region src/claude/cost-tally.ts
1014
+ /**
1015
+ * Sum every Claude invocation made inside a scope.
1016
+ *
1017
+ * A command like `record` calls Claude several times — the browser trace, the
1018
+ * codegen cleanup, one diagnosis per auto-fix retry — and the caller wants one
1019
+ * number for the whole command. Threading a cost out of each of those return
1020
+ * types would touch every layer in between, so the tally is scoped instead:
1021
+ * `invokeClaudeStreaming` adds to whichever scope is active, and nothing
1022
+ * between the two has to know.
1023
+ *
1024
+ * Scoped rather than module-global because commands run specs concurrently
1025
+ * (`drift` uses a pool). Two scopes must not fold into each other.
1026
+ */
1027
+ const tallyStore = new AsyncLocalStorage();
1028
+ /** Record one invocation against the active scope. No-op outside one. */
1029
+ function tallyInvocation(cost) {
1030
+ tallyStore.getStore()?.push(cost);
1031
+ }
1032
+ /** Run `fn` with a fresh tally. Read the total from inside with `readCostTally`. */
1033
+ async function withCostTally(fn) {
1034
+ return tallyStore.run([], fn);
1035
+ }
1036
+ /**
1037
+ * The active scope's total so far, or null outside one.
1038
+ *
1039
+ * Read rather than pushed at the caller because commands end in
1040
+ * `process.exit`, which never reaches a `finally`; whoever opened the scope
1041
+ * reads the total on the way out (see `withCostReporting`).
1042
+ *
1043
+ * Fields stay `null` when no invocation reported them, so a caller can tell
1044
+ * "nothing was billed" from "the SDK didn't say" (mock runs, SDK errors).
1045
+ */
1046
+ function readCostTally() {
1047
+ const collected = tallyStore.getStore();
1048
+ return collected === void 0 ? null : sum(collected);
1049
+ }
1050
+ function sum(costs) {
1051
+ const add = (pick) => {
1052
+ const present = costs.map(pick).filter((v) => v !== null);
1053
+ return present.length === 0 ? null : present.reduce((a, b) => a + b, 0);
1054
+ };
1055
+ return {
1056
+ totalCostUsd: add((c) => c.totalCostUsd),
1057
+ durationMs: add((c) => c.durationMs),
1058
+ durationApiMs: add((c) => c.durationApiMs),
1059
+ numTurns: add((c) => c.numTurns),
1060
+ inputTokens: add((c) => c.inputTokens),
1061
+ cacheCreationInputTokens: add((c) => c.cacheCreationInputTokens),
1062
+ cacheReadInputTokens: add((c) => c.cacheReadInputTokens),
1063
+ outputTokens: add((c) => c.outputTokens),
1064
+ models: [...new Set(costs.flatMap((c) => c.models))]
1065
+ };
1066
+ }
1067
+ //#endregion
1068
+ //#region src/claude/invoke.ts
1069
+ /** The built-in tools an allow-list names: `Bash(*)` is `Bash`; `mcp__*` are not built-ins. */
1070
+ function builtinToolNames(allowedTools) {
1071
+ const names = allowedTools.map((entry) => entry.replace(/\(.*\)$/, "")).filter((name) => !name.startsWith("mcp__"));
1072
+ return [...new Set(names)];
1073
+ }
1074
+ function resolveModel(explicit) {
1075
+ if (explicit) return explicit;
1076
+ const envModel = process.env["CCQA_MODEL"];
1077
+ return envModel && envModel.length > 0 ? envModel : void 0;
1078
+ }
1079
+ /**
1080
+ * When both credentials are present the OAuth token wins and the API key is
1081
+ * dropped. Left to the CLI the API key would win, which makes "switch a CI
1082
+ * job to the subscription token" require unwiring the key everywhere; with
1083
+ * this rule, adding the one variable is the whole switch, and removing it is
1084
+ * the whole rollback. The one place the rule lives — both the resolved view
1085
+ * and the env the SDK receives apply it through here.
1086
+ */
1087
+ function preferOauthToken(env) {
1088
+ if (env["CLAUDE_CODE_OAUTH_TOKEN"]) delete env["ANTHROPIC_API_KEY"];
1089
+ }
1090
+ /**
1091
+ * Drop endpoint variables that are present but empty, so an empty value never
1092
+ * reaches the Claude Code process as an override. "Set to nothing" is how a
1093
+ * caller that cannot omit the key says "use the default" — a CI job wiring
1094
+ * `ANTHROPIC_BASE_URL` from an unset repository variable, most of all.
1095
+ */
1096
+ function withoutEmptyEndpointVars(env) {
1097
+ const out = { ...env };
1098
+ for (const key of ENDPOINT_ENV_KEYS) if (out[key] === "") delete out[key];
1099
+ return out;
1100
+ }
1101
+ /**
1102
+ * The environment actually handed to the Claude Code process: the full process
1103
+ * environment with the caller's overrides on top, empty endpoint variables
1104
+ * dropped, and — when both credentials survive the merge — the API key removed
1105
+ * so the OAuth token wins.
1106
+ *
1107
+ * That removal MUST happen on the env the SDK receives, not only on the
1108
+ * resolved view: left to the CLI the API key would win, silently moving every
1109
+ * call from the subscription to metered billing when a CI job wires both
1110
+ * (which is exactly what happened before this function existed).
1111
+ */
1112
+ function buildInvocationEnv(env) {
1113
+ const merged = withoutEmptyEndpointVars({
1114
+ ...process.env,
1115
+ ...env
1116
+ });
1117
+ preferOauthToken(merged);
1118
+ merged["CLAUDE_CODE_DISABLE_AUTO_MEMORY"] = "1";
1119
+ return merged;
1120
+ }
1121
+ let nativeBinaryWarned = false;
1122
+ /**
1123
+ * Warn once per process when the SDK's per-platform native binary is missing:
1124
+ * every Claude call is about to fail, and the opaque per-step errors alone are
1125
+ * expensive to trace back to a lockfile that dropped an optional dependency.
1126
+ */
1127
+ function warnOnceIfNativeBinaryMissing() {
1128
+ if (nativeBinaryWarned) return;
1129
+ nativeBinaryWarned = true;
1130
+ const missing = missingNativeBinaryPackage();
1131
+ if (missing) warn(missingNativeBinaryMessage(missing));
1132
+ }
1133
+ /** Whole minutes read best, but the ceiling is set in ms and may be seconds. */
1134
+ function formatDuration(ms) {
1135
+ if (ms < 6e4 || ms % 6e4 !== 0) return `${Math.round(ms / 1e3)}s`;
1136
+ const minutes = ms / 6e4;
1137
+ return `${minutes} minute${minutes === 1 ? "" : "s"}`;
1138
+ }
1139
+ async function invokeClaudeStreaming(options, onEvent) {
1140
+ const { prompt, systemPrompt, allowedTools, disableThinking = false, mcpServers, maxTurns, timeoutMs, env, model, cwd, onAbAction, onAbActionFailed, silenceBashLog = false, envScrubMap = [], relaxAbConstraints = false } = options;
1141
+ const resolvedModel = resolveModel(model);
1142
+ const mergedEnv = buildInvocationEnv(env);
1143
+ const abortController = new AbortController();
1144
+ let lastAbToolUseId = null;
1145
+ const claimAbToolUse = (toolUseId) => {
1146
+ if (toolUseId !== lastAbToolUseId) return false;
1147
+ lastAbToolUseId = null;
1148
+ return true;
1149
+ };
1150
+ const sdkOptions = {
1151
+ systemPrompt,
1152
+ maxTurns,
1153
+ allowedTools,
1154
+ tools: builtinToolNames(allowedTools),
1155
+ strictMcpConfig: true,
1156
+ settingSources: [],
1157
+ permissionMode: "bypassPermissions",
1158
+ allowDangerouslySkipPermissions: true,
1159
+ abortController,
1160
+ ...resolvedModel ? { model: resolvedModel } : {},
1161
+ ...cwd ? { cwd } : {},
1162
+ env: mergedEnv,
1163
+ ...mcpServers ? { mcpServers } : {},
1164
+ ...disableThinking ? { thinking: { type: "disabled" } } : {},
1165
+ hooks: onAbAction || onAbActionFailed ? {
1166
+ PreToolUse: [{ hooks: [async (input) => {
1167
+ if (input.hook_event_name !== "PreToolUse") return {};
1168
+ if (input.tool_name !== "Bash") return {};
1169
+ const cmd = input.tool_input?.["command"];
1170
+ if (typeof cmd !== "string") return {};
1171
+ if (!relaxAbConstraints) {
1172
+ if (isBlockedAbSubcommand(cmd)) return {
1173
+ decision: "block",
1174
+ reason: "This agent-browser subcommand is not allowed because it cannot be recorded as a structured test action. Use only the standard commands: click, check, fill, select, hover, press, wait, find (with role/text/label/placeholder/alt/title/testid/first/last/nth). Take a fresh snapshot to find the correct selector."
1175
+ };
1176
+ if (hasRefSelector(cmd)) return {
1177
+ decision: "block",
1178
+ reason: "@ref selectors (like @e14) are session-specific and change every run. They cannot be used in generated tests. Use one of the allowed selector formats instead: [aria-label='...'], text=..., [placeholder='...'], or [type='password']. Take a fresh snapshot and find the element's aria-label or visible text. If an allowed selector already clicks the element but nothing happens, the element is clipped by an inner scroll container: `scrollintoview` it (addressed by a CSS selector, not `text=`) and click again — a @ref would not have fixed that either."
1179
+ };
1180
+ const bareTag = findPositionalBareTag(cmd);
1181
+ if (bareTag !== null) return {
1182
+ decision: "block",
1183
+ reason: `\`find ${bareTag.locator}\` with a bare tag selector (\`${bareTag.selector}\`) is rejected: it matches every <${bareTag.selector}> on the page and is non-deterministic on replay. Pass a specific attribute selector instead, e.g. \`find ${bareTag.locator} "[aria-label='...']" ${bareTag.action}\` or \`find ${bareTag.locator} "[data-qa='...']" ${bareTag.action}\`. Take a fresh snapshot to find the right attribute.`
1184
+ };
1185
+ if (hasMultipleAbInvocations(cmd)) return {
1186
+ decision: "block",
1187
+ reason: "Run each `agent-browser` call as its own Bash command. Chaining multiple invocations with &&, ;, |, or || prevents ccqa from recording them as discrete steps and lets failed attempts leak into the trace. Issue one Bash tool call per agent-browser command."
1188
+ };
1189
+ if (hasErrorSuppression(cmd)) return {
1190
+ decision: "block",
1191
+ reason: "Do not suppress errors on `agent-browser` commands. Remove `|| true`, `|| :`, `2>/dev/null`, `; true`, and similar redirects so ccqa can detect failures and roll back unsuccessful attempts. Run the command standalone and let it surface its exit code."
1192
+ };
1193
+ }
1194
+ const assertMarker = relaxAbConstraints ? null : extractCcqaAssertFromBashCommand(cmd);
1195
+ const ab = relaxAbConstraints ? null : extractAbActionFromBashCommand(cmd) ?? (assertMarker !== null ? extractObservationAbAction(cmd) : null);
1196
+ if ((ab !== null || assertMarker !== null) && onAbAction) {
1197
+ lastAbToolUseId = input.tool_use_id;
1198
+ const stepId = extractCcqaStepFromBashCommand(cmd);
1199
+ onAbAction({
1200
+ ...ab !== null ? { abAction: ab } : {},
1201
+ ...stepId ? { stepId } : {},
1202
+ ...assertMarker !== null ? { assertMarker } : {}
1203
+ });
1204
+ } else lastAbToolUseId = null;
1205
+ return {};
1206
+ }] }],
1207
+ PostToolUse: [{ hooks: [async (input) => {
1208
+ if (input.hook_event_name !== "PostToolUse") return {};
1209
+ if (input.tool_name !== "Bash") return {};
1210
+ if (!isBashToolResponseError(input.tool_response)) return {};
1211
+ if (claimAbToolUse(input.tool_use_id) && onAbActionFailed) onAbActionFailed();
1212
+ return {};
1213
+ }] }],
1214
+ PostToolUseFailure: [{ hooks: [async (input) => {
1215
+ if (input.hook_event_name !== "PostToolUseFailure") return {};
1216
+ if (input.tool_name !== "Bash") return {};
1217
+ if (claimAbToolUse(input.tool_use_id) && onAbActionFailed) onAbActionFailed();
1218
+ return {};
1219
+ }] }]
1220
+ } : void 0
1221
+ };
1222
+ warnOnceIfNativeBinaryMissing();
1223
+ const capTimer = timeoutMs === void 0 ? null : setTimeout(() => abortController.abort(), timeoutMs);
1224
+ capTimer?.unref?.();
1225
+ let result = "";
1226
+ let answered = false;
1227
+ let isError = false;
1228
+ let errorDetail = null;
1229
+ let cost = {
1230
+ totalCostUsd: null,
1231
+ durationMs: null,
1232
+ durationApiMs: null,
1233
+ numTurns: null,
1234
+ inputTokens: null,
1235
+ cacheCreationInputTokens: null,
1236
+ cacheReadInputTokens: null,
1237
+ outputTokens: null,
1238
+ models: []
1239
+ };
1240
+ const q = await buildMessageStream(prompt, sdkOptions);
1241
+ try {
1242
+ for await (const msg of q) {
1243
+ onEvent(msg);
1244
+ if (msg.type === "assistant" && !silenceBashLog) {
1245
+ for (const block of msg.message.content ?? []) if (block.type === "tool_use" && block.name === "Bash") {
1246
+ const cmd = block.input?.["command"];
1247
+ if (typeof cmd === "string") bash(scrubEnvValues(cmd, envScrubMap));
1248
+ }
1249
+ }
1250
+ if (msg.type === "result") {
1251
+ answered = true;
1252
+ isError = msg.is_error ?? false;
1253
+ if (msg.subtype === "success") result = msg.result;
1254
+ else {
1255
+ result = "";
1256
+ errorDetail = `SDK reported ${msg.subtype}`;
1257
+ }
1258
+ cost = extractInvocationCost(msg);
1259
+ }
1260
+ }
1261
+ } catch (err) {
1262
+ isError = true;
1263
+ errorDetail = err instanceof Error ? err.message : String(err);
1264
+ if (!result) result = errorDetail;
1265
+ } finally {
1266
+ if (capTimer) clearTimeout(capTimer);
1267
+ }
1268
+ if (abortController.signal.aborted && timeoutMs !== void 0 && !answered) {
1269
+ isError = true;
1270
+ errorDetail = `stopped after ${formatDuration(timeoutMs)} (host time limit)`;
1271
+ result = errorDetail;
1272
+ }
1273
+ tallyInvocation(cost);
1274
+ return {
1275
+ result,
1276
+ isError,
1277
+ errorDetail,
1278
+ cost
1279
+ };
1280
+ }
1281
+ /**
1282
+ * Pull the cost / usage / turn / duration fields off the SDK `result` message.
1283
+ * The SDK's success and error result shapes share these fields, so we read
1284
+ * them defensively as `unknown` and coerce — newer SDK versions may rename a
1285
+ * field without breaking our extraction.
1286
+ */
1287
+ function extractInvocationCost(msg) {
1288
+ const m = msg;
1289
+ const usage = m["usage"];
1290
+ const modelUsage = m["modelUsage"];
1291
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
1292
+ const models = modelUsage && typeof modelUsage === "object" ? Object.keys(modelUsage) : [];
1293
+ return {
1294
+ totalCostUsd: pricedForClaude(models) ? num(m["total_cost_usd"]) : null,
1295
+ durationMs: num(m["duration_ms"]),
1296
+ durationApiMs: num(m["duration_api_ms"]),
1297
+ numTurns: num(m["num_turns"]),
1298
+ inputTokens: num(usage?.["input_tokens"]),
1299
+ cacheCreationInputTokens: num(usage?.["cache_creation_input_tokens"]),
1300
+ cacheReadInputTokens: num(usage?.["cache_read_input_tokens"]),
1301
+ outputTokens: num(usage?.["output_tokens"]),
1302
+ models
1303
+ };
1304
+ }
1305
+ /**
1306
+ * The SDK prices an unknown model id at a default Claude rate rather than
1307
+ * returning null, so a self-hosted model would report dollars nobody is billed.
1308
+ */
1309
+ function pricedForClaude(models) {
1310
+ return models.every((id) => /claude/i.test(id));
1311
+ }
1312
+ const BLOCKED_AB_SUBCOMMANDS = new Set([
1313
+ "eval",
1314
+ "js",
1315
+ "label",
1316
+ "textbox"
1317
+ ]);
1318
+ /**
1319
+ * Shell-aware tokenizer: splits a command string into tokens respecting single/double quotes.
1320
+ * e.g. `click "[role='dialog'] button:last-child"` → ["click", "[role='dialog'] button:last-child"]
1321
+ */
1322
+ function shellTokenize(s) {
1323
+ const tokens = [];
1324
+ let cur = "";
1325
+ let quote = null;
1326
+ for (let i = 0; i < s.length; i++) {
1327
+ const ch = s[i];
1328
+ if (quote) if (ch === quote) quote = null;
1329
+ else cur += ch;
1330
+ else if (ch === "\"" || ch === "'") quote = ch;
1331
+ else if (ch === " " || ch === " ") {
1332
+ if (cur) {
1333
+ tokens.push(cur);
1334
+ cur = "";
1335
+ }
1336
+ } else cur += ch;
1337
+ }
1338
+ if (cur) tokens.push(cur);
1339
+ return tokens;
1340
+ }
1341
+ /** Extracts the subcommand from an `agent-browser [flags] <subcommand> [args...]` command string. */
1342
+ function extractAbSubcommand(cmd) {
1343
+ const abIdx = cmd.indexOf("agent-browser");
1344
+ if (abIdx === -1) return null;
1345
+ const parts = shellTokenize(cmd.slice(abIdx + 13).trim());
1346
+ let i = 0;
1347
+ while (i < parts.length && parts[i].startsWith("-")) i += 2;
1348
+ return parts[i] ?? null;
1349
+ }
1350
+ /** Returns true if the agent-browser subcommand is blocked (eval/js/find/etc). */
1351
+ function isBlockedAbSubcommand(cmd) {
1352
+ const sub = extractAbSubcommand(cmd);
1353
+ return sub !== null && BLOCKED_AB_SUBCOMMANDS.has(sub);
1354
+ }
1355
+ /**
1356
+ * Detects "the Bash tool returned an error" from a SDK PostToolUse hook's
1357
+ * `tool_response`. The SDK can shape this two ways depending on how Claude
1358
+ * Code reports Bash failures:
1359
+ *
1360
+ * - `{ is_error: true, ... }` — the canonical Bash failure shape
1361
+ * - `{ output, exitCode, killed?, ... }` — the BashOutput shape; treat
1362
+ * non-zero exit / kill as error
1363
+ *
1364
+ * We accept either. Anything else (including missing fields) is treated as a
1365
+ * successful response so we never roll back over an unrelated tool call.
1366
+ */
1367
+ function isBashToolResponseError(tool_response) {
1368
+ if (tool_response === null || typeof tool_response !== "object") return false;
1369
+ const r = tool_response;
1370
+ if (r["is_error"] === true) return true;
1371
+ if (typeof r["exitCode"] === "number" && r["exitCode"] !== 0) return true;
1372
+ if (r["killed"] === true) return true;
1373
+ return false;
1374
+ }
1375
+ /**
1376
+ * Detect `agent-browser ... find first|last|nth <bare-tag> <action>`. A bare
1377
+ * tag inside a *positional* finder matches every element of that tag on the
1378
+ * page, so "the last button" picks a different element whenever the page
1379
+ * shape shifts — recorded tests built on top are flaky by construction. The
1380
+ * check is narrow on purpose: `find role button --name X` is fine because
1381
+ * role + accessible name stays stable.
1382
+ */
1383
+ function findPositionalBareTag(cmd) {
1384
+ if (extractAbSubcommand(cmd) !== "find") return null;
1385
+ const abIdx = cmd.indexOf("agent-browser");
1386
+ const parts = shellTokenize(cmd.slice(abIdx + 13).trim());
1387
+ let i = 0;
1388
+ while (i < parts.length && parts[i].startsWith("-")) i += 2;
1389
+ const locator = parts[i + 1];
1390
+ if (locator !== "first" && locator !== "last" && locator !== "nth") return null;
1391
+ const innerIdx = locator === "nth" ? i + 3 : i + 2;
1392
+ const inner = parts[innerIdx];
1393
+ const action = parts[innerIdx + 1] ?? "";
1394
+ if (!inner) return null;
1395
+ if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(inner)) return null;
1396
+ return {
1397
+ locator,
1398
+ selector: inner,
1399
+ action
1400
+ };
1401
+ }
1402
+ /** Returns true if any argument to an agent-browser command uses a @ref selector (e.g. @e14). */
1403
+ function hasRefSelector(cmd) {
1404
+ const abIdx = cmd.indexOf("agent-browser");
1405
+ if (abIdx === -1) return false;
1406
+ const parts = shellTokenize(cmd.slice(abIdx + 13).trim());
1407
+ let i = 0;
1408
+ while (i < parts.length && parts[i].startsWith("-")) i += 2;
1409
+ i++;
1410
+ for (; i < parts.length; i++) if (/^@/.test(parts[i])) return true;
1411
+ return false;
1412
+ }
1413
+ /**
1414
+ * Split `cmd` into shell statements at unquoted separators (`;`, `|`, `&`,
1415
+ * newline; consecutive separator chars like `&&` count once). String
1416
+ * literals are honoured so `fill "a;b"` stays a single statement. This is a
1417
+ * heuristic split (no subshell grammar), shared by the compound-invocation
1418
+ * guard and the CCQA_STEP prefix extraction so both agree on what "one
1419
+ * command" means.
1420
+ */
1421
+ function splitShellStatements(cmd) {
1422
+ const statements = [];
1423
+ let start = 0;
1424
+ let quote = null;
1425
+ for (let i = 0; i < cmd.length; i++) {
1426
+ const ch = cmd[i];
1427
+ if (quote) {
1428
+ if (ch === quote) quote = null;
1429
+ continue;
1430
+ }
1431
+ if (ch === "\"" || ch === "'" || ch === "`") {
1432
+ quote = ch;
1433
+ continue;
1434
+ }
1435
+ if (ch === ";" || ch === "|" || ch === "&" || ch === "\n") {
1436
+ statements.push(cmd.slice(start, i));
1437
+ while (i + 1 < cmd.length && (cmd[i + 1] === "|" || cmd[i + 1] === "&" || cmd[i + 1] === ";" || cmd[i + 1] === "\n")) i++;
1438
+ start = i + 1;
1439
+ }
1440
+ }
1441
+ statements.push(cmd.slice(start));
1442
+ return statements;
1443
+ }
1444
+ /** One leading `KEY=value` env assignment; value may be single/double-quoted. */
1445
+ const ENV_ASSIGN_HEAD_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(?:"([^"]*)"|'([^']*)'|(\S*))(?:\s+|$)/;
1446
+ /**
1447
+ * Split a statement into its leading `KEY=value` env assignments and the
1448
+ * command they prefix. An assignment whose value is a command substitution
1449
+ * (`$(...)` / backticks) is NOT treated as a prefix — `result=$(agent-browser
1450
+ * ... snapshot)` is an assignment statement, not an agent-browser invocation,
1451
+ * and must stay invisible to the guards below.
1452
+ */
1453
+ function splitLeadingEnvAssignments(statement) {
1454
+ const env = /* @__PURE__ */ new Map();
1455
+ let command = statement.trimStart();
1456
+ for (;;) {
1457
+ const m = ENV_ASSIGN_HEAD_RE.exec(command);
1458
+ if (!m) break;
1459
+ const value = m[2] ?? m[3] ?? m[4] ?? "";
1460
+ if (value.startsWith("$(") || value.startsWith("`")) return {
1461
+ env: /* @__PURE__ */ new Map(),
1462
+ command: statement.trimStart()
1463
+ };
1464
+ env.set(m[1], value);
1465
+ command = command.slice(m[0].length);
1466
+ }
1467
+ return {
1468
+ env,
1469
+ command
1470
+ };
1471
+ }
1472
+ /** True when `command` starts with `agent-browser` as the command word. */
1473
+ function isAgentBrowserHead(command) {
1474
+ if (!command.startsWith("agent-browser")) return false;
1475
+ const after = command[13];
1476
+ return after === void 0 || !/[A-Za-z0-9_\-]/.test(after);
1477
+ }
1478
+ /** Step ids passed via `CCQA_STEP=<step-id>` must be a plain slug. */
1479
+ const STEP_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
1480
+ /**
1481
+ * Extract the step id from the `CCQA_STEP=<step-id>` env prefix on the
1482
+ * agent-browser invocation in `cmd` (e.g. `CCQA_STEP=step-03 agent-browser
1483
+ * --session s click "text=Submit"`). The prefix may sit anywhere in the
1484
+ * leading env-assignment run (`FOO=x CCQA_STEP=step-02 agent-browser ...`),
1485
+ * and the invocation may be a later statement of a compound command
1486
+ * (`cd app && CCQA_STEP=step-01 agent-browser ...`). Returns null when the
1487
+ * prefix is absent or its value is not a valid slug — callers then fall back
1488
+ * to the STEP_START text protocol.
1489
+ */
1490
+ function extractCcqaStepFromBashCommand(cmd) {
1491
+ for (const statement of splitShellStatements(cmd)) {
1492
+ const { env, command } = splitLeadingEnvAssignments(statement);
1493
+ if (!isAgentBrowserHead(command)) continue;
1494
+ const value = env.get("CCQA_STEP");
1495
+ return value !== void 0 && STEP_SLUG_RE.test(value) ? value : null;
1496
+ }
1497
+ return null;
1498
+ }
1499
+ /**
1500
+ * Extract the assert marker from the `CCQA_ASSERT=<marker>` env prefix on
1501
+ * the agent-browser invocation in `cmd`, e.g. `CCQA_STEP=step-03
1502
+ * CCQA_ASSERT=1 agent-browser --session s wait --text "Submitted" --timeout
1503
+ * 3000`. The marker declares that the command verifies a step signal;
1504
+ * `promoteMarkedAssert` maps it onto recorded assert action(s). Returns the
1505
+ * raw value — semantic validation (which markers combine with which
1506
+ * commands) happens at promotion time so mismatches surface as warnings
1507
+ * instead of being silently dropped here. Returns null when the prefix is
1508
+ * absent or empty.
1509
+ */
1510
+ function extractCcqaAssertFromBashCommand(cmd) {
1511
+ for (const statement of splitShellStatements(cmd)) {
1512
+ const { env, command } = splitLeadingEnvAssignments(statement);
1513
+ if (!isAgentBrowserHead(command)) continue;
1514
+ const value = env.get("CCQA_ASSERT");
1515
+ return value !== void 0 && value.length > 0 ? value : null;
1516
+ }
1517
+ return null;
1518
+ }
1519
+ /**
1520
+ * Returns true when `cmd` contains more than one `agent-browser` invocation
1521
+ * chained together via shell operators (`&&`, `||`, `;`, `|`, newline). The
1522
+ * PreToolUse hook only records ONE AB_ACTION per Bash call, so chained
1523
+ * invocations would silently drop every intermediate failure — turning
1524
+ * "I tried four selectors before one worked" into a clean-looking trace
1525
+ * with five orphaned actions that later fail at replay.
1526
+ *
1527
+ * Counts statements whose command word is `agent-browser`, skipping any
1528
+ * leading env assignments (the trace protocol prefixes every invocation
1529
+ * with `CCQA_STEP=<step-id>`). String literals are honoured so
1530
+ * `agent-browser fill 'agent-browser'` doesn't false-fire.
1531
+ */
1532
+ function hasMultipleAbInvocations(cmd) {
1533
+ let count = 0;
1534
+ for (const statement of splitShellStatements(cmd)) {
1535
+ if (!isAgentBrowserHead(splitLeadingEnvAssignments(statement).command)) continue;
1536
+ count++;
1537
+ if (count > 1) return true;
1538
+ }
1539
+ return false;
1540
+ }
1541
+ /**
1542
+ * Returns true when an `agent-browser` command in `cmd` has its exit
1543
+ * status hidden by a shell decorator that would prevent ccqa from rolling
1544
+ * back a failed attempt:
1545
+ *
1546
+ * - trailing `|| true` / `|| :` / `; true` (force exit 0)
1547
+ * - `2>/dev/null` and friends (drop stderr, sometimes paired with `|| true`)
1548
+ *
1549
+ * The agent-browser command itself returns exit 1 on selector miss, so
1550
+ * once one of these is present the PostToolUse hook sees `is_error=false`
1551
+ * and the bad attempt sneaks into ir.json.
1552
+ */
1553
+ function hasErrorSuppression(cmd) {
1554
+ if (cmd.indexOf("agent-browser") === -1) return false;
1555
+ if (/\|\|\s*(true|:|\s*$|#)/.test(cmd)) return true;
1556
+ if (/;\s*(true|:)\b/.test(cmd)) return true;
1557
+ if (/2\s*>\s*\/dev\/null/.test(cmd)) return true;
1558
+ if (/&\s*>\s*\/dev\/null/.test(cmd)) return true;
1559
+ return false;
1560
+ }
1561
+ /**
1562
+ * Parse an `agent-browser --session <name> <cmd> [args...]` bash command
1563
+ * and return the corresponding AB_ACTION line, or null if not an agent-browser call.
1564
+ */
1565
+ function extractAbActionFromBashCommand(cmd) {
1566
+ const subCmd = extractAbSubcommand(cmd);
1567
+ if (!subCmd) return null;
1568
+ const abIdx = cmd.indexOf("agent-browser");
1569
+ const parts = shellTokenize(cmd.slice(abIdx + 13).trim()).filter((t) => !/^(2?>|[|&>])/.test(t));
1570
+ let i = 0;
1571
+ while (i < parts.length && parts[i].startsWith("-")) i += 2;
1572
+ const args = parts.slice(i + 1);
1573
+ switch (subCmd) {
1574
+ case "cookies":
1575
+ if (args[0] === "clear") return "AB_ACTION|cookies_clear";
1576
+ return null;
1577
+ case "open": return `AB_ACTION|open|${args[0] ?? ""}`;
1578
+ case "press": return `AB_ACTION|press|${args[0] ?? ""}`;
1579
+ case "scroll": return `AB_ACTION|scroll|${args.join("|")}`;
1580
+ case "click":
1581
+ case "dblclick":
1582
+ case "check":
1583
+ case "uncheck":
1584
+ case "hover":
1585
+ case "wait": return `AB_ACTION|${subCmd}|${args[0] ?? ""}|${args[1] ?? ""}`;
1586
+ case "fill":
1587
+ case "type":
1588
+ case "select": return `AB_ACTION|${subCmd}|${args[0] ?? ""}|${args[1] ?? ""}|${args[2] ?? ""}`;
1589
+ case "drag": return `AB_ACTION|drag|${args[0] ?? ""}|${args[1] ?? ""}|${args[2] ?? ""}`;
1590
+ case "upload": {
1591
+ const sel = args[0] ?? "";
1592
+ const files = args.slice(1);
1593
+ if (!sel || files.length === 0) return null;
1594
+ return `AB_ACTION|upload|${sel}|${files.join("|")}`;
1595
+ }
1596
+ case "snapshot": return null;
1597
+ case "find": return extractFindAbAction(args);
1598
+ default: return null;
1599
+ }
1600
+ }
1601
+ /**
1602
+ * Wire lines for the observation-only probes `get count <sel>` / `get url`.
1603
+ * These commands read state without mutating it, so they have no place in
1604
+ * the replay sequence and `extractAbActionFromBashCommand` ignores them.
1605
+ * They matter only when a `CCQA_ASSERT=<marker>` env prefix declares the
1606
+ * probe verifies a step signal — the hook layer then surfaces them via this
1607
+ * function so `promoteMarkedAssert` can turn them into recorded asserts.
1608
+ * Only consulted when a marker is present; unmarked `get` commands stay
1609
+ * unobserved as before.
1610
+ */
1611
+ function extractObservationAbAction(cmd) {
1612
+ if (extractAbSubcommand(cmd) !== "get") return null;
1613
+ const abIdx = cmd.indexOf("agent-browser");
1614
+ const parts = shellTokenize(cmd.slice(abIdx + 13).trim()).filter((t) => !/^(2?>|[|&>])/.test(t));
1615
+ let i = 0;
1616
+ while (i < parts.length && parts[i].startsWith("-")) i += 2;
1617
+ const args = parts.slice(i + 1);
1618
+ if (args[0] === "count" && args[1]) return `AB_ACTION|get_count|${args[1]}`;
1619
+ if (args[0] === "url") return "AB_ACTION|get_url";
1620
+ return null;
1621
+ }
1622
+ const FIND_ACTION_SET = new Set(FIND_ACTIONS);
1623
+ const FIND_LOCATOR_SET = new Set(FIND_LOCATORS);
1624
+ /**
1625
+ * Parse the positional tokens of `agent-browser find <locator> <value> [...]
1626
+ * <action> [fillValue]` and produce a canonical
1627
+ * `AB_ACTION|find_<action>|<locator>|<value>|<extra>|<exact>|...|<label>`
1628
+ * line. The wire format keeps a fixed positional layout across locators so
1629
+ * downstream `parseAbActionLine` in `ir/from-agent-browser.ts` can split on
1630
+ * `|` alone:
1631
+ *
1632
+ * <extra> is `--name` value for role, integer index for nth, "" otherwise.
1633
+ * <exact> is the literal "exact" if --exact was passed, "" otherwise.
1634
+ *
1635
+ * Returns null for malformed invocations — the caller treats null as "not a
1636
+ * structured action" and the Bash command still runs unobserved.
1637
+ */
1638
+ function extractFindAbAction(args) {
1639
+ const locator = args[0];
1640
+ if (!locator || !FIND_LOCATOR_SET.has(locator)) return null;
1641
+ let i = 1;
1642
+ let value = args[i] ?? "";
1643
+ i++;
1644
+ let extra = "";
1645
+ if (locator === "nth") {
1646
+ extra = value;
1647
+ value = args[i] ?? "";
1648
+ i++;
1649
+ }
1650
+ let action = "";
1651
+ let name = "";
1652
+ let exact = "";
1653
+ let fillValue = "";
1654
+ for (; i < args.length; i++) {
1655
+ const tok = args[i];
1656
+ if (tok === "--name") {
1657
+ name = args[i + 1] ?? "";
1658
+ i++;
1659
+ } else if (tok === "--exact") exact = "exact";
1660
+ else if (FIND_ACTION_SET.has(tok)) action = tok;
1661
+ else if (action) fillValue = tok;
1662
+ }
1663
+ if (!action) return null;
1664
+ if (locator === "role") extra = name;
1665
+ const command = `find_${action}`;
1666
+ if (action === "fill" || action === "type") return `AB_ACTION|${command}|${locator}|${value}|${extra}|${exact}|${fillValue}|`;
1667
+ return `AB_ACTION|${command}|${locator}|${value}|${extra}|${exact}|`;
1668
+ }
1669
+ async function buildMessageStream(prompt, options) {
1670
+ const mockFile = process.env["CCQA_CLAUDE_MOCK_FILE"];
1671
+ if (mockFile) return replayMockMessages(mockFile, options);
1672
+ return query({
1673
+ prompt,
1674
+ options
1675
+ });
1676
+ }
1677
+ async function* replayMockMessages(path, options) {
1678
+ const raw = await readFile(path, "utf8");
1679
+ for (const line of raw.split("\n")) {
1680
+ const trimmed = line.trim();
1681
+ if (!trimmed) continue;
1682
+ const msg = JSON.parse(trimmed);
1683
+ await fireMockPreToolUseHooks(msg, options);
1684
+ yield msg;
1685
+ }
1686
+ }
1687
+ /**
1688
+ * The real SDK fires PreToolUse hooks as it executes tool calls; the JSONL
1689
+ * replay approximates that by invoking the configured PreToolUse hooks for
1690
+ * every Bash tool_use block before yielding its message, so e2e stubs
1691
+ * exercise the AB_ACTION recording path (including CCQA_STEP step
1692
+ * attribution). Hook decisions are ignored and post-tool hooks are not
1693
+ * simulated — the replay runs no tools, so there is nothing to block or fail.
1694
+ */
1695
+ async function fireMockPreToolUseHooks(msg, options) {
1696
+ const matchers = options.hooks?.PreToolUse;
1697
+ if (!matchers || msg.type !== "assistant") return;
1698
+ for (const block of msg.message.content ?? []) {
1699
+ if (block.type !== "tool_use" || block.name !== "Bash") continue;
1700
+ const input = {
1701
+ hook_event_name: "PreToolUse",
1702
+ tool_name: "Bash",
1703
+ tool_input: block.input,
1704
+ tool_use_id: block.id,
1705
+ session_id: "mock",
1706
+ transcript_path: "",
1707
+ cwd: process.cwd()
1708
+ };
1709
+ for (const matcher of matchers) for (const hook of matcher.hooks) await hook(input, block.id, { signal: new AbortController().signal });
1710
+ }
1711
+ }
1712
+ //#endregion
1713
+ //#region src/prompts/format.ts
1714
+ /**
1715
+ * Formatting helpers shared by the Claude prompt builders (diagnose, report,
1716
+ * drift). Centralised so the prompts cannot drift apart on mechanics that
1717
+ * must stay consistent across commands.
1718
+ */
1719
+ /** Prefix every line with its 1-based number, the form fix suggestions cite. */
1720
+ function numberLines(script) {
1721
+ return script.split("\n").map((l, i) => `${i + 1}: ${l}`).join("\n");
1722
+ }
1723
+ /**
1724
+ * The "## Output language" prompt section. Empty for "auto" so the prompt
1725
+ * stays byte-identical to the no-flag baseline. `fields` names the
1726
+ * human-readable JSON fields to translate; `verbatimNames` names the
1727
+ * enum-like values that must never be translated.
1728
+ */
1729
+ function outputLanguageBlock(outputLanguage, fields, verbatimNames) {
1730
+ if (outputLanguage === "auto") return "";
1731
+ return `## Output language
1732
+
1733
+ Write all human-readable fields (${fields}) in **${outputLanguage}** (BCP-47 tag).
1734
+ Selectors, file paths, identifiers, ${verbatimNames}, JSON keys, and quoted strings stay verbatim regardless of language.
1735
+
1736
+ `;
1737
+ }
1738
+ /**
1739
+ * The `spec`/`generated` surface-axis definitions, plus the "if both are
1740
+ * stale, answer spec" tie-break rule. Shared verbatim by the audit prompt
1741
+ * (`prompts/drift.ts`) and the run's failure-classification prompt
1742
+ * (`report/prompt.ts`): both fill the same wire field (`DriftSurfaceSchema`),
1743
+ * so a diverging definition in one would make the two paths disagree on what
1744
+ * a spec's own field means.
1745
+ */
1746
+ function surfaceDefinitionBlock() {
1747
+ return `- **\`spec\`** — spec.yaml asks about something the source no longer has. It has to be rewritten, and the code regenerated after.
1748
+ - **\`generated\`** — the spec still describes the product correctly, but the generated code reaches for a selector or string the source no longer has. Only a regeneration is needed; nobody has to rewrite the spec.
1749
+
1750
+ If both are stale, answer \`spec\`: it is the root, and fixing it regenerates the code.`;
1751
+ }
1752
+ /**
1753
+ * "surface is a separate axis from the label" clarifying example, shared for
1754
+ * the same reason as {@link surfaceDefinitionBlock}. `labelToken` is the
1755
+ * exact text to name the label by (e.g. `"TEST_DRIFT"` or `` "`TEST_DRIFT`" ``)
1756
+ * so each caller keeps its own backtick/bold convention.
1757
+ */
1758
+ function surfaceAxisAside(labelToken) {
1759
+ return `This is a separate axis from the label. A renamed selector that only the generated code names is ${labelToken} on the \`generated\` surface; a spec whose \`expected\` quotes a string the product renamed is ${labelToken} on the \`spec\` surface.`;
1760
+ }
1761
+ //#endregion
1762
+ //#region src/ir/to-agent-browser.ts
1763
+ const lit = (text) => ({
1764
+ text,
1765
+ expandsEnv: false
1766
+ });
1767
+ const val = (text) => ({
1768
+ text,
1769
+ expandsEnv: true
1770
+ });
1771
+ /**
1772
+ * Render a locator as the selector string a plain agent-browser command
1773
+ * accepts. Only `css` (verbatim) and `text` (`text=` engine form) have a
1774
+ * plain-selector form; other strategies are reachable via `find` only and
1775
+ * fall back to their raw value (callers guard against that case).
1776
+ */
1777
+ function locatorToSelector(locator) {
1778
+ return locator.by === "text" ? `text=${locator.value}` : locator.value;
1779
+ }
1780
+ /**
1781
+ * Compact human-readable locator form for logs and LLM-prompt summaries: the
1782
+ * raw selector for `css`, `by=value` otherwise. Distinct from
1783
+ * `locatorToSelector` (which produces a selector agent-browser can execute) —
1784
+ * this one is for display only and never round-trips.
1785
+ */
1786
+ function describeLocator(locator) {
1787
+ return locator.by === "css" ? locator.value : `${locator.by}=${locator.value}`;
1788
+ }
1789
+ /**
1790
+ * Canonical agent-browser argv (sans `--session`) for one action. Returns
1791
+ * null for actions with no direct argv form: observation-only `snapshot`,
1792
+ * `assert` (validation probes / abAssert* helpers live in the consumers),
1793
+ * and structurally incomplete actions (e.g. a missing locator).
1794
+ */
1795
+ function toAgentBrowserArgs(action) {
1796
+ switch (action.action) {
1797
+ case "cookies_clear": return [lit("cookies"), lit("clear")];
1798
+ case "navigate": return [lit("open"), val(action.value ?? "")];
1799
+ case "press": return [lit("press"), val(action.value ?? "")];
1800
+ case "scroll": return [
1801
+ lit("scroll"),
1802
+ lit(action.direction ?? "down"),
1803
+ ...action.pixels ? [lit(action.pixels)] : []
1804
+ ];
1805
+ case "select":
1806
+ if (!action.locator) return null;
1807
+ return [
1808
+ lit("select"),
1809
+ val(locatorToSelector(action.locator)),
1810
+ val(action.value ?? "")
1811
+ ];
1812
+ case "drag":
1813
+ if (!action.locator || !action.target) return null;
1814
+ return [
1815
+ lit("drag"),
1816
+ val(locatorToSelector(action.locator)),
1817
+ val(locatorToSelector(action.target))
1818
+ ];
1819
+ case "upload": {
1820
+ const files = action.files ?? [];
1821
+ if (!action.locator || files.length === 0) return null;
1822
+ return [
1823
+ lit("upload"),
1824
+ val(locatorToSelector(action.locator)),
1825
+ ...files.map(val)
1826
+ ];
1827
+ }
1828
+ case "wait": {
1829
+ const loc = action.locator;
1830
+ if (!loc) return null;
1831
+ if (loc.by === "text") return [
1832
+ lit("wait"),
1833
+ lit("--text"),
1834
+ val(loc.value)
1835
+ ];
1836
+ return [lit("wait"), val(locatorToSelector(loc))];
1837
+ }
1838
+ case "click":
1839
+ case "dblclick":
1840
+ case "check":
1841
+ case "uncheck":
1842
+ case "hover":
1843
+ case "focus":
1844
+ case "fill":
1845
+ case "type": return interactionToArgs(action);
1846
+ case "snapshot":
1847
+ case "assert": return null;
1848
+ }
1849
+ }
1850
+ /**
1851
+ * Element interactions come in two argv shapes: the plain command
1852
+ * (`click "<css>"`) when the locator is a raw selector string, and the
1853
+ * `find <locator> <value> <action> [input] [--name <n>] [--exact]` form for
1854
+ * semantic locators and positional (`index`) picks. `type` is a ccqa-side
1855
+ * alias of `fill` in both shapes. Flags MUST follow the action token —
1856
+ * putting them before it makes agent-browser fail with "Unknown subaction".
1857
+ */
1858
+ function interactionToArgs(action) {
1859
+ const loc = action.locator;
1860
+ if (!loc) return null;
1861
+ const abAction = action.action === "type" ? "fill" : action.action;
1862
+ const takesInput = action.action === "fill" || action.action === "type";
1863
+ if (!(loc.by !== "css" || action.index !== void 0 || action.action === "focus")) {
1864
+ const args = [lit(abAction), val(loc.value)];
1865
+ if (takesInput) args.push(val(action.value ?? ""));
1866
+ return args;
1867
+ }
1868
+ if (!loc.value) return null;
1869
+ const out = [lit("find")];
1870
+ if (action.index !== void 0) {
1871
+ if (loc.by !== "css") return null;
1872
+ if (action.index === "first" || action.index === "last") out.push(lit(action.index));
1873
+ else out.push(lit("nth"), lit(String(action.index)));
1874
+ out.push(val(loc.value));
1875
+ } else {
1876
+ if (loc.by === "css") return null;
1877
+ out.push(lit(loc.by), val(loc.value));
1878
+ }
1879
+ out.push(lit(abAction));
1880
+ if (takesInput) out.push(val(action.value ?? ""));
1881
+ if (loc.by === "role" && loc.name) out.push(lit("--name"), val(loc.name));
1882
+ if (loc.by !== "css" && loc.exact) out.push(lit("--exact"));
1883
+ return out;
1884
+ }
1885
+ //#endregion
1886
+ //#region src/diagnose/prompt.ts
1887
+ function buildDiagnosePrompt(input) {
1888
+ const { script, specYaml, actions, failureLog, pageSnapshot, outputLanguage = "auto" } = input;
1889
+ const numbered = numberLines(script);
1890
+ const actionsSummary = actions.map((a, i) => {
1891
+ const parts = [`${i + 1}. ${a.action}`];
1892
+ if (a.assert) parts.push(`assert="${a.assert}"`);
1893
+ if (a.locator) parts.push(`locator="${describeLocator(a.locator)}"`);
1894
+ if (a.index !== void 0) parts.push(`index=${a.index}`);
1895
+ if (a.value) parts.push(`value="${a.value}"`);
1896
+ if (a.observation) parts.push(`→ ${a.observation}`);
1897
+ return parts.join(" ");
1898
+ }).join("\n");
1899
+ return `You are diagnosing a failing E2E test. The test was generated from a recorded trace of the original interaction. Compare the failing run against the original spec and recorded actions to determine WHY the test failed and what the right fix is.
1900
+
1901
+ ${outputLanguageBlock(outputLanguage, "`reasoning`, `reason`", "code, type names (TIMING_ISSUE, etc.)")}## You have read-only filesystem tools
1902
+
1903
+ You can call \`Grep\`, \`Glob\`, and \`Read\` against the current repository before producing the JSON.
1904
+
1905
+ For SELECTOR_DRIFT specifically the failure log is usually NOT enough on its own — the runner only reports "selector X not visible". To confirm a rename, search the application source for the *type* of selector that's failing:
1906
+
1907
+ - For \`[aria-label='OLD']\` failures: \`Grep\` for \`aria-label=\` (or i18n key \`OLD\`) in the app source. If you find a near-miss like \`aria-label="NEW"\` whose text is a superset/rephrase of the failing label, that is your evidence.
1908
+ - For \`[placeholder='OLD']\` failures: \`Grep\` for \`placeholder=\`.
1909
+ - For \`[role='OLD']\` or \`[data-testid='OLD']\`: same pattern.
1910
+ - For \`text=OLD\` failures: \`Grep\` the source / i18n bundles for \`OLD\`. Locale files (\`*.json\`, \`*.yml\`, \`messages.ts\`, etc.) often hold the canonical strings.
1911
+
1912
+ You have **up to 10 tool turns**. Spend them on grep/read; do not loop. Only when you have concrete file:line evidence should you emit SELECTOR_DRIFT — otherwise prefer UNKNOWN with confidence < 0.4 and let the human decide.
1913
+
1914
+ Do NOT attempt to write, edit, run shell commands, or hit the network. Only Grep/Glob/Read.
1915
+
1916
+ ## Diagnosis categories
1917
+
1918
+ Pick exactly ONE category. The output JSON must follow the shape for that category.
1919
+
1920
+ 1. TIMING_ISSUE — element not yet present because the page hasn't loaded / navigated. Fix by inserting or extending sleeps.
1921
+ {
1922
+ "diagnosis": {
1923
+ "type": "TIMING_ISSUE",
1924
+ "fixes": [
1925
+ { "kind": "insert", "line": <1-based>, "seconds": <int>, "reason": "<short>" },
1926
+ { "kind": "increase", "line": <1-based of existing sleep>, "increase_to": <int>, "reason": "<short>" }
1927
+ ]
1928
+ },
1929
+ "confidence": <0.0-1.0>,
1930
+ "reasoning": "<why timing is the cause>"
1931
+ }
1932
+
1933
+ 2. OVER_ASSERTION — the test is asserting something the spec never required, OR a recorded assertion that is environment-dependent (e.g. a placeholder text that varies). The right fix is to remove those lines from the test.
1934
+ {
1935
+ "diagnosis": {
1936
+ "type": "OVER_ASSERTION",
1937
+ "lines": [<1-based line numbers to remove>],
1938
+ "reason": "<short>"
1939
+ },
1940
+ "confidence": <0.0-1.0>,
1941
+ "reasoning": "<why this assertion isn't required by the spec>"
1942
+ }
1943
+
1944
+ 3. SELECTOR_DRIFT — the page is healthy but a selector has been renamed/refined since the trace was recorded. The failure log will typically contain a snapshot showing the new selector. ONLY use this when you can name the exact replacement selector.
1945
+ {
1946
+ "diagnosis": {
1947
+ "type": "SELECTOR_DRIFT",
1948
+ "line": <1-based>,
1949
+ "oldSelector": "<exact string in current line>",
1950
+ "newSelector": "<exact replacement>",
1951
+ "reason": "<short>"
1952
+ },
1953
+ "confidence": <0.0-1.0>,
1954
+ "reasoning": "<evidence from failure log>"
1955
+ }
1956
+
1957
+ 4. DATA_MISSING — the test depends on data (a record, a setup, a logged-in state) that no longer exists. Not auto-fixable; the human must reseed or update the spec.
1958
+ {
1959
+ "diagnosis": { "type": "DATA_MISSING", "reason": "<what is missing>" },
1960
+ "confidence": <0.0-1.0>,
1961
+ "reasoning": "<evidence>"
1962
+ }
1963
+
1964
+ 5. UNKNOWN — none of the above fit, or evidence is too weak to choose.
1965
+ {
1966
+ "diagnosis": { "type": "UNKNOWN", "reason": "<short>" },
1967
+ "confidence": <0.0-1.0>,
1968
+ "reasoning": "<what you saw and why you can't classify>"
1969
+ }
1970
+
1971
+ ## Confidence guidance
1972
+
1973
+ - 0.9-1.0: failure log directly shows the cause (e.g. "selector X not found, snapshot lists Y" → SELECTOR_DRIFT)
1974
+ - 0.7-0.9: strong indirect evidence (e.g. timing pattern after navigation, or assertion text that doesn't appear in spec)
1975
+ - 0.4-0.7: plausible classification but multiple categories could explain it
1976
+ - < 0.4: prefer UNKNOWN over guessing
1977
+
1978
+ ## Rules
1979
+
1980
+ - Your **final** assistant message must start with \`{\` and end with \`}\` — a single JSON object, nothing before or after. No prose preamble like "Confirmed: ...", no markdown fences, no commentary, no tool calls in the same turn. If you have an analysis sentence, put it in the \`reasoning\` field.
1981
+ - Line numbers refer to the numbered test script below (1-based).
1982
+ - For SELECTOR_DRIFT, \`oldSelector\` must match a substring of the script at that line; \`newSelector\` must be backed by a concrete file:line you read with Grep/Read (do not invent). Cite the evidence in \`reasoning\`.
1983
+ - For OVER_ASSERTION, only include lines that contain assert calls (\`abAssert*\`) or existence-checking waits (\`abWait\`); a recorded \`abWait("[selector]")\` is an implicit existence assertion and a valid removal candidate when the spec never required that element to be present.
1984
+ - Cross-check assertions against the spec YAML. If the spec doesn't require the assertion, OVER_ASSERTION is the better diagnosis than SELECTOR_DRIFT.
1985
+
1986
+ ## Test Spec (spec.yaml)
1987
+ ${specYaml}
1988
+
1989
+ ## Recorded Actions (ir.json summary)
1990
+ ${actionsSummary}
1991
+
1992
+ ## Test Script (with line numbers)
1993
+ ${numbered}
1994
+
1995
+ ## Failure Log
1996
+ ${failureLog.slice(0, 4e3)}${pageSnapshot ? formatPageSnapshot(pageSnapshot) : ""}`;
1997
+ }
1998
+ /**
1999
+ * Page snapshot captured by ccqa right after the failure (agent-browser
2000
+ * accessibility tree). When present, it usually decides SELECTOR_DRIFT vs
2001
+ * TIMING_ISSUE: a near-miss aria-label / role / placeholder in the
2002
+ * snapshot is direct evidence of a rename, while a tree that doesn't
2003
+ * contain the failing locator at all (without a near-miss) points to a
2004
+ * still-loading page or genuinely missing element.
2005
+ */
2006
+ function formatPageSnapshot(snapshot) {
2007
+ return `
2008
+
2009
+ ## Page Snapshot (accessibility tree captured right after the failure)
2010
+
2011
+ This is the live state of the page when the test failed. Prefer this over your own assumptions:
2012
+
2013
+ - If a near-miss of the failing selector appears here (e.g. failing \`[aria-label='A']\` and snapshot contains \`aria-label="A-prime"\`), that is direct evidence of SELECTOR_DRIFT — propose the snapshot's value as \`newSelector\`.
2014
+ - If the failing locator is genuinely absent and no near-miss exists, the page may be still loading (TIMING_ISSUE) or the spec is asserting something not on this page (OVER_ASSERTION / DATA_MISSING).
2015
+ - If the snapshot looks unrelated to the spec (e.g. error page, login wall), DATA_MISSING is likely.
2016
+
2017
+ \`\`\`
2018
+ ${snapshot}
2019
+ \`\`\``;
2020
+ }
2021
+ //#endregion
2022
+ //#region src/diagnose/diagnose.ts
2023
+ async function diagnose(input, options = {}) {
2024
+ const { result: raw, isError } = await invokeClaudeStreaming({
2025
+ prompt: buildDiagnosePrompt(input),
2026
+ allowedTools: [
2027
+ "Read",
2028
+ "Grep",
2029
+ "Glob"
2030
+ ],
2031
+ maxTurns: 20,
2032
+ model: options.model
2033
+ }, () => {});
2034
+ if (isError) return {
2035
+ result: null,
2036
+ raw: raw ?? "",
2037
+ sdkError: true
2038
+ };
2039
+ if (!raw) return {
2040
+ result: null,
2041
+ raw: "",
2042
+ sdkError: false
2043
+ };
2044
+ const candidates = extractJsonCandidates(raw);
2045
+ for (const candidate of candidates) {
2046
+ let parsed;
2047
+ try {
2048
+ parsed = JSON.parse(candidate);
2049
+ } catch {
2050
+ continue;
2051
+ }
2052
+ const normalised = normaliseResult(parsed);
2053
+ if (normalised) return {
2054
+ result: normalised,
2055
+ raw,
2056
+ sdkError: false
2057
+ };
2058
+ }
2059
+ return {
2060
+ result: {
2061
+ diagnosis: {
2062
+ type: "UNKNOWN",
2063
+ reason: "diagnose returned no parseable diagnosis JSON"
2064
+ },
2065
+ confidence: 0,
2066
+ reasoning: truncate(raw, 1e3)
2067
+ },
2068
+ raw,
2069
+ sdkError: false
2070
+ };
2071
+ }
2072
+ /**
2073
+ * Pull every plausible JSON object out of `raw`. We try, in order:
2074
+ * 1. The whole string with code fences stripped (the prompt asks for
2075
+ * JSON-only, so this is the happy path).
2076
+ * 2. Each balanced `{...}` block found by scanning the text. The model
2077
+ * sometimes prefixes the JSON with a "Confirmed: ..." sentence or
2078
+ * mentions partial JSON in its tool-using reasoning; we want to
2079
+ * try the *last* well-formed object first because it's most likely
2080
+ * the final answer, then earlier ones as a fallback.
2081
+ *
2082
+ * The caller `JSON.parse`s each candidate and stops at the first match
2083
+ * that normalises to a known DiagnosisResult.
2084
+ */
2085
+ function extractJsonCandidates(raw) {
2086
+ const out = [];
2087
+ const stripped = stripFence(raw);
2088
+ if (stripped) out.push(stripped);
2089
+ const blocks = [];
2090
+ let depth = 0;
2091
+ let start = -1;
2092
+ let inString = false;
2093
+ let escaped = false;
2094
+ for (let i = 0; i < raw.length; i++) {
2095
+ const ch = raw[i];
2096
+ if (inString) {
2097
+ if (escaped) escaped = false;
2098
+ else if (ch === "\\") escaped = true;
2099
+ else if (ch === "\"") inString = false;
2100
+ continue;
2101
+ }
2102
+ if (ch === "\"") {
2103
+ inString = true;
2104
+ continue;
2105
+ }
2106
+ if (ch === "{") {
2107
+ if (depth === 0) start = i;
2108
+ depth++;
2109
+ } else if (ch === "}") {
2110
+ depth--;
2111
+ if (depth === 0 && start >= 0) {
2112
+ blocks.push(raw.slice(start, i + 1));
2113
+ start = -1;
2114
+ }
2115
+ }
2116
+ }
2117
+ for (let i = blocks.length - 1; i >= 0; i--) {
2118
+ const block = blocks[i];
2119
+ if (!out.includes(block)) out.push(block);
2120
+ }
2121
+ return out;
2122
+ }
2123
+ function truncate(s, max) {
2124
+ return s.length <= max ? s : `${s.slice(0, max)}... [truncated, ${s.length - max} more chars]`;
2125
+ }
2126
+ function stripFence(raw) {
2127
+ return raw.trim().replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "").trim();
2128
+ }
2129
+ function normaliseResult(parsed) {
2130
+ if (!isObject(parsed)) return null;
2131
+ const diagnosis = normaliseDiagnosis(parsed["diagnosis"]);
2132
+ if (!diagnosis) return null;
2133
+ return {
2134
+ diagnosis,
2135
+ confidence: typeof parsed["confidence"] === "number" ? clamp(parsed["confidence"], 0, 1) : 0,
2136
+ reasoning: typeof parsed["reasoning"] === "string" ? parsed["reasoning"] : ""
2137
+ };
2138
+ }
2139
+ function normaliseDiagnosis(raw) {
2140
+ if (!isObject(raw)) return null;
2141
+ switch (raw["type"]) {
2142
+ case "TIMING_ISSUE": {
2143
+ const fixes = normaliseSleepFixes(raw["fixes"]);
2144
+ if (fixes.length === 0) return null;
2145
+ return {
2146
+ type: "TIMING_ISSUE",
2147
+ fixes
2148
+ };
2149
+ }
2150
+ case "OVER_ASSERTION": {
2151
+ const lines = Array.isArray(raw["lines"]) ? raw["lines"].filter((n) => typeof n === "number" && Number.isFinite(n)) : [];
2152
+ if (lines.length === 0) return null;
2153
+ return {
2154
+ type: "OVER_ASSERTION",
2155
+ lines,
2156
+ reason: typeof raw["reason"] === "string" ? raw["reason"] : ""
2157
+ };
2158
+ }
2159
+ case "SELECTOR_DRIFT": {
2160
+ const line = typeof raw["line"] === "number" ? raw["line"] : null;
2161
+ const oldSelector = typeof raw["oldSelector"] === "string" ? raw["oldSelector"] : null;
2162
+ const newSelector = typeof raw["newSelector"] === "string" ? raw["newSelector"] : null;
2163
+ if (line === null || !oldSelector || !newSelector) return null;
2164
+ return {
2165
+ type: "SELECTOR_DRIFT",
2166
+ line,
2167
+ oldSelector,
2168
+ newSelector,
2169
+ reason: typeof raw["reason"] === "string" ? raw["reason"] : ""
2170
+ };
2171
+ }
2172
+ case "DATA_MISSING": return {
2173
+ type: "DATA_MISSING",
2174
+ reason: typeof raw["reason"] === "string" ? raw["reason"] : ""
2175
+ };
2176
+ case "UNKNOWN": return {
2177
+ type: "UNKNOWN",
2178
+ reason: typeof raw["reason"] === "string" ? raw["reason"] : ""
2179
+ };
2180
+ default: return null;
2181
+ }
2182
+ }
2183
+ function normaliseSleepFixes(raw) {
2184
+ if (!Array.isArray(raw)) return [];
2185
+ const out = [];
2186
+ for (const item of raw) {
2187
+ if (!isObject(item)) continue;
2188
+ const line = typeof item["line"] === "number" ? item["line"] : null;
2189
+ if (line === null) continue;
2190
+ const reason = typeof item["reason"] === "string" ? item["reason"] : "";
2191
+ if (item["kind"] === "insert") {
2192
+ const seconds = typeof item["seconds"] === "number" ? item["seconds"] : null;
2193
+ if (seconds === null) continue;
2194
+ out.push({
2195
+ kind: "insert",
2196
+ line,
2197
+ seconds,
2198
+ reason
2199
+ });
2200
+ } else if (item["kind"] === "increase") {
2201
+ const increaseTo = typeof item["increase_to"] === "number" ? item["increase_to"] : null;
2202
+ if (increaseTo === null) continue;
2203
+ out.push({
2204
+ kind: "increase",
2205
+ line,
2206
+ increase_to: increaseTo,
2207
+ reason
2208
+ });
2209
+ }
2210
+ }
2211
+ return out;
2212
+ }
2213
+ function isObject(v) {
2214
+ return typeof v === "object" && v !== null && !Array.isArray(v);
2215
+ }
2216
+ function clamp(n, lo, hi) {
2217
+ if (n < lo) return lo;
2218
+ if (n > hi) return hi;
2219
+ return n;
2220
+ }
2221
+ //#endregion
2222
+ export { bracedRefsToJsExpression as $, progressEnd as A, isExpandedActionStep as B, error as C, info as D, hint as E, withBuffer as F, tryParseTestSpec as G, isJudgeBody as H, CREDENTIAL_ENV_KEYS as I, SessionNameSchema as J, AGENT_BROWSER_TARGET as K, collectIncludedBlockNames as L, step as M, timedPhase as N, meta as O, warn as P, isParamRequired as Q, expandActionSteps as R, emitRaw as S, header as T, parseBlockSpec as U, isExpandedJudgeByLlmStep as V, parseTestSpec as W, TargetIdSchema as X, SpecModeSchema as Y, isIncludeStep as Z, buildSpecEnvScrub as _, truncate as a, promoteMarkedAssert as b, toAgentBrowserArgs as c, surfaceAxisAside as d, envRefsToJsExpression as et, surfaceDefinitionBlock as f, buildProseEnvScrubMap as g, withCostTally as h, isObject as i, run as j, progress as k, numberLines as l, readCostTally as m, diagnose as n, resolveEnvRefs as nt, describeLocator as o, invokeClaudeStreaming as p, DEFAULT_SPEC_MODE as q, extractJsonCandidates as r, locatorToSelector as s, clamp as t, iterEnvRefNames as tt, outputLanguageBlock as u, scrubEnvValues as v, fix as w, blank as x, parseAbActionLine as y, expandSpec as z };