ccqa 1.26.3 → 1.28.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
@@ -3356,6 +3356,15 @@ const LiveReportRunSchema = z.object({
3356
3356
  steps: z.array(LiveReportStepSchema),
3357
3357
  cost: ReportCostSchema
3358
3358
  });
3359
+ /**
3360
+ * What a second attempt at a failed spec showed (`--on-fail-explain-rerun`).
3361
+ * "passed" means the failure did not reproduce, which is the evidence a single
3362
+ * run cannot hold; "failed" means it did.
3363
+ *
3364
+ * The row's `status` never moves with it. The spec failed, and a passing second
3365
+ * attempt explains that failure rather than undoing it.
3366
+ */
3367
+ const ReportRerunSchema = z.object({ outcome: z.enum(["passed", "failed"]) });
3359
3368
  const ReportSpecResultSchema = z.object({
3360
3369
  feature: z.string(),
3361
3370
  spec: z.string(),
@@ -3377,6 +3386,7 @@ const ReportSpecResultSchema = z.object({
3377
3386
  assertions: z.array(ReportAssertionSchema).nullable(),
3378
3387
  analysis: FailureAnalysisSchema.nullable(),
3379
3388
  analysisSkipped: z.string().nullable(),
3389
+ rerun: ReportRerunSchema.optional(),
3380
3390
  customPromptVersion: z.string().optional(),
3381
3391
  analysisBase: z.object({
3382
3392
  ref: z.string(),
@@ -4450,6 +4460,44 @@ function withHubErrors(fn) {
4450
4460
  };
4451
4461
  }
4452
4462
  //#endregion
4463
+ //#region src/cli/repo-local-profiles.ts
4464
+ /** Where a named profile's variables lived before profiles moved to the hub. */
4465
+ const PROFILES_DIR = ".ccqa/profiles";
4466
+ /**
4467
+ * Flag `.ccqa/profiles/<name>.env` files the move to hub-stored profiles left
4468
+ * behind. Warns, never fails: the run resolved its variables from the right
4469
+ * place, so the file's existence is the only thing wrong. A tracked one is
4470
+ * called out separately — that is a committed credential, not just dead weight.
4471
+ */
4472
+ async function warnRepoLocalProfiles(cwd) {
4473
+ const paths = (await readdir(join(cwd, PROFILES_DIR)).catch(() => [])).filter((name) => name.endsWith(".env")).sort().map((name) => `${PROFILES_DIR}/${name}`);
4474
+ if (paths.length === 0) return;
4475
+ warn(`ccqa does not read repo-local profile files — the values in ${paths.join(", ")} are not in effect for this run. Profile variables come from the hub: register them with \`ccqa hub var set --profile <name>\`, then delete the ${noun(paths.length)}.`);
4476
+ const tracked = await trackedPaths(paths, cwd);
4477
+ if (tracked.length === 0) return;
4478
+ warn(`tracked by git: ${tracked.join(", ")} — a profile file holds credentials, so whatever is in there is committed. Rotate those values; deleting the ${noun(tracked.length)} now does not un-commit them.`);
4479
+ }
4480
+ function noun(n) {
4481
+ return n === 1 ? "file" : "files";
4482
+ }
4483
+ /**
4484
+ * The subset git reports as tracked. Outside a repository the question has no
4485
+ * answer, so stay silent rather than accuse or reassure on a guess.
4486
+ */
4487
+ async function trackedPaths(paths, cwd) {
4488
+ try {
4489
+ const { stdout } = await execFileP("git", [
4490
+ "ls-files",
4491
+ "-z",
4492
+ "--",
4493
+ ...paths
4494
+ ], { cwd });
4495
+ return stdout.split("\0").filter((p) => p !== "");
4496
+ } catch {
4497
+ return [];
4498
+ }
4499
+ }
4500
+ //#endregion
4453
4501
  //#region src/cli/options.ts
4454
4502
  /**
4455
4503
  * Shared `--language` flag. Every Claude-driven command writes some
@@ -4501,6 +4549,7 @@ async function applyProfileFromOption(opts) {
4501
4549
  * rather than skipping it.
4502
4550
  */
4503
4551
  async function resolveProfileEnv(opts) {
4552
+ await warnRepoLocalProfiles(opts.cwd);
4504
4553
  if (opts.profile !== void 0) await applyNamedProfile(opts.profile, opts.project, opts.cwd, opts);
4505
4554
  else await applyDefaultEnv(opts.cwd);
4506
4555
  }
@@ -4819,30 +4868,31 @@ async function readCostFileTotal(path) {
4819
4868
  //#endregion
4820
4869
  //#region src/cli/draft.ts
4821
4870
  const CATEGORY_LABEL = DRAFT_CATEGORY_LABEL;
4822
- 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("-y, --yes", "Apply each generated patch without asking [y/N]", false)).action(withUsageErrors(async (specPath, opts) => {
4871
+ 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("-y, --yes", "Apply each generated patch without asking [y/N]", false).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.")).action(withUsageErrors(async (specPath, opts) => {
4823
4872
  await withCostReporting("draft", () => runDraftCli(specPath, opts));
4824
4873
  }));
4825
4874
  async function runDraftCli(specPath, opts) {
4826
- await ensureCcqaDir();
4875
+ const cwd = resolveCwd(opts.cwd);
4876
+ await ensureCcqaDir(cwd);
4827
4877
  let featureName;
4828
4878
  let specName;
4829
4879
  let prefilledIntent = null;
4830
4880
  if (specPath) ({featureName, specName} = parseSpecPath(specPath));
4831
4881
  else {
4832
- const { naming, intent } = await proposeNaming(opts);
4882
+ const { naming, intent } = await proposeNaming(opts, cwd);
4833
4883
  featureName = naming.featureName;
4834
4884
  specName = naming.specName;
4835
4885
  prefilledIntent = intent;
4836
4886
  }
4837
- await runDraft(featureName, specName, opts, prefilledIntent);
4887
+ await runDraft(featureName, specName, opts, cwd, prefilledIntent);
4838
4888
  }
4839
- async function runDraft(featureName, specName, opts, prefilledIntent) {
4889
+ async function runDraft(featureName, specName, opts, cwd, prefilledIntent) {
4840
4890
  header("draft", `${featureName}/${specName}`);
4841
4891
  const ja = useJapanesePrompts(opts.language);
4842
4892
  const oneShot = opts.instruction !== void 0;
4843
4893
  let useIntentOnce = prefilledIntent !== null && !oneShot;
4844
4894
  while (true) {
4845
- const existing = await tryReadSpecFile(featureName, specName);
4895
+ const existing = await tryReadSpecFile(featureName, specName, cwd);
4846
4896
  const isFirstRun = existing === null;
4847
4897
  let userInput;
4848
4898
  if (oneShot) userInput = opts.instruction ?? "";
@@ -4860,7 +4910,9 @@ async function runDraft(featureName, specName, opts, prefilledIntent) {
4860
4910
  existing,
4861
4911
  userInput: userInput.trim(),
4862
4912
  autoApply: opts.yes === true,
4863
- language: opts.language
4913
+ language: opts.language,
4914
+ model: opts.model,
4915
+ cwd
4864
4916
  });
4865
4917
  if (oneShot) process.exit(turnResult.hasError && !turnResult.applied ? 1 : 0);
4866
4918
  blank();
@@ -4872,9 +4924,9 @@ async function runDraft(featureName, specName, opts, prefilledIntent) {
4872
4924
  }
4873
4925
  }
4874
4926
  async function runOneTurn(input) {
4875
- const { featureName, specName, existing, userInput, autoApply, language } = input;
4927
+ const { featureName, specName, existing, userInput, autoApply, language, model, cwd } = input;
4876
4928
  const isFirstRun = existing === null;
4877
- const systemPrompt = buildDraftSystemPrompt(await loadAvailableBlocks()) + languageDirective(language);
4929
+ const systemPrompt = buildDraftSystemPrompt(await loadAvailableBlocks(cwd)) + languageDirective(language);
4878
4930
  const userPrompt = buildDraftPrompt({
4879
4931
  mode: isFirstRun ? "create" : "refine",
4880
4932
  existing: existing ?? "",
@@ -4891,7 +4943,9 @@ async function runOneTurn(input) {
4891
4943
  "Grep",
4892
4944
  "Glob"
4893
4945
  ],
4894
- silenceBashLog: true
4946
+ silenceBashLog: true,
4947
+ ...model ? { model } : {},
4948
+ cwd
4895
4949
  }, (msg) => {
4896
4950
  if (msg.type !== "assistant") return;
4897
4951
  for (const block of msg.message.content ?? []) if (block.type === "tool_use") toolCounts[block.name] = (toolCounts[block.name] ?? 0) + 1;
@@ -4953,7 +5007,7 @@ async function runOneTurn(input) {
4953
5007
  applied: false
4954
5008
  };
4955
5009
  }
4956
- meta("saved", await saveSpecFile(featureName, specName, report.patch));
5010
+ meta("saved", await saveSpecFile(featureName, specName, report.patch, cwd));
4957
5011
  return {
4958
5012
  hasError,
4959
5013
  applied: true
@@ -5028,7 +5082,7 @@ function writeFinding(issue) {
5028
5082
  process.stdout.write(` ${issue.message}\n`);
5029
5083
  if (issue.detail) process.stdout.write(` └ ${issue.detail.replace(/\n/g, "\n ")}\n`);
5030
5084
  }
5031
- async function proposeNaming(opts) {
5085
+ async function proposeNaming(opts, cwd) {
5032
5086
  const ja = useJapanesePrompts(opts.language);
5033
5087
  const oneShot = opts.instruction !== void 0;
5034
5088
  const intent = oneShot ? opts.instruction ?? "" : await prompt(ja ? "何をテストしたいですか? > " : "What do you want to test? > ");
@@ -5036,7 +5090,7 @@ async function proposeNaming(opts) {
5036
5090
  error("intent required to propose a feature/spec name");
5037
5091
  process.exit(1);
5038
5092
  }
5039
- const tree = await listFeatureTree();
5093
+ const tree = await listFeatureTree(cwd);
5040
5094
  const treeForPrompt = tree.map((f) => ({
5041
5095
  featureName: f.featureName,
5042
5096
  specs: f.specs.map((s) => ({ specName: s.specName }))
@@ -5050,7 +5104,9 @@ async function proposeNaming(opts) {
5050
5104
  "Read",
5051
5105
  "Grep",
5052
5106
  "Glob"
5053
- ]
5107
+ ],
5108
+ ...opts.model ? { model: opts.model } : {},
5109
+ cwd
5054
5110
  }, () => {});
5055
5111
  if (isError) {
5056
5112
  error("Claude failed during naming");
@@ -6346,6 +6402,131 @@ async function readGeneratedTestSources(ref, cwd) {
6346
6402
  }
6347
6403
  return parts.join("\n\n");
6348
6404
  }
6405
+ //#endregion
6406
+ //#region src/run/explain-rerun.ts
6407
+ /**
6408
+ * `ccqa run --on-fail-explain-rerun`: run a failed spec a second time and let
6409
+ * the result settle what one run cannot.
6410
+ *
6411
+ * `ENVIRONMENT` is the only cause with no artifact to read — a service that is
6412
+ * down, an expired credential, a timing race. When the log names it the
6413
+ * classifier can call it, and when it does not the honest answer is `UNKNOWN`
6414
+ * (ADR-0016). The evidence that would settle either is whether a second
6415
+ * attempt at the same commit passes, and this phase is what collects it.
6416
+ *
6417
+ * It runs after the classification, on the rows it produced, because `auto`
6418
+ * keys off the label. The second attempt is discarded except for its verdict:
6419
+ * it is not a row of this run, it is why one of the rows is red.
6420
+ */
6421
+ const EXPLAIN_RERUN_MODES = [
6422
+ "auto",
6423
+ "always",
6424
+ "never"
6425
+ ];
6426
+ /**
6427
+ * The labels a second attempt can settle. `UNKNOWN` is the refusal the feature
6428
+ * exists to turn into an answer; `ENVIRONMENT` is rerun to confirm, since a
6429
+ * failure that reproduces is not the timing race that reading alone cannot
6430
+ * rule out.
6431
+ */
6432
+ const RERUNNABLE_LABELS = ["UNKNOWN", "ENVIRONMENT"];
6433
+ /** Evidence sentences the rerun writes onto the row, in the classifier's own currency. */
6434
+ const DID_NOT_REPRODUCE = "a second attempt at the same commit passed: the failure is not reproducible";
6435
+ const REPRODUCED = "a second attempt at the same commit failed too: the failure is reproducible";
6436
+ /**
6437
+ * Confidence for a label the rerun settled. High, because the observation is
6438
+ * direct rather than read out of a diff — but short of certainty, since "did
6439
+ * not reproduce" is still an inference about the first attempt.
6440
+ */
6441
+ const RERUN_SETTLED_CONFIDENCE = .95;
6442
+ /** Whether this row is one `mode` asks for a second attempt at. */
6443
+ function wantsRerun(row, mode) {
6444
+ if (mode === "never" || row.status !== "failed" || row.analysis === null) return false;
6445
+ return mode === "always" || RERUNNABLE_LABELS.includes(row.analysis.label);
6446
+ }
6447
+ /**
6448
+ * Rerun the failures `mode` selects and fold each verdict into its row.
6449
+ * Returns every row in the order given; the ones not rerun pass through
6450
+ * untouched.
6451
+ *
6452
+ * A rerun that throws leaves its row as the classifier left it, named in a
6453
+ * warning: a second attempt that never ran has established nothing, and
6454
+ * pretending otherwise is the one thing this phase must not do.
6455
+ */
6456
+ async function rerunExplainedFailures(rows, opts) {
6457
+ const eligible = rows.filter((row) => wantsRerun(row, opts.mode));
6458
+ if (eligible.length === 0) return [...rows];
6459
+ const budget = opts.maxSpecs ?? eligible.length;
6460
+ const skipped = eligible.slice(budget);
6461
+ emitRaw(`\n${C$1.cyan}${C$1.bold}──────── failure rerun ────────${C$1.reset}\n\n`);
6462
+ const applied = /* @__PURE__ */ new Map();
6463
+ for (const row of eligible.slice(0, budget)) {
6464
+ const key = `${row.feature}/${row.spec}`;
6465
+ info(`rerun: ${key}`);
6466
+ let outcome;
6467
+ try {
6468
+ outcome = await opts.execute({
6469
+ featureName: row.feature,
6470
+ specName: row.spec
6471
+ });
6472
+ } catch (err) {
6473
+ warn(`rerun failed to execute ${key} (${errMessage(err)}); its label stands as first classified`);
6474
+ continue;
6475
+ }
6476
+ const next = applyRerun(row, outcome);
6477
+ applied.set(key, next);
6478
+ printRerun(key, outcome, next);
6479
+ }
6480
+ if (skipped.length > 0) warn(`--on-fail-explain-rerun-max-specs ${budget} reached: not rerun, so their labels stand as first classified — ` + skipped.map((row) => `${row.feature}/${row.spec}`).join(", "));
6481
+ return rows.map((row) => applied.get(`${row.feature}/${row.spec}`) ?? row);
6482
+ }
6483
+ /**
6484
+ * Fold one verdict into its row. The row stays failed either way — the spec
6485
+ * failed, and what the rerun changes is why.
6486
+ *
6487
+ * A failure that did not reproduce is environmental, so the label says so.
6488
+ * One that did reproduce names no artifact it did not name before, so the
6489
+ * label stands: ADR-0016 asks a label to be earned, and "not a flake" earns
6490
+ * none of the three that point at something in the repository. What was
6491
+ * learned lands in the evidence instead, where a human triaging the row reads
6492
+ * it.
6493
+ */
6494
+ function applyRerun(row, outcome) {
6495
+ const analysis = row.analysis;
6496
+ if (analysis === null) return {
6497
+ ...row,
6498
+ rerun: { outcome }
6499
+ };
6500
+ const evidence = [...analysis.evidence, { detail: outcome === "passed" ? DID_NOT_REPRODUCE : REPRODUCED }];
6501
+ if (outcome === "failed" || !RERUNNABLE_LABELS.includes(analysis.label)) return {
6502
+ ...row,
6503
+ analysis: {
6504
+ ...analysis,
6505
+ evidence
6506
+ },
6507
+ rerun: { outcome }
6508
+ };
6509
+ return {
6510
+ ...row,
6511
+ analysis: {
6512
+ ...analysis,
6513
+ label: "ENVIRONMENT",
6514
+ confidence: RERUN_SETTLED_CONFIDENCE,
6515
+ headline: "the failure did not reproduce on a second attempt",
6516
+ recommendation: "treat the first attempt as environmental (a transient service, credential, seed-data or timing problem); nothing in the repository is implicated.",
6517
+ reasoning: `${analysis.reasoning}\n\n${DID_NOT_REPRODUCE}`.trim(),
6518
+ evidence
6519
+ },
6520
+ rerun: { outcome }
6521
+ };
6522
+ }
6523
+ /** One rerun spec's line in the rerun block: what happened, and where it left the label. */
6524
+ function printRerun(key, outcome, row) {
6525
+ const icon = outcome === "passed" ? `${C$1.green}✔${C$1.reset}` : `${C$1.red}✖${C$1.reset}`;
6526
+ const what = outcome === "passed" ? "did not reproduce" : "reproduced";
6527
+ const label = row.analysis?.label;
6528
+ emitRaw(`${icon} ${C$1.bold}${key}${C$1.reset} → ${what}${label ? ` ${C$1.dim}(${label})${C$1.reset}` : ""}\n`);
6529
+ }
6349
6530
  /**
6350
6531
  * Capture the PR diff used as context for failure analysis. `--relative`
6351
6532
  * re-roots paths to `cwd` and drops changes outside it, so a monorepo
@@ -9184,6 +9365,93 @@ function isStorageStateShape(state) {
9184
9365
  return typeof state === "object" && state !== null && Array.isArray(state.cookies) && Array.isArray(state.origins);
9185
9366
  }
9186
9367
  //#endregion
9368
+ //#region src/runtime/env-scrub.ts
9369
+ /**
9370
+ * Build a list of `[envValue, "${VAR}"]` pairs for every `${VAR}` reference
9371
+ * mentioned in the spec OR in any of its expanded (block-inlined) steps.
9372
+ * Used at trace time to scrub recorded Claude-text outputs so a value the
9373
+ * spec author intentionally threaded through `process.env` is preserved as
9374
+ * `${VAR}` in `ir.json` rather than baked in as the concrete
9375
+ * trace-time value.
9376
+ *
9377
+ * Why we walk `spec.steps` AND `expanded`:
9378
+ * - `spec.steps` carries the spec's own `instruction` / `expected` + each
9379
+ * include's raw `params` (which may themselves be `${ENV}` refs).
9380
+ * - `expanded` carries the inlined block-internal steps, whose
9381
+ * `instruction` / `expected` may *also* contain `${ENV}` refs that
9382
+ * don't go through include params.
9383
+ *
9384
+ * Only refs whose env value is currently non-empty land in the map —
9385
+ * scrubbing against an empty string would corrupt unrelated empty strings
9386
+ * in the action stream. Names whose env is unset are returned via
9387
+ * `unresolved` so the caller can warn the user.
9388
+ *
9389
+ * Longer values sort first so a `${SHORT}` whose value is a substring of a
9390
+ * `${LONG}` value doesn't clobber the longer one.
9391
+ *
9392
+ * `title` is deliberately NOT scanned — it never reaches the recorded action
9393
+ * stream.
9394
+ */
9395
+ function buildSpecEnvScrub(spec, expanded) {
9396
+ const refNames = /* @__PURE__ */ new Set();
9397
+ for (const step of spec.steps) if (isIncludeStep(step)) for (const v of Object.values(step.params ?? {})) collect(v, refNames);
9398
+ else {
9399
+ collect(step.instruction, refNames);
9400
+ collect(step.expected, refNames);
9401
+ }
9402
+ for (const step of expanded) {
9403
+ collect(step.instruction, refNames);
9404
+ collect(step.expected, refNames);
9405
+ }
9406
+ const map = [];
9407
+ const unresolved = [];
9408
+ for (const name of refNames) {
9409
+ const value = process.env[name];
9410
+ if (typeof value === "string" && value.length > 0) map.push([value, "${" + name + "}"]);
9411
+ else unresolved.push(name);
9412
+ }
9413
+ map.sort((a, b) => b[0].length - a[0].length);
9414
+ return {
9415
+ map,
9416
+ unresolved
9417
+ };
9418
+ }
9419
+ function collect(value, into) {
9420
+ for (const name of iterEnvRefNames(value)) into.add(name);
9421
+ }
9422
+ /** Shorter than this, a value is no secret and matches inside ordinary words. */
9423
+ const MIN_PROSE_SCRUB_LENGTH = 4;
9424
+ /** Long enough to clear the length bar, still ordinary prose / JSON. */
9425
+ const COMMON_PROSE_VALUES = new Set([
9426
+ "true",
9427
+ "false",
9428
+ "null",
9429
+ "none",
9430
+ "undefined"
9431
+ ]);
9432
+ /**
9433
+ * Scrub map for a live run, built like {@link buildSpecEnvScrub} but without
9434
+ * the values that read as ordinary text (`"1"`, `"true"`). A live step records
9435
+ * paragraphs of model prose, so replacing every occurrence of such a value
9436
+ * would cost more meaning than it protects; record scrubs single command
9437
+ * lines, where the same trade favours keeping them.
9438
+ */
9439
+ function buildLiveEnvScrubMap(spec, expanded) {
9440
+ return buildSpecEnvScrub(spec, expanded).map.filter(([value]) => value.length >= MIN_PROSE_SCRUB_LENGTH && !COMMON_PROSE_VALUES.has(value.toLowerCase()));
9441
+ }
9442
+ /**
9443
+ * Replace every occurrence of an env value with its `${VAR}` placeholder in
9444
+ * `text`. **Caller invariant**: the map must be sorted longest-value-first
9445
+ * so a shorter value doesn't shadow a longer one that contains it as a
9446
+ * substring. `buildSpecEnvScrub` upholds this; hand-built maps should too.
9447
+ */
9448
+ function scrubEnvValues(text, scrubMap) {
9449
+ if (scrubMap.length === 0) return text;
9450
+ let out = text;
9451
+ for (const [value, placeholder] of scrubMap) if (out.includes(value)) out = out.replaceAll(value, placeholder);
9452
+ return out;
9453
+ }
9454
+ //#endregion
9187
9455
  //#region src/claude/agent-browser-invoke.ts
9188
9456
  function agentBrowserInvokeBase(input) {
9189
9457
  return {
@@ -9446,7 +9714,7 @@ async function runLiveExecutor(input) {
9446
9714
  info(` retry ${attempt}/${retries} for ${step$1.id}`);
9447
9715
  }
9448
9716
  const outcome = lastOutcome;
9449
- stepResults.push({
9717
+ const recorded = scrubLiveStepText({
9450
9718
  stepId: step$1.id,
9451
9719
  source: step$1.source,
9452
9720
  instruction: step$1.instruction,
@@ -9459,10 +9727,11 @@ async function runLiveExecutor(input) {
9459
9727
  durationMs: Date.now() - stepStartedAt,
9460
9728
  cost: outcome.cost,
9461
9729
  commands: outcome.commands
9462
- });
9463
- if (outcome.status === "passed") step("STEP_DONE", step$1.id, outcome.reasoning);
9730
+ }, input.envScrubMap);
9731
+ stepResults.push(recorded);
9732
+ if (outcome.status === "passed") step("STEP_DONE", step$1.id, recorded.reasoning);
9464
9733
  else {
9465
- step("ASSERTION_FAILED", step$1.id, outcome.reasoning);
9734
+ step("ASSERTION_FAILED", step$1.id, recorded.reasoning);
9466
9735
  overallFailed = true;
9467
9736
  }
9468
9737
  }
@@ -9502,7 +9771,8 @@ async function runLiveExecutor(input) {
9502
9771
  const transcript = transcriptParts.join("\n");
9503
9772
  const after = takeScreenshot(input.sessionName, paths.afterPng, { fullPage: true });
9504
9773
  if (!after.ok) warn(`screenshot (after, ${step.id}) failed: ${after.error}`);
9505
- await writeFile(paths.logTxt, transcript || "(no assistant text captured)", "utf-8");
9774
+ const scrubbed = scrubEnvValues(transcript, input.envScrubMap);
9775
+ await writeFile(paths.logTxt, scrubbed || "(no assistant text captured)", "utf-8");
9506
9776
  const { status, reasoning } = judgeStepOutcome({
9507
9777
  step,
9508
9778
  isError,
@@ -9604,6 +9874,18 @@ function judgeStepOutcome({ step, isError, errorDetail, judged }) {
9604
9874
  reasoning: judged.stepId === step.id ? baseReason : `(stepId mismatch: model wrote ${judged.stepId}) ${baseReason}`
9605
9875
  };
9606
9876
  }
9877
+ /**
9878
+ * Scrub the strings a step carries that the model authored: its verdict prose
9879
+ * and the commands it issued. `instruction` / `expected` are copied from the
9880
+ * spec, which keeps `${VAR}` symbolic, so they need nothing.
9881
+ */
9882
+ function scrubLiveStepText(step, scrubMap) {
9883
+ return {
9884
+ ...step,
9885
+ reasoning: scrubEnvValues(step.reasoning, scrubMap),
9886
+ commands: step.commands.map((c) => scrubEnvValues(c, scrubMap))
9887
+ };
9888
+ }
9607
9889
  function buildSkippedStep(step, reason) {
9608
9890
  return {
9609
9891
  stepId: step.id,
@@ -10015,6 +10297,7 @@ async function runOneSpec(args) {
10015
10297
  }
10016
10298
  const spec = parseTestSpec(specContent);
10017
10299
  const expanded = expandSpec(spec, { blocks: await loadAllBlocks(cwd) });
10300
+ const envScrubMap = buildLiveEnvScrubMap(spec, expanded);
10018
10301
  meta("spec", spec.title);
10019
10302
  meta("steps", expanded.length);
10020
10303
  const includes = collectIncludedBlockNames(spec);
@@ -10053,6 +10336,7 @@ async function runOneSpec(args) {
10053
10336
  runId,
10054
10337
  runDir,
10055
10338
  sessionName,
10339
+ envScrubMap,
10056
10340
  statePath,
10057
10341
  verifyUrl,
10058
10342
  systemPromptSuffix: userPromptSuffix,
@@ -12565,6 +12849,7 @@ async function executeRun(targets, opts) {
12565
12849
  const forExecution = opts.dryRun !== true;
12566
12850
  const cwd = opts.cwd ?? process.cwd();
12567
12851
  const wantsLastGreen = opts.onFailExplain === true && opts.onFailExplainBase === void 0;
12852
+ const rerunMode = opts.onFailExplain === true ? opts.onFailExplainRerun ?? "never" : "never";
12568
12853
  const [head, fixedBase] = await Promise.all([getGitHead(cwd), forExecution && opts.onFailExplain && opts.onFailExplainBase !== void 0 ? resolveAnalysisBase(opts.onFailExplainBase, "--on-fail-explain-base", cwd) : null]);
12569
12854
  const git = {
12570
12855
  head,
@@ -12736,6 +13021,7 @@ async function executeRun(targets, opts) {
12736
13021
  if (opts.liveArtifactsDir) warn(`--live-artifacts-dir is ignored: ${why}`);
12737
13022
  if (opts.learnHubLivePrompt) warn(`--learn-live-prompt is ignored: ${why}`);
12738
13023
  } else if (opts.liveArtifactsDir && liveSpecs.length > 1) warn("--out is ignored when running multiple live specs");
13024
+ if (opts.onFailExplainRerun !== void 0 && rerunMode === "never" && opts.onFailExplainRerun !== "never") warn("--on-fail-explain-rerun is ignored: without --on-fail-explain nothing is classified, so a second attempt would settle nothing");
12739
13025
  if (detSpecs.length === 0 && opts.replaySkipEvidence === true) warn("--no-evidence is ignored: it only applies to agent-browser 'mode: deterministic' specs, and this run has none");
12740
13026
  blank();
12741
13027
  if (opts.dryRun) {
@@ -12817,7 +13103,7 @@ async function executeRun(targets, opts) {
12817
13103
  ...opts.language ? { language: opts.language } : {},
12818
13104
  report: incrementalReport
12819
13105
  });
12820
- const live = await runLiveSpecs(liveSpecs, {
13106
+ const liveOpts = {
12821
13107
  ...opts.model ? { model: opts.model } : {},
12822
13108
  ...opts.language ? { language: opts.language } : {},
12823
13109
  ...opts.liveArtifactsDir && liveSpecs.length === 1 ? { out: opts.liveArtifactsDir } : {},
@@ -12833,7 +13119,8 @@ async function executeRun(targets, opts) {
12833
13119
  triageUserPrompt,
12834
13120
  ...opts.teardown ? { teardown: opts.teardown } : {},
12835
13121
  report: incrementalReport
12836
- });
13122
+ };
13123
+ const live = await runLiveSpecs(liveSpecs, liveOpts);
12837
13124
  let overallExitCode = det.exitCode !== 0 ? 1 : 0;
12838
13125
  if (live.failedCount > 0) overallExitCode = 1;
12839
13126
  if (externalRows.some((r) => r.status === "failed")) overallExitCode = 1;
@@ -12851,11 +13138,23 @@ async function executeRun(targets, opts) {
12851
13138
  const analyzedExternalRows = await analyzeExternalRows(externalRows, analysisRun);
12852
13139
  report = await writeUnifiedReport({
12853
13140
  reportDir,
12854
- results: [
13141
+ results: await rerunExplainedFailures([
12855
13142
  ...detResults,
12856
13143
  ...analyzedExternalRows,
12857
13144
  ...live.reportResults
12858
- ],
13145
+ ], {
13146
+ mode: rerunMode,
13147
+ maxSpecs: opts.onFailExplainRerunMaxSpecs ?? null,
13148
+ execute: createRerunExecutor({
13149
+ detSpecs,
13150
+ liveSpecs,
13151
+ dispatch,
13152
+ liveOpts,
13153
+ opts,
13154
+ cwd,
13155
+ resources
13156
+ })
13157
+ }),
12859
13158
  git,
12860
13159
  customPromptVersion,
12861
13160
  triageUserPromptHash,
@@ -13135,6 +13434,68 @@ async function analyzeDeterministicSummaries(summaries, cwd, reportDir, { pass }
13135
13434
  return results;
13136
13435
  }
13137
13436
  /**
13437
+ * How `--on-fail-explain-rerun` re-executes one spec: on the same path that
13438
+ * just ran it, with the same inputs, so the second attempt answers the same
13439
+ * question the first did.
13440
+ *
13441
+ * Everything it writes goes to a throwaway report directory. The failed run's
13442
+ * screenshots and artifacts are the record of what happened, and a rerun that
13443
+ * shares their paths would overwrite them with a different attempt's — the
13444
+ * external runner and the live adapter both recreate a spec's directories.
13445
+ * Nothing here upserts a row or classifies: the attempt is evidence about the
13446
+ * run's row, not a row of its own.
13447
+ */
13448
+ function createRerunExecutor(ctx) {
13449
+ const detKeys = new Set(ctx.detSpecs.map(specKey));
13450
+ const liveKeys = new Set(ctx.liveSpecs.map(specKey));
13451
+ return async (ref) => {
13452
+ const key = specKey(ref);
13453
+ const scratch = await mkdtemp(join(tmpdir(), "ccqa-rerun-"));
13454
+ try {
13455
+ if (detKeys.has(key)) {
13456
+ const summary = await runOneDeterministicSpec(ref, 0, {
13457
+ cwd: ctx.cwd,
13458
+ tmpDir: scratch,
13459
+ vitestConfig: await resolveVitestConfig(ctx.cwd),
13460
+ captureOutput: false,
13461
+ reportDir: scratch,
13462
+ captureEvidence: false
13463
+ });
13464
+ return summary !== null && !failedSpec(summary) ? "passed" : "failed";
13465
+ }
13466
+ if (liveKeys.has(key)) {
13467
+ const { report: _streamed, ...liveOpts } = ctx.liveOpts;
13468
+ return (await runLiveSpecs([ref], {
13469
+ ...liveOpts,
13470
+ reportDir: scratch,
13471
+ diffProvider: null,
13472
+ concurrency: 1
13473
+ })).failedCount > 0 ? "failed" : "passed";
13474
+ }
13475
+ const group = ctx.dispatch.external.find((g) => g.specs.some((s) => specKey(s) === key));
13476
+ if (group === void 0) throw new Error(`${key} has no execution path to re-run`);
13477
+ const [row] = await group.runner.run([ref], {
13478
+ cwd: ctx.cwd,
13479
+ reportDir: scratch,
13480
+ concurrency: 1,
13481
+ resources: ctx.resources,
13482
+ ...ctx.opts.model ? { model: ctx.opts.model } : {},
13483
+ ...ctx.opts.language ? { language: ctx.opts.language } : {},
13484
+ targetId: group.targetId,
13485
+ targetConfig: group.targetConfig,
13486
+ stepEvidence: group.stepEvidence,
13487
+ onSpecComplete: async () => {}
13488
+ });
13489
+ return row?.status === "passed" ? "passed" : "failed";
13490
+ } finally {
13491
+ await rm(scratch, {
13492
+ recursive: true,
13493
+ force: true
13494
+ });
13495
+ }
13496
+ };
13497
+ }
13498
+ /**
13138
13499
  * Build the report envelope — every `RunReportData` field except `results`.
13139
13500
  * Extracted so the incremental writer (which flushes report.json after each
13140
13501
  * spec) and the final batch write share one source of truth for these fields.
@@ -13434,7 +13795,10 @@ const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command(
13434
13795
  const n = Number(raw);
13435
13796
  if (!Number.isFinite(n) || n < 0 || Math.floor(n) !== n) throw new Error(`--live-step-retry must be a non-negative integer, got "${raw}"`);
13436
13797
  return n;
13437
- }, 0).option("--live-artifacts-dir <dir>", "(live only) Override the per-spec artifact directory. Default: <specDir>/runs/<runId>. Ignored when running multiple specs.").option("--replay-skip-evidence", `(deterministic replay only) Skip step-boundary evidence capture (PNG + meta JSON written to ${DEFAULT_REPORT_DIR}/${EVIDENCE_SUBDIR}/ by default).`).optionsGroup("What to do about failures:").option("--on-fail-explain", "Classify each failure against the source diff since the commit where that spec last passed (per-spec baselines from the hub). Off by default — no Claude calls without it.").option("--on-fail-explain-base <ref>", "With --on-fail-explain: diff against <ref> instead of each spec's last green. Use when there is no hub to hold the baselines.").optionsGroup("What to do with the results:").option("--report-dir <dir>", `Directory for the structured run results (report.json + evidence PNGs), which are always written. Default: ${DEFAULT_REPORT_DIR}/.`).option("--report-format <fmt>", "Additional output format alongside HTML: 'text' (default), 'json' (writes report.json), 'github' (GitHub Actions annotations on stdout).", (raw) => {
13798
+ }, 0).option("--live-artifacts-dir <dir>", "(live only) Override the per-spec artifact directory. Default: <specDir>/runs/<runId>. Ignored when running multiple specs.").option("--replay-skip-evidence", `(deterministic replay only) Skip step-boundary evidence capture (PNG + meta JSON written to ${DEFAULT_REPORT_DIR}/${EVIDENCE_SUBDIR}/ by default).`).optionsGroup("What to do about failures:").option("--on-fail-explain", "Classify each failure against the source diff since the commit where that spec last passed (per-spec baselines from the hub). Off by default — no Claude calls without it.").option("--on-fail-explain-base <ref>", "With --on-fail-explain: diff against <ref> instead of each spec's last green. Use when there is no hub to hold the baselines.").option("--on-fail-explain-rerun <when>", "With --on-fail-explain: run a failed spec a second time so the classifier can tell a flake from a real failure. 'auto' reruns the failures whose label turns on it (UNKNOWN, ENVIRONMENT), 'always' every classified failure, 'never' (default) none. A second attempt that passes labels the row ENVIRONMENT; the spec still counts as failed. Costs a full spec execution each — live specs included.", (raw) => {
13799
+ if (EXPLAIN_RERUN_MODES.includes(raw)) return raw;
13800
+ throw new Error(`--on-fail-explain-rerun must be one of ${EXPLAIN_RERUN_MODES.join(" | ")}`);
13801
+ }, "never").option("--on-fail-explain-rerun-max-specs <n>", "Rerun at most N specs, in report order; the rest are named in the run summary and keep the label they were first given. Default: no cap. The knob for an environment having a bad day, where the alternative is turning the reruns off entirely.", parseRerunMaxSpecs).optionsGroup("What to do with the results:").option("--report-dir <dir>", `Directory for the structured run results (report.json + evidence PNGs), which are always written. Default: ${DEFAULT_REPORT_DIR}/.`).option("--report-format <fmt>", "Additional output format alongside HTML: 'text' (default), 'json' (writes report.json), 'github' (GitHub Actions annotations on stdout).", (raw) => {
13438
13802
  if (REPORT_FORMATS.includes(raw)) return raw;
13439
13803
  throw new Error(`--report-format must be one of ${REPORT_FORMATS.join(" | ")}`);
13440
13804
  }, "text").option("--report-to-hub", "Incrementally push the run report to the hub as the run progresses (open → patch per spec → finalize). Requires --hub-url/--hub-token (or CCQA_HUB_URL/CCQA_HUB_TOKEN). Without it, hub credentials are used only to fetch variables/sessions/prompts, not to push.").optionsGroup("Learning:").option("--learn-hub-live-prompt", "(live only) After the run finishes, ask Claude to refresh the \"live.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(async (targets, opts) => {
@@ -13449,6 +13813,12 @@ function parseConcurrency$1(raw) {
13449
13813
  }
13450
13814
  return n;
13451
13815
  }
13816
+ /** Parse --on-fail-explain-rerun-max-specs: a positive integer. Zero is `--on-fail-explain-rerun never`. */
13817
+ function parseRerunMaxSpecs(raw) {
13818
+ const n = Number(raw);
13819
+ if (!Number.isInteger(n) || n < 1) throw new Error(`--on-fail-explain-rerun-max-specs must be a positive integer, got "${raw}" (for none, pass --on-fail-explain-rerun never)`);
13820
+ return n;
13821
+ }
13452
13822
  /** Header label shown after `ccqa run`: the lone target, a count, or how they were selected. */
13453
13823
  function headerTarget(targets, opts) {
13454
13824
  if (targets.length === 1) return targets[0];
@@ -14270,73 +14640,6 @@ function isPassiveAction(action) {
14270
14640
  return action === "snapshot" || action === "wait" || action === "assert";
14271
14641
  }
14272
14642
  //#endregion
14273
- //#region src/runtime/env-scrub.ts
14274
- /**
14275
- * Build a list of `[envValue, "${VAR}"]` pairs for every `${VAR}` reference
14276
- * mentioned in the spec OR in any of its expanded (block-inlined) steps.
14277
- * Used at trace time to scrub recorded Claude-text outputs so a value the
14278
- * spec author intentionally threaded through `process.env` is preserved as
14279
- * `${VAR}` in `ir.json` rather than baked in as the concrete
14280
- * trace-time value.
14281
- *
14282
- * Why we walk `spec.steps` AND `expanded`:
14283
- * - `spec.steps` carries the spec's own `instruction` / `expected` + each
14284
- * include's raw `params` (which may themselves be `${ENV}` refs).
14285
- * - `expanded` carries the inlined block-internal steps, whose
14286
- * `instruction` / `expected` may *also* contain `${ENV}` refs that
14287
- * don't go through include params.
14288
- *
14289
- * Only refs whose env value is currently non-empty land in the map —
14290
- * scrubbing against an empty string would corrupt unrelated empty strings
14291
- * in the action stream. Names whose env is unset are returned via
14292
- * `unresolved` so the caller can warn the user.
14293
- *
14294
- * Longer values sort first so a `${SHORT}` whose value is a substring of a
14295
- * `${LONG}` value doesn't clobber the longer one.
14296
- *
14297
- * `title` is deliberately NOT scanned — it never reaches the recorded action
14298
- * stream.
14299
- */
14300
- function buildSpecEnvScrub(spec, expanded) {
14301
- const refNames = /* @__PURE__ */ new Set();
14302
- for (const step of spec.steps) if (isIncludeStep(step)) for (const v of Object.values(step.params ?? {})) collect(v, refNames);
14303
- else {
14304
- collect(step.instruction, refNames);
14305
- collect(step.expected, refNames);
14306
- }
14307
- for (const step of expanded) {
14308
- collect(step.instruction, refNames);
14309
- collect(step.expected, refNames);
14310
- }
14311
- const map = [];
14312
- const unresolved = [];
14313
- for (const name of refNames) {
14314
- const value = process.env[name];
14315
- if (typeof value === "string" && value.length > 0) map.push([value, "${" + name + "}"]);
14316
- else unresolved.push(name);
14317
- }
14318
- map.sort((a, b) => b[0].length - a[0].length);
14319
- return {
14320
- map,
14321
- unresolved
14322
- };
14323
- }
14324
- function collect(value, into) {
14325
- for (const name of iterEnvRefNames(value)) into.add(name);
14326
- }
14327
- /**
14328
- * Replace every occurrence of an env value with its `${VAR}` placeholder in
14329
- * `text`. **Caller invariant**: the map must be sorted longest-value-first
14330
- * so a shorter value doesn't shadow a longer one that contains it as a
14331
- * substring. `buildSpecEnvScrub` upholds this; hand-built maps should too.
14332
- */
14333
- function scrubEnvValues(text, scrubMap) {
14334
- if (scrubMap.length === 0) return text;
14335
- let out = text;
14336
- for (const [value, placeholder] of scrubMap) if (out.includes(value)) out = out.replaceAll(value, placeholder);
14337
- return out;
14338
- }
14339
- //#endregion
14340
14643
  //#region src/runtime/literal-scrub.ts
14341
14644
  /**
14342
14645
  * Patterns are listed in roughly descending confidence — a hit on `clock-hms`
@@ -17132,6 +17435,32 @@ function mergeBucket(into, from) {
17132
17435
  }
17133
17436
  }
17134
17437
  //#endregion
17438
+ //#region src/hub/core/retention.ts
17439
+ /**
17440
+ * Drop everything past the newest `maxRuns` of the (project, branch) that
17441
+ * `run` belongs to, taking each evicted run's artifacts and triage records
17442
+ * with it.
17443
+ *
17444
+ * Runs at a terminal state trigger this, rather than a sweep at startup: the
17445
+ * hub whose disk grows is the one written to constantly and never restarted,
17446
+ * which is exactly the one a startup sweep never fires on.
17447
+ *
17448
+ * Best-effort like the ledger updates it runs beside — a lost sweep costs
17449
+ * disk, while failing the push it hangs off would cost the run.
17450
+ */
17451
+ async function sweepRunRetention(storage, run, maxRuns) {
17452
+ try {
17453
+ const group = (await storage.runs.list({ project: run.project })).filter((r) => r.branch === run.branch && r.status !== "running");
17454
+ for (const evicted of group.slice(maxRuns)) {
17455
+ await storage.runs.delete(evicted.id);
17456
+ await storage.artifacts.delete(evicted.id);
17457
+ await storage.triage.deleteAll(evicted.id);
17458
+ }
17459
+ } catch (err) {
17460
+ console.error(`hub: run retention sweep failed for "${run.project}": ${err instanceof Error ? err.message : String(err)}`);
17461
+ }
17462
+ }
17463
+ //#endregion
17135
17464
  //#region src/hub/api/validate.ts
17136
17465
  /**
17137
17466
  * Validators for the request parameters handlers read straight off the wire:
@@ -17195,6 +17524,7 @@ const DEFAULT_MAX_PUSH_BYTES = 32 * 1024 * 1024;
17195
17524
  */
17196
17525
  function createPushRunHandler(config) {
17197
17526
  const maxPushBytes = config.maxPushBytes ?? DEFAULT_MAX_PUSH_BYTES;
17527
+ const maxRunsPerBranch = config.maxRunsPerBranch ?? 200;
17198
17528
  return async (ctx) => {
17199
17529
  const { project, branch, profile, kind, deployedSha } = parseRunScope(ctx);
17200
17530
  const body = await readBody(ctx.req, maxPushBytes);
@@ -17246,6 +17576,7 @@ function createPushRunHandler(config) {
17246
17576
  await config.storage.runs.create(run);
17247
17577
  await updateSpecLedger(config.storage, run, report.results);
17248
17578
  await updateDriftLedger(config.storage, run, report.results);
17579
+ await sweepRunRetention(config.storage, run, maxRunsPerBranch);
17249
17580
  sendJson(ctx.res, 201, run);
17250
17581
  } finally {
17251
17582
  await rm(dir, {
@@ -17485,6 +17816,7 @@ async function deployHeadMovedDuringRun(storage, run) {
17485
17816
  */
17486
17817
  function createPatchRunHandler(config) {
17487
17818
  const maxPushBytes = config.maxPushBytes ?? DEFAULT_MAX_PUSH_BYTES;
17819
+ const maxRunsPerBranch = config.maxRunsPerBranch ?? 200;
17488
17820
  return async (ctx) => {
17489
17821
  const id = ctx.params.id;
17490
17822
  const run = await getRunOr404(config.storage, id);
@@ -17548,7 +17880,10 @@ function createPatchRunHandler(config) {
17548
17880
  };
17549
17881
  const updated = await config.storage.runs.update(id, patch);
17550
17882
  await updateDriftLedger(config.storage, updated, mergedResults);
17551
- if (done) await updateSpecLedger(config.storage, updated, mergedResults);
17883
+ if (done) {
17884
+ await updateSpecLedger(config.storage, updated, mergedResults);
17885
+ await sweepRunRetention(config.storage, updated, maxRunsPerBranch);
17886
+ }
17552
17887
  sendJson(ctx.res, 200, updated);
17553
17888
  };
17554
17889
  }
@@ -20186,6 +20521,7 @@ const CLIENT_JS = `
20186
20521
  "detail.back": "Runs", "detail.specs": "Specs",
20187
20522
  "detail.download": "Download artifacts",
20188
20523
  "detail.triage": "Triage",
20524
+ "detail.notKept": "This run is no longer kept — the hub keeps only the most recent runs of each branch.",
20189
20525
  "meta.branch": "Branch", "meta.specs": "Specs", "meta.cost": "Cost",
20190
20526
  "meta.created": "Created", "meta.passed": "passed", "meta.profile": "Profile",
20191
20527
  "meta.drift": "Drift",
@@ -20349,6 +20685,7 @@ const CLIENT_JS = `
20349
20685
  "detail.back": "実行", "detail.specs": "スペック",
20350
20686
  "detail.download": "アーティファクトをダウンロード",
20351
20687
  "detail.triage": "トリアージ",
20688
+ "detail.notKept": "この実行はもう保持されていません — ハブは各ブランチの直近の実行だけを保持します。",
20352
20689
  "meta.branch": "ブランチ", "meta.specs": "スペック", "meta.cost": "コスト",
20353
20690
  "meta.created": "作成", "meta.passed": "合格", "meta.profile": "プロファイル",
20354
20691
  "meta.drift": "ドリフト",
@@ -20639,7 +20976,9 @@ const CLIENT_JS = `
20639
20976
  // A reverse proxy can answer with non-JSON (an HTML 502 page) — fall
20640
20977
  // back to the status line instead of a JSON-parse error message.
20641
20978
  return res.json().catch(function () { return null; }).then(function (b) {
20642
- throw new Error((b && b.error && b.error.message) || (res.status + " " + res.statusText));
20979
+ var err = new Error((b && b.error && b.error.message) || (res.status + " " + res.statusText));
20980
+ err.status = res.status;
20981
+ throw err;
20643
20982
  });
20644
20983
  }
20645
20984
  return res.status === 204 ? null : res.json();
@@ -22036,12 +22375,10 @@ const CLIENT_JS = `
22036
22375
 
22037
22376
  // ── run detail: orchestration ───────────────────────────────────────
22038
22377
 
22039
- function detailError(what) {
22040
- return function (err) {
22041
- var e = document.getElementById("detail-error");
22042
- e.hidden = false;
22043
- e.textContent = what + ": " + err.message;
22044
- };
22378
+ function detailError(msg) {
22379
+ var e = document.getElementById("detail-error");
22380
+ e.hidden = false;
22381
+ e.textContent = msg;
22045
22382
  }
22046
22383
 
22047
22384
  function openRunDetail(runId) {
@@ -22057,9 +22394,17 @@ const CLIENT_JS = `
22057
22394
  document.getElementById("detail-spec-count").textContent = "";
22058
22395
  document.getElementById("triage-summary").textContent = "";
22059
22396
 
22397
+ // Retention drops a run but never the ledger entries pointing at it, so a
22398
+ // Perspectives link can outlive its target. That 404 is the whole story of
22399
+ // the page, so it speaks for the report's failure too.
22400
+ var runGone = false;
22401
+
22060
22402
  apiFetch("/api/v1/runs/" + encodeURIComponent(runId)).then(function (run) {
22061
22403
  renderRunHead(run);
22062
- }).catch(detailError("Error loading run"));
22404
+ }).catch(function (err) {
22405
+ runGone = err.status === 404;
22406
+ detailError(runGone ? t("detail.notKept") : "Error loading run: " + err.message);
22407
+ });
22063
22408
 
22064
22409
  apiFetch("/api/v1/runs/" + encodeURIComponent(runId) + "/report").then(function (report) {
22065
22410
  // Draw the spec cards first from the report alone, then re-draw once
@@ -22075,7 +22420,9 @@ const CLIENT_JS = `
22075
22420
  loadTriage(runId, isDrift, function (loaded) {
22076
22421
  renderSpecCards(runId, report.results, loaded, isDrift);
22077
22422
  });
22078
- }).catch(detailError("Error loading report"));
22423
+ }).catch(function (err) {
22424
+ if (!runGone) detailError("Error loading report: " + err.message);
22425
+ });
22079
22426
  }
22080
22427
 
22081
22428
  // ── learning jobs ────────────────────────────────────────────────────
@@ -23778,8 +24125,8 @@ const CLIENT_JS = `
23778
24125
  }
23779
24126
 
23780
24127
  // ── profile switching (per-tab dropdowns) ──────────────────────────────
23781
- // Profiles scope variables + sessions (a profile is a set of env vars, like
23782
- // .ccqa/profiles/<name>.env) and, since ADR-0010, the needs-re-run verdict:
24128
+ // Profiles scope variables + sessions (a profile is a set of env vars) and,
24129
+ // since ADR-0010, the needs-re-run verdict:
23783
24130
  // two environments sit at different commits, so that question has no
23784
24131
  // profile-free answer. Prompts are project-wide and runs are cross-profile,
23785
24132
  // so there is still no header-level selector — Secrets and Perspectives each
@@ -24477,14 +24824,17 @@ function registerRoutes(router, config, queue) {
24477
24824
  ctx.res.setHeader("Cache-Control", "no-cache");
24478
24825
  ctx.res.end(renderHubUi());
24479
24826
  });
24827
+ const retention = config.maxRunsPerBranch != null ? { maxRunsPerBranch: config.maxRunsPerBranch } : {};
24480
24828
  router.post("/api/v1/runs", createPushRunHandler({
24481
24829
  storage,
24482
- ...config.maxPushBytes ? { maxPushBytes: config.maxPushBytes } : {}
24830
+ ...config.maxPushBytes ? { maxPushBytes: config.maxPushBytes } : {},
24831
+ ...retention
24483
24832
  }));
24484
24833
  router.post("/api/v1/runs/open", createOpenRunHandler({ storage }));
24485
24834
  router.patch("/api/v1/runs/:id", createPatchRunHandler({
24486
24835
  storage,
24487
- ...config.maxPushBytes != null ? { maxPushBytes: config.maxPushBytes } : {}
24836
+ ...config.maxPushBytes != null ? { maxPushBytes: config.maxPushBytes } : {},
24837
+ ...retention
24488
24838
  }));
24489
24839
  router.get("/api/v1/runs", createListRunsHandler(storage));
24490
24840
  router.get("/api/v1/runs/:id", createGetRunHandler(storage));
@@ -24598,12 +24948,14 @@ async function writeJson(path, value) {
24598
24948
  * update concurrently.
24599
24949
  */
24600
24950
  const updateChains = /* @__PURE__ */ new Map();
24601
- async function updateJson(path, mutate) {
24602
- const next = (updateChains.get(path) ?? Promise.resolve()).catch(() => {}).then(async () => {
24603
- const updated = mutate(await readJson(path));
24604
- await writeJson(path, updated);
24605
- return updated;
24606
- });
24951
+ /**
24952
+ * Queue `work` behind whatever is already in flight for `path`. A delete takes
24953
+ * the same chain as the updates it removes: `writeJson` recreates the parent
24954
+ * directory, so an unordered delete would be silently undone by an update that
24955
+ * was already queued.
24956
+ */
24957
+ async function serialize(path, work) {
24958
+ const next = (updateChains.get(path) ?? Promise.resolve()).catch(() => {}).then(work);
24607
24959
  updateChains.set(path, next);
24608
24960
  try {
24609
24961
  return await next;
@@ -24611,6 +24963,13 @@ async function updateJson(path, mutate) {
24611
24963
  if (updateChains.get(path) === next) updateChains.delete(path);
24612
24964
  }
24613
24965
  }
24966
+ async function updateJson(path, mutate) {
24967
+ return await serialize(path, async () => {
24968
+ const updated = mutate(await readJson(path));
24969
+ await writeJson(path, updated);
24970
+ return updated;
24971
+ });
24972
+ }
24614
24973
  /** Read a raw file, returning `null` when it doesn't exist. */
24615
24974
  async function readBytesOrNull(path) {
24616
24975
  try {
@@ -24861,6 +25220,10 @@ function createFileArtifactStore(root) {
24861
25220
  async updateJsonFile(runId, relPath, mutate) {
24862
25221
  assertSafeRelPath(relPath);
24863
25222
  await updateJson(join(artifactsRunDir(root, runId), relPath), mutate);
25223
+ },
25224
+ async delete(runId) {
25225
+ const dir = artifactsRunDir(root, runId);
25226
+ await serialize(join(dir, "report.json"), () => removePath(dir));
24864
25227
  }
24865
25228
  };
24866
25229
  }
@@ -25121,6 +25484,9 @@ function createFileRunStore(root) {
25121
25484
  };
25122
25485
  });
25123
25486
  },
25487
+ async delete(id) {
25488
+ await serialize(runMetaPath(root, id), () => removePath(runDir(root, id)));
25489
+ },
25124
25490
  async list({ project, branch, status, kinds, since, until, limit }) {
25125
25491
  const ids = await listSubdirsOrEmpty(runsDir(root));
25126
25492
  const inWindow = windowFilter({
@@ -25275,6 +25641,10 @@ function createFileTriageStore(root) {
25275
25641
  return (current ?? []).filter((r) => !(r.feature === feature && r.spec === spec));
25276
25642
  });
25277
25643
  },
25644
+ async deleteAll(runId) {
25645
+ const path = triagePath(root, runId);
25646
+ await serialize(path, () => removePath(path));
25647
+ },
25278
25648
  async list(runId) {
25279
25649
  return await readJson(triagePath(root, runId)) ?? [];
25280
25650
  }
@@ -25316,7 +25686,7 @@ function createHubStorage(config) {
25316
25686
  }
25317
25687
  //#endregion
25318
25688
  //#region src/cli/serve.ts
25319
- const serveCommand = new Command("serve").description("Start the ccqa hub: a small control-plane HTTP server that aggregates CI run results, sessions, variables, and triage records. It does not execute tests — CI/local `ccqa run` produces reports and `ccqa hub push` uploads them here. Any HTTP client (docs/hub-api.md) can talk to it.").option("--port <n>", "TCP port to listen on.", "8787").option("--data-dir <path>", "Directory to store runs, sessions, and variables in.", "./ccqa-hub-data").option("--allow-origin <origin>", "CORS-allowed origin for browser clients (repeatable). Omit for no cross-origin access.", (val, prev) => [...prev, val], []).option("--max-push-mb <n>", "Reject pushed report bundles larger than this (MB). Default 32.", parsePositiveInt).action(async (opts) => {
25689
+ const serveCommand = new Command("serve").description("Start the ccqa hub: a small control-plane HTTP server that aggregates CI run results, sessions, variables, and triage records. It does not execute tests — CI/local `ccqa run` produces reports and `ccqa hub push` uploads them here. Any HTTP client (docs/hub-api.md) can talk to it.").option("--port <n>", "TCP port to listen on.", "8787").option("--data-dir <path>", "Directory to store runs, sessions, and variables in.", "./ccqa-hub-data").option("--allow-origin <origin>", "CORS-allowed origin for browser clients (repeatable). Omit for no cross-origin access.", (val, prev) => [...prev, val], []).option("--max-push-mb <n>", "Reject pushed report bundles larger than this (MB). Default 32.", parsePositiveInt).option("--max-runs-per-branch <n>", "Keep this many runs per project and branch; older ones are deleted with their artifacts and grades. Default 200.", parsePositiveInt).action(async (opts) => {
25320
25690
  await runServe(opts);
25321
25691
  });
25322
25692
  function parsePositiveInt(raw) {
@@ -25351,7 +25721,8 @@ async function runServe(opts) {
25351
25721
  token,
25352
25722
  encryptionKey,
25353
25723
  allowedOrigins: opts.allowOrigin ?? [],
25354
- ...opts.maxPushMb ? { maxPushBytes: opts.maxPushMb * 1024 * 1024 } : {}
25724
+ ...opts.maxPushMb ? { maxPushBytes: opts.maxPushMb * 1024 * 1024 } : {},
25725
+ ...opts.maxRunsPerBranch ? { maxRunsPerBranch: opts.maxRunsPerBranch } : {}
25355
25726
  });
25356
25727
  const requestedPort = Number(opts.port);
25357
25728
  if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) {
@@ -25368,6 +25739,7 @@ async function runServe(opts) {
25368
25739
  header("serve", `port ${boundPort}`);
25369
25740
  meta("data-dir", dataDir);
25370
25741
  meta("encryption", encryptionKey ? "enabled" : "disabled (no CCQA_HUB_ENCRYPTION_KEY)");
25742
+ meta("run retention", `${opts.maxRunsPerBranch ?? 200} per project/branch`);
25371
25743
  const auth = driftAuthAvailable();
25372
25744
  meta("triage learning", auth.ok ? "available" : `unavailable (${auth.reason} — learning jobs will fail)`);
25373
25745
  if (opts.allowOrigin && opts.allowOrigin.length > 0) meta("cors", opts.allowOrigin.join(", "));
@@ -533,6 +533,12 @@ declare const ReportSpecResultSchema: z.ZodObject<{
533
533
  }>>;
534
534
  }, z.core.$strip>>;
535
535
  analysisSkipped: z.ZodNullable<z.ZodString>;
536
+ rerun: z.ZodOptional<z.ZodObject<{
537
+ outcome: z.ZodEnum<{
538
+ passed: "passed";
539
+ failed: "failed";
540
+ }>;
541
+ }, z.core.$strip>>;
536
542
  customPromptVersion: z.ZodOptional<z.ZodString>;
537
543
  analysisBase: z.ZodOptional<z.ZodNullable<z.ZodObject<{
538
544
  ref: z.ZodString;
@@ -711,6 +717,12 @@ declare const RunReportDataSchema: z.ZodObject<{
711
717
  }>>;
712
718
  }, z.core.$strip>>;
713
719
  analysisSkipped: z.ZodNullable<z.ZodString>;
720
+ rerun: z.ZodOptional<z.ZodObject<{
721
+ outcome: z.ZodEnum<{
722
+ passed: "passed";
723
+ failed: "failed";
724
+ }>;
725
+ }, z.core.$strip>>;
714
726
  customPromptVersion: z.ZodOptional<z.ZodString>;
715
727
  analysisBase: z.ZodOptional<z.ZodNullable<z.ZodObject<{
716
728
  ref: z.ZodString;
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.26.3",
3
+ "version": "1.28.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.26.3",
3
+ "version": "1.28.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {
@@ -73,6 +73,7 @@
73
73
  "test": "vitest run",
74
74
  "test:unit": "vitest run src/",
75
75
  "test:e2e": "vitest run tests/e2e",
76
+ "release:check": "node --experimental-strip-types src/release/check.ts",
76
77
  "prepublishOnly": "pnpm typecheck && pnpm test && pnpm build"
77
78
  },
78
79
  "engines": {