ccqa 1.10.1 → 1.12.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 +97 -6
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -1327,6 +1327,61 @@ function missingNativeBinaryMessage(pkg) {
|
|
|
1327
1327
|
return `${pkg} is not installed. The Claude Agent SDK needs it to start Claude on this platform, so every Claude-backed command (run in live mode, drift, diagnose) will fail. It ships as an optional dependency of the SDK, which a lockfile can drop silently: reinstall without omitting optional dependencies, or add it to your project as a direct dependency pinned to the same version as @anthropic-ai/claude-agent-sdk.`;
|
|
1328
1328
|
}
|
|
1329
1329
|
//#endregion
|
|
1330
|
+
//#region src/claude/cost-tally.ts
|
|
1331
|
+
/**
|
|
1332
|
+
* Sum every Claude invocation made inside a scope.
|
|
1333
|
+
*
|
|
1334
|
+
* A command like `record` calls Claude several times — the browser trace, the
|
|
1335
|
+
* codegen cleanup, one diagnosis per auto-fix retry — and the caller wants one
|
|
1336
|
+
* number for the whole command. Threading a cost out of each of those return
|
|
1337
|
+
* types would touch every layer in between, so the tally is scoped instead:
|
|
1338
|
+
* `invokeClaudeStreaming` adds to whichever scope is active, and nothing
|
|
1339
|
+
* between the two has to know.
|
|
1340
|
+
*
|
|
1341
|
+
* Scoped rather than module-global because commands run specs concurrently
|
|
1342
|
+
* (`drift` uses a pool). Two scopes must not fold into each other.
|
|
1343
|
+
*/
|
|
1344
|
+
const tallyStore = new AsyncLocalStorage();
|
|
1345
|
+
/** Record one invocation against the active scope. No-op outside one. */
|
|
1346
|
+
function tallyInvocation(cost) {
|
|
1347
|
+
tallyStore.getStore()?.push(cost);
|
|
1348
|
+
}
|
|
1349
|
+
/** Run `fn` with a fresh tally. Read the total from inside with `readCostTally`. */
|
|
1350
|
+
async function withCostTally(fn) {
|
|
1351
|
+
return tallyStore.run([], fn);
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* The active scope's total so far, or null outside one.
|
|
1355
|
+
*
|
|
1356
|
+
* Read rather than pushed at the caller because commands end in
|
|
1357
|
+
* `process.exit`, which never reaches a `finally`. Each one reports the
|
|
1358
|
+
* total itself, at the point it knows it is done.
|
|
1359
|
+
*
|
|
1360
|
+
* Fields stay `null` when no invocation reported them, so a caller can tell
|
|
1361
|
+
* "nothing was billed" from "the SDK didn't say" (mock runs, SDK errors).
|
|
1362
|
+
*/
|
|
1363
|
+
function readCostTally() {
|
|
1364
|
+
const collected = tallyStore.getStore();
|
|
1365
|
+
return collected === void 0 ? null : sum(collected);
|
|
1366
|
+
}
|
|
1367
|
+
function sum(costs) {
|
|
1368
|
+
const add = (pick) => {
|
|
1369
|
+
const present = costs.map(pick).filter((v) => v !== null);
|
|
1370
|
+
return present.length === 0 ? null : present.reduce((a, b) => a + b, 0);
|
|
1371
|
+
};
|
|
1372
|
+
return {
|
|
1373
|
+
totalCostUsd: add((c) => c.totalCostUsd),
|
|
1374
|
+
durationMs: add((c) => c.durationMs),
|
|
1375
|
+
durationApiMs: add((c) => c.durationApiMs),
|
|
1376
|
+
numTurns: add((c) => c.numTurns),
|
|
1377
|
+
inputTokens: add((c) => c.inputTokens),
|
|
1378
|
+
cacheCreationInputTokens: add((c) => c.cacheCreationInputTokens),
|
|
1379
|
+
cacheReadInputTokens: add((c) => c.cacheReadInputTokens),
|
|
1380
|
+
outputTokens: add((c) => c.outputTokens),
|
|
1381
|
+
models: [...new Set(costs.flatMap((c) => c.models))]
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
//#endregion
|
|
1330
1385
|
//#region src/claude/invoke.ts
|
|
1331
1386
|
function resolveModel(explicit) {
|
|
1332
1387
|
if (explicit) return explicit;
|
|
@@ -1498,6 +1553,7 @@ async function invokeClaudeStreaming(options, onEvent) {
|
|
|
1498
1553
|
errorDetail = err instanceof Error ? err.message : String(err);
|
|
1499
1554
|
if (!result) result = errorDetail;
|
|
1500
1555
|
}
|
|
1556
|
+
tallyInvocation(cost);
|
|
1501
1557
|
return {
|
|
1502
1558
|
result,
|
|
1503
1559
|
isError,
|
|
@@ -13127,8 +13183,8 @@ For each step:
|
|
|
13127
13183
|
3. Execute the action using an ALLOWED selector (see Selector Rules), prefixing the command with \`CCQA_STEP=<step-id>\` like every agent-browser command in the step.
|
|
13128
13184
|
4. Emit \`AB_ACTION|...\` for every browser action (see AB_ACTION Protocol).
|
|
13129
13185
|
5. Run \`snapshot\` again to verify the outcome.
|
|
13130
|
-
6. Confirm at least **two independent signals** (URL change, element appearance, text change, ...).
|
|
13131
|
-
7. Record
|
|
13186
|
+
6. Confirm at least **two independent signals** (URL change, element appearance, text change, ...). This is how *you* decide the step worked and it is safe to continue. It is not what gets recorded.
|
|
13187
|
+
7. Record as assertions only the signals the step's own \`expected\` asks about, by putting a \`CCQA_ASSERT=<marker>\` prefix on the verification command itself (see Assertion Protocol). Only the assert types markers cannot express fall back to \`AB_ACTION|assert|...\` text lines.
|
|
13132
13188
|
8. Emit \`STEP_DONE\`, \`ASSERTION_FAILED\`, or \`STEP_SKIPPED\`.
|
|
13133
13189
|
|
|
13134
13190
|
**Protocol lines are recorded ONLY from your plain assistant text.** Every
|
|
@@ -13259,7 +13315,9 @@ CCQA_STEP=<step-id> CCQA_ASSERT=url_contains:/dashboard agent-browser --session
|
|
|
13259
13315
|
- A marked command that exits non-zero records nothing — fix the check and re-run it.
|
|
13260
13316
|
- A marker that doesn't match its command (e.g. \`CCQA_ASSERT=1\` on a \`click\`) is ignored with a warning — never do that.
|
|
13261
13317
|
- \`get count\` and \`get url\` exit 0 regardless of what they print. **Read the output**: if the printed count / URL contradicts the marker, the signal did NOT verify — treat it as a failed verification (emit \`ASSERTION_FAILED\` if the step cannot be confirmed another way).
|
|
13262
|
-
-
|
|
13318
|
+
- **Assert what the step asks about, nothing else.** The \`expected\` is the contract; anything else you happened to see on the way is not. A nav item, a heading or a greeting that the step never mentions adds no coverage, differs between recordings of the same spec, and is the first thing to break on replay — so the next recording quietly drops it and the test gets weaker without anyone deciding that.
|
|
13319
|
+
|
|
13320
|
+
- **\`url_contains\` is opt-in, not a habit.** The same rule, in the form that gets broken most. Emit it ONLY when a step's own \`expected\` explicitly asks about the URL or path. Do NOT add a \`url_contains\` to "prove" a login succeeded, a page loaded, or a navigation happened — confirm those with \`text_visible\` / \`element_visible\` on something the destination page renders. An unrequested URL assertion adds no coverage the visible-content assert doesn't already give, and is the single most common way an environment gets baked into a test.
|
|
13263
13321
|
- **When you do assert a URL, the substring may come from ONE place only:** a \`\${VAR}\` URL that *this step's own instruction* opened, written as that \`\${VAR}\` followed by the literal tail after it. If the step opens \`\${APP_URL}/policies\`, assert \`\${APP_URL}/policies\` (or the tail \`/policies\`); the recorder resolves \`\${APP_URL}\` per environment. Never assert on a URL you merely *observed* — login redirects, identity-provider pages, and OAuth callbacks all live on a **different, environment-named origin** than the app, so any substring of them (host, origin, OR path) names the environment.
|
|
13264
13322
|
- **A leading slash does NOT make a substring safe.** \`/auth-staging\`, \`/env-qa\`, \`/tenant-acme\` look like paths but are environment labels — the first segment of an identity-provider or tenant URL, not an application route. If a substring contains an environment name, a stage token (\`dev\`, \`stg\`, \`prod\`), a tenant/org name, or any fragment of a hostname, it is forbidden even with a leading slash. The only safe path substrings are stable *application* routes off the app's own origin (\`/dashboard\`, \`/policies/new\`), taken from a \`\${VAR}\` you opened — not from a redirect you watched.
|
|
13265
13323
|
|
|
@@ -15059,12 +15117,36 @@ const generateCommand = addHubOptions(addProfileOption(addLanguageOption(new Com
|
|
|
15059
15117
|
});
|
|
15060
15118
|
}));
|
|
15061
15119
|
//#endregion
|
|
15120
|
+
//#region src/cli/cost-line.ts
|
|
15121
|
+
/**
|
|
15122
|
+
* Write what this command spent on Claude to stderr.
|
|
15123
|
+
*
|
|
15124
|
+
* stderr rather than stdout because `drift --format json` and `select-specs`
|
|
15125
|
+
* put machine-readable output on stdout, and a cost line mixed into it breaks
|
|
15126
|
+
* the consumer. Cost is diagnostics about the command, not its output.
|
|
15127
|
+
*/
|
|
15128
|
+
function reportCost() {
|
|
15129
|
+
const cost = readCostTally();
|
|
15130
|
+
if (cost === null) return;
|
|
15131
|
+
const summary = formatLiveCost(cost, { compact: false });
|
|
15132
|
+
if (summary) process.stderr.write(`[cost] ${summary}\n`);
|
|
15133
|
+
}
|
|
15134
|
+
//#endregion
|
|
15062
15135
|
//#region src/cli/record.ts
|
|
15063
15136
|
const VALIDATION_MODES = ["lenient", "strict"];
|
|
15064
15137
|
const recordCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("record").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Record a test from a spec: run agent-browser to collect actions (trace), then compile them into runnable code via the spec's target (generate) — a vitest test.spec.ts for agent-browser, a @playwright/test spec for the playwright target. Recording-backed targets only; spec-input targets like runn have no trace step (use `ccqa generate`), and agent-browser live specs need no recording.").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--validation-mode <mode>", "Post-trace validation behaviour: 'lenient' (default) tags failing actions; 'strict' drops them.", (raw) => {
|
|
15065
15138
|
if (VALIDATION_MODES.includes(raw)) return raw;
|
|
15066
15139
|
throw new Error(`--validation-mode must be one of ${VALIDATION_MODES.join(" | ")}`);
|
|
15067
15140
|
}, "lenient").option("--auto-fix <mode>", "Auto-fix behaviour during script generation: 'interactive' (default, prompt y/N; declines on non-TTY), 'auto' (apply without prompt, for CI), 'skip' (agent-browser: apply only high-confidence fixes; external targets like playwright/runn: no fix pass at all).", parseAutoFixFlag, "interactive").option("--max-retries <n>", "Maximum number of auto-fix retries", "3").option("--force", "Overwrite an existing test.spec.ts without warning").option("--no-snapshot", "Don't pin AGENT_BROWSER_SESSION / capture page snapshots after a failure (debug toggle)").option("--skip-trace", "Skip the trace step and run codegen against an existing ir.json").option("--skip-codegen", "Run only the trace step (do not generate test.spec.ts)").option("--update-agent-prompt", "After the trace finishes, ask Claude to refresh the \"record.agent\" prompt on the hub from a summary of the run. Requires a hub connection.").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(withUsageErrors(async (specPath, opts) => {
|
|
15141
|
+
await withCostTally(async () => {
|
|
15142
|
+
try {
|
|
15143
|
+
await runRecord(specPath, opts);
|
|
15144
|
+
} finally {
|
|
15145
|
+
reportCost();
|
|
15146
|
+
}
|
|
15147
|
+
});
|
|
15148
|
+
}));
|
|
15149
|
+
async function runRecord(specPath, opts) {
|
|
15068
15150
|
const { featureName, specName } = parseSpecPath(specPath);
|
|
15069
15151
|
const language = opts.language ?? "auto";
|
|
15070
15152
|
if (opts.skipTrace && opts.skipCodegen) {
|
|
@@ -15151,7 +15233,7 @@ const recordCommand = addHubOptions(addProfileOption(addLanguageOption(new Comma
|
|
|
15151
15233
|
...language ? { language } : {},
|
|
15152
15234
|
...opts.model ? { model: opts.model } : {}
|
|
15153
15235
|
});
|
|
15154
|
-
}
|
|
15236
|
+
}
|
|
15155
15237
|
/**
|
|
15156
15238
|
* Compact summary of the trace pass for the record agent-prompt refresh.
|
|
15157
15239
|
* Steps are reconstructed from the trace's status-line protocol (STEP_START
|
|
@@ -15467,6 +15549,9 @@ function driftResultsToReport(results, meta) {
|
|
|
15467
15549
|
//#region src/cli/drift.ts
|
|
15468
15550
|
const DEFAULT_CONCURRENCY = 3;
|
|
15469
15551
|
const driftCommand = addLanguageOption(new Command("drift").argument("[feature/spec]", "Optional spec id. If omitted, every spec under .ccqa/features/ is checked.").description("Standalone spec ↔ codebase static audit. Use for PR checks where the browser isn't run. For run-time audit with a structured report, see `ccqa run --report`.").option("--format <fmt>", "Output format: text | json | github", "text").option("--severity <level>", "Exit non-zero on this severity or higher: warn | error", "error").option("--concurrency <n>", `Parallel spec checks (default: ${DEFAULT_CONCURRENCY})`).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--cwd <path>", "Working directory used as both the .ccqa root and the codebase Claude reads. Useful for monorepos. Defaults to process.cwd().").option("--changed", "Restrict drift checks to the specs a change reaches, decided by `ccqa select-specs` against --base (or, in CI, $GITHUB_BASE_REF). Costs one model call; specs it cannot decide are checked rather than skipped.").option("--base <ref>", "Base ref to diff against when --changed is set. Defaults to $GITHUB_BASE_REF (CI pull_request runs); required otherwise.").option("--push", "Push the drift result to a ccqa hub as a run (kind: drift).").option("--project <name>", "Logical project name for the pushed run. Defaults to the current directory's name.").option(...hubUrlOption).option(...hubTokenOption).option(...hubHeaderOption)).action(withUsageErrors(async (specPath, opts) => {
|
|
15552
|
+
await withCostTally(() => runDrift(specPath, opts));
|
|
15553
|
+
}));
|
|
15554
|
+
async function runDrift(specPath, opts) {
|
|
15470
15555
|
const format = parseFormat$1(opts.format);
|
|
15471
15556
|
const threshold = parseSeverity(opts.severity);
|
|
15472
15557
|
const concurrency = parseConcurrency(opts.concurrency);
|
|
@@ -15518,8 +15603,9 @@ const driftCommand = addLanguageOption(new Command("drift").argument("[feature/s
|
|
|
15518
15603
|
format,
|
|
15519
15604
|
baseRef
|
|
15520
15605
|
});
|
|
15606
|
+
reportCost();
|
|
15521
15607
|
process.exit(determineExitCode(results, threshold));
|
|
15522
|
-
}
|
|
15608
|
+
}
|
|
15523
15609
|
/**
|
|
15524
15610
|
* Push a finished drift audit to a ccqa hub as a `kind: "drift"` run, so it
|
|
15525
15611
|
* shows up alongside `ccqa run` runs in the hub UI. Best-effort: a missing
|
|
@@ -15574,6 +15660,7 @@ async function pushDriftResults(args, resolveHub = resolveHubClient) {
|
|
|
15574
15660
|
}
|
|
15575
15661
|
}
|
|
15576
15662
|
function exitWithNoSpecs(format, message) {
|
|
15663
|
+
reportCost();
|
|
15577
15664
|
if (format === "json") process.stdout.write(`${JSON.stringify({ specs: [] }, null, 2)}\n`);
|
|
15578
15665
|
else if (format === "text") info(message);
|
|
15579
15666
|
process.exit(0);
|
|
@@ -15639,6 +15726,9 @@ const initCommand = new Command("init").description("Create the .ccqa/ spec skel
|
|
|
15639
15726
|
//#region src/cli/select-specs.ts
|
|
15640
15727
|
const DEFAULT_HEAD = "HEAD";
|
|
15641
15728
|
const selectSpecsCommand = new Command("select-specs").description("Decide which specs a range of commits reaches. Reads the diff and the spec inventory and returns one verdict per spec: needed | notNeeded | unknown.").requiredOption("--base <ref>", "Commit the range starts at — typically what is currently deployed, or the previous commit on the branch.").option("--head <ref>", `Commit the range ends at (default: ${DEFAULT_HEAD})`).option("--cwd <path>", "Working directory used as both the .ccqa root and the codebase Claude reads. Changes outside it are reported but never attributed to a spec. Defaults to process.cwd().").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--format <fmt>", "Output format: text | json", "text").action(async (opts) => {
|
|
15729
|
+
await withCostTally(() => runSelectSpecs(opts));
|
|
15730
|
+
});
|
|
15731
|
+
async function runSelectSpecs(opts) {
|
|
15642
15732
|
const format = parseFormat(opts.format);
|
|
15643
15733
|
const cwd = resolveCwd(opts.cwd);
|
|
15644
15734
|
const head = opts.head ?? DEFAULT_HEAD;
|
|
@@ -15684,8 +15774,9 @@ const selectSpecsCommand = new Command("select-specs").description("Decide which
|
|
|
15684
15774
|
...opts.model ? { model: opts.model } : {}
|
|
15685
15775
|
});
|
|
15686
15776
|
process.stdout.write(format === "json" ? `${JSON.stringify(report, null, 2)}\n` : renderText(report));
|
|
15777
|
+
reportCost();
|
|
15687
15778
|
process.exit(0);
|
|
15688
|
-
}
|
|
15779
|
+
}
|
|
15689
15780
|
const VERDICT_ORDER = [
|
|
15690
15781
|
"needed",
|
|
15691
15782
|
"unknown",
|
package/dist/package.json
CHANGED