ccqa 1.29.0 → 1.31.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
@@ -871,10 +871,12 @@ function cloudProviderEnabled() {
871
871
  /**
872
872
  * Probe whether the host has any credential the Anthropic SDK can pick up:
873
873
  * 1. ANTHROPIC_API_KEY env var (CI / scripted use)
874
- * 2. CLAUDE_CODE_USE_BEDROCK / CLAUDE_CODE_USE_VERTEX (cloud-provider
874
+ * 2. CLAUDE_CODE_OAUTH_TOKEN env var (a long-lived subscription token from
875
+ * `claude setup-token`, the headless-CI counterpart of a login)
876
+ * 3. CLAUDE_CODE_USE_BEDROCK / CLAUDE_CODE_USE_VERTEX (cloud-provider
875
877
  * endpoints authenticated by the cloud SDK's credential chain)
876
- * 3. ~/.claude/.credentials.json (Claude Code login, file-based platforms)
877
- * 4. macOS Keychain item "Claude Code-credentials" (Claude Code login on
878
+ * 4. ~/.claude/.credentials.json (Claude Code login, file-based platforms)
879
+ * 5. macOS Keychain item "Claude Code-credentials" (Claude Code login on
878
880
  * darwin stores the OAuth credentials in the Keychain, not on disk)
879
881
  *
880
882
  * Claude-driven hooks are opt-in, so the caller only consults this after the
@@ -882,14 +884,16 @@ function cloudProviderEnabled() {
882
884
  * that surfaces as "analysis skipped".
883
885
  */
884
886
  function driftAuthAvailable() {
885
- const key = process.env["ANTHROPIC_API_KEY"];
886
- if (typeof key === "string" && key.length > 0) return { ok: true };
887
+ for (const key of ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"]) {
888
+ const value = process.env[key];
889
+ if (typeof value === "string" && value.length > 0) return { ok: true };
890
+ }
887
891
  if (cloudProviderEnabled()) return { ok: true };
888
892
  if (existsSync(join(homedir(), ".claude", ".credentials.json"))) return { ok: true };
889
893
  if (process.platform === "darwin" && keychainHasClaudeCredentials()) return { ok: true };
890
894
  return {
891
895
  ok: false,
892
- reason: "no ANTHROPIC_API_KEY / Bedrock or Vertex env / claude login"
896
+ reason: "no ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN / Bedrock or Vertex env / claude login"
893
897
  };
894
898
  }
895
899
  /**
@@ -1509,18 +1513,27 @@ function resolveModel(explicit) {
1509
1513
  * - `ANTHROPIC_AUTH_TOKEN` — sent as `Authorization: Bearer <token>`.
1510
1514
  * - `ANTHROPIC_API_KEY` — API key, when used instead of a token.
1511
1515
  * - `ANTHROPIC_CUSTOM_HEADERS` — extra request headers.
1516
+ * - `CLAUDE_CODE_OAUTH_TOKEN` — long-lived subscription token from
1517
+ * `claude setup-token`, the headless-CI counterpart of a login.
1512
1518
  */
1513
1519
  const ENDPOINT_ENV_KEYS = [
1514
1520
  "ANTHROPIC_BASE_URL",
1515
1521
  "ANTHROPIC_AUTH_TOKEN",
1516
1522
  "ANTHROPIC_API_KEY",
1517
- "ANTHROPIC_CUSTOM_HEADERS"
1523
+ "ANTHROPIC_CUSTOM_HEADERS",
1524
+ "CLAUDE_CODE_OAUTH_TOKEN"
1518
1525
  ];
1519
1526
  /**
1520
1527
  * Collects the endpoint/auth variables set in the current process environment
1521
1528
  * so they can be forwarded, verbatim, to every Claude Code invocation. Returns
1522
1529
  * only the keys that are actually set (non-empty), so unset variables never
1523
1530
  * override the SDK's own defaults.
1531
+ *
1532
+ * When both credentials are present the OAuth token wins and the API key is
1533
+ * not forwarded. Left to the CLI the API key would win, which makes "switch a
1534
+ * CI job to the subscription token" require unwiring the key everywhere; with
1535
+ * the precedence here, adding the one variable is the whole switch, and
1536
+ * removing it is the whole rollback.
1524
1537
  */
1525
1538
  function resolveEndpointEnv() {
1526
1539
  const endpointEnv = {};
@@ -1528,6 +1541,7 @@ function resolveEndpointEnv() {
1528
1541
  const value = process.env[key];
1529
1542
  if (value && value.length > 0) endpointEnv[key] = value;
1530
1543
  }
1544
+ if (endpointEnv["CLAUDE_CODE_OAUTH_TOKEN"]) delete endpointEnv["ANTHROPIC_API_KEY"];
1531
1545
  return endpointEnv;
1532
1546
  }
1533
1547
  /**
@@ -7523,6 +7537,7 @@ z.object({
7523
7537
  const SpecDriftEntrySchema = z.object({
7524
7538
  label: DriftLabelSchema.nullable(),
7525
7539
  surface: DriftSurfaceSchema.optional(),
7540
+ subDiagnosis: DriftSubDiagnosisSchema.optional(),
7526
7541
  specChangeKind: SpecChangeKindSchema.optional(),
7527
7542
  confidence: z.number().optional(),
7528
7543
  headline: z.string().optional(),
@@ -13830,8 +13845,12 @@ function createRunTeardown() {
13830
13845
  * A command must install at most one: a second handler that also exits would
13831
13846
  * race this one and could terminate mid-finalizer. A second signal while
13832
13847
  * tearing down hard-exits immediately rather than waiting.
13848
+ *
13849
+ * `onSignal` runs synchronously before the teardown starts, so a finalizer
13850
+ * can already see which signal is ending the process (e.g. `ccqa record`
13851
+ * seals its hub run with a "terminated by signal" note).
13833
13852
  */
13834
- function installTeardownSignalHandlers(teardown) {
13853
+ function installTeardownSignalHandlers(teardown, onSignal) {
13835
13854
  let handling = false;
13836
13855
  const handler = (sig) => {
13837
13856
  const code = sig === "SIGINT" ? 130 : 143;
@@ -13840,6 +13859,7 @@ function installTeardownSignalHandlers(teardown) {
13840
13859
  return;
13841
13860
  }
13842
13861
  handling = true;
13862
+ onSignal?.(sig);
13843
13863
  teardown.run().finally(() => process.exit(code));
13844
13864
  };
13845
13865
  const onInt = () => handler("SIGINT");
@@ -14004,6 +14024,16 @@ function generateSessionName() {
14004
14024
  */
14005
14025
  function buildTraceSystemPrompt(input) {
14006
14026
  const sessionName = input.sessionName ?? generateSessionName();
14027
+ const callerGuidance = input.instruction ? `## Caller Guidance
14028
+
14029
+ The caller provided extra guidance for this recording — for example, a drift
14030
+ audit's finding about what a previous recording of this spec got wrong. Treat
14031
+ it as advice on what to avoid or verify while recording; the spec's steps
14032
+ above remain the contract for what to do.
14033
+
14034
+ ${input.instruction}
14035
+
14036
+ ` : "";
14007
14037
  const stepsText = input.steps.map((step) => `### ${step.id} [${step.source}]
14008
14038
  - **Instruction**: ${step.instruction}
14009
14039
  - **Expected**: ${step.expected}`).join("\n\n");
@@ -14391,7 +14421,7 @@ RUN_COMPLETED|passed|<summary>
14391
14421
  RUN_COMPLETED|failed|<summary>
14392
14422
  \`\`\`
14393
14423
 
14394
- ## Start
14424
+ ${callerGuidance}## Start
14395
14425
 
14396
14426
  Begin by clearing cookies, then proceed straight to the first step's instruction.
14397
14427
 
@@ -14867,7 +14897,8 @@ async function runTrace(featureName, specName, model, validationMode = "lenient"
14867
14897
  const baseSystemPrompt = buildTraceSystemPrompt({
14868
14898
  title: spec.title,
14869
14899
  steps: expanded,
14870
- sessionName
14900
+ sessionName,
14901
+ ...opts.instruction ? { instruction: opts.instruction } : {}
14871
14902
  });
14872
14903
  const promptBundle = await loadPromptBundleFromHub(opts.hubContext ?? null, "record");
14873
14904
  if (promptBundle !== null) meta("prompt", promptBundle.loaded.join(" + "));
@@ -14878,7 +14909,7 @@ async function runTrace(featureName, specName, model, validationMode = "lenient"
14878
14909
  const statusLines = [];
14879
14910
  let overallStatus = "passed";
14880
14911
  const traceActions = [];
14881
- const stepTracker = createStepTracker();
14912
+ const stepTracker = createStepTracker(opts.onStep);
14882
14913
  const withStepId = (action, stepId) => {
14883
14914
  if (!action) return null;
14884
14915
  return stepId ? {
@@ -15220,15 +15251,18 @@ function groupCountByStep(actions) {
15220
15251
  }
15221
15252
  return counts;
15222
15253
  }
15223
- function createStepTracker() {
15254
+ function createStepTracker(onChange) {
15224
15255
  let currentStepId;
15256
+ const advance = (stepId) => {
15257
+ if (stepId === currentStepId) return;
15258
+ currentStepId = stepId;
15259
+ onChange?.(stepId);
15260
+ };
15225
15261
  return {
15226
15262
  current: () => currentStepId,
15227
- fromStepStartLine: (stepId) => {
15228
- currentStepId = stepId;
15229
- },
15263
+ fromStepStartLine: advance,
15230
15264
  fromCommand: (stepId) => {
15231
- if (stepId) currentStepId = stepId;
15265
+ if (stepId) advance(stepId);
15232
15266
  return currentStepId;
15233
15267
  }
15234
15268
  };
@@ -15486,7 +15520,7 @@ async function runGenerateCli(specPath, opts) {
15486
15520
  //#endregion
15487
15521
  //#region src/cli/record.ts
15488
15522
  const VALIDATION_MODES = ["lenient", "strict"];
15489
- 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.").optionsGroup("How to record:").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--trace-validation <mode>", "What to do with actions that fail post-trace validation: 'lenient' (default) tags them; 'strict' drops them.", (raw) => {
15523
+ 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.").optionsGroup("How to record:").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--instruction <text>", "Extra guidance for the recording agent — e.g. the drift audit's finding when re-recording a drifted spec.").option("--trace-validation <mode>", "What to do with actions that fail post-trace validation: 'lenient' (default) tags them; 'strict' drops them.", (raw) => {
15490
15524
  if (VALIDATION_MODES.includes(raw)) return raw;
15491
15525
  throw new Error(`--trace-validation must be one of ${VALIDATION_MODES.join(" | ")}`);
15492
15526
  }, "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("--auto-fix-max-retries <n>", "Maximum number of auto-fix retries", "3").option("--trace-only", "Stop after the trace step; do not generate test code").option("--no-session-pin", "Don't pin AGENT_BROWSER_SESSION / capture page snapshots after a failure (debug toggle)").optionsGroup("What to do with the result:").option("--overwrite", "Replace an existing test.spec.ts without warning").option("--report-to-hub", "Leave a run (kind: record) on the hub saying this spec was recorded and what the recording spent on Claude, so a budget summed over the hub's runs sees it. It advances no ledger: a recording verifies nothing.").optionsGroup("Learning:").option("--learn-hub-trace-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.").optionsGroup("Environment and 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) => {
@@ -15543,19 +15577,30 @@ async function runRecord(specPath, opts) {
15543
15577
  if (push) info(`hub: record run opened (${push.runId})`);
15544
15578
  let recorded = false;
15545
15579
  let sealed = true;
15580
+ let tracingStep;
15581
+ let killedBy;
15546
15582
  const teardown = createRunTeardown();
15547
15583
  teardown.onFinalize(async () => {
15548
- if (push) sealed = await sealRecordPush(push, featureName, specName, recorded);
15584
+ if (!push) return;
15585
+ const note = killedBy ? `terminated by signal (${killedBy})${tracingStep ? ` during ${tracingStep}` : ""}` : void 0;
15586
+ sealed = await sealRecordPush(push, featureName, specName, recorded, note);
15587
+ });
15588
+ const disposeSignalHandlers = installTeardownSignalHandlers(teardown, (sig) => {
15589
+ killedBy = sig;
15549
15590
  });
15550
- const disposeSignalHandlers = installTeardownSignalHandlers(teardown);
15551
15591
  try {
15552
15592
  let traceResult = null;
15553
15593
  let generated = true;
15554
15594
  try {
15555
15595
  traceResult = await runTrace(featureName, specName, opts.model, opts.traceValidation ?? "lenient", language, {
15556
15596
  cwd: cwdForProfile,
15557
- hubContext
15597
+ hubContext,
15598
+ ...opts.instruction ? { instruction: opts.instruction } : {},
15599
+ onStep: (stepId) => {
15600
+ tracingStep = stepId;
15601
+ }
15558
15602
  });
15603
+ tracingStep = void 0;
15559
15604
  blank();
15560
15605
  if (!opts.traceOnly) generated = (await runGenerate(featureName, specName, {
15561
15606
  maxRetries: parseInt(opts.autoFixMaxRetries ?? "3", 10),
@@ -15594,15 +15639,23 @@ async function runRecord(specPath, opts) {
15594
15639
  * Close the record run with the one row this command produced, answering
15595
15640
  * whether it closed. One spec is recorded per invocation, so one row is the
15596
15641
  * whole run — enough for the runs list to say what the money bought.
15642
+ *
15643
+ * `failureNote` (e.g. "terminated by signal (SIGTERM) during step-03") rides
15644
+ * in the row's `failureLogExcerpt` — the field a failed `ccqa run` row already
15645
+ * uses for its failure text — so the hub says why a recording died instead of
15646
+ * a bare status:"failed". Ignored on a successful recording.
15597
15647
  */
15598
- async function sealRecordPush(push, featureName, specName, recorded) {
15648
+ async function sealRecordPush(push, featureName, specName, recorded, failureNote) {
15599
15649
  return sealHubRun(push, {
15600
- rows: [emptySpecRow({
15601
- feature: featureName,
15602
- spec: specName,
15603
- title: null,
15604
- status: recorded ? "passed" : "failed"
15605
- })],
15650
+ rows: [{
15651
+ ...emptySpecRow({
15652
+ feature: featureName,
15653
+ spec: specName,
15654
+ title: null,
15655
+ status: recorded ? "passed" : "failed"
15656
+ }),
15657
+ ...!recorded && failureNote ? { failureLogExcerpt: failureNote } : {}
15658
+ }],
15606
15659
  reportMeta: {
15607
15660
  git: {
15608
15661
  head: push.gitHead,
@@ -17443,6 +17496,7 @@ function gradedDriftEntry(ledger, key, runId, label) {
17443
17496
  };
17444
17497
  if (label === null) {
17445
17498
  delete graded.surface;
17499
+ delete graded.subDiagnosis;
17446
17500
  delete graded.headline;
17447
17501
  delete graded.confidence;
17448
17502
  }
@@ -17807,9 +17861,11 @@ async function updateDriftLedger(storage, run, results) {
17807
17861
  if (row.status === "skipped") continue;
17808
17862
  const key = `${row.feature}/${row.spec}`;
17809
17863
  const diagnosis = row.analysis ? normalizeDiagnosis(row.analysis) : null;
17864
+ const subDiagnosis = DriftSubDiagnosisSchema.safeParse(diagnosis?.subDiagnosis);
17810
17865
  ledger.specs[key] = {
17811
17866
  label: diagnosis ? diagnosis.label : null,
17812
17867
  surface: diagnosis?.surface,
17868
+ subDiagnosis: subDiagnosis.success ? subDiagnosis.data : void 0,
17813
17869
  specChangeKind: diagnosis?.specChangeKind,
17814
17870
  confidence: diagnosis?.confidence,
17815
17871
  headline: diagnosis?.headline,
@@ -35,9 +35,9 @@ declare const RunSchema: z.ZodObject<{
35
35
  running: "running";
36
36
  }>;
37
37
  kind: z.ZodDefault<z.ZodEnum<{
38
- record: "record";
39
38
  run: "run";
40
39
  drift: "drift";
40
+ record: "record";
41
41
  }>>;
42
42
  drift: z.ZodDefault<z.ZodNullable<z.ZodObject<{
43
43
  specs: z.ZodNumber;
@@ -383,6 +383,11 @@ declare const DriftLedgerResponseSchema: z.ZodObject<{
383
383
  spec: "spec";
384
384
  generated: "generated";
385
385
  }>>;
386
+ subDiagnosis: z.ZodOptional<z.ZodEnum<{
387
+ OVER_ASSERTION: "OVER_ASSERTION";
388
+ SELECTOR_DRIFT: "SELECTOR_DRIFT";
389
+ NONE: "NONE";
390
+ }>>;
386
391
  specChangeKind: z.ZodOptional<z.ZodEnum<{
387
392
  FEATURE_REMOVED: "FEATURE_REMOVED";
388
393
  BEHAVIOUR_CHANGED: "BEHAVIOUR_CHANGED";
@@ -621,9 +626,9 @@ type ReportSpecResult = z.infer<typeof ReportSpecResultSchema>;
621
626
  declare const RunReportDataSchema: z.ZodObject<{
622
627
  schemaVersion: z.ZodLiteral<1>;
623
628
  kind: z.ZodDefault<z.ZodEnum<{
624
- record: "record";
625
629
  run: "run";
626
630
  drift: "drift";
631
+ record: "record";
627
632
  }>>;
628
633
  createdAt: z.ZodString;
629
634
  runId: z.ZodNullable<z.ZodString>;
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.29.0",
3
+ "version": "1.31.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.29.0",
3
+ "version": "1.31.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {