ccqa 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/ccqa.mjs CHANGED
@@ -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({
@@ -3242,6 +3243,25 @@ function resolveHubContext(opts) {
3242
3243
  project: resolveProjectOrThrow(opts.project, opts.cwd ?? process.cwd())
3243
3244
  };
3244
3245
  }
3246
+ /**
3247
+ * Wrap a subcommand action so a `HubApiError` (hub request failed, e.g. a
3248
+ * 503 when the hub has no encryption key configured) prints a clean message
3249
+ * and exits 2, instead of surfacing as an unhandled rejection with a stack
3250
+ * trace.
3251
+ */
3252
+ function withHubErrors(fn) {
3253
+ return async (...args) => {
3254
+ try {
3255
+ await fn(...args);
3256
+ } catch (err) {
3257
+ if (err instanceof HubApiError) {
3258
+ error(`hub request failed (${err.status} ${err.code}): ${err.message}`);
3259
+ process.exit(2);
3260
+ }
3261
+ throw err;
3262
+ }
3263
+ };
3264
+ }
3245
3265
  //#endregion
3246
3266
  //#region src/cli/options.ts
3247
3267
  /**
@@ -3856,6 +3876,33 @@ function resolvePromptLocalPath(name, cwd) {
3856
3876
  return join(cwd ?? process.cwd(), PROMPT_LOCAL_PATHS[name]);
3857
3877
  }
3858
3878
  //#endregion
3879
+ //#region src/cli/git-branch.ts
3880
+ /** Best-effort current branch: CI env vars first, then git, else null. */
3881
+ async function detectBranch(cwd) {
3882
+ const fromEnv = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME;
3883
+ if (fromEnv) return fromEnv;
3884
+ try {
3885
+ const { stdout } = await execFileP("git", [
3886
+ "rev-parse",
3887
+ "--abbrev-ref",
3888
+ "HEAD"
3889
+ ], { cwd });
3890
+ const branch = stdout.trim();
3891
+ return branch && branch !== "HEAD" ? branch : null;
3892
+ } catch {
3893
+ return null;
3894
+ }
3895
+ }
3896
+ /** Best-effort current commit SHA, or null (e.g. not a git repo). */
3897
+ async function getGitHead(cwd) {
3898
+ try {
3899
+ const { stdout } = await execFileP("git", ["rev-parse", "HEAD"], { cwd });
3900
+ return stdout.trim() || null;
3901
+ } catch {
3902
+ return null;
3903
+ }
3904
+ }
3905
+ //#endregion
3859
3906
  //#region src/cli/hub.ts
3860
3907
  /**
3861
3908
  * `ccqa hub` — the client side of the ccqa hub (a results/secret control
@@ -3904,25 +3951,6 @@ async function readStdin() {
3904
3951
  for await (const chunk of process.stdin) chunks.push(chunk);
3905
3952
  return Buffer.concat(chunks).toString("utf8");
3906
3953
  }
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
3954
  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
3955
  const name = validateSessionName(rawName);
3928
3956
  const cwd = resolveCwd(opts.cwd);
@@ -4084,22 +4112,6 @@ const pushCommand = new Command("push").description("Upload the report directory
4084
4112
  info(`${resolveBaseUrl(opts)}/#/runs/${run.id}`);
4085
4113
  }));
4086
4114
  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
4115
  /** Loose check that a fetched session is agent-browser storage-state, mirroring loadStorageState. */
4104
4116
  function isStorageStateShape(state) {
4105
4117
  return typeof state === "object" && state !== null && Array.isArray(state.cookies) && Array.isArray(state.origins);
@@ -5573,6 +5585,7 @@ async function writeUnifiedReport(args) {
5573
5585
  const { reportDir, results, diff, baseRef, customPromptVersion, opts } = args;
5574
5586
  const data = {
5575
5587
  schemaVersion: 1,
5588
+ kind: "run",
5576
5589
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
5577
5590
  runId: process.env["GITHUB_RUN_ID"] ?? null,
5578
5591
  git: {
@@ -9148,6 +9161,55 @@ function determineExitCode(results, threshold) {
9148
9161
  }
9149
9162
  return 0;
9150
9163
  }
9164
+ /**
9165
+ * Spec-level status under the given threshold, mirroring determineExitCode's
9166
+ * per-issue logic (exit-code.ts) but scoped to a single SpecResult.
9167
+ */
9168
+ function specStatus(result, threshold) {
9169
+ if (result.error) return "failed";
9170
+ for (const issue of result.issues) {
9171
+ if (issue.severity === "ERROR") return "failed";
9172
+ if (threshold === "warn" && issue.severity === "WARN") return "failed";
9173
+ }
9174
+ return "passed";
9175
+ }
9176
+ /**
9177
+ * Adapts `ccqa drift` results into the shared RunReportData shape so they can
9178
+ * be pushed to the hub (`ccqa drift --push`) and rendered by the same report
9179
+ * UI as `ccqa run`/`ccqa live`. Browser-execution fields (testCounts,
9180
+ * evidence, liveRun, ...) don't apply to a drift audit and are always null.
9181
+ */
9182
+ function driftResultsToReport(results, meta) {
9183
+ const specResults = results.map((result) => ({
9184
+ feature: result.target.featureName,
9185
+ spec: result.target.specName,
9186
+ title: null,
9187
+ status: specStatus(result, meta.threshold),
9188
+ testCounts: null,
9189
+ durationMs: null,
9190
+ assertions: null,
9191
+ analysis: null,
9192
+ analysisSkipped: null,
9193
+ driftIssues: result.issues,
9194
+ failureLogExcerpt: null,
9195
+ diffExcerpt: null,
9196
+ specYaml: null,
9197
+ evidence: null,
9198
+ liveRun: null
9199
+ }));
9200
+ return {
9201
+ schemaVersion: 1,
9202
+ kind: "drift",
9203
+ createdAt: meta.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
9204
+ runId: meta.runId ?? null,
9205
+ git: meta.git,
9206
+ model: meta.model ?? null,
9207
+ language: meta.language ?? null,
9208
+ promptVersion: meta.promptVersion ?? "1",
9209
+ customPromptVersion: null,
9210
+ results: specResults
9211
+ };
9212
+ }
9151
9213
  //#endregion
9152
9214
  //#region src/drift/route-new-files.ts
9153
9215
  /**
@@ -9263,7 +9325,7 @@ Return the spec keys that might be affected by any of the new files. Conservativ
9263
9325
  //#endregion
9264
9326
  //#region src/cli/drift.ts
9265
9327
  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) => {
9328
+ 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)).action(async (specPath, opts) => {
9267
9329
  const format = parseFormat(opts.format);
9268
9330
  const threshold = parseSeverity(opts.severity);
9269
9331
  const concurrency = parseConcurrency(opts.concurrency);
@@ -9279,6 +9341,7 @@ const driftCommand = addLanguageOption(new Command("drift").argument("[feature/s
9279
9341
  header("drift", specPath ?? `${targets.length} spec${targets.length > 1 ? "s" : ""}`);
9280
9342
  if (opts.cwd) meta("cwd", cwd);
9281
9343
  }
9344
+ const baseRef = opts.changed ? resolveBaseRef(opts.base) : null;
9282
9345
  if (opts.changed) {
9283
9346
  const total = targets.length;
9284
9347
  targets = await filterByChanged({
@@ -9304,8 +9367,69 @@ const driftCommand = addLanguageOption(new Command("drift").argument("[feature/s
9304
9367
  }
9305
9368
  });
9306
9369
  process.stdout.write(renderDrift(results, format, cwd));
9370
+ if (opts.push) await pushDriftResults({
9371
+ results,
9372
+ threshold,
9373
+ cwd,
9374
+ opts,
9375
+ format,
9376
+ baseRef
9377
+ });
9307
9378
  process.exit(determineExitCode(results, threshold));
9308
9379
  });
9380
+ /**
9381
+ * Push a finished drift audit to a ccqa hub as a `kind: "drift"` run, so it
9382
+ * shows up alongside `ccqa run` runs in the hub UI. Best-effort: a missing
9383
+ * hub connection warns and returns rather than failing the command (`--push`
9384
+ * never changes drift's own exit code).
9385
+ *
9386
+ * `resolveHub` is injectable so tests can supply a fake `HubClient` without
9387
+ * a real hub connection; it defaults to the real flag/env resolution.
9388
+ */
9389
+ async function pushDriftResults(args, resolveHub = resolveHubClient) {
9390
+ const { results, threshold, cwd, opts, format, baseRef } = args;
9391
+ const hub = resolveHub(opts);
9392
+ if (!hub) {
9393
+ warn("--push requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN) — skipping push");
9394
+ return;
9395
+ }
9396
+ try {
9397
+ const project = resolveProject({
9398
+ project: opts.project,
9399
+ cwd
9400
+ });
9401
+ const [branch, head] = await Promise.all([detectBranch(cwd), getGitHead(cwd)]);
9402
+ const report = driftResultsToReport(results, {
9403
+ threshold,
9404
+ git: {
9405
+ head,
9406
+ base: baseRef ?? null
9407
+ }
9408
+ });
9409
+ const dir = await mkdtemp(join(tmpdir(), "ccqa-drift-push-"));
9410
+ try {
9411
+ await writeFile(join(dir, "report.json"), JSON.stringify(report, null, 2), "utf8");
9412
+ const archive = await packDirToTarGz(dir);
9413
+ const run = await hub.pushRun(archive, {
9414
+ project,
9415
+ ...branch ? { branch } : {},
9416
+ kind: "drift"
9417
+ });
9418
+ if (format === "text") info(`pushed drift result to hub: ${(opts.hubUrl ?? process.env.CCQA_HUB_URL ?? "").replace(/\/+$/, "")}/#/runs/${run.id}`);
9419
+ } finally {
9420
+ await rm(dir, {
9421
+ recursive: true,
9422
+ force: true
9423
+ });
9424
+ }
9425
+ } catch (err) {
9426
+ if (err instanceof HubApiError) {
9427
+ error(`hub request failed (${err.status} ${err.code}): ${err.message}`);
9428
+ process.exit(2);
9429
+ }
9430
+ throw err;
9431
+ }
9432
+ }
9309
9433
  function exitWithNoSpecs(format, message) {
9310
9434
  if (format === "json") process.stdout.write(`${JSON.stringify({ specs: [] }, null, 2)}\n`);
9311
9435
  else if (format === "text") info(message);
@@ -10073,6 +10197,13 @@ z.object({
10073
10197
  profile: z.string().nullable(),
10074
10198
  branch: z.string().nullable(),
10075
10199
  status: RunStatusSchema,
10200
+ kind: z.enum(["run", "drift"]).default("run"),
10201
+ drift: z.object({
10202
+ issues: z.number(),
10203
+ errors: z.number(),
10204
+ warnings: z.number(),
10205
+ specsWithIssues: z.number()
10206
+ }).nullable().default(null),
10076
10207
  specs: z.object({
10077
10208
  total: z.number(),
10078
10209
  passed: z.number(),
@@ -10207,6 +10338,9 @@ function createPushRunHandler(config) {
10207
10338
  const branch = requireBranch(ctx.url.searchParams.get("branch"));
10208
10339
  const profileRaw = ctx.url.searchParams.get("profile");
10209
10340
  const profile = profileRaw ? requireSafeSegment(profileRaw, "profile") : null;
10341
+ const kindRaw = ctx.url.searchParams.get("kind");
10342
+ if (kindRaw !== null && kindRaw !== "run" && kindRaw !== "drift") throw new HttpError(400, "invalid_param", `invalid kind: must be "run" or "drift"`);
10343
+ const kind = kindRaw ?? "run";
10210
10344
  const body = await readBody(ctx.req, maxPushBytes);
10211
10345
  const dir = await mkdtemp(join(tmpdir(), "ccqa-hub-push-"));
10212
10346
  try {
@@ -10224,15 +10358,19 @@ function createPushRunHandler(config) {
10224
10358
  const parsed = RunReportDataSchema.safeParse(reportJson);
10225
10359
  if (!parsed.success) throw new HttpError(400, "invalid_report", `report.json is not a valid report: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`);
10226
10360
  const report = parsed.data;
10361
+ 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
10362
  const total = report.results.length;
10228
10363
  const failed = report.results.filter((r) => r.status === "failed").length;
10229
10364
  const status = failed > 0 ? "failed" : "passed";
10365
+ const drift = kind === "drift" ? summarizeDrift(report.results) : null;
10230
10366
  const run = {
10231
10367
  id: randomUUID(),
10232
10368
  project,
10233
10369
  profile,
10234
10370
  branch,
10235
10371
  status,
10372
+ kind,
10373
+ drift,
10236
10374
  specs: {
10237
10375
  total,
10238
10376
  passed: total - failed,
@@ -10317,6 +10455,28 @@ async function getRunOr404(storage, id) {
10317
10455
  if (!run) throw new HttpError(404, "not_found", `run "${id}" not found`);
10318
10456
  return run;
10319
10457
  }
10458
+ /** Tally `driftIssues` across all specs into the `Run.drift` summary counters. */
10459
+ function summarizeDrift(results) {
10460
+ let issues = 0;
10461
+ let errors = 0;
10462
+ let warnings = 0;
10463
+ let specsWithIssues = 0;
10464
+ for (const r of results) {
10465
+ const driftIssues = r.driftIssues ?? [];
10466
+ if (driftIssues.length > 0) specsWithIssues++;
10467
+ for (const issue of driftIssues) {
10468
+ issues++;
10469
+ if (issue.severity === "ERROR") errors++;
10470
+ else if (issue.severity === "WARN") warnings++;
10471
+ }
10472
+ }
10473
+ return {
10474
+ issues,
10475
+ errors,
10476
+ warnings,
10477
+ specsWithIssues
10478
+ };
10479
+ }
10320
10480
  /**
10321
10481
  * A branch is a free-form label (e.g. `feature/foo`), stored verbatim and
10322
10482
  * never used to build a filesystem path, so `/` is allowed — only length is
@@ -11095,7 +11255,7 @@ const HTML_BODY = `
11095
11255
 
11096
11256
  <!-- Triage first: grading + learning is the most important action, so it
11097
11257
  sits above the spec list rather than being buried below it. -->
11098
- <div class="triage-head">
11258
+ <div class="triage-head" id="triage-head">
11099
11259
  <h3 style="font-size:14px" data-i18n="detail.triage">Triage</h3>
11100
11260
  <span class="triage-summary" id="triage-summary"></span>
11101
11261
  </div>
@@ -11430,7 +11590,16 @@ const CSS = `
11430
11590
  .badge-det { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
11431
11591
  .badge.drift-warn { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
11432
11592
  .badge.drift-warn .d { background: var(--amber); }
11593
+ .badge-drift { background: var(--violet-bg); color: var(--violet); border-color: var(--violet-border); }
11433
11594
  .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); }
11595
+ /* Below .chip in source order so these override its background/border/color
11596
+ when combined as class="chip drift-*-chip" (same specificity — source
11597
+ order decides). */
11598
+ .chip.kind-chip { color: var(--violet); background: var(--violet-bg); border-color: var(--violet-border); font-family: var(--font); margin-left: 6px; }
11599
+ .chip.drift-count-chip { color: var(--amber); background: var(--amber-bg); border-color: var(--amber-border); margin-left: 6px; }
11600
+ .chip.drift-errors-chip { color: var(--fail); background: var(--fail-bg); border-color: var(--fail-border); }
11601
+ .drift-meta-box { display: flex; flex-direction: column; gap: 4px; }
11602
+ .drift-meta-chips { display: flex; gap: 6px; }
11434
11603
  .specs { display: inline-flex; align-items: center; gap: 9px; }
11435
11604
  .meter { width: 54px; height: 6px; border-radius: 3px; background: var(--fail-bg); overflow: hidden; }
11436
11605
  .meter i { display: block; height: 100%; background: var(--pass); }
@@ -11519,6 +11688,7 @@ const CSS = `
11519
11688
  .drift-step { font-family: var(--mono); font-size: 11px; color: var(--muted-2); }
11520
11689
  .drift-msg { margin-top: 4px; color: var(--fg-dim); }
11521
11690
  .drift-detail { margin-top: 3px; color: var(--muted); font-size: 12px; }
11691
+ .drift-clean { color: var(--pass); font-size: 13px; }
11522
11692
 
11523
11693
  /* triage grading — an explicit question + a segmented single-select, framed
11524
11694
  as an action ("tell us the real cause"), not a data readout. */
@@ -11724,12 +11894,17 @@ const CLIENT_JS = `
11724
11894
  "detail.triage": "Triage",
11725
11895
  "meta.branch": "Branch", "meta.specs": "Specs", "meta.prompt": "Prompt",
11726
11896
  "meta.created": "Created", "meta.passed": "passed", "meta.profile": "Profile",
11897
+ "meta.drift": "Drift",
11727
11898
  "rec.title": "Recommendation",
11728
11899
  "acc.reasoning": "Reasoning", "acc.evidence": "Evidence", "acc.steps": "Live run steps",
11729
11900
  "acc.assertions": "Assertions", "acc.drift": "Drift audit",
11730
11901
  "acc.assertions.hint": "Test cases from the recorded spec run",
11731
11902
  "spec.kind.live": "Live", "spec.kind.det": "Deterministic",
11732
11903
  "spec.driftWarn": "Spec drift", "det.steps": "Steps",
11904
+ "kind.run": "Test run", "kind.drift": "Drift audit",
11905
+ "drift.summary.issues": "Issues", "drift.summary.errors": "Errors",
11906
+ "drift.summary.warnings": "Warnings", "drift.summary.specsWithIssues": "Specs with issues",
11907
+ "drift.clean": "No drift issues",
11733
11908
  "grade.question": "What was the real cause?", "grade.predicted": "predicted",
11734
11909
  "grade.ungraded": "ungraded", "grade.matches": "saved · matches",
11735
11910
  "grade.corrected": "saved · corrected", "grade.saving": "saving…",
@@ -11785,12 +11960,17 @@ const CLIENT_JS = `
11785
11960
  "detail.triage": "トリアージ",
11786
11961
  "meta.branch": "ブランチ", "meta.specs": "スペック", "meta.prompt": "プロンプト",
11787
11962
  "meta.created": "作成", "meta.passed": "合格", "meta.profile": "プロファイル",
11963
+ "meta.drift": "ドリフト",
11788
11964
  "rec.title": "推奨対応",
11789
11965
  "acc.reasoning": "推論", "acc.evidence": "根拠", "acc.steps": "実行ステップ",
11790
11966
  "acc.assertions": "アサーション", "acc.drift": "ドリフト監査",
11791
11967
  "acc.assertions.hint": "記録したスペック実行のテストケース",
11792
11968
  "spec.kind.live": "ライブ", "spec.kind.det": "決定的",
11793
11969
  "spec.driftWarn": "仕様ドリフト", "det.steps": "ステップ",
11970
+ "kind.run": "テスト実行", "kind.drift": "ドリフト監査",
11971
+ "drift.summary.issues": "問題数", "drift.summary.errors": "エラー",
11972
+ "drift.summary.warnings": "警告", "drift.summary.specsWithIssues": "問題のあるスペック",
11973
+ "drift.clean": "ドリフトの問題なし",
11794
11974
  "grade.question": "実際の原因は何でしたか?", "grade.predicted": "予測",
11795
11975
  "grade.ungraded": "未評価", "grade.matches": "保存済み · 一致",
11796
11976
  "grade.corrected": "保存済み · 修正", "grade.saving": "保存中…",
@@ -12170,6 +12350,19 @@ const CLIENT_JS = `
12170
12350
  runCell.appendChild(el("div", "runid", r.id.slice(0, 8)));
12171
12351
  var sub = el("div", "subline");
12172
12352
  sub.appendChild(ciBadge(r));
12353
+ if (r.kind === "drift") {
12354
+ sub.appendChild(el("span", "chip kind-chip", t("kind.drift")));
12355
+ if (r.drift) {
12356
+ if (r.drift.errors > 0) {
12357
+ sub.appendChild(el("span", "chip drift-errors-chip", t("drift.summary.errors") + " " + r.drift.errors));
12358
+ }
12359
+ if (r.drift.warnings > 0) {
12360
+ sub.appendChild(el("span", "chip drift-count-chip", t("drift.summary.warnings") + " " + r.drift.warnings));
12361
+ }
12362
+ }
12363
+ } else {
12364
+ sub.appendChild(el("span", "chip kind-chip", t("kind.run")));
12365
+ }
12173
12366
  runCell.appendChild(sub);
12174
12367
  tr.appendChild(runCell);
12175
12368
 
@@ -12245,7 +12438,29 @@ const CLIENT_JS = `
12245
12438
  metaItem(t("meta.branch"), branchChip);
12246
12439
  // Which environment the run executed against (recorded at push, display-only).
12247
12440
  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"));
12441
+ if (run.kind === "drift" && run.drift) {
12442
+ // Drift runs have no live/deterministic spec pass count. Errors/warnings
12443
+ // are the actionable counts and get colored chips; issues/specsWithIssues
12444
+ // are supplementary totals shown as a muted line below.
12445
+ if (run.drift.errors === 0 && run.drift.warnings === 0) {
12446
+ metaItem(t("meta.drift"), el("div", "drift-clean", t("drift.clean")));
12447
+ } else {
12448
+ var driftBox = el("div", "drift-meta-box");
12449
+ var chips = el("div", "drift-meta-chips");
12450
+ if (run.drift.errors > 0) {
12451
+ chips.appendChild(el("span", "chip drift-errors-chip", t("drift.summary.errors") + " " + run.drift.errors));
12452
+ }
12453
+ if (run.drift.warnings > 0) {
12454
+ chips.appendChild(el("span", "chip drift-count-chip", t("drift.summary.warnings") + " " + run.drift.warnings));
12455
+ }
12456
+ driftBox.appendChild(chips);
12457
+ var driftSub = el("div", "muted", t("drift.summary.specsWithIssues") + " " + run.drift.specsWithIssues + " / " + t("drift.summary.issues") + " " + run.drift.issues);
12458
+ driftBox.appendChild(driftSub);
12459
+ metaItem(t("meta.drift"), driftBox);
12460
+ }
12461
+ } else {
12462
+ metaItem(t("meta.specs"), run.specs.passed + " / " + run.specs.total + " " + t("meta.passed"));
12463
+ }
12249
12464
  metaItem(t("meta.prompt"), run.promptVersion || "—");
12250
12465
  metaItem(t("meta.created"), relTime(run.createdAt));
12251
12466
  head.appendChild(meta);
@@ -12583,7 +12798,7 @@ const CLIENT_JS = `
12583
12798
  return wrap;
12584
12799
  }
12585
12800
 
12586
- function renderSpecCard(runId, r, triageState) {
12801
+ function renderSpecCard(runId, r, triageState, isDrift) {
12587
12802
  var card = el("div", "spec-card " + r.status); // .passed / .failed rail
12588
12803
  var head = el("div", "spec-card-head");
12589
12804
  var nameBlock = el("div");
@@ -12591,7 +12806,10 @@ const CLIENT_JS = `
12591
12806
  nameBlock.appendChild(el("div", "slug", r.feature + " / " + r.spec));
12592
12807
  head.appendChild(nameBlock);
12593
12808
  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")));
12809
+ head.appendChild(
12810
+ isDrift ? el("span", "badge badge-drift", t("kind.drift"))
12811
+ : (r.liveRun ? el("span", "badge-live", t("spec.kind.live"))
12812
+ : el("span", "badge-det", t("spec.kind.det"))));
12595
12813
  head.appendChild(statusBadge(r.status));
12596
12814
  var hasDriftError = r.driftIssues && r.driftIssues.some(function (d) { return d.severity === "ERROR"; });
12597
12815
  if (hasDriftError) {
@@ -12605,6 +12823,11 @@ const CLIENT_JS = `
12605
12823
  var body = el("div", "spec-card-body");
12606
12824
  var any = false;
12607
12825
 
12826
+ if (isDrift && (!r.driftIssues || r.driftIssues.length === 0)) {
12827
+ body.appendChild(el("div", "drift-clean", t("drift.clean")));
12828
+ any = true;
12829
+ }
12830
+
12608
12831
  if (r.status === "failed" && r.analysis) {
12609
12832
  // Tier2: the verdict block (analysis) + the grading action, always shown.
12610
12833
  body.appendChild(analysisSection(runId, r));
@@ -12651,10 +12874,10 @@ const CLIENT_JS = `
12651
12874
  return card;
12652
12875
  }
12653
12876
 
12654
- function renderSpecCards(runId, results, triageState) {
12877
+ function renderSpecCards(runId, results, triageState, isDrift) {
12655
12878
  var container = document.getElementById("spec-cards");
12656
12879
  clear(container);
12657
- results.forEach(function (r) { container.appendChild(renderSpecCard(runId, r, triageState)); });
12880
+ results.forEach(function (r) { container.appendChild(renderSpecCard(runId, r, triageState, isDrift)); });
12658
12881
  document.getElementById("detail-spec-count").textContent = "· " + results.length;
12659
12882
  }
12660
12883
 
@@ -12791,9 +13014,14 @@ const CLIENT_JS = `
12791
13014
  // triage loads so the saved grades restore. Keeping these separate means
12792
13015
  // a triage failure (or a throw while rendering cards) can't be mislabelled
12793
13016
  // as the other, and neither escapes its own catch.
12794
- renderSpecCards(runId, report.results, { byKey: {} });
13017
+ var isDrift = report.kind === "drift";
13018
+ document.getElementById("triage-head").hidden = isDrift;
13019
+ document.getElementById("matrix-card").hidden = isDrift;
13020
+ document.getElementById("triage-progress").hidden = isDrift;
13021
+ renderSpecCards(runId, report.results, { byKey: {} }, isDrift);
13022
+ if (isDrift) return; // drift runs have no triage: skip loadTriage entirely
12795
13023
  loadTriage(runId, function (triageState) {
12796
- renderSpecCards(runId, report.results, triageState);
13024
+ renderSpecCards(runId, report.results, triageState, isDrift);
12797
13025
  });
12798
13026
  }).catch(detailError("Error loading report"));
12799
13027
  }
@@ -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;
@@ -197,6 +207,7 @@ interface HubClient {
197
207
  project: string;
198
208
  branch?: string;
199
209
  profile?: string;
210
+ kind?: "run" | "drift";
200
211
  }): Promise<Run>;
201
212
  listRuns(q?: {
202
213
  project?: string;
@@ -96,6 +96,7 @@ function createHubClient(opts) {
96
96
  const params = new URLSearchParams({ project: meta.project });
97
97
  if (meta.branch) params.set("branch", meta.branch);
98
98
  if (meta.profile) params.set("profile", meta.profile);
99
+ if (meta.kind) params.set("kind", meta.kind);
99
100
  return json(`/api/v1/runs?${params}`, {
100
101
  method: "POST",
101
102
  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.12.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {