ccqa 1.10.1 → 1.11.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,
@@ -15059,12 +15115,36 @@ const generateCommand = addHubOptions(addProfileOption(addLanguageOption(new Com
15059
15115
  });
15060
15116
  }));
15061
15117
  //#endregion
15118
+ //#region src/cli/cost-line.ts
15119
+ /**
15120
+ * Write what this command spent on Claude to stderr.
15121
+ *
15122
+ * stderr rather than stdout because `drift --format json` and `select-specs`
15123
+ * put machine-readable output on stdout, and a cost line mixed into it breaks
15124
+ * the consumer. Cost is diagnostics about the command, not its output.
15125
+ */
15126
+ function reportCost() {
15127
+ const cost = readCostTally();
15128
+ if (cost === null) return;
15129
+ const summary = formatLiveCost(cost, { compact: false });
15130
+ if (summary) process.stderr.write(`[cost] ${summary}\n`);
15131
+ }
15132
+ //#endregion
15062
15133
  //#region src/cli/record.ts
15063
15134
  const VALIDATION_MODES = ["lenient", "strict"];
15064
15135
  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
15136
  if (VALIDATION_MODES.includes(raw)) return raw;
15066
15137
  throw new Error(`--validation-mode must be one of ${VALIDATION_MODES.join(" | ")}`);
15067
15138
  }, "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) => {
15139
+ await withCostTally(async () => {
15140
+ try {
15141
+ await runRecord(specPath, opts);
15142
+ } finally {
15143
+ reportCost();
15144
+ }
15145
+ });
15146
+ }));
15147
+ async function runRecord(specPath, opts) {
15068
15148
  const { featureName, specName } = parseSpecPath(specPath);
15069
15149
  const language = opts.language ?? "auto";
15070
15150
  if (opts.skipTrace && opts.skipCodegen) {
@@ -15151,7 +15231,7 @@ const recordCommand = addHubOptions(addProfileOption(addLanguageOption(new Comma
15151
15231
  ...language ? { language } : {},
15152
15232
  ...opts.model ? { model: opts.model } : {}
15153
15233
  });
15154
- }));
15234
+ }
15155
15235
  /**
15156
15236
  * Compact summary of the trace pass for the record agent-prompt refresh.
15157
15237
  * Steps are reconstructed from the trace's status-line protocol (STEP_START
@@ -15467,6 +15547,9 @@ function driftResultsToReport(results, meta) {
15467
15547
  //#region src/cli/drift.ts
15468
15548
  const DEFAULT_CONCURRENCY = 3;
15469
15549
  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) => {
15550
+ await withCostTally(() => runDrift(specPath, opts));
15551
+ }));
15552
+ async function runDrift(specPath, opts) {
15470
15553
  const format = parseFormat$1(opts.format);
15471
15554
  const threshold = parseSeverity(opts.severity);
15472
15555
  const concurrency = parseConcurrency(opts.concurrency);
@@ -15518,8 +15601,9 @@ const driftCommand = addLanguageOption(new Command("drift").argument("[feature/s
15518
15601
  format,
15519
15602
  baseRef
15520
15603
  });
15604
+ reportCost();
15521
15605
  process.exit(determineExitCode(results, threshold));
15522
- }));
15606
+ }
15523
15607
  /**
15524
15608
  * Push a finished drift audit to a ccqa hub as a `kind: "drift"` run, so it
15525
15609
  * shows up alongside `ccqa run` runs in the hub UI. Best-effort: a missing
@@ -15574,6 +15658,7 @@ async function pushDriftResults(args, resolveHub = resolveHubClient) {
15574
15658
  }
15575
15659
  }
15576
15660
  function exitWithNoSpecs(format, message) {
15661
+ reportCost();
15577
15662
  if (format === "json") process.stdout.write(`${JSON.stringify({ specs: [] }, null, 2)}\n`);
15578
15663
  else if (format === "text") info(message);
15579
15664
  process.exit(0);
@@ -15639,6 +15724,9 @@ const initCommand = new Command("init").description("Create the .ccqa/ spec skel
15639
15724
  //#region src/cli/select-specs.ts
15640
15725
  const DEFAULT_HEAD = "HEAD";
15641
15726
  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) => {
15727
+ await withCostTally(() => runSelectSpecs(opts));
15728
+ });
15729
+ async function runSelectSpecs(opts) {
15642
15730
  const format = parseFormat(opts.format);
15643
15731
  const cwd = resolveCwd(opts.cwd);
15644
15732
  const head = opts.head ?? DEFAULT_HEAD;
@@ -15684,8 +15772,9 @@ const selectSpecsCommand = new Command("select-specs").description("Decide which
15684
15772
  ...opts.model ? { model: opts.model } : {}
15685
15773
  });
15686
15774
  process.stdout.write(format === "json" ? `${JSON.stringify(report, null, 2)}\n` : renderText(report));
15775
+ reportCost();
15687
15776
  process.exit(0);
15688
- });
15777
+ }
15689
15778
  const VERDICT_ORDER = [
15690
15779
  "needed",
15691
15780
  "unknown",
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.10.1",
3
+ "version": "1.11.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.11.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {