ccqa 1.12.0 → 1.14.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
@@ -477,7 +477,7 @@ function getBlocksDir(cwd) {
477
477
  /**
478
478
  * Inverse of `getBlockDir`. Given a file path that appears in a git diff,
479
479
  * return the block name if the path points at the block's spec.yaml, else
480
- * null. Used by `drift --changed` to invalidate specs whose included blocks
480
+ * null. Used by `audit --only-affected-by` to invalidate specs whose included blocks
481
481
  * were edited. (v0.4 inlines blocks into every spec's own trace, so the
482
482
  * block directory holds only spec.yaml — no per-block recording lives
483
483
  * here anymore.)
@@ -524,24 +524,22 @@ const USER_PROMPT_MAX_BYTES = 32768;
524
524
  * Load the prompt bundle from the hub for one guidance kind ("record" /
525
525
  * "live" / an LLM-generation target such as "playwright" or "runn").
526
526
  * Best-effort: no hub client, a fetch failure, or both prompts absent all
527
- * resolve to null a broken/missing hub prompt must never stop a run.
527
+ * A prompt that was never stored resolves to null. A hub that cannot be
528
+ * reached throws: running with silently different guidance than the project
529
+ * configured is worse than stopping.
528
530
  */
529
531
  async function loadPromptBundleFromHub(ctx, kind) {
530
532
  if (!ctx) return null;
531
533
  const userName = `${kind}.user`;
532
534
  const agentName = `${kind}.agent`;
533
- try {
534
- const [userText, agentText] = await Promise.all([ctx.hub.getPrompt(ctx.project, userName).then(normalizePromptText), ctx.hub.getPrompt(ctx.project, agentName).then(normalizePromptText)]);
535
- return assemblePromptBundle({
536
- text: userText,
537
- label: userName
538
- }, {
539
- text: agentText,
540
- label: agentName
541
- });
542
- } catch {
543
- return null;
544
- }
535
+ const [userText, agentText] = await Promise.all([ctx.hub.getPrompt(ctx.project, userName).then(normalizePromptText), ctx.hub.getPrompt(ctx.project, agentName).then(normalizePromptText)]);
536
+ return assemblePromptBundle({
537
+ text: userText,
538
+ label: userName
539
+ }, {
540
+ text: agentText,
541
+ label: agentName
542
+ });
545
543
  }
546
544
  /**
547
545
  * Shared concatenation logic behind `loadPromptBundleFromHub`: section
@@ -557,7 +555,7 @@ function assemblePromptBundle(user, agent) {
557
555
  loaded.push(user.label);
558
556
  }
559
557
  if (agent.text !== null) {
560
- sections.push(`### Agent learnings (auto-updated by ccqa --update-agent-prompt)\n\n${agent.text}`);
558
+ sections.push(`### Agent learnings (auto-updated by ccqa's --learn-*-prompt flags)\n\n${agent.text}`);
561
559
  loaded.push(agent.label);
562
560
  }
563
561
  let text = sections.join("\n\n");
@@ -2475,7 +2473,7 @@ const FailureAnalysisSchema = z.object({
2475
2473
  });
2476
2474
  /**
2477
2475
  * What a drift audit may conclude, in the same vocabulary `ccqa run
2478
- * --failure-analysis` uses for a failure. One question, one answer, the same
2476
+ * --on-fail-explain` uses for a failure. One question, one answer, the same
2479
2477
  * words whether it was reached by running the spec or by reading the code — so
2480
2478
  * a reader never translates between two taxonomies, and the hub renders,
2481
2479
  * grades and learns from both through one path.
@@ -3312,7 +3310,7 @@ function relativeToCwd(path, cwd) {
3312
3310
  /** The model's reply: a diagnosis, or `null` for "the spec still matches the code". */
3313
3311
  const DriftReplySchema = z.object({ drift: DriftDiagnosisSchema.nullable() });
3314
3312
  /**
3315
- * How a label reads against `--severity`. The threshold asks "would a
3313
+ * How a label reads against `--exit-on`. The threshold asks "would a
3316
3314
  * deterministic replay fail today", which is what the label already answers:
3317
3315
  * both findings mean the spec no longer describes the code, while `UNKNOWN`
3318
3316
  * means the audit could not tell and should not fail a build on its own.
@@ -3326,7 +3324,7 @@ const DEFAULT_CONCURRENCY$1 = 3;
3326
3324
  /**
3327
3325
  * Run drift checks against a list of pre-collected targets. Pure library
3328
3326
  * function: no commander, no process.exit, no stdout writes. Callers handle
3329
- * presentation. `cli/drift` does the full sweep with `--changed` scoping;
3327
+ * presentation. `cli/audit` does the full sweep with `--only-affected-by` scoping;
3330
3328
  * `cli/run` calls this with just the failing specs after vitest.
3331
3329
  */
3332
3330
  async function analyzeDrift(input) {
@@ -3971,14 +3969,10 @@ ${customPrompt.guidance}
3971
3969
  */
3972
3970
  async function fetchCustomPrompt(ctx) {
3973
3971
  if (!ctx) return null;
3974
- try {
3975
- const raw = await ctx.hub.getPrompt(ctx.project, "analysis-custom-prompt");
3976
- if (raw === null) return null;
3977
- const parsed = AnalysisCustomPromptSchema.safeParse(JSON.parse(raw));
3978
- return parsed.success ? parsed.data : null;
3979
- } catch {
3980
- return null;
3981
- }
3972
+ const raw = await ctx.hub.getPrompt(ctx.project, "analysis-custom-prompt");
3973
+ if (raw === null) return null;
3974
+ const parsed = AnalysisCustomPromptSchema.safeParse(JSON.parse(raw));
3975
+ return parsed.success ? parsed.data : null;
3982
3976
  }
3983
3977
  /**
3984
3978
  * Render the human-maintained `triage.user` guidance as a prompt section, or
@@ -4006,12 +4000,8 @@ ${trimmed}
4006
4000
  */
4007
4001
  async function fetchTriageUserPrompt(ctx) {
4008
4002
  if (!ctx) return null;
4009
- try {
4010
- const trimmed = (await ctx.hub.getPrompt(ctx.project, "triage.user"))?.trim();
4011
- return trimmed ? trimmed : null;
4012
- } catch {
4013
- return null;
4014
- }
4003
+ const trimmed = (await ctx.hub.getPrompt(ctx.project, "triage.user"))?.trim();
4004
+ return trimmed ? trimmed : null;
4015
4005
  }
4016
4006
  /**
4017
4007
  * Short, stable content hash for a `triage.user` prompt. The Markdown body
@@ -4426,16 +4416,6 @@ Write the runbook to \`${suggestedPath}\` unless the conventions/examples clearl
4426
4416
  //#region src/drift/affected.ts
4427
4417
  const execFileP = promisify(execFile);
4428
4418
  /**
4429
- * GITHUB_BASE_REF holds a bare branch name (e.g. "main"); the local checkout
4430
- * only has it as a remote-tracking ref, so prefix `origin/` unless already
4431
- * qualified. Used by `ccqa run`'s resolveAnalysisBase (`src/run/git-context.ts`),
4432
- * which both `ccqa run --changed` and `ccqa drift --changed` resolve their
4433
- * base through, so the rule can't drift between them.
4434
- */
4435
- function normalizeGithubBaseRef(ref) {
4436
- return ref.startsWith("origin/") ? ref : `origin/${ref}`;
4437
- }
4438
- /**
4439
4419
  * Paths that differ between `base` and `head` (two-dot: `git diff base..head`),
4440
4420
  * from `cwd`. Renames are reported under their NEW path with status
4441
4421
  * "renamed" — the OLD path is dropped since only the current layout matters.
@@ -4822,12 +4802,12 @@ async function readDotenv(path) {
4822
4802
  }
4823
4803
  return parseDotenv(content);
4824
4804
  }
4825
- /** Absolute path of the default `.env` ccqa loads when `--profile` is absent. */
4805
+ /** Absolute path of the default `.env` ccqa loads when `--hub-profile` is absent. */
4826
4806
  function defaultEnvPath(cwd) {
4827
4807
  return join(cwd, ".env");
4828
4808
  }
4829
4809
  /**
4830
- * Load `<cwd>/.env`, the default when no `--profile` is given. A missing `.env`
4810
+ * Load `<cwd>/.env`, the default when no `--hub-profile` is given. A missing `.env`
4831
4811
  * is fine (returns `null`) — the run falls back to the existing `process.env`.
4832
4812
  */
4833
4813
  async function loadDefaultEnv(cwd) {
@@ -5033,7 +5013,7 @@ function addLanguageOption(command) {
5033
5013
  * `record`), registered identically so help text and behaviour don't drift.
5034
5014
  */
5035
5015
  function addProfileOption(command) {
5036
- return command.option("--profile <name>", "Load this profile's variables from the hub into the environment before resolving spec ${VAR} references (URLs, credentials), so one spec can target dev/stg/prd without per-environment copies. Profile values override the inherited environment. Requires --hub-url/--hub-token (or CCQA_HUB_URL/CCQA_HUB_TOKEN).");
5016
+ return command.option("--hub-profile <name>", "Load this profile's variables from the hub into the environment before resolving spec ${VAR} references (URLs, credentials), so one spec can target dev/stg/prd without per-environment copies. Profile values override the inherited environment. Requires --hub-url/--hub-token (or CCQA_HUB_URL/CCQA_HUB_TOKEN).");
5037
5017
  }
5038
5018
  /**
5039
5019
  * Shared `--hub-url` / `--hub-token` flags for commands that optionally talk
@@ -5225,7 +5205,7 @@ const DraftNamingSchema = z.object({
5225
5205
  //#endregion
5226
5206
  //#region src/cli/draft.ts
5227
5207
  const CATEGORY_LABEL = DRAFT_CATEGORY_LABEL;
5228
- const draftCommand = addLanguageOption(new Command("draft").argument("[feature/spec]", "Optional spec path (e.g. tasks/create-and-complete). If omitted, Claude proposes one from your intent.").description("Interactively draft and refine a spec.yaml with Claude Code").option("--instruction <text>", "Non-interactive single-shot instruction (skips the interactive loop)").option("--apply", "Auto-apply each generated patch without [y/N] confirmation", false)).action(withUsageErrors(async (specPath, opts) => {
5208
+ 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) => {
5229
5209
  await ensureCcqaDir();
5230
5210
  let featureName;
5231
5211
  let specName;
@@ -5262,7 +5242,7 @@ async function runDraft(featureName, specName, opts, prefilledIntent) {
5262
5242
  specName,
5263
5243
  existing,
5264
5244
  userInput: userInput.trim(),
5265
- autoApply: opts.apply === true,
5245
+ autoApply: opts.yes === true,
5266
5246
  language: opts.language
5267
5247
  });
5268
5248
  if (oneShot) process.exit(turnResult.hasError && !turnResult.applied ? 1 : 0);
@@ -5482,7 +5462,7 @@ async function proposeNaming(opts) {
5482
5462
  const final = ensureUnique(tree, sanitized.featureName, sanitized.specName);
5483
5463
  meta("proposed", `${final.featureName}/${final.specName}`);
5484
5464
  if (proposed.reason) meta("reason", proposed.reason);
5485
- if (oneShot || opts.apply === true) return {
5465
+ if (oneShot || opts.yes === true) return {
5486
5466
  naming: final,
5487
5467
  intent: intent.trim()
5488
5468
  };
@@ -5911,7 +5891,7 @@ async function runVerificationLoop(p, ref, state) {
5911
5891
  const runCommand = p.ctx.targetConfig.runCommand;
5912
5892
  if (!runCommand) return true;
5913
5893
  const maxRetries = p.ctx.fix.mode === "non-interactive" ? 0 : p.ctx.fix.maxRetries;
5914
- if (!p.ctx.fix.useSnapshot) warn(`--no-snapshot has no effect on the ${p.target} target — it captures no browser snapshot; the fix loop uses the command's output instead`);
5894
+ if (!p.ctx.fix.useSnapshot) warn(`--no-session-pin has no effect on the ${p.target} target — it captures no browser snapshot; the fix loop uses the command's output instead`);
5915
5895
  for (let attempt = 0;; attempt++) {
5916
5896
  const testFiles = [...state.entries()].filter(([, f]) => f.kind === "test").map(([rel]) => rel);
5917
5897
  const artifactsDir = await mkdtemp(join(tmpdir(), "ccqa-verify-artifacts-"));
@@ -6099,8 +6079,8 @@ const C$1 = {
6099
6079
  * script, so it keeps its own caller in `cli/run-live.ts`; only the
6100
6080
  * `ANALYSIS_DISABLED` string is shared with it.
6101
6081
  */
6102
- /** `analysisSkipped` for a failed row when `--failure-analysis` was not requested. */
6103
- const ANALYSIS_DISABLED = "skipped: --failure-analysis not enabled";
6082
+ /** `analysisSkipped` for a failed row when `--on-fail-explain` was not requested. */
6083
+ const ANALYSIS_DISABLED = "skipped: --on-fail-explain not enabled";
6104
6084
  /**
6105
6085
  * Create one analysis pass. The returned object is stateful on purpose: the
6106
6086
  * "source diff unavailable" notice and the summary block's header are printed
@@ -6531,15 +6511,13 @@ function createDiffProvider(args) {
6531
6511
  }
6532
6512
  //#endregion
6533
6513
  //#region src/run/git-context.ts
6534
- /** The `--failure-analysis` value that selects per-spec hub-ledger baselines. */
6535
- const LAST_GREEN = "last-green";
6536
6514
  /**
6537
- * The `--changed` value that selects specs from the hub's re-run verdicts
6538
- * (`rerun-selection.ts` acts on it). Kept here beside `LAST_GREEN` so the two
6539
- * "not a git ref" keywords, and the rules that reject each on the flag it does
6540
- * not belong to, sit in one place.
6515
+ * Marks a baseline as "each spec's own last green commit" rather than one
6516
+ * shared ref. Not a flag value `--on-fail-explain` uses per-spec baselines
6517
+ * unless `--on-fail-explain-base` names a ref but the report and the log
6518
+ * lines need a word for it.
6541
6519
  */
6542
- const LAST_RUN = "last-run";
6520
+ const LAST_GREEN = "last-green";
6543
6521
  /** Resolve `ref` to a full commit sha, or null when it does not exist locally. */
6544
6522
  async function resolveCommitSha(ref, cwd) {
6545
6523
  try {
@@ -6555,35 +6533,17 @@ async function resolveCommitSha(ref, cwd) {
6555
6533
  }
6556
6534
  }
6557
6535
  /**
6558
- * Resolve a `[base]` flag value (from `--failure-analysis [base]` or
6559
- * `--changed [base]`) to a verified baseline, failing fast before any spec
6560
- * runs when it cannot be resolved.
6561
- *
6562
- * - a string value is an explicit ref;
6563
- * - bare `true` derives the ref from GITHUB_BASE_REF (pull_request events)
6564
- * and errors outside that context;
6565
- * - the ref must resolve to a local commit, so a shallow CI checkout that
6566
- * never fetched the base surfaces here as an actionable error instead of
6567
- * an empty diff downstream.
6536
+ * Resolve a base ref to a verified baseline, failing fast — before any spec
6537
+ * runs when it cannot be resolved. The ref must resolve to a local commit,
6538
+ * so a shallow CI checkout that never fetched the base surfaces here as an
6539
+ * actionable error instead of an empty diff downstream.
6568
6540
  *
6569
6541
  * `flagName` only shapes the error messages.
6570
6542
  */
6571
- async function resolveAnalysisBase(flagValue, flagName, cwd, baseExample) {
6572
- let ref;
6573
- let source;
6574
- if (flagValue === "last-green") throw new RunUsageError(`${flagName}=${LAST_GREEN} is not supported — last-green baselines are per-spec and only apply to --failure-analysis`);
6575
- if (flagValue === "last-run") throw new RunUsageError(`${flagName}=${LAST_RUN} is not supported — last-run selects which specs to run and only applies to --changed`);
6576
- if (typeof flagValue === "string") {
6577
- ref = flagValue;
6578
- source = "explicit";
6579
- } else {
6580
- const ghBase = process.env["GITHUB_BASE_REF"];
6581
- if (!ghBase) throw new RunUsageError(`${flagName} without a base needs GITHUB_BASE_REF (a pull_request workflow); outside that context pass the base explicitly, e.g. ${baseExample ?? `${flagName}=origin/main`}`);
6582
- ref = normalizeGithubBaseRef(ghBase);
6583
- source = "github-base-ref";
6584
- }
6543
+ async function resolveAnalysisBase(ref, flagName, cwd) {
6544
+ const source = "explicit";
6585
6545
  const sha = await resolveCommitSha(ref, cwd);
6586
- if (sha === null) throw new RunUsageError(`${flagName}: '${ref}' is not a resolvable git ref in this checkout. If this is CI, the base may not be fetched (try fetch-depth: 0). If '${ref}' was meant as a spec target, put spec targets before flags or use ${flagName}=<ref>.`);
6546
+ if (sha === null) throw new RunUsageError(`${flagName}: '${ref}' is not a resolvable git ref in this checkout. If this is CI, the base may not be fetched (try fetch-depth: 0). If '${ref}' was meant as a spec target, put spec targets before flags.`);
6587
6547
  return {
6588
6548
  ref,
6589
6549
  sha,
@@ -6640,7 +6600,7 @@ async function detectDefaultBranch(cwd) {
6640
6600
  /**
6641
6601
  * Fetch the last-green ledger for this run — one hub round trip, logged as
6642
6602
  * the run's analysis-base meta line. Fails fast (RunUsageError) when the hub
6643
- * can't serve it: `--failure-analysis=last-green` explicitly opted into
6603
+ * can't serve it: `--on-fail-explain` explicitly opted into
6644
6604
  * hub-backed baselines, so a broken hub connection is a usage error, never a
6645
6605
  * silent no-baseline run.
6646
6606
  */
@@ -6655,7 +6615,7 @@ async function fetchLastGreenLedger(hubCtx, profile, cwd) {
6655
6615
  ...profile ? { profile } : {}
6656
6616
  });
6657
6617
  } catch (err) {
6658
- throw new RunUsageError(`--failure-analysis=${LAST_GREEN}: could not fetch the last-green ledger from the hub: ${err instanceof Error ? err.message : String(err)}`);
6618
+ throw new RunUsageError(`--on-fail-explain: could not fetch the last-green ledger from the hub: ${err instanceof Error ? err.message : String(err)}`);
6659
6619
  }
6660
6620
  const n = Object.keys(entries).length;
6661
6621
  const scope = branch === fallbackBranch ? branch : `${branch} → ${fallbackBranch}`;
@@ -6663,7 +6623,7 @@ async function fetchLastGreenLedger(hubCtx, profile, cwd) {
6663
6623
  return entries;
6664
6624
  }
6665
6625
  /**
6666
- * Per-spec baseline resolver for `--failure-analysis=last-green`. A spec
6626
+ * Per-spec baseline resolver for `--on-fail-explain` without an explicit base. A spec
6667
6627
  * missing from the ledger (never green on a pushed run yet) or whose
6668
6628
  * baseline commit isn't in this checkout resolves to a skip — the run
6669
6629
  * continues; only that spec's classification is withheld, with the reason
@@ -6717,7 +6677,7 @@ async function deployHeadSha(hub, project, profile) {
6717
6677
  *
6718
6678
  * Captured before any spec executes and asserted on both push paths
6719
6679
  * (`?deployedSha=` on `POST /runs` via `ccqa hub push`, and on `POST
6720
- * /runs/open` for `--push-report`). Left to itself the hub reads its own
6680
+ * /runs/open` for `--report-to-hub`). Left to itself the hub reads its own
6721
6681
  * deploy-log head when the call lands — after the whole run for a single-shot
6722
6682
  * push, after the deterministic phase for an incremental one — so a deploy
6723
6683
  * landing in that window would be recorded as the run's baseline and
@@ -6742,7 +6702,7 @@ async function tryDeployHeadSha(hubCtx, profile) {
6742
6702
  *
6743
6703
  * This exists because a selection can be wrong in a way that costs money.
6744
6704
  * `ccqa select-specs`'s model judgment is not infallible, and both
6745
- * `--changed <ref>` and `--changed=last-run` decide from it, so a human has to
6705
+ * `--only-affected-by` and `--only-hub-stale` decide from it, so a human has to
6746
6706
  * be able to read the selection back before a live spec spends a Claude
6747
6707
  * budget on it.
6748
6708
  *
@@ -6774,9 +6734,53 @@ function formatDryRunLines(agentBrowser, routed) {
6774
6734
  return tagged.map((t) => ` ${t.key.padEnd(width)} ${t.tag}`);
6775
6735
  }
6776
6736
  //#endregion
6737
+ //#region src/run/audited-clean.ts
6738
+ /**
6739
+ * Fetch the drift ledger and reduce it to the specs that are safe to run.
6740
+ *
6741
+ * A spec qualifies only when the ledger holds an entry for it *and* that entry
6742
+ * found no drift. A spec that has never been audited does not qualify: the
6743
+ * point of the flag is to spend a run only where a cheap audit already said
6744
+ * the spec still describes the code, and "never looked" is not that.
6745
+ */
6746
+ async function fetchAuditedLedger(hubCtx) {
6747
+ let ledger;
6748
+ try {
6749
+ ledger = await hubCtx.hub.getDriftLedger(hubCtx.project);
6750
+ } catch (err) {
6751
+ throw new RunUsageError(`--only-hub-audited-clean: could not fetch the drift ledger from the hub: ${errMessage(err)}`);
6752
+ }
6753
+ const clean = /* @__PURE__ */ new Set();
6754
+ const audited = /* @__PURE__ */ new Set();
6755
+ for (const [key, entry] of Object.entries(ledger.specs)) {
6756
+ audited.add(key);
6757
+ if (entry.label === null) clean.add(key);
6758
+ }
6759
+ return {
6760
+ clean,
6761
+ audited
6762
+ };
6763
+ }
6764
+ function selectAuditedClean(specs, ledger) {
6765
+ const selected = [];
6766
+ let unaudited = 0;
6767
+ let drifted = 0;
6768
+ for (const spec of specs) {
6769
+ const key = specKey(spec);
6770
+ if (ledger.clean.has(key)) selected.push(spec);
6771
+ else if (ledger.audited.has(key)) drifted++;
6772
+ else unaudited++;
6773
+ }
6774
+ return {
6775
+ selected,
6776
+ unaudited,
6777
+ drifted
6778
+ };
6779
+ }
6780
+ //#endregion
6777
6781
  //#region src/run/rerun-selection.ts
6778
6782
  /**
6779
- * `ccqa run --changed=last-run`: select specs from the hub's re-run verdicts
6783
+ * `ccqa run --only-hub-stale`: select specs from the hub's re-run verdicts
6780
6784
  * instead of from a git diff (ADR-0010). The baseline is not a ref at all —
6781
6785
  * it is each spec's own last run, positioned against the deploy log the
6782
6786
  * consuming deploy job feeds the hub — so this path does no git work.
@@ -6788,12 +6792,12 @@ function formatDryRunLines(agentBrowser, routed) {
6788
6792
  /** First ccqa release whose hub serves `GET /projects/:project/rerun`. */
6789
6793
  const RERUN_MIN_HUB_VERSION = "1.9";
6790
6794
  /**
6791
- * The profile `--changed=last-run` asks about. Mandatory: two environments sit
6795
+ * The profile `--only-hub-stale` asks about. Mandatory: two environments sit
6792
6796
  * at different commits and the deploy log is per-profile, so "needs re-run"
6793
6797
  * has no profile-free answer.
6794
6798
  */
6795
6799
  function requireRerunProfile(profile) {
6796
- if (profile === void 0) throw new RunUsageError(`--changed=${LAST_RUN} requires --profile <name>: the deploy log it reads is per-profile, so which specs need a re-run has no answer without one`);
6800
+ if (profile === void 0) throw new RunUsageError("--only-hub-stale requires --hub-profile <name>: the deploy log it reads is per-profile, so which specs need a re-run has no answer without one");
6797
6801
  return profile;
6798
6802
  }
6799
6803
  /**
@@ -6806,9 +6810,9 @@ async function fetchRerunReport(hubCtx, profile) {
6806
6810
  report = await hubCtx.hub.getRerun(hubCtx.project, { profile });
6807
6811
  } catch (err) {
6808
6812
  if (err instanceof HubApiError && err.status === 404) throw new RunUsageError(explainNotFound(hubCtx, err));
6809
- throw new RunUsageError(`--changed=${LAST_RUN}: could not ask the hub which specs need a re-run: ${errMessage(err)}`);
6813
+ throw new RunUsageError(`--only-hub-stale: could not ask the hub which specs need a re-run: ${errMessage(err)}`);
6810
6814
  }
6811
- if (report.deployHead === null) throw new RunUsageError(`--changed=${LAST_RUN}: no deploy has been recorded for profile "${profile}" of project "${hubCtx.project}", so nothing can be compared against. Wire \`ccqa hub deploy record\` into the deploy job, or pass an explicit baseline (--changed=<ref>).`);
6815
+ if (report.deployHead === null) throw new RunUsageError(`--only-hub-stale: no deploy has been recorded for profile "${profile}" of project "${hubCtx.project}", so nothing can be compared against. Wire \`ccqa hub deploy record\` into the deploy job, or select with --only-affected-by <ref> instead.`);
6812
6816
  return {
6813
6817
  ...report,
6814
6818
  deployHead: report.deployHead
@@ -6820,8 +6824,8 @@ async function fetchRerunReport(hubCtx, profile) {
6820
6824
  * means the hub does not serve this route at all.
6821
6825
  */
6822
6826
  function explainNotFound(hubCtx, err) {
6823
- if (err.code === "no_perspectives") return `--changed=${LAST_RUN}: project "${hubCtx.project}" has no perspectives document on the hub, so no spec is registered to compare against a deploy. Run \`ccqa perspectives\` first.`;
6824
- return `--changed=${LAST_RUN}: this hub does not serve re-run verdicts — it needs ccqa ${RERUN_MIN_HUB_VERSION} or newer. Upgrade the hub, or pass an explicit baseline (--changed=<ref>).`;
6827
+ if (err.code === "no_perspectives") return `--only-hub-stale: project "${hubCtx.project}" has no perspectives document on the hub, so no spec is registered to compare against a deploy. Run \`ccqa perspectives\` first.`;
6828
+ return `--only-hub-stale: this hub does not serve re-run verdicts — it needs ccqa ${RERUN_MIN_HUB_VERSION} or newer. Upgrade the hub, or select with --only-affected-by <ref> instead.`;
6825
6829
  }
6826
6830
  /** States the summary line reports, worst-known-first. */
6827
6831
  const SUMMARY_ORDER = [
@@ -6842,7 +6846,7 @@ const UNANSWERABLE = new Set([
6842
6846
  *
6843
6847
  * `needed` is always selected. `unknown` and `neverRun` are "the question
6844
6848
  * cannot be answered", so they are excluded by default and opted into with
6845
- * `--include-unknown` — fail-open on request, never silently. `notNeeded` and
6849
+ * `--only-hub-stale-with-unknown` — fail-open on request, never silently. `notNeeded` and
6846
6850
  * `notEvaluated` are never selected.
6847
6851
  */
6848
6852
  function selectSpecsNeedingRerun(specs, report, opts) {
@@ -7620,7 +7624,7 @@ function safeOriginPath(href) {
7620
7624
  *
7621
7625
  * Two kinds share one namespace:
7622
7626
  * - "guidance": the record/live prompt bundle — `.user.md` (human-maintained)
7623
- * and `.agent.md` (auto-rewritten by `ccqa run --update-agent-prompt`) —
7627
+ * and `.agent.md` (auto-rewritten by `ccqa run --learn-hub-live-prompt`) —
7624
7628
  * plus `triage.user`, the human-maintained guidance injected into the
7625
7629
  * failure-analysis (triage) prompt.
7626
7630
  * - "custom-prompt": `analysis-custom-prompt` — Claude-written calibration guidance
@@ -8089,7 +8093,7 @@ z.object({
8089
8093
  lastRed: z.record(z.string(), SpecLedgerEntrySchema).default({})
8090
8094
  });
8091
8095
  /**
8092
- * One spec's last drift audit, as recorded by `ccqa drift --push`. Unlike the
8096
+ * One spec's last drift audit, as recorded by `ccqa audit --report-to-hub`. Unlike the
8093
8097
  * spec ledger above, this carries no profile: drift asks whether a spec still
8094
8098
  * describes the code, which has nothing to do with which environment is
8095
8099
  * running it (ADR-0010 draws the same line for "needs re-run").
@@ -8299,7 +8303,7 @@ function isCcqaPath(path) {
8299
8303
  }
8300
8304
  /**
8301
8305
  * A malformed reply costs the whole selection, so it is worth one more call
8302
- * before giving up. `ccqa drift` retries per spec for the same reason; this
8306
+ * before giving up. `ccqa audit` retries per spec for the same reason; this
8303
8307
  * call carries every undecided spec at once, so the blast radius is larger,
8304
8308
  * not smaller. Observed in practice: three runs over one commit produced a
8305
8309
  * parse failure, a clean answer, and a different clean answer.
@@ -8463,6 +8467,101 @@ function oneLine$1(text) {
8463
8467
  return text.trim().replace(/\s+/g, " ");
8464
8468
  }
8465
8469
  //#endregion
8470
+ //#region src/cli/session.ts
8471
+ const AB = resolveAgentBrowserBin$1();
8472
+ /**
8473
+ * Run agent-browser attached to the user's terminal (no timeout, inherited
8474
+ * stdio) so a human can complete an interactive login during `bootstrap`.
8475
+ * Distinct from runtime/spawn-ab.ts, which pipes stdio and hard-times-out for
8476
+ * non-interactive automation.
8477
+ */
8478
+ function runAbInteractive(args) {
8479
+ return spawnSync(AB, args, { stdio: "inherit" }).status ?? 1;
8480
+ }
8481
+ function validateName(name) {
8482
+ const parsed = SessionNameSchema.safeParse(name);
8483
+ if (!parsed.success) {
8484
+ error(`invalid session name "${name}": ${parsed.error.issues[0]?.message ?? "bad name"}`);
8485
+ process.exit(2);
8486
+ }
8487
+ return parsed.data;
8488
+ }
8489
+ const profileOption$1 = ["--profile <name>", "Sessions bucket to read/write on the hub. Defaults to 'default'."];
8490
+ const projectOption$1 = ["--project <name>", "Project the session belongs to on the hub. Defaults to the current directory's name."];
8491
+ const sessionCaptureCommand = new Command("capture").description("Open a headed browser so you can log in by hand, then upload the resulting session (cookies + localStorage) to the hub for `session:` specs to restore.").argument("<name>", "Session name to save").option("--url <url>", "URL to open first (e.g. the login page). Omit to start with a blank tab.").option(...profileOption$1).option(...hubUrlOption).option(...hubTokenOption).option(...projectOption$1).option("--cwd <path>", "Directory the default --project name is derived from (defaults to the current directory).").action(async (rawName, opts) => {
8492
+ const name = validateName(rawName);
8493
+ resolveCwd(opts.cwd);
8494
+ const project = resolveProject(opts);
8495
+ let hub;
8496
+ try {
8497
+ hub = requireHubClient(opts);
8498
+ } catch (err) {
8499
+ if (!(err instanceof HubConnectionError)) throw err;
8500
+ error(err.message);
8501
+ process.exit(2);
8502
+ }
8503
+ header("session capture", name);
8504
+ meta("project", project);
8505
+ meta("profile", opts.profile ?? "default");
8506
+ blank();
8507
+ const openArgs = [
8508
+ "--headed",
8509
+ "open",
8510
+ ...opts.url ? [opts.url] : ["about:blank"]
8511
+ ];
8512
+ info("opening a browser — log in by hand, then return here.");
8513
+ const openStatus = runAbInteractive(openArgs);
8514
+ if (openStatus !== 0) {
8515
+ error(`agent-browser open exited ${openStatus}`);
8516
+ process.exit(1);
8517
+ }
8518
+ const rl = createInterface({
8519
+ input: process.stdin,
8520
+ output: process.stdout
8521
+ });
8522
+ await rl.question("\nPress Enter once you are fully logged in to save the session… ");
8523
+ rl.close();
8524
+ const tmpDir = await mkdtemp(join(tmpdir(), "ccqa-session-bootstrap-"));
8525
+ try {
8526
+ const tmpPath = join(tmpDir, "state.json");
8527
+ const saveStatus = runAbInteractive([
8528
+ "state",
8529
+ "save",
8530
+ tmpPath
8531
+ ]);
8532
+ runAbInteractive(["close"]);
8533
+ if (saveStatus !== 0) {
8534
+ error(`agent-browser state save exited ${saveStatus}`);
8535
+ process.exit(1);
8536
+ }
8537
+ const state = await loadStorageState(tmpPath);
8538
+ let payload = state;
8539
+ if (opts.url) {
8540
+ info("verifying the saved session restores to a signed-in page…");
8541
+ const check = verifySessionRestores(tmpPath, opts.url);
8542
+ if (!check.restored) {
8543
+ error(`session did not restore cleanly: ${check.reason}`);
8544
+ hint("fully load the application (sign in, open the target workspace/page, wait for it to settle) before pressing Enter, then run bootstrap again. Nothing was uploaded.");
8545
+ process.exit(1);
8546
+ }
8547
+ info("restore verified — the session starts signed in.");
8548
+ payload = {
8549
+ ...state,
8550
+ [SESSION_VERIFY_URL_KEY]: opts.url
8551
+ };
8552
+ } else warn("no --url given — the session can't be verified now, and runs can't health-check it before executing steps; strongly consider re-running with --url <a signed-in page URL>.");
8553
+ await hub.putSession(project, opts.profile ?? "default", name, payload);
8554
+ } finally {
8555
+ await rm(tmpDir, {
8556
+ recursive: true,
8557
+ force: true
8558
+ });
8559
+ }
8560
+ blank();
8561
+ info(`uploaded session "${name}" to the hub (encrypted at rest)`);
8562
+ hint("reference it from a spec with: session: " + name);
8563
+ });
8564
+ //#endregion
8466
8565
  //#region src/cli/hub.ts
8467
8566
  /**
8468
8567
  * `ccqa hub` — the client side of the ccqa hub (a results/secret control
@@ -8473,8 +8572,8 @@ function oneLine$1(text) {
8473
8572
  * talk to the hub over the same public REST API (docs/hub-api.md) via
8474
8573
  * `ccqa/hub-client`.
8475
8574
  */
8476
- const profileOption$1 = ["--profile <name>", "Profile bucket the session/variable belongs to. Defaults to 'default'."];
8477
- const projectOption$1 = ["--project <name>", "Project the session/variable belongs to on the hub. Defaults to the current directory's name."];
8575
+ const profileOption = ["--profile <name>", "Profile bucket the session/variable belongs to. Defaults to 'default'."];
8576
+ const projectOption = ["--project <name>", "Project the session/variable belongs to on the hub. Defaults to the current directory's name."];
8478
8577
  const cwdOption = ["--cwd <path>", "Directory the default --project name is derived from (defaults to the current directory)."];
8479
8578
  /**
8480
8579
  * The hub base URL from flags / env (trailing slashes trimmed), or exit 2.
@@ -8511,7 +8610,7 @@ async function readStdin() {
8511
8610
  for await (const chunk of process.stdin) chunks.push(chunk);
8512
8611
  return Buffer.concat(chunks).toString("utf8");
8513
8612
  }
8514
- 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) => {
8613
+ 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).option(...profileOption).option("--cwd <path>", "Project root containing .ccqa/ (defaults to the current directory).").action(withHubErrors(async (rawName, opts) => {
8515
8614
  const name = validateSessionName(rawName);
8516
8615
  const cwd = resolveCwd(opts.cwd);
8517
8616
  const project = resolveProject(opts);
@@ -8522,7 +8621,7 @@ const sessionPush = new Command("push").description("Upload a locally-saved brow
8522
8621
  state = await loadStorageState(path);
8523
8622
  } catch (err) {
8524
8623
  error(`could not read session "${name}" at ${path}: ${err instanceof Error ? err.message : String(err)}`);
8525
- hint(`create it first with: ccqa session bootstrap ${name}${opts.profile ? ` --profile ${opts.profile}` : ""}`);
8624
+ hint(`create it first with: ccqa hub session capture ${name}${opts.profile ? ` --profile ${opts.profile}` : ""}`);
8526
8625
  process.exit(2);
8527
8626
  }
8528
8627
  await connect(opts).putSession(project, profile, name, state);
@@ -8531,7 +8630,7 @@ const sessionPush = new Command("push").description("Upload a locally-saved brow
8531
8630
  meta("profile", profile);
8532
8631
  info(`uploaded session "${name}" to the hub (encrypted at rest)`);
8533
8632
  }));
8534
- const sessionLs = new Command("ls").description("List sessions stored on the hub for a project/profile (names + last-updated times). `ls` shows metadata only.").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption$1).option(...profileOption$1).option(...cwdOption).action(withHubErrors(async (opts) => {
8633
+ const sessionLs = new Command("ls").description("List sessions stored on the hub for a project/profile (names + last-updated times). `ls` shows metadata only.").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption).option(...profileOption).option(...cwdOption).action(withHubErrors(async (opts) => {
8535
8634
  const project = resolveProject(opts);
8536
8635
  const profile = opts.profile ?? "default";
8537
8636
  const sessions = await connect(opts).listSessions(project, profile);
@@ -8542,7 +8641,7 @@ const sessionLs = new Command("ls").description("List sessions stored on the hub
8542
8641
  }
8543
8642
  for (const s of sessions) meta(s.name, `updated ${s.updatedAt}`);
8544
8643
  }));
8545
- const sessionRm = new Command("rm").description("Delete a session from the hub.").argument("<name>", "Session name to delete").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption$1).option(...profileOption$1).option(...cwdOption).action(withHubErrors(async (rawName, opts) => {
8644
+ const sessionRm = new Command("rm").description("Delete a session from the hub.").argument("<name>", "Session name to delete").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption).option(...profileOption).option(...cwdOption).action(withHubErrors(async (rawName, opts) => {
8546
8645
  const name = validateSessionName(rawName);
8547
8646
  const project = resolveProject(opts);
8548
8647
  const profile = opts.profile ?? "default";
@@ -8550,8 +8649,8 @@ const sessionRm = new Command("rm").description("Delete a session from the hub."
8550
8649
  header("hub session rm", name);
8551
8650
  info(`deleted session "${name}" from the hub`);
8552
8651
  }));
8553
- const sessionCommand$1 = new Command("session").description("Manage browser sessions stored on the hub (fetched automatically by `ccqa run` / `ccqa record` at run time).").addCommand(sessionPush).addCommand(sessionLs).addCommand(sessionRm);
8554
- const varSet = new Command("set").description("Store an environment variable on the hub, fetched at run time by `ccqa run` / `ccqa record`. Use --sensitive to hide the value from `ls` output (it is still returned in full to the run).").argument("<name>", "Variable name (e.g. BASE_URL)").option("--value <value>", "Variable value. Omit to read the value from stdin (better for secrets).").option("--sensitive", "Hide the value in `ls` output. Any token holder can still read it via the run-time fetch.").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption$1).option(...profileOption$1).option(...cwdOption).action(withHubErrors(async (name, opts) => {
8652
+ const sessionCommand = new Command("session").description("Manage browser sessions stored on the hub (fetched automatically by `ccqa run` / `ccqa record` at run time).").addCommand(sessionCaptureCommand).addCommand(sessionPush).addCommand(sessionLs).addCommand(sessionRm);
8653
+ const varSet = new Command("set").description("Store an environment variable on the hub, fetched at run time by `ccqa run` / `ccqa record`. Use --sensitive to hide the value from `ls` output (it is still returned in full to the run).").argument("<name>", "Variable name (e.g. BASE_URL)").option("--value <value>", "Variable value. Omit to read the value from stdin (better for secrets).").option("--sensitive", "Hide the value in `ls` output. Any token holder can still read it via the run-time fetch.").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption).option(...profileOption).option(...cwdOption).action(withHubErrors(async (name, opts) => {
8555
8654
  const project = resolveProject(opts);
8556
8655
  const profile = opts.profile ?? "default";
8557
8656
  const value = opts.value ?? (await readStdin()).trim();
@@ -8569,7 +8668,7 @@ const varSet = new Command("set").description("Store an environment variable on
8569
8668
  meta("sensitive", String(opts.sensitive ?? false));
8570
8669
  info(`stored variable "${name}" on the hub`);
8571
8670
  }));
8572
- const varLs = new Command("ls").description("List variables stored on the hub for a project/profile. Non-sensitive values are shown inline; sensitive ones are hidden here but still fetched at run time by `ccqa run` / `ccqa record`.").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption$1).option(...profileOption$1).option(...cwdOption).action(withHubErrors(async (opts) => {
8671
+ const varLs = new Command("ls").description("List variables stored on the hub for a project/profile. Non-sensitive values are shown inline; sensitive ones are hidden here but still fetched at run time by `ccqa run` / `ccqa record`.").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption).option(...profileOption).option(...cwdOption).action(withHubErrors(async (opts) => {
8573
8672
  const project = resolveProject(opts);
8574
8673
  const profile = opts.profile ?? "default";
8575
8674
  const variables = await connect(opts).listVariables(project, profile);
@@ -8583,7 +8682,7 @@ const varLs = new Command("ls").description("List variables stored on the hub fo
8583
8682
  meta(v.name, shown);
8584
8683
  }
8585
8684
  }));
8586
- const varRm = new Command("rm").description("Delete a variable from the hub.").argument("<name>", "Variable name to delete").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption$1).option(...profileOption$1).option(...cwdOption).action(withHubErrors(async (name, opts) => {
8685
+ const varRm = new Command("rm").description("Delete a variable from the hub.").argument("<name>", "Variable name to delete").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption).option(...profileOption).option(...cwdOption).action(withHubErrors(async (name, opts) => {
8587
8686
  const project = resolveProject(opts);
8588
8687
  const profile = opts.profile ?? "default";
8589
8688
  await connect(opts).deleteVariable(project, profile, name);
@@ -8599,7 +8698,7 @@ function validatePromptName(rawName) {
8599
8698
  }
8600
8699
  return rawName;
8601
8700
  }
8602
- const promptPush = new Command("push").description("Upload a locally-generated prompt asset to the hub, so it's available to other environments running against this project.").argument("<name>", `Prompt name (${PROMPT_NAMES.join(", ")})`).option(...hubUrlOption).option(...hubTokenOption).option(...projectOption$1).option(...cwdOption).action(withHubErrors(async (rawName, opts) => {
8701
+ const promptPush = new Command("push").description("Upload a locally-generated prompt asset to the hub, so it's available to other environments running against this project.").argument("<name>", `Prompt name (${PROMPT_NAMES.join(", ")})`).option(...hubUrlOption).option(...hubTokenOption).option(...projectOption).option(...cwdOption).action(withHubErrors(async (rawName, opts) => {
8603
8702
  const name = validatePromptName(rawName);
8604
8703
  const cwd = resolveCwd(opts.cwd);
8605
8704
  const project = resolveProject(opts);
@@ -8609,12 +8708,12 @@ const promptPush = new Command("push").description("Upload a locally-generated p
8609
8708
  body = await readFile(path, "utf8");
8610
8709
  } catch (err) {
8611
8710
  error(`could not read prompt "${name}" at ${path}: ${err instanceof Error ? err.message : String(err)}`);
8612
- hint("nothing to push; generate it first (e.g. ccqa run --update-agent-prompt)");
8711
+ hint("nothing to push; generate it first (e.g. ccqa run --learn-hub-live-prompt)");
8613
8712
  process.exit(2);
8614
8713
  }
8615
8714
  if (body.trim().length === 0) {
8616
8715
  error(`prompt "${name}" at ${path} is empty`);
8617
- hint("nothing to push; generate it first (e.g. ccqa run --update-agent-prompt)");
8716
+ hint("nothing to push; generate it first (e.g. ccqa run --learn-hub-live-prompt)");
8618
8717
  process.exit(2);
8619
8718
  }
8620
8719
  await connect(opts).putPrompt(project, name, body);
@@ -8622,7 +8721,7 @@ const promptPush = new Command("push").description("Upload a locally-generated p
8622
8721
  meta("project", project);
8623
8722
  info(`uploaded prompt "${name}" to the hub`);
8624
8723
  }));
8625
- const promptLs = new Command("ls").description("List prompts stored on the hub for a project (name, kind, last-updated). Prompts are project-wide (not per-profile). `ls` shows metadata only.").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption$1).option(...cwdOption).action(withHubErrors(async (opts) => {
8724
+ const promptLs = new Command("ls").description("List prompts stored on the hub for a project (name, kind, last-updated). Prompts are project-wide (not per-profile). `ls` shows metadata only.").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption).option(...cwdOption).action(withHubErrors(async (opts) => {
8626
8725
  const project = resolveProject(opts);
8627
8726
  const prompts = await connect(opts).listPrompts(project);
8628
8727
  header("hub prompts", project);
@@ -8632,7 +8731,7 @@ const promptLs = new Command("ls").description("List prompts stored on the hub f
8632
8731
  }
8633
8732
  for (const p of prompts) meta(p.name, `${p.kind}, updated ${p.updatedAt}`);
8634
8733
  }));
8635
- const promptRm = new Command("rm").description("Delete a prompt from the hub.").argument("<name>", "Prompt name to delete").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption$1).option(...cwdOption).action(withHubErrors(async (rawName, opts) => {
8734
+ const promptRm = new Command("rm").description("Delete a prompt from the hub.").argument("<name>", "Prompt name to delete").option(...hubUrlOption).option(...hubTokenOption).option(...projectOption).option(...cwdOption).action(withHubErrors(async (rawName, opts) => {
8636
8735
  const name = validatePromptName(rawName);
8637
8736
  const project = resolveProject(opts);
8638
8737
  await connect(opts).deletePrompt(project, name);
@@ -8640,7 +8739,7 @@ const promptRm = new Command("rm").description("Delete a prompt from the hub.").
8640
8739
  info(`deleted prompt "${name}" from the hub`);
8641
8740
  }));
8642
8741
  const promptCommand = new Command("prompt").description("Manage prompt assets (per-flow user/agent guidance, triage user guidance, analysis custom prompt) stored on the hub (fetched automatically by `ccqa run` at run time).").addCommand(promptPush).addCommand(promptLs).addCommand(promptRm);
8643
- const deployRecord = new Command("record").description("Tell the hub what a deploy shipped, so it can answer which specs need a re-run (`ccqa run --changed=last-run`). Run this from the deploy job, after the deploy succeeds. The changed paths are computed locally with a two-dot `git diff <previous> <sha>`; a job that has only curl and git can POST the same body directly (see docs/hub.md).").requiredOption("--profile <name>", "Environment this deploy landed in (e.g. 'stg'). Required: dev and stg sit at different commits, so the deploy log is per-profile.").requiredOption("--sha <sha>", "Commit that was deployed.").option("--previous <sha>", "Commit this deploy replaced. Defaults to the profile's current deploy-log head on the hub. With neither, there's nothing to diff against: changedPaths is unset and --select is skipped.").option("--ref <ref>", "Ref that was deployed (branch or tag). Recorded for display only.").option("--select", "Also decide which specs this deploy reaches (`ccqa select-specs`) and send the verdict with it. Without it the deploy is a hole in the range: specs behind it report 'unknown' rather than 'not needed'.").option("-m, --model <name>", "Model for --select. Claude alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option(...hubUrlOption).option(...hubTokenOption).option("--project <name>", "Project whose deploy log this entry joins. Defaults to the current directory's name.").option("--cwd <path>", "Directory the git diff and the default --project name are resolved against.").action(withHubErrors(async (opts) => {
8742
+ const deployRecord = new Command("record").description("Tell the hub what a deploy shipped, so it can answer which specs need a re-run (`ccqa run --only-hub-stale`). Run this from the deploy job, after the deploy succeeds. The changed paths are computed locally with a two-dot `git diff <previous> <sha>`; a job that has only curl and git can POST the same body directly (see docs/hub.md).").requiredOption("--profile <name>", "Environment this deploy landed in (e.g. 'stg'). Required: dev and stg sit at different commits, so the deploy log is per-profile.").requiredOption("--sha <sha>", "Commit that was deployed.").option("--previous <sha>", "Commit this deploy replaced. Defaults to the profile's current deploy-log head on the hub. With neither, there's nothing to diff against: changedPaths is unset and --select is skipped.").option("--ref <ref>", "Ref that was deployed (branch or tag). Recorded for display only.").option("--select", "Also decide which specs this deploy reaches (`ccqa select-specs`) and send the verdict with it. Without it the deploy is a hole in the range: specs behind it report 'unknown' rather than 'not needed'.").option("-m, --model <name>", "Model for --select. Claude alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option(...hubUrlOption).option(...hubTokenOption).option("--project <name>", "Project whose deploy log this entry joins. Defaults to the current directory's name.").option("--cwd <path>", "Directory the git diff and the default --project name are resolved against.").action(withHubErrors(async (opts) => {
8644
8743
  const cwd = resolveCwd(opts.cwd);
8645
8744
  const project = resolveProject(opts);
8646
8745
  const hub = connect(opts);
@@ -8719,10 +8818,10 @@ function describeSelection(selection, diffAvailable) {
8719
8818
  const values = Object.values(selection);
8720
8819
  return `${values.filter((s) => s.verdict === "needed").length} needed / ${values.filter((s) => s.verdict === "unknown").length} unknown / ${values.length} specs`;
8721
8820
  }
8722
- const deployCommand = new Command("deploy").description("Report deploys to the hub, the input behind `ccqa run --changed=last-run`.").addCommand(deployRecord);
8723
- const pushCommand = new Command("push").description("Upload the report directory of a finished `ccqa run --report` to the hub as a run. Run this after `ccqa run` (use `if: always()` in CI so failing runs are pushed too).").option("--report <dir>", `Report directory to push. Default: ${DEFAULT_REPORT_DIR}/`).option("--project <name>", "Logical project name for the run. Defaults to the current directory's name.").option("--branch <name>", "Branch label. Defaults to $GITHUB_HEAD_REF / $GITHUB_REF_NAME / current git branch.").option("--profile <name>", "Profile (environment) the run executed against. Recorded for display; runs are not scoped by profile.").option(...hubUrlOption).option(...hubTokenOption).option("--cwd <path>", "Directory the report dir is resolved against (defaults to the current directory).").action(withHubErrors(async (opts) => {
8821
+ const deployCommand = new Command("deploy").description("Report deploys to the hub, the input behind `ccqa run --only-hub-stale`.").addCommand(deployRecord);
8822
+ const pushCommand = new Command("push").description("Upload the report directory of a finished `ccqa run --report` to the hub as a run. Run this after `ccqa run` (use `if: always()` in CI so failing runs are pushed too).").option("--report-dir <dir>", `Report directory to push. Default: ${DEFAULT_REPORT_DIR}/`).option("--project <name>", "Logical project name for the run. Defaults to the current directory's name.").option("--branch <name>", "Branch label. Defaults to $GITHUB_HEAD_REF / $GITHUB_REF_NAME / current git branch.").option("--profile <name>", "Profile (environment) the run executed against. Recorded for display; runs are not scoped by profile.").option(...hubUrlOption).option(...hubTokenOption).option("--cwd <path>", "Directory the report dir is resolved against (defaults to the current directory).").action(withHubErrors(async (opts) => {
8724
8823
  const cwd = resolveCwd(opts.cwd);
8725
- const reportDir = join(cwd, opts.report ?? "ccqa-report");
8824
+ const reportDir = join(cwd, opts.reportDir ?? "ccqa-report");
8726
8825
  const project = resolveProject(opts);
8727
8826
  let report;
8728
8827
  try {
@@ -8754,7 +8853,7 @@ const pushCommand = new Command("push").description("Upload the report directory
8754
8853
  meta("specs", `${run.specs.passed}/${run.specs.total} passed`);
8755
8854
  info(`${resolveBaseUrl(opts)}/#/runs/${run.id}`);
8756
8855
  }));
8757
- 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(deployCommand).addCommand(sessionCommand$1).addCommand(varCommand).addCommand(promptCommand);
8856
+ 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(deployCommand).addCommand(sessionCommand).addCommand(varCommand).addCommand(promptCommand);
8758
8857
  /** Loose check that a fetched session is agent-browser storage-state, mirroring loadStorageState. */
8759
8858
  function isStorageStateShape(state) {
8760
8859
  return typeof state === "object" && state !== null && Array.isArray(state.cookies) && Array.isArray(state.origins);
@@ -8862,7 +8961,7 @@ function generateLiveSessionName() {
8862
8961
  * Project-specific guidance ("the admin tenant is foo.example", "session
8863
8962
  * times out at X minutes", …) is appended from
8864
8963
  * `.ccqa/prompts/live.user.md` (human-maintained) and
8865
- * `.ccqa/prompts/live.agent.md` (updated by `ccqa run --update-agent-prompt`)
8964
+ * `.ccqa/prompts/live.agent.md` (updated by `ccqa run --learn-hub-live-prompt`)
8866
8965
  * by the caller, so ccqa stays clean of downstream-product context.
8867
8966
  *
8868
8967
  * Constraint posture: `ccqa record` (trace) enforces a strict selector
@@ -9617,7 +9716,7 @@ const verifiedSessions = /* @__PURE__ */ new Set();
9617
9716
  * each named session from the hub (`.ccqa/sessions/*.json` is no longer
9618
9717
  * read here). Every name must load as a valid agent-browser state (the spec
9619
9718
  * assumes it starts signed-in); a missing/malformed session fails with a
9620
- * `ccqa session bootstrap` hint instead of running unauthenticated.
9719
+ * `ccqa hub session capture` hint instead of running unauthenticated.
9621
9720
  *
9622
9721
  * If a session carries an embedded verify URL (bootstrap saved it), the
9623
9722
  * restore is health-checked before the run starts, so an expired/unusable
@@ -9660,7 +9759,7 @@ async function resolveSessionState(names, hubCtx, profile, verify = verifySessio
9660
9759
  if (!check.restored) return {
9661
9760
  ok: false,
9662
9761
  error: `session '${name}' did not restore to a signed-in page — ${check.reason}`,
9663
- hint: `re-bootstrap it: ccqa session bootstrap ${name}${profileFlag}`
9762
+ hint: `re-bootstrap it: ccqa hub session capture ${name}${profileFlag}`
9664
9763
  };
9665
9764
  verifiedSessions.add(memoKey);
9666
9765
  }
@@ -9670,7 +9769,7 @@ async function resolveSessionState(names, hubCtx, profile, verify = verifySessio
9670
9769
  if (broken.length > 0) return {
9671
9770
  ok: false,
9672
9771
  error: `session not usable on the hub: ${broken.join(", ")}`,
9673
- hint: `create it with: ${broken.map((name) => `ccqa session bootstrap ${name}${profileFlag}`).join(" · ")}`
9772
+ hint: `create it with: ${broken.map((name) => `ccqa hub session capture ${name}${profileFlag}`).join(" · ")}`
9674
9773
  };
9675
9774
  const statePath = await writeMergedTempState(mergeStorageStates(loaded));
9676
9775
  return {
@@ -11543,7 +11642,7 @@ async function groupSpecsByTarget(specs, config, cwd, resolve = resolveTarget) {
11543
11642
  * then each external target group through its runner. Every row is upserted
11544
11643
  * into the incremental report the moment it exists — the runner reports each
11545
11644
  * spec through `onSpecComplete` as it finishes — so an interrupt keeps what
11546
- * already ran and `--push-report` streams spec by spec. Rows are also
11645
+ * already ran and `--report-to-hub` streams spec by spec. Rows are also
11547
11646
  * returned for the tail phase (failure analysis) and the final batch write. A
11548
11647
  * crashing runner marks its own specs failed instead of aborting the run.
11549
11648
  */
@@ -11679,7 +11778,7 @@ function createIncrementalReport(reportDir, envelope, sink) {
11679
11778
  //#endregion
11680
11779
  //#region src/prompts/agent-update.ts
11681
11780
  /**
11682
- * Build the prompts used by `--update-agent-prompt` to refresh
11781
+ * Build the prompts used by the `--learn-*-prompt` flags to refresh
11683
11782
  * `.ccqa/prompts/<kind>.agent.md` after a run:
11684
11783
  * - `ccqa run` (live) → `live.agent`
11685
11784
  * - `ccqa record` (trace) → `record.agent`
@@ -11929,16 +12028,13 @@ Write the new contents of \`${agentMdLabel}\`. Output ONLY the file contents —
11929
12028
  * so the run exit code is unaffected by this opt-in side step.
11930
12029
  */
11931
12030
  async function updateAgentPrompt(args) {
11932
- const { kind, runSummary, hubContext, model, language } = args;
12031
+ const { kind, flag, runSummary, hubContext, model, language } = args;
11933
12032
  const auth = driftAuthAvailable();
11934
12033
  if (!auth.ok) {
11935
- warn(`--update-agent-prompt skipped (${auth.reason})`);
11936
- return;
11937
- }
11938
- if (!hubContext) {
11939
- warn("--update-agent-prompt skipped (hub connection required; pass --hub-url/--hub-token or set CCQA_HUB_URL/CCQA_HUB_TOKEN)");
12034
+ warn(`${flag} skipped (${auth.reason})`);
11940
12035
  return;
11941
12036
  }
12037
+ if (!hubContext) throw new Error(`${flag} requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)`);
11942
12038
  const { hub, project } = hubContext;
11943
12039
  const promptName = `${kind}.agent`;
11944
12040
  try {
@@ -11950,7 +12046,7 @@ async function updateAgentPrompt(args) {
11950
12046
  };
11951
12047
  const systemPrompt = buildAgentUpdateSystemPrompt(promptInput);
11952
12048
  const userPrompt = buildAgentUpdateUserPrompt(promptInput);
11953
- info(`--update-agent-prompt: refreshing prompt "${promptName}" on the hub (project ${project})`);
12049
+ info(`${flag}: refreshing prompt "${promptName}" on the hub (project ${project})`);
11954
12050
  const { result, isError } = await invokeClaudeStreaming({
11955
12051
  prompt: userPrompt,
11956
12052
  systemPrompt,
@@ -11960,20 +12056,20 @@ async function updateAgentPrompt(args) {
11960
12056
  ...model ? { model } : {}
11961
12057
  }, () => {});
11962
12058
  if (isError || !result || result.trim().length === 0) {
11963
- warn(`--update-agent-prompt: Claude returned no usable output${isError ? " (SDK error)" : ""}; leaving prompt "${promptName}" unchanged`);
12059
+ warn(`${flag}: Claude returned no usable output${isError ? " (SDK error)" : ""}; leaving prompt "${promptName}" unchanged`);
11964
12060
  return;
11965
12061
  }
11966
12062
  if (result.trim() === "NO_UPDATE") {
11967
- info(`--update-agent-prompt: no new learnings from this run; prompt "${promptName}" left unchanged`);
12063
+ info(`${flag}: no new learnings from this run; prompt "${promptName}" left unchanged`);
11968
12064
  return;
11969
12065
  }
11970
12066
  const newText = stripCodeFences(result.trim()) + "\n";
11971
12067
  await hub.putPrompt(project, promptName, newText);
11972
- info(`--update-agent-prompt: updated prompt "${promptName}" on the hub`);
11973
- info("--update-agent-prompt: review it in the hub UI's Prompts tab");
12068
+ info(`${flag}: updated prompt "${promptName}" on the hub`);
12069
+ info(`${flag}: review it in the hub UI's Prompts tab`);
11974
12070
  } catch (err) {
11975
12071
  if (err instanceof HubApiError) {
11976
- warn(`--update-agent-prompt skipped (hub request failed: ${err.status} ${err.code}: ${err.message})`);
12072
+ warn(`${flag} skipped (hub request failed: ${err.status} ${err.code}: ${err.message})`);
11977
12073
  return;
11978
12074
  }
11979
12075
  throw err;
@@ -11992,7 +12088,7 @@ function stripCodeFences(text) {
11992
12088
  //#region src/cli/changed-specs.ts
11993
12089
  /**
11994
12090
  * Filter specs to those a range of commits reaches. Powers `ccqa run
11995
- * --changed <ref>`; `ccqa drift --changed` uses the same call.
12091
+ * --only-affected-by <ref>`; `ccqa audit` uses the same call.
11996
12092
  *
11997
12093
  * The decision is made by `ccqa select-specs`, which reads the diff against
11998
12094
  * what each spec actually does. That costs one model call, against saving the
@@ -12003,8 +12099,8 @@ function stripCodeFences(text) {
12003
12099
  * the safe reading of that is to run the spec.
12004
12100
  */
12005
12101
  async function collectChangedSpecs(specs, opts) {
12006
- const { cwd, base, model, quiet, baseExample } = opts;
12007
- const resolved = await resolveAnalysisBase(base, "--changed", cwd, baseExample);
12102
+ const { cwd, base, model, quiet, flagName } = opts;
12103
+ const resolved = await resolveAnalysisBase(base, flagName ?? "--only-affected-by", cwd);
12008
12104
  const meta$2 = (key, value) => {
12009
12105
  if (!quiet) meta(key, value);
12010
12106
  };
@@ -12055,12 +12151,23 @@ async function resolveVitestConfig(cwd) {
12055
12151
  }
12056
12152
  /**
12057
12153
  * Resolve the report directory. A report (report.json + evidence) is always
12058
- * written now, so this is never undefined: `--report <dir>` only picks *where*
12059
- * it lands, defaulting to `DEFAULT_REPORT_DIR`. `--report` with no value (a
12060
- * bare boolean flag) also means "default location".
12154
+ * written, so `--report-dir` only picks *where* it lands.
12061
12155
  */
12062
- function resolveReportDir(report, cwd) {
12063
- return resolve(cwd, typeof report === "string" ? report : DEFAULT_REPORT_DIR);
12156
+ function resolveReportDir(reportDir, cwd) {
12157
+ return resolve(cwd, reportDir ?? "ccqa-report");
12158
+ }
12159
+ /**
12160
+ * Turn a hub transport failure into a usage error, as a `.catch` so the
12161
+ * tuple types of the `Promise.all` it guards survive.
12162
+ *
12163
+ * Without it the raw `fetch failed` escapes as an unhandled rejection: a stack
12164
+ * trace and exit 1, where the user needs "the hub is unreachable" and exit 2.
12165
+ * Errors the callers already shaped pass through — they say more than this
12166
+ * wrapper could.
12167
+ */
12168
+ function asHubReadError(err) {
12169
+ if (err instanceof RunUsageError) throw err;
12170
+ throw new RunUsageError(`could not read from the hub: ${errMessage(err)}`);
12064
12171
  }
12065
12172
  /** De-dupe by `featureName/specName`, keeping first-seen order. */
12066
12173
  function dedupeSpecs(specs) {
@@ -12082,13 +12189,14 @@ function dedupeSpecs(specs) {
12082
12189
  * maps it to `process.exit(2)`).
12083
12190
  */
12084
12191
  async function executeRun(targets, opts) {
12085
- if (opts.changed && targets.length > 0) throw new RunUsageError("--changed and an explicit spec target cannot be combined");
12086
- const rerunProfile = opts.changed === "last-run" ? requireRerunProfile(opts.profile) : null;
12087
- if (opts.includeUnknown && rerunProfile === null) warn(`--include-unknown is ignored: it only applies to --changed=${LAST_RUN}`);
12192
+ const filtering = Boolean(opts.onlyAffectedBy || opts.onlyHubStale || opts.onlyHubAuditedClean);
12193
+ if (filtering && targets.length > 0) throw new RunUsageError("a --only-* filter and an explicit spec target cannot be combined");
12194
+ const rerunProfile = opts.onlyHubStale === true ? requireRerunProfile(opts.hubProfile) : null;
12195
+ if (opts.onlyHubStaleWithUnknown && rerunProfile === null) warn("--only-hub-stale-with-unknown is ignored: it only applies to --only-hub-stale");
12088
12196
  const forExecution = opts.dryRun !== true;
12089
12197
  const cwd = opts.cwd ?? process.cwd();
12090
- const wantsLastGreen = opts.failureAnalysis === LAST_GREEN;
12091
- const [head, fixedBase] = await Promise.all([getGitHead(cwd), forExecution && opts.failureAnalysis && !wantsLastGreen ? resolveAnalysisBase(opts.failureAnalysis, "--failure-analysis", cwd) : null]);
12198
+ const wantsLastGreen = opts.onFailExplain === true && opts.onFailExplainBase === void 0;
12199
+ const [head, fixedBase] = await Promise.all([getGitHead(cwd), forExecution && opts.onFailExplain && opts.onFailExplainBase !== void 0 ? resolveAnalysisBase(opts.onFailExplainBase, "--on-fail-explain-base", cwd) : null]);
12092
12200
  const git = {
12093
12201
  head,
12094
12202
  base: wantsLastGreen ? {
@@ -12109,8 +12217,8 @@ async function executeRun(targets, opts) {
12109
12217
  meta("analysis-base", `${fixedBase.ref} (${fixedBase.sha.slice(0, 12)}, ${fixedBase.source})`);
12110
12218
  }
12111
12219
  if (forExecution) try {
12112
- if (opts.profile !== void 0) await resolveProfileEnv({
12113
- profile: opts.profile,
12220
+ if (opts.hubProfile !== void 0) await resolveProfileEnv({
12221
+ profile: opts.hubProfile,
12114
12222
  project: resolveProjectOrThrow(opts.project, cwd),
12115
12223
  cwd,
12116
12224
  hubUrl: opts.hubUrl,
@@ -12126,7 +12234,7 @@ async function executeRun(targets, opts) {
12126
12234
  if (err instanceof RunUsageError) throw err;
12127
12235
  if (err instanceof ProjectNameError) throw new RunUsageError(err.message);
12128
12236
  if (err instanceof HubConnectionError || err instanceof HubApiError) throw new RunUsageError(err.message);
12129
- throw new RunUsageError(`failed to load profile "${opts.profile}": ${errMessage(err)}`);
12237
+ throw new RunUsageError(`failed to load profile "${opts.hubProfile}": ${errMessage(err)}`);
12130
12238
  }
12131
12239
  let hubCtx = null;
12132
12240
  try {
@@ -12140,23 +12248,27 @@ async function executeRun(targets, opts) {
12140
12248
  } catch {
12141
12249
  hubCtx = null;
12142
12250
  }
12143
- if (wantsLastGreen && hubCtx == null) throw new RunUsageError(`--failure-analysis=${LAST_GREEN} requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)`);
12251
+ if (wantsLastGreen && hubCtx == null) throw new RunUsageError("--on-fail-explain needs a hub connection for the per-spec last-green baselines (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN), or an explicit --on-fail-explain-base <ref>");
12144
12252
  const ledgerHub = wantsLastGreen ? hubCtx : null;
12145
- if (rerunProfile !== null && hubCtx == null) throw new RunUsageError(`--changed=${LAST_RUN} requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)`);
12146
- const [customPrompt, triageUserPrompt, ledgerEntries, rerunReport, fetchedDeployHead] = await Promise.all([
12253
+ if (rerunProfile !== null && hubCtx == null) throw new RunUsageError("--only-hub-stale requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)");
12254
+ if (opts.onlyHubAuditedClean && hubCtx == null) throw new RunUsageError("--only-hub-audited-clean requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)");
12255
+ if (opts.reportToHub && hubCtx == null) throw new RunUsageError("--report-to-hub requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)");
12256
+ if (opts.learnHubLivePrompt && hubCtx == null) throw new RunUsageError("--learn-hub-live-prompt requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)");
12257
+ const [customPrompt, triageUserPrompt, ledgerEntries, rerunReport, fetchedDeployHead, auditedLedger] = await Promise.all([
12147
12258
  forExecution ? fetchCustomPrompt(hubCtx) : null,
12148
12259
  forExecution ? fetchTriageUserPrompt(hubCtx) : null,
12149
- forExecution && ledgerHub ? fetchLastGreenLedger(ledgerHub, opts.profile, cwd) : null,
12260
+ forExecution && ledgerHub ? fetchLastGreenLedger(ledgerHub, opts.hubProfile, cwd) : null,
12150
12261
  rerunProfile !== null && hubCtx ? fetchRerunReport(hubCtx, rerunProfile) : null,
12151
- forExecution && hubCtx && opts.profile && rerunProfile === null ? tryDeployHeadSha(hubCtx, opts.profile) : null
12152
- ]);
12262
+ forExecution && hubCtx && opts.hubProfile && rerunProfile === null ? tryDeployHeadSha(hubCtx, opts.hubProfile) : null,
12263
+ opts.onlyHubAuditedClean && hubCtx ? fetchAuditedLedger(hubCtx) : null
12264
+ ]).catch(asHubReadError);
12153
12265
  const deployedSha = rerunReport?.deployHead.sha ?? fetchedDeployHead;
12154
12266
  if (ledgerEntries) diffProvider = createDiffProvider({
12155
12267
  resolveBase: createLastGreenResolver(ledgerEntries, cwd),
12156
12268
  cwd
12157
12269
  });
12158
12270
  const triageUserPromptHash = triageUserPrompt ? hashTriageUserPrompt(triageUserPrompt) : null;
12159
- const reportDir = resolveReportDir(opts.report, cwd);
12271
+ const reportDir = resolveReportDir(opts.reportDir, cwd);
12160
12272
  const analysisDeps = {
12161
12273
  diffProvider,
12162
12274
  auth: diffProvider ? driftAuthAvailable() : {
@@ -12172,22 +12284,28 @@ async function executeRun(targets, opts) {
12172
12284
  };
12173
12285
  const enumerateAll = () => listAllSpecsWithSpecFile(cwd);
12174
12286
  let specs = dedupeSpecs((await Promise.all((targets.length ? targets : [void 0]).map((t) => resolveSpecTargets(t, enumerateAll, cwd)))).flat());
12175
- if (opts.changed) {
12287
+ if (filtering) {
12176
12288
  const before = specs.length;
12177
12289
  let unanswerable = 0;
12178
12290
  if (rerunReport) {
12179
- const selection = selectSpecsNeedingRerun(specs, rerunReport, { includeUnknown: opts.includeUnknown === true });
12291
+ const selection = selectSpecsNeedingRerun(specs, rerunReport, { includeUnknown: opts.onlyHubStaleWithUnknown === true });
12180
12292
  specs = selection.selected;
12181
12293
  unanswerable = selection.excludedUnanswerable;
12182
- meta("rerun-base", `deploy ${rerunReport.deployHead.sha.slice(0, 12)} (profile ${rerunReport.profile})`);
12183
- meta("rerun-states", selection.summary);
12184
- } else specs = (await collectChangedSpecs(specs, {
12294
+ meta("stale-base", `deploy ${rerunReport.deployHead.sha.slice(0, 12)} (profile ${rerunReport.profile})`);
12295
+ meta("stale-states", selection.summary);
12296
+ }
12297
+ if (auditedLedger) {
12298
+ const picked = selectAuditedClean(specs, auditedLedger);
12299
+ specs = picked.selected;
12300
+ meta("audit-states", `${picked.selected.length} clean, ${picked.drifted} drifted, ${picked.unaudited} never audited`);
12301
+ }
12302
+ if (opts.onlyAffectedBy) specs = (await collectChangedSpecs(specs, {
12185
12303
  cwd,
12186
- base: opts.changed,
12304
+ base: opts.onlyAffectedBy,
12187
12305
  ...opts.model ? { model: opts.model } : {}
12188
12306
  })).specs;
12189
- meta("changed-scoped", `${specs.length} of ${before} spec${before === 1 ? "" : "s"}`);
12190
- if (specs.length === 0 && unanswerable > 0) hint(`${unanswerable} spec(s) were excluded because the hub could not tell whether they need a re-run; pass --include-unknown to run them anyway`);
12307
+ meta("selected", `${specs.length} of ${before} spec${before === 1 ? "" : "s"}`);
12308
+ if (specs.length === 0 && unanswerable > 0) hint(`${unanswerable} spec(s) were excluded because the hub could not tell whether they need a re-run; pass --only-hub-stale-with-unknown to run them anyway`);
12191
12309
  }
12192
12310
  if (specs.length === 0) {
12193
12311
  warn("no specs to run");
@@ -12210,11 +12328,11 @@ async function executeRun(targets, opts) {
12210
12328
  if (dispatch.external.length > 0) meta("external", dispatch.external.map((g) => `${g.targetId} ${g.specs.length}`).join(" / "));
12211
12329
  if (liveSpecs.length === 0) {
12212
12330
  const why = "it only applies to agent-browser 'mode: live' specs, and this run has none";
12213
- if (typeof opts.retry === "number" && opts.retry > 0) warn(`--retry is ignored: ${why}`);
12214
- if (opts.out) warn(`--out is ignored: ${why}`);
12215
- if (opts.updateAgentPrompt) warn(`--update-agent-prompt is ignored: ${why}`);
12216
- } else if (opts.out && liveSpecs.length > 1) warn("--out is ignored when running multiple live specs");
12217
- if (detSpecs.length === 0 && opts.evidence === false) warn("--no-evidence is ignored: it only applies to agent-browser 'mode: deterministic' specs, and this run has none");
12331
+ if (typeof opts.liveStepRetry === "number" && opts.liveStepRetry > 0) warn(`--live-step-retry is ignored: ${why}`);
12332
+ if (opts.liveArtifactsDir) warn(`--live-artifacts-dir is ignored: ${why}`);
12333
+ if (opts.learnHubLivePrompt) warn(`--learn-live-prompt is ignored: ${why}`);
12334
+ } else if (opts.liveArtifactsDir && liveSpecs.length > 1) warn("--out is ignored when running multiple live specs");
12335
+ 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");
12218
12336
  blank();
12219
12337
  if (opts.dryRun) {
12220
12338
  for (const line of formatDryRunLines(withMode, dispatch)) emitRaw(line + "\n");
@@ -12227,17 +12345,16 @@ async function executeRun(targets, opts) {
12227
12345
  };
12228
12346
  }
12229
12347
  const det = await runDeterministicSpecs(detSpecs, opts, cwd, reportDir);
12230
- if (opts.pushReport && hubCtx == null) warn("--push-report requires --hub-url/--hub-token (or CCQA_HUB_URL/CCQA_HUB_TOKEN); skipping push");
12231
12348
  let hubRunId = null;
12232
12349
  let hubSink;
12233
- if (hubCtx != null && opts.pushReport) try {
12350
+ if (hubCtx != null && opts.reportToHub) try {
12234
12351
  const branch = await detectBranch(cwd);
12235
12352
  const ciRunId = githubRunId();
12236
12353
  const runUrl = githubRunUrl();
12237
12354
  const opened = await hubCtx.hub.openRun({
12238
12355
  project: hubCtx.project,
12239
12356
  ...branch ? { branch } : {},
12240
- ...opts.profile ? { profile: opts.profile } : {},
12357
+ ...opts.hubProfile ? { profile: opts.hubProfile } : {},
12241
12358
  ...git.head ? { gitHead: git.head } : {},
12242
12359
  ...deployedSha ? { deployedSha } : {},
12243
12360
  ...ciRunId ? { ciRunId } : {},
@@ -12259,7 +12376,7 @@ async function executeRun(targets, opts) {
12259
12376
  }
12260
12377
  } };
12261
12378
  } catch (err) {
12262
- warn(`hub: could not open incremental run (${errMessage(err)}); continuing with local report only`);
12379
+ throw new RunUsageError(`--report-to-hub: could not open a run on the hub (${errMessage(err)})`);
12263
12380
  }
12264
12381
  const incrementalReport = createIncrementalReport(reportDir, buildReportEnvelope({
12265
12382
  git,
@@ -12293,12 +12410,12 @@ async function executeRun(targets, opts) {
12293
12410
  const live = await runLiveSpecs(liveSpecs, {
12294
12411
  ...opts.model ? { model: opts.model } : {},
12295
12412
  ...opts.language ? { language: opts.language } : {},
12296
- ...opts.out && liveSpecs.length === 1 ? { out: opts.out } : {},
12413
+ ...opts.liveArtifactsDir && liveSpecs.length === 1 ? { out: opts.liveArtifactsDir } : {},
12297
12414
  cwd,
12298
12415
  reportDir,
12299
- ...typeof opts.retry === "number" ? { retry: opts.retry } : {},
12416
+ ...typeof opts.liveStepRetry === "number" ? { retry: opts.liveStepRetry } : {},
12300
12417
  concurrency: opts.concurrency ?? 1,
12301
- ...opts.profile ? { profile: opts.profile } : {},
12418
+ ...opts.hubProfile ? { profile: opts.hubProfile } : {},
12302
12419
  diffProvider,
12303
12420
  hubContext: hubCtx,
12304
12421
  customPrompt,
@@ -12360,10 +12477,11 @@ async function executeRun(targets, opts) {
12360
12477
  }
12361
12478
  }
12362
12479
  }
12363
- if (opts.updateAgentPrompt && liveSpecs.length > 0) {
12480
+ if (opts.learnHubLivePrompt && liveSpecs.length > 0) {
12364
12481
  blank();
12365
12482
  await updateAgentPrompt({
12366
12483
  kind: "live",
12484
+ flag: "--learn-live-prompt",
12367
12485
  runSummary: buildLiveRunSummary(live.reportResults),
12368
12486
  hubContext: hubCtx,
12369
12487
  ...opts.model ? { model: opts.model } : {},
@@ -12449,8 +12567,8 @@ async function runDeterministicSpecs(specs, opts, cwd, reportDirAbs) {
12449
12567
  };
12450
12568
  const tmpDir = await mkdtemp(join(tmpdir(), "ccqa-run-"));
12451
12569
  const vitestConfig = await resolveVitestConfig(cwd);
12452
- const captureOutput = opts.failureAnalysis !== false;
12453
- const captureEvidence = opts.evidence !== false;
12570
+ const captureOutput = true;
12571
+ const captureEvidence = opts.replaySkipEvidence !== true;
12454
12572
  const concurrency = Math.max(1, opts.concurrency ?? 1);
12455
12573
  const ctx = {
12456
12574
  cwd,
@@ -12658,7 +12776,7 @@ async function writeUnifiedReport(args) {
12658
12776
  const jsonPath = join(reportDir, "report.json");
12659
12777
  await writeFile(jsonPath, JSON.stringify(data, null, 2) + "\n", "utf8");
12660
12778
  info(`run report (json) written to ${jsonPath}`);
12661
- if (opts.format === "github") for (const line of emitGithubAnnotations(data)) emitRaw(line + "\n");
12779
+ if (opts.reportFormat === "github") for (const line of emitGithubAnnotations(data)) emitRaw(line + "\n");
12662
12780
  return data;
12663
12781
  }
12664
12782
  /**
@@ -12672,7 +12790,7 @@ const PATCH_FILES_RAW_BUDGET = 20 * 1024 * 1024;
12672
12790
  /**
12673
12791
  * Add one row's file assets to `acc` as `{ reportDir-relative posix path →
12674
12792
  * base64 }`. Every kind of screenshot a row can carry has to be collected here,
12675
- * or `--push-report` — the way CI publishes — silently ships a report whose
12793
+ * or `--report-to-hub` — the way CI publishes — silently ships a report whose
12676
12794
  * images 404 on the hub: a live row's per-step PNGs
12677
12795
  * (`liveRun.steps[].beforePng/afterPng`), a script-driven row's step evidence
12678
12796
  * (`evidence[].pngPath` / `beforePngPath`, written by agent-browser replays and
@@ -12901,14 +13019,14 @@ function installTeardownSignalHandlers(teardown) {
12901
13019
  }
12902
13020
  //#endregion
12903
13021
  //#region src/cli/run.ts
12904
- const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("run").argument("[targets...]", "Specs to run, space-separated: each '<feature>/<spec>', '<feature>', or omit for all. Duplicates are de-duped.").description("Run specs, on any target. Agent-browser specs replay the recorded test.spec.ts under vitest (default), or, with spec.yaml `mode: live`, have Claude drive agent-browser live per step. External-target specs (playwright, runn) run through the target's configured `runCommand`. A structured report (report.json + evidence) is always written; use --push-report to also stream it to a hub.").option("--report [dir]", `Directory for the structured run results (report.json + evidence PNGs) that are always written. Default: ${DEFAULT_REPORT_DIR}/. Pass this only to change the location.`).option("--push-report", "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.").option("--changed [base]", "Restrict execution to specs ccqa select-specs judges needed against the git diff against [base]. Without a value the base comes from $GITHUB_BASE_REF (pull_request CI); elsewhere pass it explicitly (e.g. --changed=origin/main), or pass 'last-run' to run the specs the hub says need one — each spec's own last run compared against the deploy log (requires a hub connection and --profile; no git diff involved). Cannot be combined with an explicit spec id.").option("--include-unknown", "(--changed=last-run only) Also run specs whose re-run need the hub cannot answer ('unknown') and specs that have never run ('neverRun'). Off by default: an unanswerable question is reported, not guessed.").option("--dry-run", "Print the specs this invocation would run, then exit 0 without executing anything and without writing a report. Works with every selection mode.").option("--failure-analysis [base]", "Classify each failure (TEST_DRIFT / SPEC_CHANGE / PRODUCT_BUG) against the source diff since [base]. Without a value the base comes from $GITHUB_BASE_REF (pull_request CI); elsewhere pass it explicitly (e.g. --failure-analysis=origin/main), or pass 'last-green' to diff each spec against the commit where it last passed (per-spec baselines from the hub; requires a hub connection). Off by default — no Claude calls without it.").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--format <fmt>", "Additional output format alongside HTML when --report is set: 'text' (default), 'json' (writes report.json), 'github' (GitHub Actions annotations on stdout).", (raw) => {
12905
- if (REPORT_FORMATS.includes(raw)) return raw;
12906
- throw new Error(`--format must be one of ${REPORT_FORMATS.join(" | ")}`);
12907
- }, "text").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--no-evidence", `(deterministic only) Skip step-boundary evidence capture (PNG + meta JSON written to ${DEFAULT_REPORT_DIR}/${EVIDENCE_SUBDIR}/ by default).`).option("--retry <n>", "(live only) Retry each failed step up to N more times before recording failure. Default 0.", (raw) => {
13022
+ const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("run").argument("[targets...]", "Specs to run, space-separated: each '<feature>/<spec>', '<feature>', or omit for all. Duplicates are de-duped.").description("Run specs, on any target. Agent-browser specs replay the recorded test.spec.ts under vitest (default), or, with spec.yaml `mode: live`, have Claude drive agent-browser live per step. External-target specs (playwright, runn) run through the target's configured `runCommand`. A structured report (report.json + evidence) is always written; use --report-to-hub to also stream it to a hub.").optionsGroup("Which specs to run:").option("--only-affected-by <ref>", "Only specs `ccqa select-specs` judges reached by the diff against <ref> (e.g. origin/main). In pull_request CI, pass $GITHUB_BASE_REF. Cannot be combined with an explicit spec id.").option("--only-hub-stale", "Only specs the hub says are no longer covered by their last result — each spec's own last run compared against the hub's deploy log. No git diff involved. Requires a hub connection and --hub-profile.").option("--only-hub-stale-with-unknown", "With --only-hub-stale: also take specs whose re-run need the hub cannot answer ('unknown') and specs that never ran ('neverRun'). Off by default: an unanswerable question is reported, not guessed.").option("--only-hub-audited-clean", "Only specs the hub's drift ledger records as audited with no drift. A spec that has never been audited is not taken: this flag spends a run where a cheap audit already cleared the spec, and \"never looked\" is not that. Requires a hub connection.").option("--dry-run", "Print the specs this invocation would run, then exit 0 without executing anything and without writing a report. Works with every selection flag.").optionsGroup("How to run them:").option("--concurrency <n>", "Run up to N specs in parallel within each phase (deterministic / external-target / live), never across phases. Default 1 (sequential). Live specs each get an isolated agent-browser session; high values spawn many headed Chrome instances.", parseConcurrency$1, 1).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--live-step-retry <n>", "(live only) Retry each failed step up to N more times before recording failure. This retries a step, not the whole spec — see --on-fail-explain-rerun for that.", (raw) => {
12908
13023
  const n = Number(raw);
12909
- if (!Number.isFinite(n) || n < 0 || Math.floor(n) !== n) throw new Error(`--retry must be a non-negative integer, got "${raw}"`);
13024
+ if (!Number.isFinite(n) || n < 0 || Math.floor(n) !== n) throw new Error(`--live-step-retry must be a non-negative integer, got "${raw}"`);
12910
13025
  return n;
12911
- }, 0).option("--out <dir>", "(live only) Override the per-spec artifact directory. Default: <specDir>/runs/<runId>. Ignored when running multiple specs.").option("--update-agent-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.").option("--concurrency <n>", "Run up to N specs in parallel within each phase (deterministic / external-target / live), never across phases. Default 1 (sequential). Live specs each get an isolated agent-browser session; high values spawn many headed Chrome instances.", parseConcurrency$1, 1).option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(async (targets, opts) => {
13026
+ }, 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) => {
13027
+ if (REPORT_FORMATS.includes(raw)) return raw;
13028
+ throw new Error(`--report-format must be one of ${REPORT_FORMATS.join(" | ")}`);
13029
+ }, "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) => {
12912
13030
  await runCliAction(targets, opts);
12913
13031
  });
12914
13032
  /** Parse --concurrency: a positive integer. Rejects 0, negatives, non-integers. */
@@ -12920,12 +13038,16 @@ function parseConcurrency$1(raw) {
12920
13038
  }
12921
13039
  return n;
12922
13040
  }
12923
- /** Header label shown after `ccqa run`: the lone target, a count, or a mode marker. */
13041
+ /** Header label shown after `ccqa run`: the lone target, a count, or how they were selected. */
12924
13042
  function headerTarget(targets, opts) {
12925
13043
  if (targets.length === 1) return targets[0];
12926
13044
  if (targets.length > 1) return `${targets.length} targets`;
12927
- if (opts.changed === "last-run") return "(needs re-run)";
12928
- return opts.changed ? "(changed)" : "(all specs)";
13045
+ const filters = [
13046
+ opts.onlyAffectedBy ? "affected" : null,
13047
+ opts.onlyHubStale ? "stale" : null,
13048
+ opts.onlyHubAuditedClean ? "audited clean" : null
13049
+ ].filter((s) => s !== null);
13050
+ return filters.length === 0 ? "(all specs)" : `(${filters.join(" + ")})`;
12929
13051
  }
12930
13052
  /**
12931
13053
  * CLI entry point: calls the library pipeline and maps its result back to a
@@ -14425,8 +14547,8 @@ For each test case above, write a 1–2 sentence factual \`summary\` of what it
14425
14547
  }
14426
14548
  //#endregion
14427
14549
  //#region src/cli/perspectives.ts
14428
- const perspectivesCommand = addHubOptions(addLanguageOption(new Command("perspectives").description("Generate/update the project's perspectives document on the hub — a factual inventory of existing test coverage (no severity, no gap analysis)").option("--instruction <text>", "Hint to steer how summaries are written").option("--apply", "Auto-apply without [y/N] confirmation", false).option("--check", "Verify the hub document still matches the local specs (mechanical fields only) and exit 1 when it is stale. No Claude calls — cheap enough for CI.", false).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID").option("--project <name>", "Hub project to store the document under (default: cwd directory name)"))).action(withHubErrors(async (opts) => {
14429
- if (opts.check) await runPerspectivesCheck(opts);
14550
+ const perspectivesCommand = addHubOptions(addLanguageOption(new Command("perspectives").description("Generate/update the project's perspectives document on the hub — a factual inventory of existing test coverage (no severity, no gap analysis)").option("--instruction <text>", "Hint to steer how summaries are written").option("-y, --yes", "Apply without asking [y/N]", false).option("--verify", "Check the hub document against the local specs (mechanical fields only) and exit 1 when it is stale. No Claude calls — cheap enough for CI.", false).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID").option("--project <name>", "Hub project to store the document under (default: cwd directory name)"))).action(withHubErrors(async (opts) => {
14551
+ if (opts.verify) await runPerspectivesCheck(opts);
14430
14552
  else await runPerspectives(opts);
14431
14553
  }));
14432
14554
  /**
@@ -14549,7 +14671,7 @@ async function runPerspectives(opts) {
14549
14671
  info("--- proposed changes (YAML view of the hub document) ---");
14550
14672
  printUnifiedDiff(existingYaml, next);
14551
14673
  blank();
14552
- if (!(opts.apply === true || /^y/i.test(await prompt(useJapanesePrompts(opts.language) ? "hub に perspectives を保存しますか? [y/N] " : "Push perspectives to the hub? [y/N] ")))) {
14674
+ if (!(opts.yes === true || /^y/i.test(await prompt(useJapanesePrompts(opts.language) ? "hub に perspectives を保存しますか? [y/N] " : "Push perspectives to the hub? [y/N] ")))) {
14553
14675
  info("aborted — no changes written.");
14554
14676
  return;
14555
14677
  }
@@ -14980,7 +15102,7 @@ async function runGenerateLocked(featureName, specName, opts, cwd) {
14980
15102
  }, cwd) ?? null;
14981
15103
  if (existingOutput && !opts.force) {
14982
15104
  if (!await confirmOverwrite(existingOutput)) {
14983
- info("aborted; pass --force to overwrite without prompting");
15105
+ info("aborted; pass --overwrite to replace it without prompting");
14984
15106
  return;
14985
15107
  }
14986
15108
  }
@@ -15021,20 +15143,21 @@ async function runGenerateLocked(featureName, specName, opts, cwd) {
15021
15143
  hint(`run 'ccqa run ${featureName}/${specName}' to execute the test`);
15022
15144
  }
15023
15145
  /**
15024
- * `ccqa generate --update-agent-prompt`: refresh the target's learned
15146
+ * `ccqa generate --learn-hub-codegen-prompt`: refresh the target's learned
15025
15147
  * `<target>.agent` playbook from this generation. Only targets that declare a
15026
15148
  * `guidanceKind` (the LLM-generating ones: playwright, runn) have such a
15027
15149
  * prompt — agent-browser's codegen is mechanical, so point at `ccqa record
15028
- * --update-agent-prompt` for its tracer instead.
15150
+ * --learn-hub-trace-prompt` for its tracer instead.
15029
15151
  */
15030
15152
  async function runGenerateAgentPromptUpdate(target, featureName, specName, result, opts, cwd) {
15031
15153
  if (target.guidanceKind === void 0) {
15032
- warn(`--update-agent-prompt has no effect on the "${target.id}" target — it has no learned generation prompt (only LLM-generating targets like playwright/runn do)`);
15154
+ warn(`--learn-hub-codegen-prompt has no effect on the "${target.id}" target — it has no learned generation prompt (only LLM-generating targets like playwright/runn do)`);
15033
15155
  return;
15034
15156
  }
15035
15157
  blank();
15036
15158
  await updateAgentPrompt({
15037
15159
  kind: target.guidanceKind,
15160
+ flag: "--learn-hub-codegen-prompt",
15038
15161
  runSummary: buildGenerateRunSummary(target.id, featureName, specName, result, cwd),
15039
15162
  hubContext: opts.hubContext ?? null,
15040
15163
  ...opts.model ? { model: opts.model } : {},
@@ -15060,7 +15183,7 @@ async function confirmOverwrite(path) {
15060
15183
  rl.close();
15061
15184
  }
15062
15185
  }
15063
- const generateCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("generate").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Generate test code from a spec via its target plugin. Recording-backed targets compile the existing ir.json (run `ccqa record` first); spec-input targets generate directly from the spec.").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--target <id>", "Generate through this target instead of the spec's own — e.g. emit a Playwright spec from an agent-browser recording. The spec's `target:` stays the default for `ccqa run`.").option("--auto-fix <mode>", "Auto-fix behaviour during script generation: 'interactive' (default, prompt y/N; declines on non-TTY), 'auto' (apply without prompt, for CI), 'skip' (agent-browser: apply only high-confidence fixes; external targets like playwright/runn: no fix pass at all).", parseAutoFixFlag, "interactive").option("--max-retries <n>", "Maximum number of auto-fix retries", "3").option("--force", "Overwrite previously generated test code without warning").option("--no-snapshot", "Don't pin AGENT_BROWSER_SESSION / capture page snapshots after a failure (debug toggle)").option("--update-agent-prompt", "After generation, ask Claude to refresh the target's \"<target>.agent\" learning prompt on the hub from a summary of the run. LLM-generating targets (playwright, runn) only; requires a hub connection.").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(withUsageErrors(async (specPath, opts) => {
15186
+ const generateCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("generate").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Generate test code from a spec via its target plugin. Recording-backed targets compile the existing ir.json (run `ccqa record` first); spec-input targets generate directly from the spec.").optionsGroup("How to generate:").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--target <id>", "Generate through this target instead of the spec's own — e.g. emit a Playwright spec from an agent-browser recording. The spec's `target:` stays the default for `ccqa run`.").option("--auto-fix <mode>", "Auto-fix behaviour during script generation: 'interactive' (default, prompt y/N; declines on non-TTY), 'auto' (apply without prompt, for CI), 'skip' (agent-browser: apply only high-confidence fixes; external targets like playwright/runn: no fix pass at all).", parseAutoFixFlag, "interactive").option("--auto-fix-max-retries <n>", "Maximum number of auto-fix retries", "3").option("--no-session-pin", "Don't pin AGENT_BROWSER_SESSION / capture page snapshots after a failure (debug toggle)").optionsGroup("What to do with the result:").option("--overwrite", "Replace previously generated test code without warning").optionsGroup("Learning:").option("--learn-hub-codegen-prompt", "After generation, ask Claude to refresh the target's \"<target>.agent\" learning prompt on the hub from a summary of the run. LLM-generating targets (playwright, runn) only; requires a hub connection.").optionsGroup("Environment and connection:").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(withUsageErrors(async (specPath, opts) => {
15064
15187
  const { featureName, specName } = parseSpecPath(specPath);
15065
15188
  const language = opts.language ?? "auto";
15066
15189
  const cwd = resolveCwd(opts.cwd);
@@ -15069,9 +15192,9 @@ const generateCommand = addHubOptions(addProfileOption(addLanguageOption(new Com
15069
15192
  hubToken: opts.hubToken,
15070
15193
  hubHeader: opts.hubHeader
15071
15194
  });
15072
- const project = opts.profile !== void 0 || hubClient !== null ? resolveProject(opts) : void 0;
15073
- if (opts.profile !== void 0) await applyProfileFromOption({
15074
- profile: opts.profile,
15195
+ const project = opts.hubProfile !== void 0 || hubClient !== null ? resolveProject(opts) : void 0;
15196
+ if (opts.hubProfile !== void 0) await applyProfileFromOption({
15197
+ profile: opts.hubProfile,
15075
15198
  project,
15076
15199
  cwd,
15077
15200
  hubUrl: opts.hubUrl,
@@ -15083,22 +15206,26 @@ const generateCommand = addHubOptions(addProfileOption(addLanguageOption(new Com
15083
15206
  project: "",
15084
15207
  cwd
15085
15208
  });
15209
+ if (opts.learnHubCodegenPrompt && hubClient === null) {
15210
+ error("--learn-hub-codegen-prompt requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)");
15211
+ process.exit(2);
15212
+ }
15086
15213
  const hubContext = hubClient && project ? {
15087
15214
  hub: hubClient,
15088
15215
  project
15089
15216
  } : null;
15090
15217
  try {
15091
15218
  await runGenerate(featureName, specName, {
15092
- maxRetries: parseInt(opts.maxRetries ?? "3", 10),
15219
+ maxRetries: parseInt(opts.autoFixMaxRetries ?? "3", 10),
15093
15220
  fixMode: toFixMode(opts.autoFix ?? "interactive"),
15094
- force: opts.force ?? false,
15095
- useSnapshot: opts.snapshot !== false,
15221
+ force: opts.overwrite ?? false,
15222
+ useSnapshot: opts.sessionPin !== false,
15096
15223
  language,
15097
15224
  model: opts.model,
15098
15225
  targetOverride: opts.target,
15099
15226
  cwd,
15100
15227
  hubContext,
15101
- updateAgentPrompt: opts.updateAgentPrompt ?? false
15228
+ updateAgentPrompt: opts.learnHubCodegenPrompt ?? false
15102
15229
  });
15103
15230
  } catch (e) {
15104
15231
  if (e instanceof SpecLockedError) {
@@ -15134,10 +15261,10 @@ function reportCost() {
15134
15261
  //#endregion
15135
15262
  //#region src/cli/record.ts
15136
15263
  const VALIDATION_MODES = ["lenient", "strict"];
15137
- const recordCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("record").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Record a test from a spec: run agent-browser to collect actions (trace), then compile them into runnable code via the spec's target (generate) — a vitest test.spec.ts for agent-browser, a @playwright/test spec for the playwright target. Recording-backed targets only; spec-input targets like runn have no trace step (use `ccqa generate`), and agent-browser live specs need no recording.").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--validation-mode <mode>", "Post-trace validation behaviour: 'lenient' (default) tags failing actions; 'strict' drops them.", (raw) => {
15264
+ const recordCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("record").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Record a test from a spec: run agent-browser to collect actions (trace), then compile them into runnable code via the spec's target (generate) — a vitest test.spec.ts for agent-browser, a @playwright/test spec for the playwright target. Recording-backed targets only; spec-input targets like runn have no trace step (use `ccqa generate`), and agent-browser live specs need no recording.").optionsGroup("How to record:").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--trace-validation <mode>", "What to do with actions that fail post-trace validation: 'lenient' (default) tags them; 'strict' drops them.", (raw) => {
15138
15265
  if (VALIDATION_MODES.includes(raw)) return raw;
15139
- throw new Error(`--validation-mode must be one of ${VALIDATION_MODES.join(" | ")}`);
15140
- }, "lenient").option("--auto-fix <mode>", "Auto-fix behaviour during script generation: 'interactive' (default, prompt y/N; declines on non-TTY), 'auto' (apply without prompt, for CI), 'skip' (agent-browser: apply only high-confidence fixes; external targets like playwright/runn: no fix pass at all).", parseAutoFixFlag, "interactive").option("--max-retries <n>", "Maximum number of auto-fix retries", "3").option("--force", "Overwrite an existing test.spec.ts without warning").option("--no-snapshot", "Don't pin AGENT_BROWSER_SESSION / capture page snapshots after a failure (debug toggle)").option("--skip-trace", "Skip the trace step and run codegen against an existing ir.json").option("--skip-codegen", "Run only the trace step (do not generate test.spec.ts)").option("--update-agent-prompt", "After the trace finishes, ask Claude to refresh the \"record.agent\" prompt on the hub from a summary of the run. Requires a hub connection.").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(withUsageErrors(async (specPath, opts) => {
15266
+ throw new Error(`--trace-validation must be one of ${VALIDATION_MODES.join(" | ")}`);
15267
+ }, "lenient").option("--auto-fix <mode>", "Auto-fix behaviour during script generation: 'interactive' (default, prompt y/N; declines on non-TTY), 'auto' (apply without prompt, for CI), 'skip' (agent-browser: apply only high-confidence fixes; external targets like playwright/runn: no fix pass at all).", parseAutoFixFlag, "interactive").option("--auto-fix-max-retries <n>", "Maximum number of auto-fix retries", "3").option("--trace-only", "Stop after the trace step; do not generate test code").option("--no-session-pin", "Don't pin AGENT_BROWSER_SESSION / capture page snapshots after a failure (debug toggle)").optionsGroup("What to do with the result:").option("--overwrite", "Replace an existing test.spec.ts without warning").optionsGroup("Learning:").option("--learn-hub-trace-prompt", "After the trace finishes, ask Claude to refresh the \"record.agent\" prompt on the hub from a summary of the run. Requires a hub connection.").optionsGroup("Environment and connection:").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(withUsageErrors(async (specPath, opts) => {
15141
15268
  await withCostTally(async () => {
15142
15269
  try {
15143
15270
  await runRecord(specPath, opts);
@@ -15149,10 +15276,6 @@ const recordCommand = addHubOptions(addProfileOption(addLanguageOption(new Comma
15149
15276
  async function runRecord(specPath, opts) {
15150
15277
  const { featureName, specName } = parseSpecPath(specPath);
15151
15278
  const language = opts.language ?? "auto";
15152
- if (opts.skipTrace && opts.skipCodegen) {
15153
- error("--skip-trace and --skip-codegen cannot be combined; nothing would run");
15154
- process.exit(2);
15155
- }
15156
15279
  const cwdForProfile = resolveCwd(opts.cwd);
15157
15280
  const spec = parseTestSpec(await readSpecFile(featureName, specName, cwdForProfile));
15158
15281
  const config = await loadProjectConfig(cwdForProfile);
@@ -15161,9 +15284,9 @@ async function runRecord(specPath, opts) {
15161
15284
  error(`target "${target.id}" does not use a browser recording — run 'ccqa generate ${featureName}/${specName}' instead`);
15162
15285
  process.exit(2);
15163
15286
  }
15164
- const project = opts.profile !== void 0 ? resolveProject(opts) : void 0;
15165
- if (opts.profile !== void 0) await applyProfileFromOption({
15166
- profile: opts.profile,
15287
+ const project = opts.hubProfile !== void 0 ? resolveProject(opts) : void 0;
15288
+ if (opts.hubProfile !== void 0) await applyProfileFromOption({
15289
+ profile: opts.hubProfile,
15167
15290
  project,
15168
15291
  cwd: cwdForProfile,
15169
15292
  hubUrl: opts.hubUrl,
@@ -15185,6 +15308,10 @@ async function runRecord(specPath, opts) {
15185
15308
  hub: hubClientForTrace,
15186
15309
  project: hubProject
15187
15310
  } : null;
15311
+ if (opts.learnHubTracePrompt && hubContext === null) {
15312
+ error("--learn-hub-trace-prompt requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)");
15313
+ process.exit(2);
15314
+ }
15188
15315
  const releaseLock = await acquireSpecLock(featureName, specName, "record", cwdForProfile).catch((e) => {
15189
15316
  if (e instanceof SpecLockedError) {
15190
15317
  error(e.message);
@@ -15194,18 +15321,16 @@ async function runRecord(specPath, opts) {
15194
15321
  });
15195
15322
  let traceResult = null;
15196
15323
  try {
15197
- if (!opts.skipTrace) {
15198
- traceResult = await runTrace(featureName, specName, opts.model, opts.validationMode ?? "lenient", language, {
15199
- cwd: cwdForProfile,
15200
- hubContext
15201
- });
15202
- blank();
15203
- }
15204
- if (!opts.skipCodegen) await runGenerate(featureName, specName, {
15205
- maxRetries: parseInt(opts.maxRetries ?? "3", 10),
15324
+ traceResult = await runTrace(featureName, specName, opts.model, opts.traceValidation ?? "lenient", language, {
15325
+ cwd: cwdForProfile,
15326
+ hubContext
15327
+ });
15328
+ blank();
15329
+ if (!opts.traceOnly) await runGenerate(featureName, specName, {
15330
+ maxRetries: parseInt(opts.autoFixMaxRetries ?? "3", 10),
15206
15331
  fixMode: toFixMode(opts.autoFix ?? "interactive"),
15207
- force: opts.force ?? false,
15208
- useSnapshot: opts.snapshot !== false,
15332
+ force: opts.overwrite ?? false,
15333
+ useSnapshot: opts.sessionPin !== false,
15209
15334
  language,
15210
15335
  model: opts.model,
15211
15336
  cwd: cwdForProfile,
@@ -15214,11 +15339,11 @@ async function runRecord(specPath, opts) {
15214
15339
  } finally {
15215
15340
  await releaseLock();
15216
15341
  }
15217
- if (opts.updateAgentPrompt) if (traceResult === null) warn("--update-agent-prompt is ignored when --skip-trace is set (no run summary available)");
15218
- else {
15342
+ if (opts.learnHubTracePrompt && traceResult !== null) {
15219
15343
  blank();
15220
15344
  await updateAgentPrompt({
15221
15345
  kind: "record",
15346
+ flag: "--learn-hub-trace-prompt",
15222
15347
  runSummary: buildRecordRunSummary(featureName, specName, traceResult),
15223
15348
  hubContext,
15224
15349
  ...opts.model ? { model: opts.model } : {},
@@ -15499,8 +15624,8 @@ function specStatus(result, threshold) {
15499
15624
  return "passed";
15500
15625
  }
15501
15626
  /**
15502
- * Adapts `ccqa drift` results into the shared RunReportData shape so they can
15503
- * be pushed to the hub (`ccqa drift --push`) and rendered by the same report
15627
+ * Adapts `ccqa audit` results into the shared RunReportData shape so they can
15628
+ * be pushed to the hub (`ccqa audit --report-to-hub`) and rendered by the same report
15504
15629
  * UI as `ccqa run`/`ccqa live`. Browser-execution fields (testCounts,
15505
15630
  * evidence, liveRun, ...) don't apply to a drift audit and are always null —
15506
15631
  * which is why `mode` is carried separately: nothing ran, but which surfaces
@@ -15546,35 +15671,34 @@ function driftResultsToReport(results, meta) {
15546
15671
  };
15547
15672
  }
15548
15673
  //#endregion
15549
- //#region src/cli/drift.ts
15674
+ //#region src/cli/audit.ts
15550
15675
  const DEFAULT_CONCURRENCY = 3;
15551
- const driftCommand = addLanguageOption(new Command("drift").argument("[feature/spec]", "Optional spec id. If omitted, every spec under .ccqa/features/ is checked.").description("Standalone spec codebase static audit. Use for PR checks where the browser isn't run. For run-time audit with a structured report, see `ccqa run --report`.").option("--format <fmt>", "Output format: text | json | github", "text").option("--severity <level>", "Exit non-zero on this severity or higher: warn | error", "error").option("--concurrency <n>", `Parallel spec checks (default: ${DEFAULT_CONCURRENCY})`).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--cwd <path>", "Working directory used as both the .ccqa root and the codebase Claude reads. Useful for monorepos. Defaults to process.cwd().").option("--changed", "Restrict drift checks to the specs a change reaches, decided by `ccqa select-specs` against --base (or, in CI, $GITHUB_BASE_REF). Costs one model call; specs it cannot decide are checked rather than skipped.").option("--base <ref>", "Base ref to diff against when --changed is set. Defaults to $GITHUB_BASE_REF (CI pull_request runs); required otherwise.").option("--push", "Push the drift result to a ccqa hub as a run (kind: drift).").option("--project <name>", "Logical project name for the pushed run. Defaults to the current directory's name.").option(...hubUrlOption).option(...hubTokenOption).option(...hubHeaderOption)).action(withUsageErrors(async (specPath, opts) => {
15552
- await withCostTally(() => runDrift(specPath, opts));
15676
+ const auditCommand = addLanguageOption(new Command("audit").argument("[feature/spec]", "Optional spec id. If omitted, every spec under .ccqa/features/ is checked.").description("Read each spec against the code it describes and report where the two have drifted. Static: no browser is run, so this is the cheap check to put in front of `ccqa run`.").optionsGroup("Which specs to audit:").option("--only-affected-by <ref>", "Only specs `ccqa select-specs` judges reached by the diff against <ref> (e.g. origin/main). In pull_request CI, pass $GITHUB_BASE_REF. Costs one model call; specs it cannot decide are audited rather than skipped.").optionsGroup("How to run it:").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.").optionsGroup("What to do with the results:").option("--report-format <fmt>", "Output format: text | json | github", "text").option("--report-to-hub", "Push the result to a ccqa hub as a run (kind: drift), which is what updates the drift ledger `ccqa run --only-hub-audited-clean` reads.").option("--exit-on <level>", "Exit non-zero on this severity or higher: warn | error", "error").optionsGroup("Environment and connection:").option("--cwd <path>", "Working directory used as both the .ccqa root and the codebase Claude reads. Useful for monorepos. Defaults to process.cwd().").option("--project <name>", "Logical project name for the pushed run. Defaults to the current directory's name.").option(...hubUrlOption).option(...hubTokenOption).option(...hubHeaderOption)).action(withUsageErrors(async (specPath, opts) => {
15677
+ await withCostTally(() => runAudit(specPath, opts));
15553
15678
  }));
15554
- async function runDrift(specPath, opts) {
15555
- const format = parseFormat$1(opts.format);
15556
- const threshold = parseSeverity(opts.severity);
15679
+ async function runAudit(specPath, opts) {
15680
+ const format = parseFormat$1(opts.reportFormat);
15681
+ const threshold = parseSeverity(opts.exitOn);
15557
15682
  const concurrency = parseConcurrency(opts.concurrency);
15558
15683
  const cwd = resolveCwd(opts.cwd);
15559
15684
  await ensureCcqaDir(cwd);
15560
- if (opts.changed && specPath) {
15561
- error("--changed and an explicit spec id cannot be combined; --changed only applies to a full sweep");
15685
+ if (opts.onlyAffectedBy && specPath) {
15686
+ error("--only-affected-by and an explicit spec id cannot be combined; it only applies to a full sweep");
15562
15687
  process.exit(2);
15563
15688
  }
15564
15689
  let targets = await collectTargets(specPath, cwd);
15565
15690
  if (targets.length === 0) exitWithNoSpecs(format, "no test specs found under .ccqa/features/");
15566
15691
  if (format === "text") {
15567
- header("drift", specPath ?? `${targets.length} spec${targets.length > 1 ? "s" : ""}`);
15692
+ header("audit", specPath ?? `${targets.length} spec${targets.length > 1 ? "s" : ""}`);
15568
15693
  if (opts.cwd) meta("cwd", cwd);
15569
15694
  }
15570
15695
  let baseRef = null;
15571
- if (opts.changed) {
15696
+ if (opts.onlyAffectedBy) {
15572
15697
  const total = targets.length;
15573
15698
  const selection = await collectChangedSpecs(targets, {
15574
15699
  cwd,
15575
- base: opts.base ?? true,
15700
+ base: opts.onlyAffectedBy,
15576
15701
  quiet: format !== "text",
15577
- baseExample: "--base origin/main",
15578
15702
  ...opts.model ? { model: opts.model } : {}
15579
15703
  });
15580
15704
  targets = selection.specs;
@@ -15595,7 +15719,7 @@ async function runDrift(specPath, opts) {
15595
15719
  }
15596
15720
  });
15597
15721
  process.stdout.write(renderDrift(results, format, cwd));
15598
- if (opts.push) await pushDriftResults({
15722
+ if (opts.reportToHub) await pushDriftResults({
15599
15723
  results,
15600
15724
  threshold,
15601
15725
  cwd,
@@ -15608,9 +15732,9 @@ async function runDrift(specPath, opts) {
15608
15732
  }
15609
15733
  /**
15610
15734
  * Push a finished drift audit to a ccqa hub as a `kind: "drift"` run, so it
15611
- * shows up alongside `ccqa run` runs in the hub UI. Best-effort: a missing
15612
- * hub connection warns and returns rather than failing the command (`--push`
15613
- * never changes drift's own exit code).
15735
+ * shows up alongside `ccqa run` runs in the hub UI. A missing hub connection
15736
+ * is a usage error, not a silent skip a CI job that asked to publish and
15737
+ * did not must say so.
15614
15738
  *
15615
15739
  * `resolveHub` is injectable so tests can supply a fake `HubClient` without
15616
15740
  * a real hub connection; it defaults to the real flag/env resolution.
@@ -15619,8 +15743,8 @@ async function pushDriftResults(args, resolveHub = resolveHubClient) {
15619
15743
  const { results, threshold, cwd, opts, format, baseRef } = args;
15620
15744
  const hub = resolveHub(opts);
15621
15745
  if (!hub) {
15622
- warn("--push requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN) — skipping push");
15623
- return;
15746
+ error("--report-to-hub requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)");
15747
+ process.exit(2);
15624
15748
  }
15625
15749
  try {
15626
15750
  const project = resolveProject({
@@ -15697,7 +15821,7 @@ function parseFormat$1(raw) {
15697
15821
  function parseSeverity(raw) {
15698
15822
  const v = raw ?? "error";
15699
15823
  if (v === "warn" || v === "error") return v;
15700
- error(`invalid --severity: ${v} (expected warn|error)`);
15824
+ error(`invalid --exit-on: ${v} (expected warn|error)`);
15701
15825
  process.exit(2);
15702
15826
  }
15703
15827
  function parseConcurrency(raw) {
@@ -15805,102 +15929,6 @@ function parseFormat(raw) {
15805
15929
  process.exit(2);
15806
15930
  }
15807
15931
  //#endregion
15808
- //#region src/cli/session.ts
15809
- const AB = resolveAgentBrowserBin$1();
15810
- /**
15811
- * Run agent-browser attached to the user's terminal (no timeout, inherited
15812
- * stdio) so a human can complete an interactive login during `bootstrap`.
15813
- * Distinct from runtime/spawn-ab.ts, which pipes stdio and hard-times-out for
15814
- * non-interactive automation.
15815
- */
15816
- function runAbInteractive(args) {
15817
- return spawnSync(AB, args, { stdio: "inherit" }).status ?? 1;
15818
- }
15819
- function validateName(name) {
15820
- const parsed = SessionNameSchema.safeParse(name);
15821
- if (!parsed.success) {
15822
- error(`invalid session name "${name}": ${parsed.error.issues[0]?.message ?? "bad name"}`);
15823
- process.exit(2);
15824
- }
15825
- return parsed.data;
15826
- }
15827
- const profileOption = ["--profile <name>", "Sessions bucket to read/write on the hub. Defaults to 'default'."];
15828
- const projectOption = ["--project <name>", "Project the session belongs to on the hub. Defaults to the current directory's name."];
15829
- const bootstrapCommand = new Command("bootstrap").description("Open a headed browser so you can log in by hand, then upload the resulting session (cookies + localStorage) to the hub for `session:` specs to restore.").argument("<name>", "Session name to save").option("--url <url>", "URL to open first (e.g. the login page). Omit to start with a blank tab.").option(...profileOption).option(...hubUrlOption).option(...hubTokenOption).option(...projectOption).option("--cwd <path>", "Directory the default --project name is derived from (defaults to the current directory).").action(async (rawName, opts) => {
15830
- const name = validateName(rawName);
15831
- resolveCwd(opts.cwd);
15832
- const project = resolveProject(opts);
15833
- let hub;
15834
- try {
15835
- hub = requireHubClient(opts);
15836
- } catch (err) {
15837
- if (!(err instanceof HubConnectionError)) throw err;
15838
- error(err.message);
15839
- process.exit(2);
15840
- }
15841
- header("session bootstrap", name);
15842
- meta("project", project);
15843
- meta("profile", opts.profile ?? "default");
15844
- blank();
15845
- const openArgs = [
15846
- "--headed",
15847
- "open",
15848
- ...opts.url ? [opts.url] : ["about:blank"]
15849
- ];
15850
- info("opening a browser — log in by hand, then return here.");
15851
- const openStatus = runAbInteractive(openArgs);
15852
- if (openStatus !== 0) {
15853
- error(`agent-browser open exited ${openStatus}`);
15854
- process.exit(1);
15855
- }
15856
- const rl = createInterface({
15857
- input: process.stdin,
15858
- output: process.stdout
15859
- });
15860
- await rl.question("\nPress Enter once you are fully logged in to save the session… ");
15861
- rl.close();
15862
- const tmpDir = await mkdtemp(join(tmpdir(), "ccqa-session-bootstrap-"));
15863
- try {
15864
- const tmpPath = join(tmpDir, "state.json");
15865
- const saveStatus = runAbInteractive([
15866
- "state",
15867
- "save",
15868
- tmpPath
15869
- ]);
15870
- runAbInteractive(["close"]);
15871
- if (saveStatus !== 0) {
15872
- error(`agent-browser state save exited ${saveStatus}`);
15873
- process.exit(1);
15874
- }
15875
- const state = await loadStorageState(tmpPath);
15876
- let payload = state;
15877
- if (opts.url) {
15878
- info("verifying the saved session restores to a signed-in page…");
15879
- const check = verifySessionRestores(tmpPath, opts.url);
15880
- if (!check.restored) {
15881
- error(`session did not restore cleanly: ${check.reason}`);
15882
- hint("fully load the application (sign in, open the target workspace/page, wait for it to settle) before pressing Enter, then run bootstrap again. Nothing was uploaded.");
15883
- process.exit(1);
15884
- }
15885
- info("restore verified — the session starts signed in.");
15886
- payload = {
15887
- ...state,
15888
- [SESSION_VERIFY_URL_KEY]: opts.url
15889
- };
15890
- } else warn("no --url given — the session can't be verified now, and runs can't health-check it before executing steps; strongly consider re-running with --url <a signed-in page URL>.");
15891
- await hub.putSession(project, opts.profile ?? "default", name, payload);
15892
- } finally {
15893
- await rm(tmpDir, {
15894
- recursive: true,
15895
- force: true
15896
- });
15897
- }
15898
- blank();
15899
- info(`uploaded session "${name}" to the hub (encrypted at rest)`);
15900
- hint("reference it from a spec with: session: " + name);
15901
- });
15902
- const sessionCommand = new Command("session").description("Manage saved browser sessions (cookies + localStorage) for `session:` specs. Use `ccqa hub session ls` to list sessions stored on the hub.").addCommand(bootstrapCommand);
15903
- //#endregion
15904
15932
  //#region src/hub/api/respond.ts
15905
15933
  /** The message of an unknown throwable, for a log line or an error body. */
15906
15934
  function errMsg(err) {
@@ -16263,7 +16291,7 @@ function countSpecs(results) {
16263
16291
  * or `red` (the last outcome), which are orthogonal axes.
16264
16292
  *
16265
16293
  * Best-effort — a ledger failure must not fail the push; the ledger is an
16266
- * accelerator for `--failure-analysis=last-green` and re-run selection, not
16294
+ * accelerator for `--on-fail-explain` baselines and re-run selection, not
16267
16295
  * part of the run record. Runs without a branch or gitHead can't be placed in
16268
16296
  * the ledger and are skipped.
16269
16297
  *
@@ -16907,7 +16935,7 @@ function createGetLastGreenHandler(storage) {
16907
16935
  /**
16908
16936
  * GET /api/v1/projects/:project/drift
16909
16937
  *
16910
- * Every spec's last `ccqa drift --push` audit, keyed by "feature/spec". No
16938
+ * Every spec's last `ccqa audit --report-to-hub` result, keyed by "feature/spec". No
16911
16939
  * `?profile=` — drift asks whether a spec still describes the code, which
16912
16940
  * has nothing to do with which environment is running it, unlike the
16913
16941
  * `/rerun` and `/last-green` endpoints. Merged across every branch (newest
@@ -18009,7 +18037,7 @@ const HTML_BODY = `
18009
18037
  <div style="font-weight:600" data-i18n="session.help.title">How to get this JSON</div>
18010
18038
  <ol class="help-steps">
18011
18039
  <li><span class="step-n">1</span><div class="step-b"><span data-i18n="session.help.step1">Run this in your terminal and log in by hand when the browser opens:</span>
18012
- <div class="cmd"><code id="session-help-cmd">ccqa session bootstrap &lt;name&gt;</code><button type="button" class="copy" id="session-help-copy"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg><span data-i18n="common.copy">Copy</span></button></div>
18040
+ <div class="cmd"><code id="session-help-cmd">ccqa hub session capture &lt;name&gt;</code><button type="button" class="copy" id="session-help-copy"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg><span data-i18n="common.copy">Copy</span></button></div>
18013
18041
  </div></li>
18014
18042
  <li><span class="step-n">2</span><div class="step-b"><span data-i18n="session.help.step2">Open the saved file and paste its contents below:</span>
18015
18043
  <div style="margin-top:5px"><span class="path">.ccqa/sessions/&lt;profile&gt;/&lt;name&gt;.json</span></div>
@@ -20135,7 +20163,7 @@ const CLIENT_JS = `
20135
20163
  }
20136
20164
 
20137
20165
  // A drift-kind row's diagnosis lives in analysis regardless of status
20138
- // (an UNKNOWN-labelled finding below the --severity threshold still
20166
+ // (an UNKNOWN-labelled finding below the --exit-on threshold still
20139
20167
  // "passes" but has something to show); a normal run only ever classifies
20140
20168
  // a failed spec.
20141
20169
  var hasAnalysis = isDrift ? !!r.analysis : r.status === "failed" && r.analysis;
@@ -21128,7 +21156,7 @@ const CLIENT_JS = `
21128
21156
  //
21129
21157
  // "unknown" keeps its own state rather than folding into the last result:
21130
21158
  // it means the hub cannot say whether that result still holds, and
21131
- // --changed=last-run does not re-run it without --include-unknown. Showing
21159
+ // --only-hub-stale does not re-run it without --only-hub-stale-with-unknown. Showing
21132
21160
  // it as passed or failed would claim a confidence nothing supports.
21133
21161
  function perspRunState(rr) {
21134
21162
  if (!rr) return null;
@@ -23366,17 +23394,21 @@ function resolvePackageJson() {
23366
23394
  const { version } = JSON.parse(readFileSync(resolvePackageJson(), "utf8"));
23367
23395
  const program = new Command();
23368
23396
  program.name("ccqa").description("E2E test CLI powered by Claude Code — agent-browser by default, or Playwright / runn targets").version(version);
23397
+ program.commandsGroup("Write specs:");
23369
23398
  program.addCommand(initCommand);
23370
23399
  program.addCommand(draftCommand);
23371
23400
  program.addCommand(perspectivesCommand);
23401
+ program.commandsGroup("Build tests from them:");
23372
23402
  program.addCommand(recordCommand);
23373
23403
  program.addCommand(generateCommand);
23404
+ program.commandsGroup("Check them:");
23374
23405
  program.addCommand(runCommand);
23375
- program.addCommand(driftCommand);
23376
- program.addCommand(selectSpecsCommand);
23377
- program.addCommand(sessionCommand);
23378
- program.addCommand(serveCommand);
23406
+ program.addCommand(auditCommand);
23407
+ program.commandsGroup("Hub:");
23379
23408
  program.addCommand(hubCommand);
23409
+ program.addCommand(serveCommand);
23410
+ program.commandsGroup("Building blocks:");
23411
+ program.addCommand(selectSpecsCommand);
23380
23412
  program.parse();
23381
23413
  //#endregion
23382
23414
  export {};