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/README.md +25 -25
- package/dist/bin/ccqa.mjs +390 -358
- package/dist/hub-client/index.d.mts +4 -4
- package/dist/package.json +1 -1
- package/package.json +1 -1
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 `
|
|
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
|
-
*
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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 --
|
|
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
|
-
* --
|
|
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 `--
|
|
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/
|
|
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
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
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
|
-
|
|
4010
|
-
|
|
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("--
|
|
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.
|
|
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.
|
|
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-
|
|
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 `--
|
|
6103
|
-
const ANALYSIS_DISABLED = "skipped: --
|
|
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
|
-
*
|
|
6538
|
-
*
|
|
6539
|
-
*
|
|
6540
|
-
*
|
|
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
|
|
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
|
|
6559
|
-
*
|
|
6560
|
-
*
|
|
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(
|
|
6572
|
-
|
|
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
|
|
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: `--
|
|
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(`--
|
|
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 `--
|
|
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 `--
|
|
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
|
-
* `--
|
|
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 --
|
|
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 `--
|
|
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(
|
|
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(`--
|
|
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(`--
|
|
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 `--
|
|
6824
|
-
return `--
|
|
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
|
-
* `--
|
|
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 --
|
|
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
|
|
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
|
|
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
|
|
8477
|
-
const projectOption
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 --
|
|
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 --
|
|
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
|
|
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
|
|
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 --
|
|
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 --
|
|
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.
|
|
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
|
|
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 --
|
|
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
|
|
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
|
|
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
|
|
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 `--
|
|
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 `--
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
11973
|
-
info(
|
|
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(
|
|
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
|
-
* --
|
|
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,
|
|
12007
|
-
const resolved = await resolveAnalysisBase(base, "--
|
|
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
|
|
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(
|
|
12063
|
-
return resolve(cwd,
|
|
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
|
-
|
|
12086
|
-
|
|
12087
|
-
|
|
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.
|
|
12091
|
-
const [head, fixedBase] = await Promise.all([getGitHead(cwd), forExecution && opts.
|
|
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.
|
|
12113
|
-
profile: opts.
|
|
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.
|
|
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(
|
|
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(
|
|
12146
|
-
|
|
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.
|
|
12260
|
+
forExecution && ledgerHub ? fetchLastGreenLedger(ledgerHub, opts.hubProfile, cwd) : null,
|
|
12150
12261
|
rerunProfile !== null && hubCtx ? fetchRerunReport(hubCtx, rerunProfile) : null,
|
|
12151
|
-
forExecution && hubCtx && opts.
|
|
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.
|
|
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 (
|
|
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.
|
|
12291
|
+
const selection = selectSpecsNeedingRerun(specs, rerunReport, { includeUnknown: opts.onlyHubStaleWithUnknown === true });
|
|
12180
12292
|
specs = selection.selected;
|
|
12181
12293
|
unanswerable = selection.excludedUnanswerable;
|
|
12182
|
-
meta("
|
|
12183
|
-
meta("
|
|
12184
|
-
}
|
|
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.
|
|
12304
|
+
base: opts.onlyAffectedBy,
|
|
12187
12305
|
...opts.model ? { model: opts.model } : {}
|
|
12188
12306
|
})).specs;
|
|
12189
|
-
meta("
|
|
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 --
|
|
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.
|
|
12214
|
-
if (opts.
|
|
12215
|
-
if (opts.
|
|
12216
|
-
} else if (opts.
|
|
12217
|
-
if (detSpecs.length === 0 && opts.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
12413
|
+
...opts.liveArtifactsDir && liveSpecs.length === 1 ? { out: opts.liveArtifactsDir } : {},
|
|
12297
12414
|
cwd,
|
|
12298
12415
|
reportDir,
|
|
12299
|
-
...typeof opts.
|
|
12416
|
+
...typeof opts.liveStepRetry === "number" ? { retry: opts.liveStepRetry } : {},
|
|
12300
12417
|
concurrency: opts.concurrency ?? 1,
|
|
12301
|
-
...opts.
|
|
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.
|
|
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 =
|
|
12453
|
-
const captureEvidence = opts.
|
|
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.
|
|
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 `--
|
|
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 --
|
|
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("--
|
|
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
|
|
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
|
-
|
|
12928
|
-
|
|
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("--
|
|
14429
|
-
if (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.
|
|
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 --
|
|
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 --
|
|
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
|
-
* --
|
|
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(`--
|
|
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("--
|
|
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.
|
|
15073
|
-
if (opts.
|
|
15074
|
-
profile: opts.
|
|
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.
|
|
15219
|
+
maxRetries: parseInt(opts.autoFixMaxRetries ?? "3", 10),
|
|
15093
15220
|
fixMode: toFixMode(opts.autoFix ?? "interactive"),
|
|
15094
|
-
force: opts.
|
|
15095
|
-
useSnapshot: opts.
|
|
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.
|
|
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
|
|
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
|
|
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("--
|
|
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.
|
|
15165
|
-
if (opts.
|
|
15166
|
-
profile: opts.
|
|
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
|
-
|
|
15198
|
-
|
|
15199
|
-
|
|
15200
|
-
|
|
15201
|
-
|
|
15202
|
-
|
|
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.
|
|
15208
|
-
useSnapshot: opts.
|
|
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.
|
|
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
|
|
15503
|
-
* be pushed to the hub (`ccqa
|
|
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/
|
|
15674
|
+
//#region src/cli/audit.ts
|
|
15550
15675
|
const DEFAULT_CONCURRENCY = 3;
|
|
15551
|
-
const
|
|
15552
|
-
await withCostTally(() =>
|
|
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
|
|
15555
|
-
const format = parseFormat$1(opts.
|
|
15556
|
-
const threshold = parseSeverity(opts.
|
|
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.
|
|
15561
|
-
error("--
|
|
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("
|
|
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.
|
|
15696
|
+
if (opts.onlyAffectedBy) {
|
|
15572
15697
|
const total = targets.length;
|
|
15573
15698
|
const selection = await collectChangedSpecs(targets, {
|
|
15574
15699
|
cwd,
|
|
15575
|
-
base: opts.
|
|
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.
|
|
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.
|
|
15612
|
-
*
|
|
15613
|
-
*
|
|
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
|
-
|
|
15623
|
-
|
|
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 --
|
|
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 `--
|
|
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
|
|
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
|
|
18040
|
+
<div class="cmd"><code id="session-help-cmd">ccqa hub session capture <name></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/<profile>/<name>.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 --
|
|
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
|
-
// --
|
|
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(
|
|
23376
|
-
program.
|
|
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 {};
|