ccqa 0.11.0 → 0.13.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
@@ -2630,6 +2630,7 @@ const ReportSpecResultSchema = z.object({
2630
2630
  });
2631
2631
  const RunReportDataSchema = z.object({
2632
2632
  schemaVersion: z.literal(1),
2633
+ kind: z.enum(["run", "drift"]).default("run"),
2633
2634
  createdAt: z.string(),
2634
2635
  runId: z.string().nullable(),
2635
2636
  git: z.object({
@@ -3198,6 +3199,44 @@ function resolveProjectOrThrow(project, cwd) {
3198
3199
  */
3199
3200
  const hubUrlOption = ["--hub-url <url>", "ccqa hub base URL (or CCQA_HUB_URL)."];
3200
3201
  const hubTokenOption = ["--hub-token <token>", "ccqa hub bearer token (or CCQA_HUB_TOKEN)."];
3202
+ const hubHeaderOption = [
3203
+ "--hub-header <header>",
3204
+ "Extra header sent with every hub request, as \"key:value\" (or CCQA_HUB_HEADER). Repeatable. For infra that gates the hub behind a header check (e.g. a load balancer bypass rule).",
3205
+ collectHubHeader,
3206
+ []
3207
+ ];
3208
+ /** commander `--hub-header` accumulator: collects repeated flags into an array. */
3209
+ function collectHubHeader(value, previous) {
3210
+ return [...previous, value];
3211
+ }
3212
+ /**
3213
+ * Parse `"key:value"` entries (from `--hub-header`, repeatable) into a
3214
+ * headers map. The value may itself contain `:` (e.g. a URL), so only the
3215
+ * first colon is treated as the separator. Throws on an entry with no colon.
3216
+ */
3217
+ function parseHubHeaders(entries) {
3218
+ const headers = {};
3219
+ for (const entry of entries) {
3220
+ const i = entry.indexOf(":");
3221
+ if (i < 0) throw new Error(`invalid --hub-header (expected "key:value"): ${entry}`);
3222
+ const key = entry.slice(0, i).trim();
3223
+ const value = entry.slice(i + 1).trim();
3224
+ if (!key) throw new Error(`invalid --hub-header (expected "key:value"): ${entry}`);
3225
+ headers[key] = value;
3226
+ }
3227
+ return headers;
3228
+ }
3229
+ /**
3230
+ * Resolve custom hub headers from flags, falling back to `CCQA_HUB_HEADER`
3231
+ * (a single `"key:value"` entry) when no `--hub-header` flag was given.
3232
+ * CI setups typically pass exactly one gateway header via env; `--hub-header`
3233
+ * is repeatable for the (rarer) local/multi-header case.
3234
+ */
3235
+ function resolveHubHeaders(hubHeader) {
3236
+ if (hubHeader && hubHeader.length > 0) return parseHubHeaders(hubHeader);
3237
+ const envHeader = process.env.CCQA_HUB_HEADER;
3238
+ if (envHeader) return parseHubHeaders([envHeader]);
3239
+ }
3201
3240
  /** Thrown by `requireHubClient` when the URL and/or token can't be resolved from flags/env. */
3202
3241
  var HubConnectionError = class extends Error {
3203
3242
  constructor(message) {
@@ -3214,9 +3253,11 @@ function resolveHubClient(opts) {
3214
3253
  const baseUrl = opts.hubUrl ?? process.env.CCQA_HUB_URL;
3215
3254
  const token = opts.hubToken ?? process.env.CCQA_HUB_TOKEN;
3216
3255
  if (!baseUrl || !token) return null;
3256
+ const headers = resolveHubHeaders(opts.hubHeader);
3217
3257
  return createHubClient({
3218
3258
  baseUrl: baseUrl.replace(/\/+$/, ""),
3219
- token
3259
+ token,
3260
+ ...headers ? { headers } : {}
3220
3261
  });
3221
3262
  }
3222
3263
  /** Same as `resolveHubClient`, but throws `HubConnectionError` instead of returning `null`. */
@@ -3242,6 +3283,25 @@ function resolveHubContext(opts) {
3242
3283
  project: resolveProjectOrThrow(opts.project, opts.cwd ?? process.cwd())
3243
3284
  };
3244
3285
  }
3286
+ /**
3287
+ * Wrap a subcommand action so a `HubApiError` (hub request failed, e.g. a
3288
+ * 503 when the hub has no encryption key configured) prints a clean message
3289
+ * and exits 2, instead of surfacing as an unhandled rejection with a stack
3290
+ * trace.
3291
+ */
3292
+ function withHubErrors(fn) {
3293
+ return async (...args) => {
3294
+ try {
3295
+ await fn(...args);
3296
+ } catch (err) {
3297
+ if (err instanceof HubApiError) {
3298
+ error(`hub request failed (${err.status} ${err.code}): ${err.message}`);
3299
+ process.exit(2);
3300
+ }
3301
+ throw err;
3302
+ }
3303
+ };
3304
+ }
3245
3305
  //#endregion
3246
3306
  //#region src/cli/options.ts
3247
3307
  /**
@@ -3266,7 +3326,7 @@ function addProfileOption(command) {
3266
3326
  * options so help text and behaviour don't drift.
3267
3327
  */
3268
3328
  function addHubOptions(command) {
3269
- return command.option(...hubUrlOption).option(...hubTokenOption);
3329
+ return command.option(...hubUrlOption).option(...hubTokenOption).option(...hubHeaderOption);
3270
3330
  }
3271
3331
  /**
3272
3332
  * CLI wrapper around `resolveProfileEnv`: on failure, print the error and
@@ -3856,6 +3916,33 @@ function resolvePromptLocalPath(name, cwd) {
3856
3916
  return join(cwd ?? process.cwd(), PROMPT_LOCAL_PATHS[name]);
3857
3917
  }
3858
3918
  //#endregion
3919
+ //#region src/cli/git-branch.ts
3920
+ /** Best-effort current branch: CI env vars first, then git, else null. */
3921
+ async function detectBranch(cwd) {
3922
+ const fromEnv = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME;
3923
+ if (fromEnv) return fromEnv;
3924
+ try {
3925
+ const { stdout } = await execFileP("git", [
3926
+ "rev-parse",
3927
+ "--abbrev-ref",
3928
+ "HEAD"
3929
+ ], { cwd });
3930
+ const branch = stdout.trim();
3931
+ return branch && branch !== "HEAD" ? branch : null;
3932
+ } catch {
3933
+ return null;
3934
+ }
3935
+ }
3936
+ /** Best-effort current commit SHA, or null (e.g. not a git repo). */
3937
+ async function getGitHead(cwd) {
3938
+ try {
3939
+ const { stdout } = await execFileP("git", ["rev-parse", "HEAD"], { cwd });
3940
+ return stdout.trim() || null;
3941
+ } catch {
3942
+ return null;
3943
+ }
3944
+ }
3945
+ //#endregion
3859
3946
  //#region src/cli/hub.ts
3860
3947
  /**
3861
3948
  * `ccqa hub` — the client side of the ccqa hub (a results/secret control
@@ -3904,25 +3991,6 @@ async function readStdin() {
3904
3991
  for await (const chunk of process.stdin) chunks.push(chunk);
3905
3992
  return Buffer.concat(chunks).toString("utf8");
3906
3993
  }
3907
- /**
3908
- * Wrap a subcommand action so a `HubApiError` (hub request failed, e.g. a
3909
- * 503 when the hub has no encryption key configured) prints a clean message
3910
- * and exits 2, instead of surfacing as an unhandled rejection with a stack
3911
- * trace.
3912
- */
3913
- function withHubErrors(fn) {
3914
- return async (...args) => {
3915
- try {
3916
- await fn(...args);
3917
- } catch (err) {
3918
- if (err instanceof HubApiError) {
3919
- error(`hub request failed (${err.status} ${err.code}): ${err.message}`);
3920
- process.exit(2);
3921
- }
3922
- throw err;
3923
- }
3924
- };
3925
- }
3926
3994
  const sessionPush = new Command("push").description("Upload a locally-saved browser session (.ccqa/sessions/<profile>/<name>.json) to the hub, so it's available for `ccqa run` to fetch at run time. Encrypted at rest on the hub.").argument("<name>", "Session name to upload (resolves to .ccqa/sessions/<profile>/<name>.json)").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption$1).option(...profileOption$1).option("--cwd <path>", "Project root containing .ccqa/ (defaults to the current directory).").action(withHubErrors(async (rawName, opts) => {
3927
3995
  const name = validateSessionName(rawName);
3928
3996
  const cwd = resolveCwd(opts.cwd);
@@ -4084,22 +4152,6 @@ const pushCommand = new Command("push").description("Upload the report directory
4084
4152
  info(`${resolveBaseUrl(opts)}/#/runs/${run.id}`);
4085
4153
  }));
4086
4154
  const hubCommand = new Command("hub").description("Client for a ccqa hub: push run results and manage sessions/variables/prompts used by `ccqa run`. See docs/hub.md.").addCommand(pushCommand).addCommand(sessionCommand$1).addCommand(varCommand).addCommand(promptCommand);
4087
- /** Best-effort current branch: CI env vars first, then git, else null. */
4088
- async function detectBranch(cwd) {
4089
- const fromEnv = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME;
4090
- if (fromEnv) return fromEnv;
4091
- try {
4092
- const { stdout } = await execFileP("git", [
4093
- "rev-parse",
4094
- "--abbrev-ref",
4095
- "HEAD"
4096
- ], { cwd });
4097
- const branch = stdout.trim();
4098
- return branch && branch !== "HEAD" ? branch : null;
4099
- } catch {
4100
- return null;
4101
- }
4102
- }
4103
4155
  /** Loose check that a fetched session is agent-browser storage-state, mirroring loadStorageState. */
4104
4156
  function isStorageStateShape(state) {
4105
4157
  return typeof state === "object" && state !== null && Array.isArray(state.cookies) && Array.isArray(state.origins);
@@ -5206,7 +5258,8 @@ async function executeRun(targets, opts) {
5206
5258
  project: projectForProfile,
5207
5259
  cwd,
5208
5260
  hubUrl: opts.hubUrl,
5209
- hubToken: opts.hubToken
5261
+ hubToken: opts.hubToken,
5262
+ hubHeader: opts.hubHeader
5210
5263
  });
5211
5264
  } else await resolveProfileEnv({
5212
5265
  profile: void 0,
@@ -5224,6 +5277,7 @@ async function executeRun(targets, opts) {
5224
5277
  hubCtx = resolveHubContext({
5225
5278
  hubUrl: opts.hubUrl,
5226
5279
  hubToken: opts.hubToken,
5280
+ hubHeader: opts.hubHeader,
5227
5281
  project: opts.project,
5228
5282
  cwd
5229
5283
  });
@@ -5573,6 +5627,7 @@ async function writeUnifiedReport(args) {
5573
5627
  const { reportDir, results, diff, baseRef, customPromptVersion, opts } = args;
5574
5628
  const data = {
5575
5629
  schemaVersion: 1,
5630
+ kind: "run",
5576
5631
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
5577
5632
  runId: process.env["GITHUB_RUN_ID"] ?? null,
5578
5633
  git: {
@@ -8584,7 +8639,8 @@ const recordCommand = addHubOptions(addProfileOption(addLanguageOption(new Comma
8584
8639
  project,
8585
8640
  cwd: cwdForProfile,
8586
8641
  hubUrl: opts.hubUrl,
8587
- hubToken: opts.hubToken
8642
+ hubToken: opts.hubToken,
8643
+ hubHeader: opts.hubHeader
8588
8644
  });
8589
8645
  else await applyProfileFromOption({
8590
8646
  profile: void 0,
@@ -8593,7 +8649,8 @@ const recordCommand = addHubOptions(addProfileOption(addLanguageOption(new Comma
8593
8649
  });
8594
8650
  const hubClientForTrace = resolveHubClient({
8595
8651
  hubUrl: opts.hubUrl,
8596
- hubToken: opts.hubToken
8652
+ hubToken: opts.hubToken,
8653
+ hubHeader: opts.hubHeader
8597
8654
  });
8598
8655
  const hubContext = hubClientForTrace && project ? {
8599
8656
  hub: hubClientForTrace,
@@ -9148,6 +9205,55 @@ function determineExitCode(results, threshold) {
9148
9205
  }
9149
9206
  return 0;
9150
9207
  }
9208
+ /**
9209
+ * Spec-level status under the given threshold, mirroring determineExitCode's
9210
+ * per-issue logic (exit-code.ts) but scoped to a single SpecResult.
9211
+ */
9212
+ function specStatus(result, threshold) {
9213
+ if (result.error) return "failed";
9214
+ for (const issue of result.issues) {
9215
+ if (issue.severity === "ERROR") return "failed";
9216
+ if (threshold === "warn" && issue.severity === "WARN") return "failed";
9217
+ }
9218
+ return "passed";
9219
+ }
9220
+ /**
9221
+ * Adapts `ccqa drift` results into the shared RunReportData shape so they can
9222
+ * be pushed to the hub (`ccqa drift --push`) and rendered by the same report
9223
+ * UI as `ccqa run`/`ccqa live`. Browser-execution fields (testCounts,
9224
+ * evidence, liveRun, ...) don't apply to a drift audit and are always null.
9225
+ */
9226
+ function driftResultsToReport(results, meta) {
9227
+ const specResults = results.map((result) => ({
9228
+ feature: result.target.featureName,
9229
+ spec: result.target.specName,
9230
+ title: null,
9231
+ status: specStatus(result, meta.threshold),
9232
+ testCounts: null,
9233
+ durationMs: null,
9234
+ assertions: null,
9235
+ analysis: null,
9236
+ analysisSkipped: null,
9237
+ driftIssues: result.issues,
9238
+ failureLogExcerpt: null,
9239
+ diffExcerpt: null,
9240
+ specYaml: null,
9241
+ evidence: null,
9242
+ liveRun: null
9243
+ }));
9244
+ return {
9245
+ schemaVersion: 1,
9246
+ kind: "drift",
9247
+ createdAt: meta.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
9248
+ runId: meta.runId ?? null,
9249
+ git: meta.git,
9250
+ model: meta.model ?? null,
9251
+ language: meta.language ?? null,
9252
+ promptVersion: meta.promptVersion ?? "1",
9253
+ customPromptVersion: null,
9254
+ results: specResults
9255
+ };
9256
+ }
9151
9257
  //#endregion
9152
9258
  //#region src/drift/route-new-files.ts
9153
9259
  /**
@@ -9263,7 +9369,7 @@ Return the spec keys that might be affected by any of the new files. Conservativ
9263
9369
  //#endregion
9264
9370
  //#region src/cli/drift.ts
9265
9371
  const DEFAULT_CONCURRENCY = 3;
9266
- 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 specs whose relatedPaths intersect the git diff against --base (or, in CI, $GITHUB_BASE_REF, else origin/main). New files are routed to specs via a single lightweight Claude call.").option("--base <ref>", "Base ref to diff against when --changed is set. Defaults to $GITHUB_BASE_REF (CI) or origin/main.")).action(async (specPath, opts) => {
9372
+ 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 specs whose relatedPaths intersect the git diff against --base (or, in CI, $GITHUB_BASE_REF, else origin/main). New files are routed to specs via a single lightweight Claude call.").option("--base <ref>", "Base ref to diff against when --changed is set. Defaults to $GITHUB_BASE_REF (CI) or origin/main.").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(async (specPath, opts) => {
9267
9373
  const format = parseFormat(opts.format);
9268
9374
  const threshold = parseSeverity(opts.severity);
9269
9375
  const concurrency = parseConcurrency(opts.concurrency);
@@ -9279,6 +9385,7 @@ const driftCommand = addLanguageOption(new Command("drift").argument("[feature/s
9279
9385
  header("drift", specPath ?? `${targets.length} spec${targets.length > 1 ? "s" : ""}`);
9280
9386
  if (opts.cwd) meta("cwd", cwd);
9281
9387
  }
9388
+ const baseRef = opts.changed ? resolveBaseRef(opts.base) : null;
9282
9389
  if (opts.changed) {
9283
9390
  const total = targets.length;
9284
9391
  targets = await filterByChanged({
@@ -9304,8 +9411,69 @@ const driftCommand = addLanguageOption(new Command("drift").argument("[feature/s
9304
9411
  }
9305
9412
  });
9306
9413
  process.stdout.write(renderDrift(results, format, cwd));
9414
+ if (opts.push) await pushDriftResults({
9415
+ results,
9416
+ threshold,
9417
+ cwd,
9418
+ opts,
9419
+ format,
9420
+ baseRef
9421
+ });
9307
9422
  process.exit(determineExitCode(results, threshold));
9308
9423
  });
9424
+ /**
9425
+ * Push a finished drift audit to a ccqa hub as a `kind: "drift"` run, so it
9426
+ * shows up alongside `ccqa run` runs in the hub UI. Best-effort: a missing
9427
+ * hub connection warns and returns rather than failing the command (`--push`
9428
+ * never changes drift's own exit code).
9429
+ *
9430
+ * `resolveHub` is injectable so tests can supply a fake `HubClient` without
9431
+ * a real hub connection; it defaults to the real flag/env resolution.
9432
+ */
9433
+ async function pushDriftResults(args, resolveHub = resolveHubClient) {
9434
+ const { results, threshold, cwd, opts, format, baseRef } = args;
9435
+ const hub = resolveHub(opts);
9436
+ if (!hub) {
9437
+ warn("--push requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN) — skipping push");
9438
+ return;
9439
+ }
9440
+ try {
9441
+ const project = resolveProject({
9442
+ project: opts.project,
9443
+ cwd
9444
+ });
9445
+ const [branch, head] = await Promise.all([detectBranch(cwd), getGitHead(cwd)]);
9446
+ const report = driftResultsToReport(results, {
9447
+ threshold,
9448
+ git: {
9449
+ head,
9450
+ base: baseRef ?? null
9451
+ }
9452
+ });
9453
+ const dir = await mkdtemp(join(tmpdir(), "ccqa-drift-push-"));
9454
+ try {
9455
+ await writeFile(join(dir, "report.json"), JSON.stringify(report, null, 2), "utf8");
9456
+ const archive = await packDirToTarGz(dir);
9457
+ const run = await hub.pushRun(archive, {
9458
+ project,
9459
+ ...branch ? { branch } : {},
9460
+ kind: "drift"
9461
+ });
9462
+ if (format === "text") info(`pushed drift result to hub: ${(opts.hubUrl ?? process.env.CCQA_HUB_URL ?? "").replace(/\/+$/, "")}/#/runs/${run.id}`);
9463
+ } finally {
9464
+ await rm(dir, {
9465
+ recursive: true,
9466
+ force: true
9467
+ });
9468
+ }
9469
+ } catch (err) {
9470
+ if (err instanceof HubApiError) {
9471
+ error(`hub request failed (${err.status} ${err.code}): ${err.message}`);
9472
+ process.exit(2);
9473
+ }
9474
+ throw err;
9475
+ }
9476
+ }
9309
9477
  function exitWithNoSpecs(format, message) {
9310
9478
  if (format === "json") process.stdout.write(`${JSON.stringify({ specs: [] }, null, 2)}\n`);
9311
9479
  else if (format === "text") info(message);
@@ -10073,6 +10241,13 @@ z.object({
10073
10241
  profile: z.string().nullable(),
10074
10242
  branch: z.string().nullable(),
10075
10243
  status: RunStatusSchema,
10244
+ kind: z.enum(["run", "drift"]).default("run"),
10245
+ drift: z.object({
10246
+ issues: z.number(),
10247
+ errors: z.number(),
10248
+ warnings: z.number(),
10249
+ specsWithIssues: z.number()
10250
+ }).nullable().default(null),
10076
10251
  specs: z.object({
10077
10252
  total: z.number(),
10078
10253
  passed: z.number(),
@@ -10207,6 +10382,9 @@ function createPushRunHandler(config) {
10207
10382
  const branch = requireBranch(ctx.url.searchParams.get("branch"));
10208
10383
  const profileRaw = ctx.url.searchParams.get("profile");
10209
10384
  const profile = profileRaw ? requireSafeSegment(profileRaw, "profile") : null;
10385
+ const kindRaw = ctx.url.searchParams.get("kind");
10386
+ if (kindRaw !== null && kindRaw !== "run" && kindRaw !== "drift") throw new HttpError(400, "invalid_param", `invalid kind: must be "run" or "drift"`);
10387
+ const kind = kindRaw ?? "run";
10210
10388
  const body = await readBody(ctx.req, maxPushBytes);
10211
10389
  const dir = await mkdtemp(join(tmpdir(), "ccqa-hub-push-"));
10212
10390
  try {
@@ -10224,15 +10402,19 @@ function createPushRunHandler(config) {
10224
10402
  const parsed = RunReportDataSchema.safeParse(reportJson);
10225
10403
  if (!parsed.success) throw new HttpError(400, "invalid_report", `report.json is not a valid report: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`);
10226
10404
  const report = parsed.data;
10405
+ if (report.kind !== kind) throw new HttpError(400, "kind_mismatch", `?kind=${kind} does not match report.json's kind ("${report.kind}") — push with the matching ?kind= query param`);
10227
10406
  const total = report.results.length;
10228
10407
  const failed = report.results.filter((r) => r.status === "failed").length;
10229
10408
  const status = failed > 0 ? "failed" : "passed";
10409
+ const drift = kind === "drift" ? summarizeDrift(report.results) : null;
10230
10410
  const run = {
10231
10411
  id: randomUUID(),
10232
10412
  project,
10233
10413
  profile,
10234
10414
  branch,
10235
10415
  status,
10416
+ kind,
10417
+ drift,
10236
10418
  specs: {
10237
10419
  total,
10238
10420
  passed: total - failed,
@@ -10317,6 +10499,28 @@ async function getRunOr404(storage, id) {
10317
10499
  if (!run) throw new HttpError(404, "not_found", `run "${id}" not found`);
10318
10500
  return run;
10319
10501
  }
10502
+ /** Tally `driftIssues` across all specs into the `Run.drift` summary counters. */
10503
+ function summarizeDrift(results) {
10504
+ let issues = 0;
10505
+ let errors = 0;
10506
+ let warnings = 0;
10507
+ let specsWithIssues = 0;
10508
+ for (const r of results) {
10509
+ const driftIssues = r.driftIssues ?? [];
10510
+ if (driftIssues.length > 0) specsWithIssues++;
10511
+ for (const issue of driftIssues) {
10512
+ issues++;
10513
+ if (issue.severity === "ERROR") errors++;
10514
+ else if (issue.severity === "WARN") warnings++;
10515
+ }
10516
+ }
10517
+ return {
10518
+ issues,
10519
+ errors,
10520
+ warnings,
10521
+ specsWithIssues
10522
+ };
10523
+ }
10320
10524
  /**
10321
10525
  * A branch is a free-form label (e.g. `feature/foo`), stored verbatim and
10322
10526
  * never used to build a filesystem path, so `/` is allowed — only length is
@@ -11095,7 +11299,7 @@ const HTML_BODY = `
11095
11299
 
11096
11300
  <!-- Triage first: grading + learning is the most important action, so it
11097
11301
  sits above the spec list rather than being buried below it. -->
11098
- <div class="triage-head">
11302
+ <div class="triage-head" id="triage-head">
11099
11303
  <h3 style="font-size:14px" data-i18n="detail.triage">Triage</h3>
11100
11304
  <span class="triage-summary" id="triage-summary"></span>
11101
11305
  </div>
@@ -11430,7 +11634,16 @@ const CSS = `
11430
11634
  .badge-det { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
11431
11635
  .badge.drift-warn { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
11432
11636
  .badge.drift-warn .d { background: var(--amber); }
11637
+ .badge-drift { background: var(--violet-bg); color: var(--violet); border-color: var(--violet-border); }
11433
11638
  .chip { display: inline-flex; align-items: center; padding: 1px 8px; border-radius: 6px; background: var(--surface-3); border: 1px solid var(--border); color: var(--fg-dim); font-size: 12px; font-family: var(--mono); }
11639
+ /* Below .chip in source order so these override its background/border/color
11640
+ when combined as class="chip drift-*-chip" (same specificity — source
11641
+ order decides). */
11642
+ .chip.kind-chip { color: var(--violet); background: var(--violet-bg); border-color: var(--violet-border); font-family: var(--font); margin-left: 6px; }
11643
+ .chip.drift-count-chip { color: var(--amber); background: var(--amber-bg); border-color: var(--amber-border); margin-left: 6px; }
11644
+ .chip.drift-errors-chip { color: var(--fail); background: var(--fail-bg); border-color: var(--fail-border); margin-left: 6px; }
11645
+ .drift-meta-box { display: flex; flex-direction: column; gap: 4px; }
11646
+ .drift-meta-chips { display: flex; gap: 6px; }
11434
11647
  .specs { display: inline-flex; align-items: center; gap: 9px; }
11435
11648
  .meter { width: 54px; height: 6px; border-radius: 3px; background: var(--fail-bg); overflow: hidden; }
11436
11649
  .meter i { display: block; height: 100%; background: var(--pass); }
@@ -11519,6 +11732,7 @@ const CSS = `
11519
11732
  .drift-step { font-family: var(--mono); font-size: 11px; color: var(--muted-2); }
11520
11733
  .drift-msg { margin-top: 4px; color: var(--fg-dim); }
11521
11734
  .drift-detail { margin-top: 3px; color: var(--muted); font-size: 12px; }
11735
+ .drift-clean { color: var(--pass); font-size: 13px; }
11522
11736
 
11523
11737
  /* triage grading — an explicit question + a segmented single-select, framed
11524
11738
  as an action ("tell us the real cause"), not a data readout. */
@@ -11724,12 +11938,17 @@ const CLIENT_JS = `
11724
11938
  "detail.triage": "Triage",
11725
11939
  "meta.branch": "Branch", "meta.specs": "Specs", "meta.prompt": "Prompt",
11726
11940
  "meta.created": "Created", "meta.passed": "passed", "meta.profile": "Profile",
11941
+ "meta.drift": "Drift",
11727
11942
  "rec.title": "Recommendation",
11728
11943
  "acc.reasoning": "Reasoning", "acc.evidence": "Evidence", "acc.steps": "Live run steps",
11729
11944
  "acc.assertions": "Assertions", "acc.drift": "Drift audit",
11730
11945
  "acc.assertions.hint": "Test cases from the recorded spec run",
11731
11946
  "spec.kind.live": "Live", "spec.kind.det": "Deterministic",
11732
11947
  "spec.driftWarn": "Spec drift", "det.steps": "Steps",
11948
+ "kind.run": "Test run", "kind.drift": "Drift audit",
11949
+ "drift.summary.issues": "Issues", "drift.summary.errors": "Errors",
11950
+ "drift.summary.warnings": "Warnings", "drift.summary.specsWithIssues": "Specs with issues",
11951
+ "drift.clean": "No drift issues",
11733
11952
  "grade.question": "What was the real cause?", "grade.predicted": "predicted",
11734
11953
  "grade.ungraded": "ungraded", "grade.matches": "saved · matches",
11735
11954
  "grade.corrected": "saved · corrected", "grade.saving": "saving…",
@@ -11785,12 +12004,17 @@ const CLIENT_JS = `
11785
12004
  "detail.triage": "トリアージ",
11786
12005
  "meta.branch": "ブランチ", "meta.specs": "スペック", "meta.prompt": "プロンプト",
11787
12006
  "meta.created": "作成", "meta.passed": "合格", "meta.profile": "プロファイル",
12007
+ "meta.drift": "ドリフト",
11788
12008
  "rec.title": "推奨対応",
11789
12009
  "acc.reasoning": "推論", "acc.evidence": "根拠", "acc.steps": "実行ステップ",
11790
12010
  "acc.assertions": "アサーション", "acc.drift": "ドリフト監査",
11791
12011
  "acc.assertions.hint": "記録したスペック実行のテストケース",
11792
12012
  "spec.kind.live": "ライブ", "spec.kind.det": "決定的",
11793
12013
  "spec.driftWarn": "仕様ドリフト", "det.steps": "ステップ",
12014
+ "kind.run": "テスト実行", "kind.drift": "ドリフト監査",
12015
+ "drift.summary.issues": "問題数", "drift.summary.errors": "エラー",
12016
+ "drift.summary.warnings": "警告", "drift.summary.specsWithIssues": "問題のあるスペック",
12017
+ "drift.clean": "ドリフトの問題なし",
11794
12018
  "grade.question": "実際の原因は何でしたか?", "grade.predicted": "予測",
11795
12019
  "grade.ungraded": "未評価", "grade.matches": "保存済み · 一致",
11796
12020
  "grade.corrected": "保存済み · 修正", "grade.saving": "保存中…",
@@ -12170,6 +12394,19 @@ const CLIENT_JS = `
12170
12394
  runCell.appendChild(el("div", "runid", r.id.slice(0, 8)));
12171
12395
  var sub = el("div", "subline");
12172
12396
  sub.appendChild(ciBadge(r));
12397
+ if (r.kind === "drift") {
12398
+ sub.appendChild(el("span", "chip kind-chip", t("kind.drift")));
12399
+ if (r.drift) {
12400
+ if (r.drift.errors > 0) {
12401
+ sub.appendChild(el("span", "chip drift-errors-chip", t("drift.summary.errors") + " " + r.drift.errors));
12402
+ }
12403
+ if (r.drift.warnings > 0) {
12404
+ sub.appendChild(el("span", "chip drift-count-chip", t("drift.summary.warnings") + " " + r.drift.warnings));
12405
+ }
12406
+ }
12407
+ } else {
12408
+ sub.appendChild(el("span", "chip kind-chip", t("kind.run")));
12409
+ }
12173
12410
  runCell.appendChild(sub);
12174
12411
  tr.appendChild(runCell);
12175
12412
 
@@ -12245,7 +12482,29 @@ const CLIENT_JS = `
12245
12482
  metaItem(t("meta.branch"), branchChip);
12246
12483
  // Which environment the run executed against (recorded at push, display-only).
12247
12484
  if (run.profile) metaItem(t("meta.profile"), el("span", "chip", run.profile));
12248
- metaItem(t("meta.specs"), run.specs.passed + " / " + run.specs.total + " " + t("meta.passed"));
12485
+ if (run.kind === "drift" && run.drift) {
12486
+ // Drift runs have no live/deterministic spec pass count. Errors/warnings
12487
+ // are the actionable counts and get colored chips; issues/specsWithIssues
12488
+ // are supplementary totals shown as a muted line below.
12489
+ if (run.drift.errors === 0 && run.drift.warnings === 0) {
12490
+ metaItem(t("meta.drift"), el("div", "drift-clean", t("drift.clean")));
12491
+ } else {
12492
+ var driftBox = el("div", "drift-meta-box");
12493
+ var chips = el("div", "drift-meta-chips");
12494
+ if (run.drift.errors > 0) {
12495
+ chips.appendChild(el("span", "chip drift-errors-chip", t("drift.summary.errors") + " " + run.drift.errors));
12496
+ }
12497
+ if (run.drift.warnings > 0) {
12498
+ chips.appendChild(el("span", "chip drift-count-chip", t("drift.summary.warnings") + " " + run.drift.warnings));
12499
+ }
12500
+ driftBox.appendChild(chips);
12501
+ var driftSub = el("div", "muted", t("drift.summary.specsWithIssues") + " " + run.drift.specsWithIssues + " / " + t("drift.summary.issues") + " " + run.drift.issues);
12502
+ driftBox.appendChild(driftSub);
12503
+ metaItem(t("meta.drift"), driftBox);
12504
+ }
12505
+ } else {
12506
+ metaItem(t("meta.specs"), run.specs.passed + " / " + run.specs.total + " " + t("meta.passed"));
12507
+ }
12249
12508
  metaItem(t("meta.prompt"), run.promptVersion || "—");
12250
12509
  metaItem(t("meta.created"), relTime(run.createdAt));
12251
12510
  head.appendChild(meta);
@@ -12583,7 +12842,7 @@ const CLIENT_JS = `
12583
12842
  return wrap;
12584
12843
  }
12585
12844
 
12586
- function renderSpecCard(runId, r, triageState) {
12845
+ function renderSpecCard(runId, r, triageState, isDrift) {
12587
12846
  var card = el("div", "spec-card " + r.status); // .passed / .failed rail
12588
12847
  var head = el("div", "spec-card-head");
12589
12848
  var nameBlock = el("div");
@@ -12591,7 +12850,10 @@ const CLIENT_JS = `
12591
12850
  nameBlock.appendChild(el("div", "slug", r.feature + " / " + r.spec));
12592
12851
  head.appendChild(nameBlock);
12593
12852
  head.appendChild(el("div", "spacer"));
12594
- head.appendChild(r.liveRun ? el("span", "badge-live", t("spec.kind.live")) : el("span", "badge-det", t("spec.kind.det")));
12853
+ head.appendChild(
12854
+ isDrift ? el("span", "badge badge-drift", t("kind.drift"))
12855
+ : (r.liveRun ? el("span", "badge-live", t("spec.kind.live"))
12856
+ : el("span", "badge-det", t("spec.kind.det"))));
12595
12857
  head.appendChild(statusBadge(r.status));
12596
12858
  var hasDriftError = r.driftIssues && r.driftIssues.some(function (d) { return d.severity === "ERROR"; });
12597
12859
  if (hasDriftError) {
@@ -12605,6 +12867,11 @@ const CLIENT_JS = `
12605
12867
  var body = el("div", "spec-card-body");
12606
12868
  var any = false;
12607
12869
 
12870
+ if (isDrift && (!r.driftIssues || r.driftIssues.length === 0)) {
12871
+ body.appendChild(el("div", "drift-clean", t("drift.clean")));
12872
+ any = true;
12873
+ }
12874
+
12608
12875
  if (r.status === "failed" && r.analysis) {
12609
12876
  // Tier2: the verdict block (analysis) + the grading action, always shown.
12610
12877
  body.appendChild(analysisSection(runId, r));
@@ -12651,10 +12918,10 @@ const CLIENT_JS = `
12651
12918
  return card;
12652
12919
  }
12653
12920
 
12654
- function renderSpecCards(runId, results, triageState) {
12921
+ function renderSpecCards(runId, results, triageState, isDrift) {
12655
12922
  var container = document.getElementById("spec-cards");
12656
12923
  clear(container);
12657
- results.forEach(function (r) { container.appendChild(renderSpecCard(runId, r, triageState)); });
12924
+ results.forEach(function (r) { container.appendChild(renderSpecCard(runId, r, triageState, isDrift)); });
12658
12925
  document.getElementById("detail-spec-count").textContent = "· " + results.length;
12659
12926
  }
12660
12927
 
@@ -12791,9 +13058,14 @@ const CLIENT_JS = `
12791
13058
  // triage loads so the saved grades restore. Keeping these separate means
12792
13059
  // a triage failure (or a throw while rendering cards) can't be mislabelled
12793
13060
  // as the other, and neither escapes its own catch.
12794
- renderSpecCards(runId, report.results, { byKey: {} });
13061
+ var isDrift = report.kind === "drift";
13062
+ document.getElementById("triage-head").hidden = isDrift;
13063
+ document.getElementById("matrix-card").hidden = isDrift;
13064
+ document.getElementById("triage-progress").hidden = isDrift;
13065
+ renderSpecCards(runId, report.results, { byKey: {} }, isDrift);
13066
+ if (isDrift) return; // drift runs have no triage: skip loadTriage entirely
12795
13067
  loadTriage(runId, function (triageState) {
12796
- renderSpecCards(runId, report.results, triageState);
13068
+ renderSpecCards(runId, report.results, triageState, isDrift);
12797
13069
  });
12798
13070
  }).catch(detailError("Error loading report"));
12799
13071
  }
@@ -31,6 +31,16 @@ declare const RunSchema: z.ZodObject<{
31
31
  passed: "passed";
32
32
  failed: "failed";
33
33
  }>;
34
+ kind: z.ZodDefault<z.ZodEnum<{
35
+ run: "run";
36
+ drift: "drift";
37
+ }>>;
38
+ drift: z.ZodDefault<z.ZodNullable<z.ZodObject<{
39
+ issues: z.ZodNumber;
40
+ errors: z.ZodNumber;
41
+ warnings: z.ZodNumber;
42
+ specsWithIssues: z.ZodNumber;
43
+ }, z.core.$strip>>>;
34
44
  specs: z.ZodObject<{
35
45
  total: z.ZodNumber;
36
46
  passed: z.ZodNumber;
@@ -171,6 +181,12 @@ type LabelsExport = z.infer<typeof LabelsExportSchema>;
171
181
  interface HubClientOptions {
172
182
  baseUrl: string;
173
183
  token: string;
184
+ /**
185
+ * Extra headers sent on every request, ahead of any per-call headers and
186
+ * the `Authorization` header (e.g. an infra gateway/ALB bypass header in
187
+ * CI — never overrides `Authorization`).
188
+ */
189
+ headers?: Record<string, string>;
174
190
  /** Override for testing; defaults to the global `fetch`. */
175
191
  fetchImpl?: typeof fetch;
176
192
  }
@@ -197,6 +213,7 @@ interface HubClient {
197
213
  project: string;
198
214
  branch?: string;
199
215
  profile?: string;
216
+ kind?: "run" | "drift";
200
217
  }): Promise<Run>;
201
218
  listRuns(q?: {
202
219
  project?: string;
@@ -51,6 +51,7 @@ function createHubClient(opts) {
51
51
  ...init,
52
52
  signal,
53
53
  headers: {
54
+ ...opts.headers,
54
55
  ...init.headers,
55
56
  Authorization: `Bearer ${opts.token}`
56
57
  }
@@ -96,6 +97,7 @@ function createHubClient(opts) {
96
97
  const params = new URLSearchParams({ project: meta.project });
97
98
  if (meta.branch) params.set("branch", meta.branch);
98
99
  if (meta.profile) params.set("profile", meta.profile);
100
+ if (meta.kind) params.set("kind", meta.kind);
99
101
  return json(`/api/v1/runs?${params}`, {
100
102
  method: "POST",
101
103
  headers: { "Content-Type": "application/gzip" },
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "0.11.0",
3
+ "version": "0.13.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": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {