ccqa 1.10.0 → 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
@@ -39,6 +39,26 @@ const EVIDENCE_SUBDIR = "evidence";
39
39
  /** Per-spec run artifacts for external (runCommand) targets: `artifacts/<feature>__<spec>/`. */
40
40
  const ARTIFACTS_SUBDIR = "artifacts";
41
41
  //#endregion
42
+ //#region src/run/errors.ts
43
+ /**
44
+ * Usage error (bad flag combination, broken profile, failed `git diff`, …)
45
+ * thrown by the `run` pipeline and the helpers it calls, e.g.
46
+ * `collectChangedSpecs`. `executeRun` never calls `process.exit`, so each
47
+ * host maps this itself: the CLI action catches it and exits with
48
+ * `exitCode`; the hub runner records it as a run-level error.
49
+ */
50
+ var RunUsageError = class extends Error {
51
+ exitCode = 2;
52
+ constructor(message) {
53
+ super(message);
54
+ this.name = "RunUsageError";
55
+ }
56
+ };
57
+ /** An error's message, whatever was thrown. Shared so the run modules report failures alike. */
58
+ function errMessage(err) {
59
+ return err instanceof Error ? err.message : String(err);
60
+ }
61
+ //#endregion
42
62
  //#region src/runtime/env-vars.ts
43
63
  const ENV_VAR_RE = /\$\{([A-Z_][A-Z0-9_]*)\}|\$([A-Z_][A-Z0-9_]*)/g;
44
64
  const ANY_VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
@@ -399,7 +419,7 @@ function parseSpecPath(specPath) {
399
419
  featureName: parts[0],
400
420
  specName: parts[1]
401
421
  };
402
- throw new Error(`Invalid spec path: "${specPath}". Expected "<feature>/<spec>" or "features/<feature>/test-cases/<spec>".`);
422
+ throw new RunUsageError(`Invalid spec path: "${specPath}". Expected "<feature>/<spec>" or "features/<feature>/test-cases/<spec>".`);
403
423
  }
404
424
  function getFeatureDir(featureName, cwd) {
405
425
  return join(getCcqaDir(cwd), "features", featureName);
@@ -1307,6 +1327,61 @@ function missingNativeBinaryMessage(pkg) {
1307
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.`;
1308
1328
  }
1309
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
1310
1385
  //#region src/claude/invoke.ts
1311
1386
  function resolveModel(explicit) {
1312
1387
  if (explicit) return explicit;
@@ -1478,6 +1553,7 @@ async function invokeClaudeStreaming(options, onEvent) {
1478
1553
  errorDetail = err instanceof Error ? err.message : String(err);
1479
1554
  if (!result) result = errorDetail;
1480
1555
  }
1556
+ tallyInvocation(cost);
1481
1557
  return {
1482
1558
  result,
1483
1559
  isError,
@@ -4669,6 +4745,34 @@ function isWithin(rootAbs, abs) {
4669
4745
  return abs === rootAbs || abs.startsWith(rootAbs + sep);
4670
4746
  }
4671
4747
  //#endregion
4748
+ //#region src/cli/usage-errors.ts
4749
+ /**
4750
+ * Turn a `RunUsageError` into `[error] <message>` plus its exit code, for a
4751
+ * commander action.
4752
+ *
4753
+ * The helpers shared across commands — `resolveAnalysisBase`,
4754
+ * `collectChangedSpecs` — signal a bad invocation this way, so every command
4755
+ * that calls one needs the same boundary. Without it the process dies on an
4756
+ * unhandled rejection and prints a stack trace: `bin/ccqa.ts` installs no
4757
+ * global handler, so there is nowhere else for it to land.
4758
+ *
4759
+ * Lives here rather than beside `RunUsageError` because `src/run/errors.ts` is
4760
+ * deliberately dependency-free, and this needs the logger.
4761
+ */
4762
+ function withUsageErrors(fn) {
4763
+ return async (...args) => {
4764
+ try {
4765
+ await fn(...args);
4766
+ } catch (err) {
4767
+ if (err instanceof RunUsageError) {
4768
+ error(err.message);
4769
+ process.exit(err.exitCode);
4770
+ }
4771
+ throw err;
4772
+ }
4773
+ };
4774
+ }
4775
+ //#endregion
4672
4776
  //#region src/runtime/profile-env.ts
4673
4777
  /**
4674
4778
  * Profile env vars are hub-sourced (pulled via `ccqa hub`) and merged into
@@ -5121,7 +5225,7 @@ const DraftNamingSchema = z.object({
5121
5225
  //#endregion
5122
5226
  //#region src/cli/draft.ts
5123
5227
  const CATEGORY_LABEL = DRAFT_CATEGORY_LABEL;
5124
- const draftCommand = addLanguageOption(new Command("draft").argument("[feature/spec]", "Optional spec path (e.g. tasks/create-and-complete). If omitted, Claude proposes one from your intent.").description("Interactively draft and refine a spec.yaml with Claude Code").option("--instruction <text>", "Non-interactive single-shot instruction (skips the interactive loop)").option("--apply", "Auto-apply each generated patch without [y/N] confirmation", false)).action(async (specPath, opts) => {
5228
+ const draftCommand = addLanguageOption(new Command("draft").argument("[feature/spec]", "Optional spec path (e.g. tasks/create-and-complete). If omitted, Claude proposes one from your intent.").description("Interactively draft and refine a spec.yaml with Claude Code").option("--instruction <text>", "Non-interactive single-shot instruction (skips the interactive loop)").option("--apply", "Auto-apply each generated patch without [y/N] confirmation", false)).action(withUsageErrors(async (specPath, opts) => {
5125
5229
  await ensureCcqaDir();
5126
5230
  let featureName;
5127
5231
  let specName;
@@ -5134,7 +5238,7 @@ const draftCommand = addLanguageOption(new Command("draft").argument("[feature/s
5134
5238
  prefilledIntent = intent;
5135
5239
  }
5136
5240
  await runDraft(featureName, specName, opts, prefilledIntent);
5137
- });
5241
+ }));
5138
5242
  async function runDraft(featureName, specName, opts, prefilledIntent) {
5139
5243
  header("draft", `${featureName}/${specName}`);
5140
5244
  const ja = useJapanesePrompts(opts.language);
@@ -6426,26 +6530,6 @@ function createDiffProvider(args) {
6426
6530
  } };
6427
6531
  }
6428
6532
  //#endregion
6429
- //#region src/run/errors.ts
6430
- /**
6431
- * Usage error (bad flag combination, broken profile, failed `git diff`, …)
6432
- * thrown by the `run` pipeline and the helpers it calls, e.g.
6433
- * `collectChangedSpecs`. `executeRun` never calls `process.exit`, so each
6434
- * host maps this itself: the CLI action catches it and exits with
6435
- * `exitCode`; the hub runner records it as a run-level error.
6436
- */
6437
- var RunUsageError = class extends Error {
6438
- exitCode = 2;
6439
- constructor(message) {
6440
- super(message);
6441
- this.name = "RunUsageError";
6442
- }
6443
- };
6444
- /** An error's message, whatever was thrown. Shared so the run modules report failures alike. */
6445
- function errMessage(err) {
6446
- return err instanceof Error ? err.message : String(err);
6447
- }
6448
- //#endregion
6449
6533
  //#region src/run/git-context.ts
6450
6534
  /** The `--failure-analysis` value that selects per-spec hub-ledger baselines. */
6451
6535
  const LAST_GREEN = "last-green";
@@ -8214,6 +8298,14 @@ function isCcqaPath(path) {
8214
8298
  return /(?:^|\/)\.ccqa\//.test(path);
8215
8299
  }
8216
8300
  /**
8301
+ * A malformed reply costs the whole selection, so it is worth one more call
8302
+ * before giving up. `ccqa drift` retries per spec for the same reason; this
8303
+ * call carries every undecided spec at once, so the blast radius is larger,
8304
+ * not smaller. Observed in practice: three runs over one commit produced a
8305
+ * parse failure, a clean answer, and a different clean answer.
8306
+ */
8307
+ const MAX_ATTEMPTS = 2;
8308
+ /**
8217
8309
  * One model call for the whole undecided set, not one per spec: the specs are
8218
8310
  * judged against the same diff, and seeing them together is what lets the
8219
8311
  * model tell them apart.
@@ -8224,32 +8316,44 @@ function isCcqaPath(path) {
8224
8316
  */
8225
8317
  async function judgeWithModel(input) {
8226
8318
  const { productChanges, undecided, cwd, base, head, model } = input;
8227
- const { result, isError } = await invokeClaudeStreaming({
8228
- prompt: buildSelectPrompt({
8229
- changed: productChanges,
8230
- specs: undecided,
8231
- base,
8232
- head
8233
- }),
8234
- systemPrompt: buildSelectSystemPrompt(),
8235
- allowedTools: [
8236
- "Read",
8237
- "Grep",
8238
- "Glob"
8239
- ],
8240
- silenceBashLog: true,
8241
- cwd,
8242
- ...model ? { model } : {}
8243
- }, (_msg) => {});
8244
- if (isError) return abandonSelection(undecided, "the selection model returned an error");
8245
- const json = extractJsonBlock(result);
8246
- if (!json) return abandonSelection(undecided, "the selection model returned no JSON block");
8247
8319
  let parsed;
8248
- try {
8249
- parsed = JSON.parse(json);
8250
- } catch (e) {
8251
- return abandonSelection(undecided, `the selection model's JSON did not parse: ${e.message}`);
8320
+ let lastError = "";
8321
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
8322
+ const { result, isError } = await invokeClaudeStreaming({
8323
+ prompt: buildSelectPrompt({
8324
+ changed: productChanges,
8325
+ specs: undecided,
8326
+ base,
8327
+ head
8328
+ }),
8329
+ systemPrompt: buildSelectSystemPrompt(),
8330
+ allowedTools: [
8331
+ "Read",
8332
+ "Grep",
8333
+ "Glob"
8334
+ ],
8335
+ silenceBashLog: true,
8336
+ cwd,
8337
+ ...model ? { model } : {}
8338
+ }, (_msg) => {});
8339
+ if (isError) {
8340
+ lastError = "the selection model returned an error";
8341
+ continue;
8342
+ }
8343
+ const json = extractJsonBlock(result);
8344
+ if (!json) {
8345
+ lastError = "the selection model returned no JSON block";
8346
+ continue;
8347
+ }
8348
+ try {
8349
+ parsed = JSON.parse(json);
8350
+ lastError = "";
8351
+ break;
8352
+ } catch (e) {
8353
+ lastError = `the selection model's JSON did not parse: ${e.message}`;
8354
+ }
8252
8355
  }
8356
+ if (lastError) return abandonSelection(undecided, `${lastError} (${MAX_ATTEMPTS} attempts)`);
8253
8357
  const changedPaths = new Set(productChanges.map((f) => f.path));
8254
8358
  const byUndecidedKey = new Map(undecided.map((s) => [specKey(s), s]));
8255
8359
  const answers = /* @__PURE__ */ new Map();
@@ -14954,7 +15058,7 @@ async function confirmOverwrite(path) {
14954
15058
  rl.close();
14955
15059
  }
14956
15060
  }
14957
- const generateCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("generate").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Generate test code from a spec via its target plugin. Recording-backed targets compile the existing ir.json (run `ccqa record` first); spec-input targets generate directly from the spec.").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--target <id>", "Generate through this target instead of the spec's own — e.g. emit a Playwright spec from an agent-browser recording. The spec's `target:` stays the default for `ccqa run`.").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 previously generated test code without warning").option("--no-snapshot", "Don't pin AGENT_BROWSER_SESSION / capture page snapshots after a failure (debug toggle)").option("--update-agent-prompt", "After generation, ask Claude to refresh the target's \"<target>.agent\" learning prompt on the hub from a summary of the run. LLM-generating targets (playwright, runn) only; 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(async (specPath, opts) => {
15061
+ const generateCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("generate").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Generate test code from a spec via its target plugin. Recording-backed targets compile the existing ir.json (run `ccqa record` first); spec-input targets generate directly from the spec.").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--target <id>", "Generate through this target instead of the spec's own — e.g. emit a Playwright spec from an agent-browser recording. The spec's `target:` stays the default for `ccqa run`.").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 previously generated test code without warning").option("--no-snapshot", "Don't pin AGENT_BROWSER_SESSION / capture page snapshots after a failure (debug toggle)").option("--update-agent-prompt", "After generation, ask Claude to refresh the target's \"<target>.agent\" learning prompt on the hub from a summary of the run. LLM-generating targets (playwright, runn) only; 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) => {
14958
15062
  const { featureName, specName } = parseSpecPath(specPath);
14959
15063
  const language = opts.language ?? "auto";
14960
15064
  const cwd = resolveCwd(opts.cwd);
@@ -15009,14 +15113,38 @@ const generateCommand = addHubOptions(addProfileOption(addLanguageOption(new Com
15009
15113
  ...language ? { language } : {},
15010
15114
  ...opts.model ? { model: opts.model } : {}
15011
15115
  });
15012
- });
15116
+ }));
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
+ }
15013
15132
  //#endregion
15014
15133
  //#region src/cli/record.ts
15015
15134
  const VALIDATION_MODES = ["lenient", "strict"];
15016
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) => {
15017
15136
  if (VALIDATION_MODES.includes(raw)) return raw;
15018
15137
  throw new Error(`--validation-mode must be one of ${VALIDATION_MODES.join(" | ")}`);
15019
- }, "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(async (specPath, opts) => {
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) {
15020
15148
  const { featureName, specName } = parseSpecPath(specPath);
15021
15149
  const language = opts.language ?? "auto";
15022
15150
  if (opts.skipTrace && opts.skipCodegen) {
@@ -15103,7 +15231,7 @@ const recordCommand = addHubOptions(addProfileOption(addLanguageOption(new Comma
15103
15231
  ...language ? { language } : {},
15104
15232
  ...opts.model ? { model: opts.model } : {}
15105
15233
  });
15106
- });
15234
+ }
15107
15235
  /**
15108
15236
  * Compact summary of the trace pass for the record agent-prompt refresh.
15109
15237
  * Steps are reconstructed from the trace's status-line protocol (STEP_START
@@ -15416,37 +15544,12 @@ function driftResultsToReport(results, meta) {
15416
15544
  };
15417
15545
  }
15418
15546
  //#endregion
15419
- //#region src/cli/usage-errors.ts
15420
- /**
15421
- * Turn a `RunUsageError` into `[error] <message>` plus its exit code, for a
15422
- * commander action.
15423
- *
15424
- * The helpers shared across commands — `resolveAnalysisBase`,
15425
- * `collectChangedSpecs` — signal a bad invocation this way, so every command
15426
- * that calls one needs the same boundary. Without it the process dies on an
15427
- * unhandled rejection and prints a stack trace: `bin/ccqa.ts` installs no
15428
- * global handler, so there is nowhere else for it to land.
15429
- *
15430
- * Lives here rather than beside `RunUsageError` because `src/run/errors.ts` is
15431
- * deliberately dependency-free, and this needs the logger.
15432
- */
15433
- function withUsageErrors(fn) {
15434
- return async (...args) => {
15435
- try {
15436
- await fn(...args);
15437
- } catch (err) {
15438
- if (err instanceof RunUsageError) {
15439
- error(err.message);
15440
- process.exit(err.exitCode);
15441
- }
15442
- throw err;
15443
- }
15444
- };
15445
- }
15446
- //#endregion
15447
15547
  //#region src/cli/drift.ts
15448
15548
  const DEFAULT_CONCURRENCY = 3;
15449
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) {
15450
15553
  const format = parseFormat$1(opts.format);
15451
15554
  const threshold = parseSeverity(opts.severity);
15452
15555
  const concurrency = parseConcurrency(opts.concurrency);
@@ -15498,8 +15601,9 @@ const driftCommand = addLanguageOption(new Command("drift").argument("[feature/s
15498
15601
  format,
15499
15602
  baseRef
15500
15603
  });
15604
+ reportCost();
15501
15605
  process.exit(determineExitCode(results, threshold));
15502
- }));
15606
+ }
15503
15607
  /**
15504
15608
  * Push a finished drift audit to a ccqa hub as a `kind: "drift"` run, so it
15505
15609
  * shows up alongside `ccqa run` runs in the hub UI. Best-effort: a missing
@@ -15554,6 +15658,7 @@ async function pushDriftResults(args, resolveHub = resolveHubClient) {
15554
15658
  }
15555
15659
  }
15556
15660
  function exitWithNoSpecs(format, message) {
15661
+ reportCost();
15557
15662
  if (format === "json") process.stdout.write(`${JSON.stringify({ specs: [] }, null, 2)}\n`);
15558
15663
  else if (format === "text") info(message);
15559
15664
  process.exit(0);
@@ -15619,6 +15724,9 @@ const initCommand = new Command("init").description("Create the .ccqa/ spec skel
15619
15724
  //#region src/cli/select-specs.ts
15620
15725
  const DEFAULT_HEAD = "HEAD";
15621
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) {
15622
15730
  const format = parseFormat(opts.format);
15623
15731
  const cwd = resolveCwd(opts.cwd);
15624
15732
  const head = opts.head ?? DEFAULT_HEAD;
@@ -15664,8 +15772,9 @@ const selectSpecsCommand = new Command("select-specs").description("Decide which
15664
15772
  ...opts.model ? { model: opts.model } : {}
15665
15773
  });
15666
15774
  process.stdout.write(format === "json" ? `${JSON.stringify(report, null, 2)}\n` : renderText(report));
15775
+ reportCost();
15667
15776
  process.exit(0);
15668
- });
15777
+ }
15669
15778
  const VERDICT_ORDER = [
15670
15779
  "needed",
15671
15780
  "unknown",
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.10.0",
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.0",
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": {