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