ccqa 1.10.1 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/ccqa.mjs CHANGED
@@ -1327,6 +1327,61 @@ function missingNativeBinaryMessage(pkg) {
1327
1327
  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.`;
1328
1328
  }
1329
1329
  //#endregion
1330
+ //#region src/claude/cost-tally.ts
1331
+ /**
1332
+ * Sum every Claude invocation made inside a scope.
1333
+ *
1334
+ * A command like `record` calls Claude several times — the browser trace, the
1335
+ * codegen cleanup, one diagnosis per auto-fix retry — and the caller wants one
1336
+ * number for the whole command. Threading a cost out of each of those return
1337
+ * types would touch every layer in between, so the tally is scoped instead:
1338
+ * `invokeClaudeStreaming` adds to whichever scope is active, and nothing
1339
+ * between the two has to know.
1340
+ *
1341
+ * Scoped rather than module-global because commands run specs concurrently
1342
+ * (`drift` uses a pool). Two scopes must not fold into each other.
1343
+ */
1344
+ const tallyStore = new AsyncLocalStorage();
1345
+ /** Record one invocation against the active scope. No-op outside one. */
1346
+ function tallyInvocation(cost) {
1347
+ tallyStore.getStore()?.push(cost);
1348
+ }
1349
+ /** Run `fn` with a fresh tally. Read the total from inside with `readCostTally`. */
1350
+ async function withCostTally(fn) {
1351
+ return tallyStore.run([], fn);
1352
+ }
1353
+ /**
1354
+ * The active scope's total so far, or null outside one.
1355
+ *
1356
+ * Read rather than pushed at the caller because commands end in
1357
+ * `process.exit`, which never reaches a `finally`. Each one reports the
1358
+ * total itself, at the point it knows it is done.
1359
+ *
1360
+ * Fields stay `null` when no invocation reported them, so a caller can tell
1361
+ * "nothing was billed" from "the SDK didn't say" (mock runs, SDK errors).
1362
+ */
1363
+ function readCostTally() {
1364
+ const collected = tallyStore.getStore();
1365
+ return collected === void 0 ? null : sum(collected);
1366
+ }
1367
+ function sum(costs) {
1368
+ const add = (pick) => {
1369
+ const present = costs.map(pick).filter((v) => v !== null);
1370
+ return present.length === 0 ? null : present.reduce((a, b) => a + b, 0);
1371
+ };
1372
+ return {
1373
+ totalCostUsd: add((c) => c.totalCostUsd),
1374
+ durationMs: add((c) => c.durationMs),
1375
+ durationApiMs: add((c) => c.durationApiMs),
1376
+ numTurns: add((c) => c.numTurns),
1377
+ inputTokens: add((c) => c.inputTokens),
1378
+ cacheCreationInputTokens: add((c) => c.cacheCreationInputTokens),
1379
+ cacheReadInputTokens: add((c) => c.cacheReadInputTokens),
1380
+ outputTokens: add((c) => c.outputTokens),
1381
+ models: [...new Set(costs.flatMap((c) => c.models))]
1382
+ };
1383
+ }
1384
+ //#endregion
1330
1385
  //#region src/claude/invoke.ts
1331
1386
  function resolveModel(explicit) {
1332
1387
  if (explicit) return explicit;
@@ -1498,6 +1553,7 @@ async function invokeClaudeStreaming(options, onEvent) {
1498
1553
  errorDetail = err instanceof Error ? err.message : String(err);
1499
1554
  if (!result) result = errorDetail;
1500
1555
  }
1556
+ tallyInvocation(cost);
1501
1557
  return {
1502
1558
  result,
1503
1559
  isError,
@@ -13127,8 +13183,8 @@ For each step:
13127
13183
  3. Execute the action using an ALLOWED selector (see Selector Rules), prefixing the command with \`CCQA_STEP=<step-id>\` like every agent-browser command in the step.
13128
13184
  4. Emit \`AB_ACTION|...\` for every browser action (see AB_ACTION Protocol).
13129
13185
  5. Run \`snapshot\` again to verify the outcome.
13130
- 6. Confirm at least **two independent signals** (URL change, element appearance, text change, ...).
13131
- 7. Record each verified signal as an assertion by putting a \`CCQA_ASSERT=<marker>\` prefix on the verification command itself (see Assertion Protocol). Only the assert types markers cannot express fall back to \`AB_ACTION|assert|...\` text lines.
13186
+ 6. Confirm at least **two independent signals** (URL change, element appearance, text change, ...). This is how *you* decide the step worked and it is safe to continue. It is not what gets recorded.
13187
+ 7. Record as assertions only the signals the step's own \`expected\` asks about, by putting a \`CCQA_ASSERT=<marker>\` prefix on the verification command itself (see Assertion Protocol). Only the assert types markers cannot express fall back to \`AB_ACTION|assert|...\` text lines.
13132
13188
  8. Emit \`STEP_DONE\`, \`ASSERTION_FAILED\`, or \`STEP_SKIPPED\`.
13133
13189
 
13134
13190
  **Protocol lines are recorded ONLY from your plain assistant text.** Every
@@ -13259,7 +13315,9 @@ CCQA_STEP=<step-id> CCQA_ASSERT=url_contains:/dashboard agent-browser --session
13259
13315
  - A marked command that exits non-zero records nothing — fix the check and re-run it.
13260
13316
  - A marker that doesn't match its command (e.g. \`CCQA_ASSERT=1\` on a \`click\`) is ignored with a warning — never do that.
13261
13317
  - \`get count\` and \`get url\` exit 0 regardless of what they print. **Read the output**: if the printed count / URL contradicts the marker, the signal did NOT verify — treat it as a failed verification (emit \`ASSERTION_FAILED\` if the step cannot be confirmed another way).
13262
- - **\`url_contains\` is opt-in, not a habit.** Emit it ONLY when a step's own \`expected\` explicitly asks about the URL or path. Do NOT add a \`url_contains\` to "prove" a login succeeded, a page loaded, or a navigation happened confirm those with \`text_visible\` / \`element_visible\` on something the destination page renders. An unrequested URL assertion adds no coverage the visible-content assert doesn't already give, and is the single most common way an environment gets baked into a test.
13318
+ - **Assert what the step asks about, nothing else.** The \`expected\` is the contract; anything else you happened to see on the way is not. A nav item, a heading or a greeting that the step never mentions adds no coverage, differs between recordings of the same spec, and is the first thing to break on replay so the next recording quietly drops it and the test gets weaker without anyone deciding that.
13319
+
13320
+ - **\`url_contains\` is opt-in, not a habit.** The same rule, in the form that gets broken most. Emit it ONLY when a step's own \`expected\` explicitly asks about the URL or path. Do NOT add a \`url_contains\` to "prove" a login succeeded, a page loaded, or a navigation happened — confirm those with \`text_visible\` / \`element_visible\` on something the destination page renders. An unrequested URL assertion adds no coverage the visible-content assert doesn't already give, and is the single most common way an environment gets baked into a test.
13263
13321
  - **When you do assert a URL, the substring may come from ONE place only:** a \`\${VAR}\` URL that *this step's own instruction* opened, written as that \`\${VAR}\` followed by the literal tail after it. If the step opens \`\${APP_URL}/policies\`, assert \`\${APP_URL}/policies\` (or the tail \`/policies\`); the recorder resolves \`\${APP_URL}\` per environment. Never assert on a URL you merely *observed* — login redirects, identity-provider pages, and OAuth callbacks all live on a **different, environment-named origin** than the app, so any substring of them (host, origin, OR path) names the environment.
13264
13322
  - **A leading slash does NOT make a substring safe.** \`/auth-staging\`, \`/env-qa\`, \`/tenant-acme\` look like paths but are environment labels — the first segment of an identity-provider or tenant URL, not an application route. If a substring contains an environment name, a stage token (\`dev\`, \`stg\`, \`prod\`), a tenant/org name, or any fragment of a hostname, it is forbidden even with a leading slash. The only safe path substrings are stable *application* routes off the app's own origin (\`/dashboard\`, \`/policies/new\`), taken from a \`\${VAR}\` you opened — not from a redirect you watched.
13265
13323
 
@@ -15059,12 +15117,36 @@ const generateCommand = addHubOptions(addProfileOption(addLanguageOption(new Com
15059
15117
  });
15060
15118
  }));
15061
15119
  //#endregion
15120
+ //#region src/cli/cost-line.ts
15121
+ /**
15122
+ * Write what this command spent on Claude to stderr.
15123
+ *
15124
+ * stderr rather than stdout because `drift --format json` and `select-specs`
15125
+ * put machine-readable output on stdout, and a cost line mixed into it breaks
15126
+ * the consumer. Cost is diagnostics about the command, not its output.
15127
+ */
15128
+ function reportCost() {
15129
+ const cost = readCostTally();
15130
+ if (cost === null) return;
15131
+ const summary = formatLiveCost(cost, { compact: false });
15132
+ if (summary) process.stderr.write(`[cost] ${summary}\n`);
15133
+ }
15134
+ //#endregion
15062
15135
  //#region src/cli/record.ts
15063
15136
  const VALIDATION_MODES = ["lenient", "strict"];
15064
15137
  const recordCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("record").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Record a test from a spec: run agent-browser to collect actions (trace), then compile them into runnable code via the spec's target (generate) — a vitest test.spec.ts for agent-browser, a @playwright/test spec for the playwright target. Recording-backed targets only; spec-input targets like runn have no trace step (use `ccqa generate`), and agent-browser live specs need no recording.").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--validation-mode <mode>", "Post-trace validation behaviour: 'lenient' (default) tags failing actions; 'strict' drops them.", (raw) => {
15065
15138
  if (VALIDATION_MODES.includes(raw)) return raw;
15066
15139
  throw new Error(`--validation-mode must be one of ${VALIDATION_MODES.join(" | ")}`);
15067
15140
  }, "lenient").option("--auto-fix <mode>", "Auto-fix behaviour during script generation: 'interactive' (default, prompt y/N; declines on non-TTY), 'auto' (apply without prompt, for CI), 'skip' (agent-browser: apply only high-confidence fixes; external targets like playwright/runn: no fix pass at all).", parseAutoFixFlag, "interactive").option("--max-retries <n>", "Maximum number of auto-fix retries", "3").option("--force", "Overwrite an existing test.spec.ts without warning").option("--no-snapshot", "Don't pin AGENT_BROWSER_SESSION / capture page snapshots after a failure (debug toggle)").option("--skip-trace", "Skip the trace step and run codegen against an existing ir.json").option("--skip-codegen", "Run only the trace step (do not generate test.spec.ts)").option("--update-agent-prompt", "After the trace finishes, ask Claude to refresh the \"record.agent\" prompt on the hub from a summary of the run. Requires a hub connection.").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(withUsageErrors(async (specPath, opts) => {
15141
+ await withCostTally(async () => {
15142
+ try {
15143
+ await runRecord(specPath, opts);
15144
+ } finally {
15145
+ reportCost();
15146
+ }
15147
+ });
15148
+ }));
15149
+ async function runRecord(specPath, opts) {
15068
15150
  const { featureName, specName } = parseSpecPath(specPath);
15069
15151
  const language = opts.language ?? "auto";
15070
15152
  if (opts.skipTrace && opts.skipCodegen) {
@@ -15151,7 +15233,7 @@ const recordCommand = addHubOptions(addProfileOption(addLanguageOption(new Comma
15151
15233
  ...language ? { language } : {},
15152
15234
  ...opts.model ? { model: opts.model } : {}
15153
15235
  });
15154
- }));
15236
+ }
15155
15237
  /**
15156
15238
  * Compact summary of the trace pass for the record agent-prompt refresh.
15157
15239
  * Steps are reconstructed from the trace's status-line protocol (STEP_START
@@ -15467,6 +15549,9 @@ function driftResultsToReport(results, meta) {
15467
15549
  //#region src/cli/drift.ts
15468
15550
  const DEFAULT_CONCURRENCY = 3;
15469
15551
  const driftCommand = addLanguageOption(new Command("drift").argument("[feature/spec]", "Optional spec id. If omitted, every spec under .ccqa/features/ is checked.").description("Standalone spec ↔ codebase static audit. Use for PR checks where the browser isn't run. For run-time audit with a structured report, see `ccqa run --report`.").option("--format <fmt>", "Output format: text | json | github", "text").option("--severity <level>", "Exit non-zero on this severity or higher: warn | error", "error").option("--concurrency <n>", `Parallel spec checks (default: ${DEFAULT_CONCURRENCY})`).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--cwd <path>", "Working directory used as both the .ccqa root and the codebase Claude reads. Useful for monorepos. Defaults to process.cwd().").option("--changed", "Restrict drift checks to the specs a change reaches, decided by `ccqa select-specs` against --base (or, in CI, $GITHUB_BASE_REF). Costs one model call; specs it cannot decide are checked rather than skipped.").option("--base <ref>", "Base ref to diff against when --changed is set. Defaults to $GITHUB_BASE_REF (CI pull_request runs); required otherwise.").option("--push", "Push the drift result to a ccqa hub as a run (kind: drift).").option("--project <name>", "Logical project name for the pushed run. Defaults to the current directory's name.").option(...hubUrlOption).option(...hubTokenOption).option(...hubHeaderOption)).action(withUsageErrors(async (specPath, opts) => {
15552
+ await withCostTally(() => runDrift(specPath, opts));
15553
+ }));
15554
+ async function runDrift(specPath, opts) {
15470
15555
  const format = parseFormat$1(opts.format);
15471
15556
  const threshold = parseSeverity(opts.severity);
15472
15557
  const concurrency = parseConcurrency(opts.concurrency);
@@ -15518,8 +15603,9 @@ const driftCommand = addLanguageOption(new Command("drift").argument("[feature/s
15518
15603
  format,
15519
15604
  baseRef
15520
15605
  });
15606
+ reportCost();
15521
15607
  process.exit(determineExitCode(results, threshold));
15522
- }));
15608
+ }
15523
15609
  /**
15524
15610
  * Push a finished drift audit to a ccqa hub as a `kind: "drift"` run, so it
15525
15611
  * shows up alongside `ccqa run` runs in the hub UI. Best-effort: a missing
@@ -15574,6 +15660,7 @@ async function pushDriftResults(args, resolveHub = resolveHubClient) {
15574
15660
  }
15575
15661
  }
15576
15662
  function exitWithNoSpecs(format, message) {
15663
+ reportCost();
15577
15664
  if (format === "json") process.stdout.write(`${JSON.stringify({ specs: [] }, null, 2)}\n`);
15578
15665
  else if (format === "text") info(message);
15579
15666
  process.exit(0);
@@ -15639,6 +15726,9 @@ const initCommand = new Command("init").description("Create the .ccqa/ spec skel
15639
15726
  //#region src/cli/select-specs.ts
15640
15727
  const DEFAULT_HEAD = "HEAD";
15641
15728
  const selectSpecsCommand = new Command("select-specs").description("Decide which specs a range of commits reaches. Reads the diff and the spec inventory and returns one verdict per spec: needed | notNeeded | unknown.").requiredOption("--base <ref>", "Commit the range starts at — typically what is currently deployed, or the previous commit on the branch.").option("--head <ref>", `Commit the range ends at (default: ${DEFAULT_HEAD})`).option("--cwd <path>", "Working directory used as both the .ccqa root and the codebase Claude reads. Changes outside it are reported but never attributed to a spec. Defaults to process.cwd().").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--format <fmt>", "Output format: text | json", "text").action(async (opts) => {
15729
+ await withCostTally(() => runSelectSpecs(opts));
15730
+ });
15731
+ async function runSelectSpecs(opts) {
15642
15732
  const format = parseFormat(opts.format);
15643
15733
  const cwd = resolveCwd(opts.cwd);
15644
15734
  const head = opts.head ?? DEFAULT_HEAD;
@@ -15684,8 +15774,9 @@ const selectSpecsCommand = new Command("select-specs").description("Decide which
15684
15774
  ...opts.model ? { model: opts.model } : {}
15685
15775
  });
15686
15776
  process.stdout.write(format === "json" ? `${JSON.stringify(report, null, 2)}\n` : renderText(report));
15777
+ reportCost();
15687
15778
  process.exit(0);
15688
- });
15779
+ }
15689
15780
  const VERDICT_ORDER = [
15690
15781
  "needed",
15691
15782
  "unknown",
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.10.1",
3
+ "version": "1.12.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.10.1",
3
+ "version": "1.12.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {