ccqa 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/ccqa.mjs CHANGED
@@ -3326,7 +3326,7 @@ const DEFAULT_CONCURRENCY$1 = 3;
3326
3326
  /**
3327
3327
  * Run drift checks against a list of pre-collected targets. Pure library
3328
3328
  * function: no commander, no process.exit, no stdout writes. Callers handle
3329
- * presentation. `cli/drift` does the full sweep with `--changed` scoping;
3329
+ * presentation. `cli/audit` does the full sweep with `--only-affected-by` scoping;
3330
3330
  * `cli/run` calls this with just the failing specs after vitest.
3331
3331
  */
3332
3332
  async function analyzeDrift(input) {
@@ -4426,16 +4426,6 @@ Write the runbook to \`${suggestedPath}\` unless the conventions/examples clearl
4426
4426
  //#region src/drift/affected.ts
4427
4427
  const execFileP = promisify(execFile);
4428
4428
  /**
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
4429
  * Paths that differ between `base` and `head` (two-dot: `git diff base..head`),
4440
4430
  * from `cwd`. Renames are reported under their NEW path with status
4441
4431
  * "renamed" — the OLD path is dropped since only the current layout matters.
@@ -5225,7 +5215,7 @@ const DraftNamingSchema = z.object({
5225
5215
  //#endregion
5226
5216
  //#region src/cli/draft.ts
5227
5217
  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) => {
5218
+ 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
5219
  await ensureCcqaDir();
5230
5220
  let featureName;
5231
5221
  let specName;
@@ -5262,7 +5252,7 @@ async function runDraft(featureName, specName, opts, prefilledIntent) {
5262
5252
  specName,
5263
5253
  existing,
5264
5254
  userInput: userInput.trim(),
5265
- autoApply: opts.apply === true,
5255
+ autoApply: opts.yes === true,
5266
5256
  language: opts.language
5267
5257
  });
5268
5258
  if (oneShot) process.exit(turnResult.hasError && !turnResult.applied ? 1 : 0);
@@ -5482,7 +5472,7 @@ async function proposeNaming(opts) {
5482
5472
  const final = ensureUnique(tree, sanitized.featureName, sanitized.specName);
5483
5473
  meta("proposed", `${final.featureName}/${final.specName}`);
5484
5474
  if (proposed.reason) meta("reason", proposed.reason);
5485
- if (oneShot || opts.apply === true) return {
5475
+ if (oneShot || opts.yes === true) return {
5486
5476
  naming: final,
5487
5477
  intent: intent.trim()
5488
5478
  };
@@ -6531,15 +6521,13 @@ function createDiffProvider(args) {
6531
6521
  }
6532
6522
  //#endregion
6533
6523
  //#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
6524
  /**
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.
6525
+ * Marks a baseline as "each spec's own last green commit" rather than one
6526
+ * shared ref. Not a flag value `--on-fail-explain` uses per-spec baselines
6527
+ * unless `--on-fail-explain-base` names a ref but the report and the log
6528
+ * lines need a word for it.
6541
6529
  */
6542
- const LAST_RUN = "last-run";
6530
+ const LAST_GREEN = "last-green";
6543
6531
  /** Resolve `ref` to a full commit sha, or null when it does not exist locally. */
6544
6532
  async function resolveCommitSha(ref, cwd) {
6545
6533
  try {
@@ -6555,35 +6543,17 @@ async function resolveCommitSha(ref, cwd) {
6555
6543
  }
6556
6544
  }
6557
6545
  /**
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.
6546
+ * Resolve a base ref to a verified baseline, failing fast — before any spec
6547
+ * runs when it cannot be resolved. The ref must resolve to a local commit,
6548
+ * so a shallow CI checkout that never fetched the base surfaces here as an
6549
+ * actionable error instead of an empty diff downstream.
6568
6550
  *
6569
6551
  * `flagName` only shapes the error messages.
6570
6552
  */
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
- }
6553
+ async function resolveAnalysisBase(ref, flagName, cwd) {
6554
+ const source = "explicit";
6585
6555
  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>.`);
6556
+ 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
6557
  return {
6588
6558
  ref,
6589
6559
  sha,
@@ -6655,7 +6625,7 @@ async function fetchLastGreenLedger(hubCtx, profile, cwd) {
6655
6625
  ...profile ? { profile } : {}
6656
6626
  });
6657
6627
  } 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)}`);
6628
+ throw new RunUsageError(`--on-fail-explain: could not fetch the last-green ledger from the hub: ${err instanceof Error ? err.message : String(err)}`);
6659
6629
  }
6660
6630
  const n = Object.keys(entries).length;
6661
6631
  const scope = branch === fallbackBranch ? branch : `${branch} → ${fallbackBranch}`;
@@ -6774,6 +6744,50 @@ function formatDryRunLines(agentBrowser, routed) {
6774
6744
  return tagged.map((t) => ` ${t.key.padEnd(width)} ${t.tag}`);
6775
6745
  }
6776
6746
  //#endregion
6747
+ //#region src/run/audited-clean.ts
6748
+ /**
6749
+ * Fetch the drift ledger and reduce it to the specs that are safe to run.
6750
+ *
6751
+ * A spec qualifies only when the ledger holds an entry for it *and* that entry
6752
+ * found no drift. A spec that has never been audited does not qualify: the
6753
+ * point of the flag is to spend a run only where a cheap audit already said
6754
+ * the spec still describes the code, and "never looked" is not that.
6755
+ */
6756
+ async function fetchAuditedLedger(hubCtx) {
6757
+ let ledger;
6758
+ try {
6759
+ ledger = await hubCtx.hub.getDriftLedger(hubCtx.project);
6760
+ } catch (err) {
6761
+ throw new RunUsageError(`--only-audited-clean: could not fetch the drift ledger from the hub: ${errMessage(err)}`);
6762
+ }
6763
+ const clean = /* @__PURE__ */ new Set();
6764
+ const audited = /* @__PURE__ */ new Set();
6765
+ for (const [key, entry] of Object.entries(ledger.specs)) {
6766
+ audited.add(key);
6767
+ if (entry.label === null) clean.add(key);
6768
+ }
6769
+ return {
6770
+ clean,
6771
+ audited
6772
+ };
6773
+ }
6774
+ function selectAuditedClean(specs, ledger) {
6775
+ const selected = [];
6776
+ let unaudited = 0;
6777
+ let drifted = 0;
6778
+ for (const spec of specs) {
6779
+ const key = specKey(spec);
6780
+ if (ledger.clean.has(key)) selected.push(spec);
6781
+ else if (ledger.audited.has(key)) drifted++;
6782
+ else unaudited++;
6783
+ }
6784
+ return {
6785
+ selected,
6786
+ unaudited,
6787
+ drifted
6788
+ };
6789
+ }
6790
+ //#endregion
6777
6791
  //#region src/run/rerun-selection.ts
6778
6792
  /**
6779
6793
  * `ccqa run --changed=last-run`: select specs from the hub's re-run verdicts
@@ -6793,7 +6807,7 @@ const RERUN_MIN_HUB_VERSION = "1.9";
6793
6807
  * has no profile-free answer.
6794
6808
  */
6795
6809
  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`);
6810
+ if (profile === void 0) throw new RunUsageError("--only-stale requires --profile <name>: the deploy log it reads is per-profile, so which specs need a re-run has no answer without one");
6797
6811
  return profile;
6798
6812
  }
6799
6813
  /**
@@ -6806,9 +6820,9 @@ async function fetchRerunReport(hubCtx, profile) {
6806
6820
  report = await hubCtx.hub.getRerun(hubCtx.project, { profile });
6807
6821
  } catch (err) {
6808
6822
  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)}`);
6823
+ throw new RunUsageError(`--only-stale: could not ask the hub which specs need a re-run: ${errMessage(err)}`);
6810
6824
  }
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>).`);
6825
+ if (report.deployHead === null) throw new RunUsageError(`--only-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 pass an explicit baseline (--changed=<ref>).`);
6812
6826
  return {
6813
6827
  ...report,
6814
6828
  deployHead: report.deployHead
@@ -6820,8 +6834,8 @@ async function fetchRerunReport(hubCtx, profile) {
6820
6834
  * means the hub does not serve this route at all.
6821
6835
  */
6822
6836
  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>).`;
6837
+ if (err.code === "no_perspectives") return `--only-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.`;
6838
+ return `--only-stale: 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>).`;
6825
6839
  }
6826
6840
  /** States the summary line reports, worst-known-first. */
6827
6841
  const SUMMARY_ORDER = [
@@ -8463,6 +8477,101 @@ function oneLine$1(text) {
8463
8477
  return text.trim().replace(/\s+/g, " ");
8464
8478
  }
8465
8479
  //#endregion
8480
+ //#region src/cli/session.ts
8481
+ const AB = resolveAgentBrowserBin$1();
8482
+ /**
8483
+ * Run agent-browser attached to the user's terminal (no timeout, inherited
8484
+ * stdio) so a human can complete an interactive login during `bootstrap`.
8485
+ * Distinct from runtime/spawn-ab.ts, which pipes stdio and hard-times-out for
8486
+ * non-interactive automation.
8487
+ */
8488
+ function runAbInteractive(args) {
8489
+ return spawnSync(AB, args, { stdio: "inherit" }).status ?? 1;
8490
+ }
8491
+ function validateName(name) {
8492
+ const parsed = SessionNameSchema.safeParse(name);
8493
+ if (!parsed.success) {
8494
+ error(`invalid session name "${name}": ${parsed.error.issues[0]?.message ?? "bad name"}`);
8495
+ process.exit(2);
8496
+ }
8497
+ return parsed.data;
8498
+ }
8499
+ const profileOption$1 = ["--profile <name>", "Sessions bucket to read/write on the hub. Defaults to 'default'."];
8500
+ const projectOption$1 = ["--project <name>", "Project the session belongs to on the hub. Defaults to the current directory's name."];
8501
+ 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) => {
8502
+ const name = validateName(rawName);
8503
+ resolveCwd(opts.cwd);
8504
+ const project = resolveProject(opts);
8505
+ let hub;
8506
+ try {
8507
+ hub = requireHubClient(opts);
8508
+ } catch (err) {
8509
+ if (!(err instanceof HubConnectionError)) throw err;
8510
+ error(err.message);
8511
+ process.exit(2);
8512
+ }
8513
+ header("session bootstrap", name);
8514
+ meta("project", project);
8515
+ meta("profile", opts.profile ?? "default");
8516
+ blank();
8517
+ const openArgs = [
8518
+ "--headed",
8519
+ "open",
8520
+ ...opts.url ? [opts.url] : ["about:blank"]
8521
+ ];
8522
+ info("opening a browser — log in by hand, then return here.");
8523
+ const openStatus = runAbInteractive(openArgs);
8524
+ if (openStatus !== 0) {
8525
+ error(`agent-browser open exited ${openStatus}`);
8526
+ process.exit(1);
8527
+ }
8528
+ const rl = createInterface({
8529
+ input: process.stdin,
8530
+ output: process.stdout
8531
+ });
8532
+ await rl.question("\nPress Enter once you are fully logged in to save the session… ");
8533
+ rl.close();
8534
+ const tmpDir = await mkdtemp(join(tmpdir(), "ccqa-session-bootstrap-"));
8535
+ try {
8536
+ const tmpPath = join(tmpDir, "state.json");
8537
+ const saveStatus = runAbInteractive([
8538
+ "state",
8539
+ "save",
8540
+ tmpPath
8541
+ ]);
8542
+ runAbInteractive(["close"]);
8543
+ if (saveStatus !== 0) {
8544
+ error(`agent-browser state save exited ${saveStatus}`);
8545
+ process.exit(1);
8546
+ }
8547
+ const state = await loadStorageState(tmpPath);
8548
+ let payload = state;
8549
+ if (opts.url) {
8550
+ info("verifying the saved session restores to a signed-in page…");
8551
+ const check = verifySessionRestores(tmpPath, opts.url);
8552
+ if (!check.restored) {
8553
+ error(`session did not restore cleanly: ${check.reason}`);
8554
+ 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.");
8555
+ process.exit(1);
8556
+ }
8557
+ info("restore verified — the session starts signed in.");
8558
+ payload = {
8559
+ ...state,
8560
+ [SESSION_VERIFY_URL_KEY]: opts.url
8561
+ };
8562
+ } 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>.");
8563
+ await hub.putSession(project, opts.profile ?? "default", name, payload);
8564
+ } finally {
8565
+ await rm(tmpDir, {
8566
+ recursive: true,
8567
+ force: true
8568
+ });
8569
+ }
8570
+ blank();
8571
+ info(`uploaded session "${name}" to the hub (encrypted at rest)`);
8572
+ hint("reference it from a spec with: session: " + name);
8573
+ });
8574
+ //#endregion
8466
8575
  //#region src/cli/hub.ts
8467
8576
  /**
8468
8577
  * `ccqa hub` — the client side of the ccqa hub (a results/secret control
@@ -8473,8 +8582,8 @@ function oneLine$1(text) {
8473
8582
  * talk to the hub over the same public REST API (docs/hub-api.md) via
8474
8583
  * `ccqa/hub-client`.
8475
8584
  */
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."];
8585
+ const profileOption = ["--profile <name>", "Profile bucket the session/variable belongs to. Defaults to 'default'."];
8586
+ const projectOption = ["--project <name>", "Project the session/variable belongs to on the hub. Defaults to the current directory's name."];
8478
8587
  const cwdOption = ["--cwd <path>", "Directory the default --project name is derived from (defaults to the current directory)."];
8479
8588
  /**
8480
8589
  * The hub base URL from flags / env (trailing slashes trimmed), or exit 2.
@@ -8511,7 +8620,7 @@ async function readStdin() {
8511
8620
  for await (const chunk of process.stdin) chunks.push(chunk);
8512
8621
  return Buffer.concat(chunks).toString("utf8");
8513
8622
  }
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) => {
8623
+ 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
8624
  const name = validateSessionName(rawName);
8516
8625
  const cwd = resolveCwd(opts.cwd);
8517
8626
  const project = resolveProject(opts);
@@ -8531,7 +8640,7 @@ const sessionPush = new Command("push").description("Upload a locally-saved brow
8531
8640
  meta("profile", profile);
8532
8641
  info(`uploaded session "${name}" to the hub (encrypted at rest)`);
8533
8642
  }));
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) => {
8643
+ 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
8644
  const project = resolveProject(opts);
8536
8645
  const profile = opts.profile ?? "default";
8537
8646
  const sessions = await connect(opts).listSessions(project, profile);
@@ -8542,7 +8651,7 @@ const sessionLs = new Command("ls").description("List sessions stored on the hub
8542
8651
  }
8543
8652
  for (const s of sessions) meta(s.name, `updated ${s.updatedAt}`);
8544
8653
  }));
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) => {
8654
+ 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
8655
  const name = validateSessionName(rawName);
8547
8656
  const project = resolveProject(opts);
8548
8657
  const profile = opts.profile ?? "default";
@@ -8550,8 +8659,8 @@ const sessionRm = new Command("rm").description("Delete a session from the hub."
8550
8659
  header("hub session rm", name);
8551
8660
  info(`deleted session "${name}" from the hub`);
8552
8661
  }));
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) => {
8662
+ 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);
8663
+ 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
8664
  const project = resolveProject(opts);
8556
8665
  const profile = opts.profile ?? "default";
8557
8666
  const value = opts.value ?? (await readStdin()).trim();
@@ -8569,7 +8678,7 @@ const varSet = new Command("set").description("Store an environment variable on
8569
8678
  meta("sensitive", String(opts.sensitive ?? false));
8570
8679
  info(`stored variable "${name}" on the hub`);
8571
8680
  }));
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) => {
8681
+ 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
8682
  const project = resolveProject(opts);
8574
8683
  const profile = opts.profile ?? "default";
8575
8684
  const variables = await connect(opts).listVariables(project, profile);
@@ -8583,7 +8692,7 @@ const varLs = new Command("ls").description("List variables stored on the hub fo
8583
8692
  meta(v.name, shown);
8584
8693
  }
8585
8694
  }));
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) => {
8695
+ 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
8696
  const project = resolveProject(opts);
8588
8697
  const profile = opts.profile ?? "default";
8589
8698
  await connect(opts).deleteVariable(project, profile, name);
@@ -8599,7 +8708,7 @@ function validatePromptName(rawName) {
8599
8708
  }
8600
8709
  return rawName;
8601
8710
  }
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) => {
8711
+ 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
8712
  const name = validatePromptName(rawName);
8604
8713
  const cwd = resolveCwd(opts.cwd);
8605
8714
  const project = resolveProject(opts);
@@ -8622,7 +8731,7 @@ const promptPush = new Command("push").description("Upload a locally-generated p
8622
8731
  meta("project", project);
8623
8732
  info(`uploaded prompt "${name}" to the hub`);
8624
8733
  }));
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) => {
8734
+ 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
8735
  const project = resolveProject(opts);
8627
8736
  const prompts = await connect(opts).listPrompts(project);
8628
8737
  header("hub prompts", project);
@@ -8632,7 +8741,7 @@ const promptLs = new Command("ls").description("List prompts stored on the hub f
8632
8741
  }
8633
8742
  for (const p of prompts) meta(p.name, `${p.kind}, updated ${p.updatedAt}`);
8634
8743
  }));
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) => {
8744
+ 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
8745
  const name = validatePromptName(rawName);
8637
8746
  const project = resolveProject(opts);
8638
8747
  await connect(opts).deletePrompt(project, name);
@@ -8720,9 +8829,9 @@ function describeSelection(selection, diffAvailable) {
8720
8829
  return `${values.filter((s) => s.verdict === "needed").length} needed / ${values.filter((s) => s.verdict === "unknown").length} unknown / ${values.length} specs`;
8721
8830
  }
8722
8831
  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) => {
8832
+ 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
8833
  const cwd = resolveCwd(opts.cwd);
8725
- const reportDir = join(cwd, opts.report ?? "ccqa-report");
8834
+ const reportDir = join(cwd, opts.reportDir ?? "ccqa-report");
8726
8835
  const project = resolveProject(opts);
8727
8836
  let report;
8728
8837
  try {
@@ -8754,7 +8863,7 @@ const pushCommand = new Command("push").description("Upload the report directory
8754
8863
  meta("specs", `${run.specs.passed}/${run.specs.total} passed`);
8755
8864
  info(`${resolveBaseUrl(opts)}/#/runs/${run.id}`);
8756
8865
  }));
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);
8866
+ 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
8867
  /** Loose check that a fetched session is agent-browser storage-state, mirroring loadStorageState. */
8759
8868
  function isStorageStateShape(state) {
8760
8869
  return typeof state === "object" && state !== null && Array.isArray(state.cookies) && Array.isArray(state.origins);
@@ -11929,14 +12038,14 @@ Write the new contents of \`${agentMdLabel}\`. Output ONLY the file contents —
11929
12038
  * so the run exit code is unaffected by this opt-in side step.
11930
12039
  */
11931
12040
  async function updateAgentPrompt(args) {
11932
- const { kind, runSummary, hubContext, model, language } = args;
12041
+ const { kind, flag, runSummary, hubContext, model, language } = args;
11933
12042
  const auth = driftAuthAvailable();
11934
12043
  if (!auth.ok) {
11935
- warn(`--update-agent-prompt skipped (${auth.reason})`);
12044
+ warn(`${flag} skipped (${auth.reason})`);
11936
12045
  return;
11937
12046
  }
11938
12047
  if (!hubContext) {
11939
- warn("--update-agent-prompt skipped (hub connection required; pass --hub-url/--hub-token or set CCQA_HUB_URL/CCQA_HUB_TOKEN)");
12048
+ warn(`${flag} skipped (hub connection required; pass --hub-url/--hub-token or set CCQA_HUB_URL/CCQA_HUB_TOKEN)`);
11940
12049
  return;
11941
12050
  }
11942
12051
  const { hub, project } = hubContext;
@@ -11950,7 +12059,7 @@ async function updateAgentPrompt(args) {
11950
12059
  };
11951
12060
  const systemPrompt = buildAgentUpdateSystemPrompt(promptInput);
11952
12061
  const userPrompt = buildAgentUpdateUserPrompt(promptInput);
11953
- info(`--update-agent-prompt: refreshing prompt "${promptName}" on the hub (project ${project})`);
12062
+ info(`${flag}: refreshing prompt "${promptName}" on the hub (project ${project})`);
11954
12063
  const { result, isError } = await invokeClaudeStreaming({
11955
12064
  prompt: userPrompt,
11956
12065
  systemPrompt,
@@ -11960,20 +12069,20 @@ async function updateAgentPrompt(args) {
11960
12069
  ...model ? { model } : {}
11961
12070
  }, () => {});
11962
12071
  if (isError || !result || result.trim().length === 0) {
11963
- warn(`--update-agent-prompt: Claude returned no usable output${isError ? " (SDK error)" : ""}; leaving prompt "${promptName}" unchanged`);
12072
+ warn(`${flag}: Claude returned no usable output${isError ? " (SDK error)" : ""}; leaving prompt "${promptName}" unchanged`);
11964
12073
  return;
11965
12074
  }
11966
12075
  if (result.trim() === "NO_UPDATE") {
11967
- info(`--update-agent-prompt: no new learnings from this run; prompt "${promptName}" left unchanged`);
12076
+ info(`${flag}: no new learnings from this run; prompt "${promptName}" left unchanged`);
11968
12077
  return;
11969
12078
  }
11970
12079
  const newText = stripCodeFences(result.trim()) + "\n";
11971
12080
  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");
12081
+ info(`${flag}: updated prompt "${promptName}" on the hub`);
12082
+ info(`${flag}: review it in the hub UI's Prompts tab`);
11974
12083
  } catch (err) {
11975
12084
  if (err instanceof HubApiError) {
11976
- warn(`--update-agent-prompt skipped (hub request failed: ${err.status} ${err.code}: ${err.message})`);
12085
+ warn(`${flag} skipped (hub request failed: ${err.status} ${err.code}: ${err.message})`);
11977
12086
  return;
11978
12087
  }
11979
12088
  throw err;
@@ -11992,7 +12101,7 @@ function stripCodeFences(text) {
11992
12101
  //#region src/cli/changed-specs.ts
11993
12102
  /**
11994
12103
  * Filter specs to those a range of commits reaches. Powers `ccqa run
11995
- * --changed <ref>`; `ccqa drift --changed` uses the same call.
12104
+ * --only-affected-by <ref>`; `ccqa audit` uses the same call.
11996
12105
  *
11997
12106
  * The decision is made by `ccqa select-specs`, which reads the diff against
11998
12107
  * what each spec actually does. That costs one model call, against saving the
@@ -12003,8 +12112,8 @@ function stripCodeFences(text) {
12003
12112
  * the safe reading of that is to run the spec.
12004
12113
  */
12005
12114
  async function collectChangedSpecs(specs, opts) {
12006
- const { cwd, base, model, quiet, baseExample } = opts;
12007
- const resolved = await resolveAnalysisBase(base, "--changed", cwd, baseExample);
12115
+ const { cwd, base, model, quiet, flagName } = opts;
12116
+ const resolved = await resolveAnalysisBase(base, flagName ?? "--only-affected-by", cwd);
12008
12117
  const meta$2 = (key, value) => {
12009
12118
  if (!quiet) meta(key, value);
12010
12119
  };
@@ -12055,12 +12164,10 @@ async function resolveVitestConfig(cwd) {
12055
12164
  }
12056
12165
  /**
12057
12166
  * 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".
12167
+ * written, so `--report-dir` only picks *where* it lands.
12061
12168
  */
12062
- function resolveReportDir(report, cwd) {
12063
- return resolve(cwd, typeof report === "string" ? report : DEFAULT_REPORT_DIR);
12169
+ function resolveReportDir(reportDir, cwd) {
12170
+ return resolve(cwd, reportDir ?? "ccqa-report");
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.onlyStale || opts.onlyAuditedClean);
12193
+ if (filtering && targets.length > 0) throw new RunUsageError("a --only-* filter and an explicit spec target cannot be combined");
12194
+ const rerunProfile = opts.onlyStale === true ? requireRerunProfile(opts.profile) : null;
12195
+ if (opts.onlyStaleWithUnknown && rerunProfile === null) warn("--only-stale-with-unknown is ignored: it only applies to --only-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 ? {
@@ -12140,15 +12248,17 @@ 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-stale requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)");
12254
+ if (opts.onlyAuditedClean && hubCtx == null) throw new RunUsageError("--only-audited-clean requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)");
12255
+ const [customPrompt, triageUserPrompt, ledgerEntries, rerunReport, fetchedDeployHead, auditedLedger] = await Promise.all([
12147
12256
  forExecution ? fetchCustomPrompt(hubCtx) : null,
12148
12257
  forExecution ? fetchTriageUserPrompt(hubCtx) : null,
12149
12258
  forExecution && ledgerHub ? fetchLastGreenLedger(ledgerHub, opts.profile, cwd) : null,
12150
12259
  rerunProfile !== null && hubCtx ? fetchRerunReport(hubCtx, rerunProfile) : null,
12151
- forExecution && hubCtx && opts.profile && rerunProfile === null ? tryDeployHeadSha(hubCtx, opts.profile) : null
12260
+ forExecution && hubCtx && opts.profile && rerunProfile === null ? tryDeployHeadSha(hubCtx, opts.profile) : null,
12261
+ opts.onlyAuditedClean && hubCtx ? fetchAuditedLedger(hubCtx) : null
12152
12262
  ]);
12153
12263
  const deployedSha = rerunReport?.deployHead.sha ?? fetchedDeployHead;
12154
12264
  if (ledgerEntries) diffProvider = createDiffProvider({
@@ -12156,7 +12266,7 @@ async function executeRun(targets, opts) {
12156
12266
  cwd
12157
12267
  });
12158
12268
  const triageUserPromptHash = triageUserPrompt ? hashTriageUserPrompt(triageUserPrompt) : null;
12159
- const reportDir = resolveReportDir(opts.report, cwd);
12269
+ const reportDir = resolveReportDir(opts.reportDir, cwd);
12160
12270
  const analysisDeps = {
12161
12271
  diffProvider,
12162
12272
  auth: diffProvider ? driftAuthAvailable() : {
@@ -12172,22 +12282,28 @@ async function executeRun(targets, opts) {
12172
12282
  };
12173
12283
  const enumerateAll = () => listAllSpecsWithSpecFile(cwd);
12174
12284
  let specs = dedupeSpecs((await Promise.all((targets.length ? targets : [void 0]).map((t) => resolveSpecTargets(t, enumerateAll, cwd)))).flat());
12175
- if (opts.changed) {
12285
+ if (filtering) {
12176
12286
  const before = specs.length;
12177
12287
  let unanswerable = 0;
12178
12288
  if (rerunReport) {
12179
- const selection = selectSpecsNeedingRerun(specs, rerunReport, { includeUnknown: opts.includeUnknown === true });
12289
+ const selection = selectSpecsNeedingRerun(specs, rerunReport, { includeUnknown: opts.onlyStaleWithUnknown === true });
12180
12290
  specs = selection.selected;
12181
12291
  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, {
12292
+ meta("stale-base", `deploy ${rerunReport.deployHead.sha.slice(0, 12)} (profile ${rerunReport.profile})`);
12293
+ meta("stale-states", selection.summary);
12294
+ }
12295
+ if (auditedLedger) {
12296
+ const picked = selectAuditedClean(specs, auditedLedger);
12297
+ specs = picked.selected;
12298
+ meta("audit-states", `${picked.selected.length} clean, ${picked.drifted} drifted, ${picked.unaudited} never audited`);
12299
+ }
12300
+ if (opts.onlyAffectedBy) specs = (await collectChangedSpecs(specs, {
12185
12301
  cwd,
12186
- base: opts.changed,
12302
+ base: opts.onlyAffectedBy,
12187
12303
  ...opts.model ? { model: opts.model } : {}
12188
12304
  })).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`);
12305
+ meta("selected", `${specs.length} of ${before} spec${before === 1 ? "" : "s"}`);
12306
+ 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-stale-with-unknown to run them anyway`);
12191
12307
  }
12192
12308
  if (specs.length === 0) {
12193
12309
  warn("no specs to run");
@@ -12210,11 +12326,11 @@ async function executeRun(targets, opts) {
12210
12326
  if (dispatch.external.length > 0) meta("external", dispatch.external.map((g) => `${g.targetId} ${g.specs.length}`).join(" / "));
12211
12327
  if (liveSpecs.length === 0) {
12212
12328
  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");
12329
+ if (typeof opts.liveStepRetry === "number" && opts.liveStepRetry > 0) warn(`--live-step-retry is ignored: ${why}`);
12330
+ if (opts.liveArtifactsDir) warn(`--live-artifacts-dir is ignored: ${why}`);
12331
+ if (opts.learnLivePrompt) warn(`--learn-live-prompt is ignored: ${why}`);
12332
+ } else if (opts.liveArtifactsDir && liveSpecs.length > 1) warn("--out is ignored when running multiple live specs");
12333
+ 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
12334
  blank();
12219
12335
  if (opts.dryRun) {
12220
12336
  for (const line of formatDryRunLines(withMode, dispatch)) emitRaw(line + "\n");
@@ -12227,10 +12343,10 @@ async function executeRun(targets, opts) {
12227
12343
  };
12228
12344
  }
12229
12345
  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");
12346
+ if (opts.reportToHub && hubCtx == null) warn("--push-report requires --hub-url/--hub-token (or CCQA_HUB_URL/CCQA_HUB_TOKEN); skipping push");
12231
12347
  let hubRunId = null;
12232
12348
  let hubSink;
12233
- if (hubCtx != null && opts.pushReport) try {
12349
+ if (hubCtx != null && opts.reportToHub) try {
12234
12350
  const branch = await detectBranch(cwd);
12235
12351
  const ciRunId = githubRunId();
12236
12352
  const runUrl = githubRunUrl();
@@ -12293,10 +12409,10 @@ async function executeRun(targets, opts) {
12293
12409
  const live = await runLiveSpecs(liveSpecs, {
12294
12410
  ...opts.model ? { model: opts.model } : {},
12295
12411
  ...opts.language ? { language: opts.language } : {},
12296
- ...opts.out && liveSpecs.length === 1 ? { out: opts.out } : {},
12412
+ ...opts.liveArtifactsDir && liveSpecs.length === 1 ? { out: opts.liveArtifactsDir } : {},
12297
12413
  cwd,
12298
12414
  reportDir,
12299
- ...typeof opts.retry === "number" ? { retry: opts.retry } : {},
12415
+ ...typeof opts.liveStepRetry === "number" ? { retry: opts.liveStepRetry } : {},
12300
12416
  concurrency: opts.concurrency ?? 1,
12301
12417
  ...opts.profile ? { profile: opts.profile } : {},
12302
12418
  diffProvider,
@@ -12360,10 +12476,11 @@ async function executeRun(targets, opts) {
12360
12476
  }
12361
12477
  }
12362
12478
  }
12363
- if (opts.updateAgentPrompt && liveSpecs.length > 0) {
12479
+ if (opts.learnLivePrompt && liveSpecs.length > 0) {
12364
12480
  blank();
12365
12481
  await updateAgentPrompt({
12366
12482
  kind: "live",
12483
+ flag: "--learn-live-prompt",
12367
12484
  runSummary: buildLiveRunSummary(live.reportResults),
12368
12485
  hubContext: hubCtx,
12369
12486
  ...opts.model ? { model: opts.model } : {},
@@ -12449,8 +12566,8 @@ async function runDeterministicSpecs(specs, opts, cwd, reportDirAbs) {
12449
12566
  };
12450
12567
  const tmpDir = await mkdtemp(join(tmpdir(), "ccqa-run-"));
12451
12568
  const vitestConfig = await resolveVitestConfig(cwd);
12452
- const captureOutput = opts.failureAnalysis !== false;
12453
- const captureEvidence = opts.evidence !== false;
12569
+ const captureOutput = true;
12570
+ const captureEvidence = opts.replaySkipEvidence !== true;
12454
12571
  const concurrency = Math.max(1, opts.concurrency ?? 1);
12455
12572
  const ctx = {
12456
12573
  cwd,
@@ -12658,7 +12775,7 @@ async function writeUnifiedReport(args) {
12658
12775
  const jsonPath = join(reportDir, "report.json");
12659
12776
  await writeFile(jsonPath, JSON.stringify(data, null, 2) + "\n", "utf8");
12660
12777
  info(`run report (json) written to ${jsonPath}`);
12661
- if (opts.format === "github") for (const line of emitGithubAnnotations(data)) emitRaw(line + "\n");
12778
+ if (opts.reportFormat === "github") for (const line of emitGithubAnnotations(data)) emitRaw(line + "\n");
12662
12779
  return data;
12663
12780
  }
12664
12781
  /**
@@ -12901,14 +13018,14 @@ function installTeardownSignalHandlers(teardown) {
12901
13018
  }
12902
13019
  //#endregion
12903
13020
  //#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) => {
13021
+ 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-stale", "Only specs whose last result no longer holds — each spec's own last run compared against the hub's deploy log. No git diff involved. Requires a hub connection and --profile.").option("--only-stale-with-unknown", "With --only-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-audited-clean", "Only specs `ccqa audit` last found no drift in. 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
13022
  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}"`);
13023
+ 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
13024
  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) => {
13025
+ }, 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) => {
13026
+ if (REPORT_FORMATS.includes(raw)) return raw;
13027
+ throw new Error(`--report-format must be one of ${REPORT_FORMATS.join(" | ")}`);
13028
+ }, "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-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
13029
  await runCliAction(targets, opts);
12913
13030
  });
12914
13031
  /** Parse --concurrency: a positive integer. Rejects 0, negatives, non-integers. */
@@ -12920,12 +13037,16 @@ function parseConcurrency$1(raw) {
12920
13037
  }
12921
13038
  return n;
12922
13039
  }
12923
- /** Header label shown after `ccqa run`: the lone target, a count, or a mode marker. */
13040
+ /** Header label shown after `ccqa run`: the lone target, a count, or how they were selected. */
12924
13041
  function headerTarget(targets, opts) {
12925
13042
  if (targets.length === 1) return targets[0];
12926
13043
  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)";
13044
+ const filters = [
13045
+ opts.onlyAffectedBy ? "affected" : null,
13046
+ opts.onlyStale ? "stale" : null,
13047
+ opts.onlyAuditedClean ? "audited clean" : null
13048
+ ].filter((s) => s !== null);
13049
+ return filters.length === 0 ? "(all specs)" : `(${filters.join(" + ")})`;
12929
13050
  }
12930
13051
  /**
12931
13052
  * CLI entry point: calls the library pipeline and maps its result back to a
@@ -14425,8 +14546,8 @@ For each test case above, write a 1–2 sentence factual \`summary\` of what it
14425
14546
  }
14426
14547
  //#endregion
14427
14548
  //#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);
14549
+ 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) => {
14550
+ if (opts.verify) await runPerspectivesCheck(opts);
14430
14551
  else await runPerspectives(opts);
14431
14552
  }));
14432
14553
  /**
@@ -14549,7 +14670,7 @@ async function runPerspectives(opts) {
14549
14670
  info("--- proposed changes (YAML view of the hub document) ---");
14550
14671
  printUnifiedDiff(existingYaml, next);
14551
14672
  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] ")))) {
14673
+ if (!(opts.yes === true || /^y/i.test(await prompt(useJapanesePrompts(opts.language) ? "hub に perspectives を保存しますか? [y/N] " : "Push perspectives to the hub? [y/N] ")))) {
14553
14674
  info("aborted — no changes written.");
14554
14675
  return;
14555
14676
  }
@@ -14980,7 +15101,7 @@ async function runGenerateLocked(featureName, specName, opts, cwd) {
14980
15101
  }, cwd) ?? null;
14981
15102
  if (existingOutput && !opts.force) {
14982
15103
  if (!await confirmOverwrite(existingOutput)) {
14983
- info("aborted; pass --force to overwrite without prompting");
15104
+ info("aborted; pass --overwrite to replace it without prompting");
14984
15105
  return;
14985
15106
  }
14986
15107
  }
@@ -15035,6 +15156,7 @@ async function runGenerateAgentPromptUpdate(target, featureName, specName, resul
15035
15156
  blank();
15036
15157
  await updateAgentPrompt({
15037
15158
  kind: target.guidanceKind,
15159
+ flag: "--learn-codegen-prompt",
15038
15160
  runSummary: buildGenerateRunSummary(target.id, featureName, specName, result, cwd),
15039
15161
  hubContext: opts.hubContext ?? null,
15040
15162
  ...opts.model ? { model: opts.model } : {},
@@ -15060,7 +15182,7 @@ async function confirmOverwrite(path) {
15060
15182
  rl.close();
15061
15183
  }
15062
15184
  }
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) => {
15185
+ 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-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
15186
  const { featureName, specName } = parseSpecPath(specPath);
15065
15187
  const language = opts.language ?? "auto";
15066
15188
  const cwd = resolveCwd(opts.cwd);
@@ -15089,16 +15211,16 @@ const generateCommand = addHubOptions(addProfileOption(addLanguageOption(new Com
15089
15211
  } : null;
15090
15212
  try {
15091
15213
  await runGenerate(featureName, specName, {
15092
- maxRetries: parseInt(opts.maxRetries ?? "3", 10),
15214
+ maxRetries: parseInt(opts.autoFixMaxRetries ?? "3", 10),
15093
15215
  fixMode: toFixMode(opts.autoFix ?? "interactive"),
15094
- force: opts.force ?? false,
15095
- useSnapshot: opts.snapshot !== false,
15216
+ force: opts.overwrite ?? false,
15217
+ useSnapshot: opts.sessionPin !== false,
15096
15218
  language,
15097
15219
  model: opts.model,
15098
15220
  targetOverride: opts.target,
15099
15221
  cwd,
15100
15222
  hubContext,
15101
- updateAgentPrompt: opts.updateAgentPrompt ?? false
15223
+ updateAgentPrompt: opts.learnCodegenPrompt ?? false
15102
15224
  });
15103
15225
  } catch (e) {
15104
15226
  if (e instanceof SpecLockedError) {
@@ -15134,10 +15256,10 @@ function reportCost() {
15134
15256
  //#endregion
15135
15257
  //#region src/cli/record.ts
15136
15258
  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) => {
15259
+ 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
15260
  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) => {
15261
+ throw new Error(`--trace-validation must be one of ${VALIDATION_MODES.join(" | ")}`);
15262
+ }, "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-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
15263
  await withCostTally(async () => {
15142
15264
  try {
15143
15265
  await runRecord(specPath, opts);
@@ -15149,10 +15271,6 @@ const recordCommand = addHubOptions(addProfileOption(addLanguageOption(new Comma
15149
15271
  async function runRecord(specPath, opts) {
15150
15272
  const { featureName, specName } = parseSpecPath(specPath);
15151
15273
  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
15274
  const cwdForProfile = resolveCwd(opts.cwd);
15157
15275
  const spec = parseTestSpec(await readSpecFile(featureName, specName, cwdForProfile));
15158
15276
  const config = await loadProjectConfig(cwdForProfile);
@@ -15194,18 +15312,16 @@ async function runRecord(specPath, opts) {
15194
15312
  });
15195
15313
  let traceResult = null;
15196
15314
  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),
15315
+ traceResult = await runTrace(featureName, specName, opts.model, opts.traceValidation ?? "lenient", language, {
15316
+ cwd: cwdForProfile,
15317
+ hubContext
15318
+ });
15319
+ blank();
15320
+ if (!opts.traceOnly) await runGenerate(featureName, specName, {
15321
+ maxRetries: parseInt(opts.autoFixMaxRetries ?? "3", 10),
15206
15322
  fixMode: toFixMode(opts.autoFix ?? "interactive"),
15207
- force: opts.force ?? false,
15208
- useSnapshot: opts.snapshot !== false,
15323
+ force: opts.overwrite ?? false,
15324
+ useSnapshot: opts.sessionPin !== false,
15209
15325
  language,
15210
15326
  model: opts.model,
15211
15327
  cwd: cwdForProfile,
@@ -15214,11 +15330,11 @@ async function runRecord(specPath, opts) {
15214
15330
  } finally {
15215
15331
  await releaseLock();
15216
15332
  }
15217
- if (opts.updateAgentPrompt) if (traceResult === null) warn("--update-agent-prompt is ignored when --skip-trace is set (no run summary available)");
15218
- else {
15333
+ if (opts.learnTracePrompt && traceResult !== null) {
15219
15334
  blank();
15220
15335
  await updateAgentPrompt({
15221
15336
  kind: "record",
15337
+ flag: "--learn-trace-prompt",
15222
15338
  runSummary: buildRecordRunSummary(featureName, specName, traceResult),
15223
15339
  hubContext,
15224
15340
  ...opts.model ? { model: opts.model } : {},
@@ -15500,7 +15616,7 @@ function specStatus(result, threshold) {
15500
15616
  }
15501
15617
  /**
15502
15618
  * 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
15619
+ * be pushed to the hub (`ccqa audit --report-to-hub`) and rendered by the same report
15504
15620
  * UI as `ccqa run`/`ccqa live`. Browser-execution fields (testCounts,
15505
15621
  * evidence, liveRun, ...) don't apply to a drift audit and are always null —
15506
15622
  * which is why `mode` is carried separately: nothing ran, but which surfaces
@@ -15546,35 +15662,34 @@ function driftResultsToReport(results, meta) {
15546
15662
  };
15547
15663
  }
15548
15664
  //#endregion
15549
- //#region src/cli/drift.ts
15665
+ //#region src/cli/audit.ts
15550
15666
  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));
15667
+ 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-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) => {
15668
+ await withCostTally(() => runAudit(specPath, opts));
15553
15669
  }));
15554
- async function runDrift(specPath, opts) {
15555
- const format = parseFormat$1(opts.format);
15556
- const threshold = parseSeverity(opts.severity);
15670
+ async function runAudit(specPath, opts) {
15671
+ const format = parseFormat$1(opts.reportFormat);
15672
+ const threshold = parseSeverity(opts.exitOn);
15557
15673
  const concurrency = parseConcurrency(opts.concurrency);
15558
15674
  const cwd = resolveCwd(opts.cwd);
15559
15675
  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");
15676
+ if (opts.onlyAffectedBy && specPath) {
15677
+ error("--only-affected-by and an explicit spec id cannot be combined; it only applies to a full sweep");
15562
15678
  process.exit(2);
15563
15679
  }
15564
15680
  let targets = await collectTargets(specPath, cwd);
15565
15681
  if (targets.length === 0) exitWithNoSpecs(format, "no test specs found under .ccqa/features/");
15566
15682
  if (format === "text") {
15567
- header("drift", specPath ?? `${targets.length} spec${targets.length > 1 ? "s" : ""}`);
15683
+ header("audit", specPath ?? `${targets.length} spec${targets.length > 1 ? "s" : ""}`);
15568
15684
  if (opts.cwd) meta("cwd", cwd);
15569
15685
  }
15570
15686
  let baseRef = null;
15571
- if (opts.changed) {
15687
+ if (opts.onlyAffectedBy) {
15572
15688
  const total = targets.length;
15573
15689
  const selection = await collectChangedSpecs(targets, {
15574
15690
  cwd,
15575
- base: opts.base ?? true,
15691
+ base: opts.onlyAffectedBy,
15576
15692
  quiet: format !== "text",
15577
- baseExample: "--base origin/main",
15578
15693
  ...opts.model ? { model: opts.model } : {}
15579
15694
  });
15580
15695
  targets = selection.specs;
@@ -15595,7 +15710,7 @@ async function runDrift(specPath, opts) {
15595
15710
  }
15596
15711
  });
15597
15712
  process.stdout.write(renderDrift(results, format, cwd));
15598
- if (opts.push) await pushDriftResults({
15713
+ if (opts.reportToHub) await pushDriftResults({
15599
15714
  results,
15600
15715
  threshold,
15601
15716
  cwd,
@@ -15609,7 +15724,7 @@ async function runDrift(specPath, opts) {
15609
15724
  /**
15610
15725
  * Push a finished drift audit to a ccqa hub as a `kind: "drift"` run, so it
15611
15726
  * 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`
15727
+ * hub connection warns and returns rather than failing the command (`--report-to-hub`
15613
15728
  * never changes drift's own exit code).
15614
15729
  *
15615
15730
  * `resolveHub` is injectable so tests can supply a fake `HubClient` without
@@ -15619,7 +15734,7 @@ async function pushDriftResults(args, resolveHub = resolveHubClient) {
15619
15734
  const { results, threshold, cwd, opts, format, baseRef } = args;
15620
15735
  const hub = resolveHub(opts);
15621
15736
  if (!hub) {
15622
- warn("--push requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN) — skipping push");
15737
+ warn("--report-to-hub requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN) — skipping push");
15623
15738
  return;
15624
15739
  }
15625
15740
  try {
@@ -15805,102 +15920,6 @@ function parseFormat(raw) {
15805
15920
  process.exit(2);
15806
15921
  }
15807
15922
  //#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
15923
  //#region src/hub/api/respond.ts
15905
15924
  /** The message of an unknown throwable, for a log line or an error body. */
15906
15925
  function errMsg(err) {
@@ -23366,17 +23385,21 @@ function resolvePackageJson() {
23366
23385
  const { version } = JSON.parse(readFileSync(resolvePackageJson(), "utf8"));
23367
23386
  const program = new Command();
23368
23387
  program.name("ccqa").description("E2E test CLI powered by Claude Code — agent-browser by default, or Playwright / runn targets").version(version);
23388
+ program.commandsGroup("Write specs:");
23369
23389
  program.addCommand(initCommand);
23370
23390
  program.addCommand(draftCommand);
23371
23391
  program.addCommand(perspectivesCommand);
23392
+ program.commandsGroup("Build tests from them:");
23372
23393
  program.addCommand(recordCommand);
23373
23394
  program.addCommand(generateCommand);
23395
+ program.commandsGroup("Check them:");
23374
23396
  program.addCommand(runCommand);
23375
- program.addCommand(driftCommand);
23376
- program.addCommand(selectSpecsCommand);
23377
- program.addCommand(sessionCommand);
23378
- program.addCommand(serveCommand);
23397
+ program.addCommand(auditCommand);
23398
+ program.commandsGroup("Hub:");
23379
23399
  program.addCommand(hubCommand);
23400
+ program.addCommand(serveCommand);
23401
+ program.commandsGroup("Building blocks:");
23402
+ program.addCommand(selectSpecsCommand);
23380
23403
  program.parse();
23381
23404
  //#endregion
23382
23405
  export {};