ccqa 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/ccqa.mjs CHANGED
@@ -11,10 +11,10 @@ import { basename, dirname, isAbsolute, join, normalize, posix, relative, resolv
11
11
  import { parse, stringify } from "yaml";
12
12
  import { ZodError, z } from "zod";
13
13
  import { execFile, spawn, spawnSync } from "node:child_process";
14
- import { query } from "@anthropic-ai/claude-agent-sdk";
14
+ import { createSdkMcpServer, query, tool } from "@anthropic-ai/claude-agent-sdk";
15
15
  import { AsyncLocalStorage } from "node:async_hooks";
16
- import { promisify } from "node:util";
17
16
  import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
17
+ import { promisify } from "node:util";
18
18
  import { gunzipSync, gzipSync } from "node:zlib";
19
19
  import { createInterface } from "node:readline";
20
20
  import { createInterface as createInterface$1 } from "node:readline/promises";
@@ -1287,7 +1287,7 @@ function warnOnceIfNativeBinaryMissing() {
1287
1287
  if (missing) warn(missingNativeBinaryMessage(missing));
1288
1288
  }
1289
1289
  async function invokeClaudeStreaming(options, onEvent) {
1290
- const { prompt, systemPrompt, allowedTools, disableBuiltinTools = false, disableThinking = false, maxTurns, env, model, cwd, onAbAction, onAbActionFailed, silenceBashLog = false, relaxAbConstraints = false } = options;
1290
+ const { prompt, systemPrompt, allowedTools, disableBuiltinTools = false, disableThinking = false, mcpServers, maxTurns, env, model, cwd, onAbAction, onAbActionFailed, silenceBashLog = false, relaxAbConstraints = false } = options;
1291
1291
  const resolvedModel = resolveModel(model);
1292
1292
  const hasEndpointEnv = Object.keys(resolveEndpointEnv()).length > 0;
1293
1293
  const mergedEnv = env || hasEndpointEnv ? {
@@ -1309,6 +1309,7 @@ async function invokeClaudeStreaming(options, onEvent) {
1309
1309
  ...resolvedModel ? { model: resolvedModel } : {},
1310
1310
  ...cwd ? { cwd } : {},
1311
1311
  ...mergedEnv ? { env: mergedEnv } : {},
1312
+ ...mcpServers ? { mcpServers } : {},
1312
1313
  ...disableBuiltinTools ? { tools: [] } : {},
1313
1314
  ...disableThinking ? { thinking: { type: "disabled" } } : {},
1314
1315
  hooks: onAbAction || onAbActionFailed ? {
@@ -2284,171 +2285,6 @@ async function checkSpec(target, opts) {
2284
2285
  };
2285
2286
  }
2286
2287
  //#endregion
2287
- //#region src/drift/affected.ts
2288
- const execFileP = promisify(execFile);
2289
- /**
2290
- * Resolve the base ref to diff against for `ccqa drift --changed`.
2291
- * Precedence: explicit override > GITHUB_BASE_REF > origin/main.
2292
- */
2293
- function resolveBaseRef(explicit) {
2294
- if (explicit && explicit.length > 0) return explicit;
2295
- const ghBase = process.env["GITHUB_BASE_REF"];
2296
- if (ghBase && ghBase.length > 0) return ghBase.startsWith("origin/") ? ghBase : `origin/${ghBase}`;
2297
- return "origin/main";
2298
- }
2299
- /**
2300
- * Run `git diff --name-status base...HEAD` from `cwd` and return one entry per
2301
- * changed file. Renames are reported under their NEW path with status
2302
- * "renamed" — the OLD path is dropped because the spec mapping is against the
2303
- * post-rename layout.
2304
- *
2305
- * Paths are re-rooted to be relative to `cwd`, not the git repo root. In a
2306
- * monorepo where `cwd` is a sub-package (e.g. `apps/foo`), git emits paths
2307
- * relative to the repo root, but specs declare relatedPaths relative to
2308
- * their own package. Changes outside `cwd` are kept under their repo-root
2309
- * path and flagged `outsideCwd` — they only scope a spec in when the spec
2310
- * explicitly declares a repo-root-relative glob, so an unrelated PR can
2311
- * never accidentally match the app-relative globs.
2312
- */
2313
- async function getChangedFiles(base, cwd) {
2314
- const [{ stdout: rootOut }, { stdout: diffOut }] = await Promise.all([execFileP("git", ["rev-parse", "--show-toplevel"], { cwd }), execFileP("git", [
2315
- "diff",
2316
- "--name-status",
2317
- "-M",
2318
- `${base}...HEAD`
2319
- ], {
2320
- cwd,
2321
- maxBuffer: 32 * 1024 * 1024
2322
- })]);
2323
- return rerootChangedFiles(parseGitDiffOutput(diffOut), rootOut.trim(), cwd);
2324
- }
2325
- /**
2326
- * Convert paths in `entries` from git-repo-root relative to `cwd` relative.
2327
- * Entries outside `cwd` keep their repo-root path and are flagged
2328
- * `outsideCwd`. Exported for unit tests.
2329
- */
2330
- function rerootChangedFiles(entries, repoRoot, cwd) {
2331
- const prefix = relative(repoRoot, cwd);
2332
- if (!prefix) return entries;
2333
- const out = [];
2334
- for (const e of entries) {
2335
- const rel = relative(prefix, e.path);
2336
- if (rel.startsWith("..") || rel === "") out.push({
2337
- ...e,
2338
- outsideCwd: true
2339
- });
2340
- else out.push({
2341
- ...e,
2342
- path: rel
2343
- });
2344
- }
2345
- return out;
2346
- }
2347
- function parseGitDiffOutput(stdout) {
2348
- const out = [];
2349
- for (const line of stdout.split("\n")) {
2350
- if (!line.trim()) continue;
2351
- const parts = line.split(" ");
2352
- const code = parts[0];
2353
- if (!code) continue;
2354
- if (code.startsWith("R")) {
2355
- const newPath = parts[2];
2356
- if (newPath) out.push({
2357
- path: newPath,
2358
- status: "renamed"
2359
- });
2360
- continue;
2361
- }
2362
- if (code.startsWith("C")) {
2363
- const newPath = parts[2];
2364
- if (newPath) out.push({
2365
- path: newPath,
2366
- status: "added"
2367
- });
2368
- continue;
2369
- }
2370
- const path = parts[1];
2371
- if (!path) continue;
2372
- switch (code[0]) {
2373
- case "A":
2374
- out.push({
2375
- path,
2376
- status: "added"
2377
- });
2378
- break;
2379
- case "M":
2380
- case "T":
2381
- out.push({
2382
- path,
2383
- status: "modified"
2384
- });
2385
- break;
2386
- case "D":
2387
- out.push({
2388
- path,
2389
- status: "deleted"
2390
- });
2391
- break;
2392
- default: out.push({
2393
- path,
2394
- status: "modified"
2395
- });
2396
- }
2397
- }
2398
- return out;
2399
- }
2400
- function stripLeadingDotSlash(s) {
2401
- return s.startsWith("./") ? s.slice(2) : s;
2402
- }
2403
- const REGEX_CACHE = /* @__PURE__ */ new Map();
2404
- /** Compiles `pattern` to a RegExp, memoized so repeated `--changed` matches don't re-build. */
2405
- function compileGlob(pattern) {
2406
- const cached = REGEX_CACHE.get(pattern);
2407
- if (cached) return cached;
2408
- const compiled = globToRegExp(stripLeadingDotSlash(pattern));
2409
- REGEX_CACHE.set(pattern, compiled);
2410
- return compiled;
2411
- }
2412
- function globToRegExp(pattern) {
2413
- let re = "^";
2414
- let i = 0;
2415
- while (i < pattern.length) {
2416
- const ch = pattern[i];
2417
- if (ch === "?") {
2418
- re += "[^/]";
2419
- i++;
2420
- continue;
2421
- }
2422
- if (ch !== "*") {
2423
- re += /[.+^${}()|[\]\\]/.test(ch) ? "\\" + ch : ch;
2424
- i++;
2425
- continue;
2426
- }
2427
- if (pattern[i + 1] !== "*") {
2428
- re += "[^/]*";
2429
- i++;
2430
- continue;
2431
- }
2432
- const hasLeadingSlash = re.endsWith("/");
2433
- const hasTrailingSlash = pattern[i + 2] === "/";
2434
- if (hasLeadingSlash) re = re.slice(0, -1);
2435
- if (hasLeadingSlash || hasTrailingSlash) re += "(?:/?.*)?";
2436
- else re += ".*";
2437
- i += hasTrailingSlash ? 3 : 2;
2438
- }
2439
- return new RegExp(re + "$");
2440
- }
2441
- /**
2442
- * Returns true if `changedPath` is covered by any of `relatedPaths`. An empty
2443
- * `relatedPaths` returns false — callers handle the "unscoped spec" case
2444
- * separately (treat the spec as always-affected) before calling this.
2445
- */
2446
- function isPathAffectedBy(changedPath, relatedPaths) {
2447
- const stripped = stripLeadingDotSlash(changedPath);
2448
- for (const pattern of relatedPaths) if (compileGlob(pattern).test(stripped)) return true;
2449
- return false;
2450
- }
2451
- //#endregion
2452
2288
  //#region src/drift/auth.ts
2453
2289
  /**
2454
2290
  * Probe whether the host has any credential the Anthropic SDK can pick up:
@@ -3153,6 +2989,10 @@ const ReportSpecResultSchema = z.object({
3153
2989
  assertions: z.array(ReportAssertionSchema).nullable(),
3154
2990
  analysis: FailureAnalysisSchema.nullable(),
3155
2991
  analysisSkipped: z.string().nullable(),
2992
+ analysisBase: z.object({
2993
+ ref: z.string(),
2994
+ sha: z.string()
2995
+ }).nullable().optional(),
3156
2996
  driftIssues: z.array(DraftIssueSchema).nullable(),
3157
2997
  failureLogExcerpt: z.string().nullable(),
3158
2998
  diffExcerpt: z.string().nullable(),
@@ -3161,15 +3001,33 @@ const ReportSpecResultSchema = z.object({
3161
3001
  artifacts: z.array(ReportArtifactSchema).optional(),
3162
3002
  liveRun: LiveReportRunSchema.nullable()
3163
3003
  });
3004
+ /**
3005
+ * Which rule produced the analysis baseline: "explicit" (a value was
3006
+ * passed), "github-base-ref" (derived from a pull_request event), or
3007
+ * "last-green" (per-spec baselines from the hub ledger — `baseSha` is then
3008
+ * null and each analyzed row carries its own `analysisBase`). Lets accuracy
3009
+ * numbers be stratified by baseline provenance. Single source of truth —
3010
+ * `src/run/git-context.ts`'s `BaseSource` type and the hub's PATCH schema
3011
+ * both derive from this.
3012
+ */
3013
+ const BaseSourceSchema = z.enum([
3014
+ "explicit",
3015
+ "github-base-ref",
3016
+ "last-green"
3017
+ ]);
3018
+ /** The report envelope's git block; also referenced by the hub's PATCH reportMeta schema. */
3019
+ const GitEnvelopeSchema = z.object({
3020
+ head: z.string().nullable(),
3021
+ base: z.string().nullable(),
3022
+ baseSha: z.string().nullable().optional(),
3023
+ baseSource: BaseSourceSchema.nullable().optional()
3024
+ });
3164
3025
  const RunReportDataSchema = z.object({
3165
3026
  schemaVersion: z.literal(1),
3166
3027
  kind: z.enum(["run", "drift"]).default("run"),
3167
3028
  createdAt: z.string(),
3168
3029
  runId: z.string().nullable(),
3169
- git: z.object({
3170
- head: z.string().nullable(),
3171
- base: z.string().nullable()
3172
- }),
3030
+ git: GitEnvelopeSchema,
3173
3031
  model: z.string().nullable(),
3174
3032
  language: z.string().nullable().default(null),
3175
3033
  promptVersion: z.string(),
@@ -3294,6 +3152,14 @@ function hashTriageUserPrompt(text) {
3294
3152
  }
3295
3153
  //#endregion
3296
3154
  //#region src/report/prompt.ts
3155
+ /**
3156
+ * Fully-qualified name of the on-demand file-diff tool, as the model calls
3157
+ * it. Lives here (not analyze.ts) because the prompt text below references
3158
+ * it — one source of truth. The name is the SDK's `mcp__<server>__<tool>`
3159
+ * composition of the server ("diff") and tool ("changed_file_diff") that
3160
+ * analyze.ts registers; changing either side must keep the two in sync.
3161
+ */
3162
+ const CHANGED_FILE_DIFF_TOOL = "mcp__diff__changed_file_diff";
3297
3163
  function buildFailureAnalysisPrompt(input) {
3298
3164
  const { script, specYaml, failureLog, liveTranscriptExcerpt, diffPatch, changedFiles, baseRef, driftIssues, outputLanguage = "auto", triageUserPrompt, customPrompt } = input;
3299
3165
  const triageUserPromptBlock = buildTriageUserPromptBlock(triageUserPrompt);
@@ -3317,6 +3183,8 @@ You can call \`Grep\`, \`Glob\`, and \`Read\` against the current repository (po
3317
3183
  - read the changed files in full when the truncated patch is not enough,
3318
3184
  - check whether the element/flow the spec describes still exists in the source.
3319
3185
 
3186
+ You can also call \`${CHANGED_FILE_DIFF_TOOL}\` with a file path to fetch that file's diff hunk for this run's base...HEAD range. The inline patch below is scoped to this spec's relatedPaths — files OUTSIDE that scope still appear in "Changed files (name-status)" but their hunks are not inlined. Before blaming (or ruling out) such a file, fetch its diff with this tool; Read only shows you its post-change state, not what changed.
3187
+
3320
3188
  You have **up to 12 tool turns**. Do NOT write, edit, run shell commands, or hit the network.
3321
3189
 
3322
3190
  ## Decision guidance
@@ -3421,19 +3289,45 @@ ${liveTranscriptExcerpt}`);
3421
3289
  //#endregion
3422
3290
  //#region src/report/analyze.ts
3423
3291
  /**
3292
+ * In-process MCP server exposing one tool: the diff hunk of a named changed
3293
+ * file. The inline patch in the prompt is only the relatedPaths-scoped seed;
3294
+ * this is the pull side — the model fetches hunks for files outside that
3295
+ * scope (or truncated inside it) only when it decides they matter, so the
3296
+ * full diff never has to ride in the prompt. Read-only over data already
3297
+ * captured in memory: no shell, no git access granted. The server/tool
3298
+ * names must compose to CHANGED_FILE_DIFF_TOOL (prompt.ts), which is how
3299
+ * the prompt tells the model to call it.
3300
+ */
3301
+ function buildDiffMcpServer(getFileDiff) {
3302
+ return createSdkMcpServer({
3303
+ name: "diff",
3304
+ version: "1.0.0",
3305
+ tools: [tool("changed_file_diff", "Return the unified diff (base...HEAD) of one changed file from this run's diff range. Works for ANY file listed in 'Changed files (name-status)', including files outside the spec's relatedPaths scope whose hunks are not in the inline patch.", { path: z.string().describe("File path exactly as it appears in the name-status list") }, async ({ path }) => {
3306
+ const hunk = getFileDiff(path);
3307
+ if (hunk) info(` diff tool: ${path}`);
3308
+ return { content: [{
3309
+ type: "text",
3310
+ text: hunk ?? `No diff found for "${path}" in this run's diff range. Check the exact path in the name-status list (paths are relative to the working directory).`
3311
+ }] };
3312
+ })]
3313
+ });
3314
+ }
3315
+ /**
3424
3316
  * Classify one failing spec into TEST_DRIFT / SPEC_CHANGE / PRODUCT_BUG /
3425
3317
  * UNKNOWN. Same resilience contract as diagnose(): read-only tools, JSON-only
3426
3318
  * final message, and any parse failure degrades to UNKNOWN with confidence 0
3427
3319
  * rather than throwing — the report must always render.
3428
3320
  */
3429
- async function analyzeFailure(input, options = {}) {
3321
+ async function analyzeFailure(input, options) {
3430
3322
  const { result: raw, isError } = await invokeClaudeStreaming({
3431
3323
  prompt: buildFailureAnalysisPrompt(input),
3432
3324
  allowedTools: [
3433
3325
  "Read",
3434
3326
  "Grep",
3435
- "Glob"
3327
+ "Glob",
3328
+ CHANGED_FILE_DIFF_TOOL
3436
3329
  ],
3330
+ mcpServers: { diff: buildDiffMcpServer(options.getFileDiff) },
3437
3331
  silenceBashLog: true,
3438
3332
  maxTurns: 12,
3439
3333
  ...options.model ? { model: options.model } : {},
@@ -3458,61 +3352,240 @@ async function analyzeFailure(input, options = {}) {
3458
3352
  sdkError: false
3459
3353
  };
3460
3354
  }
3461
- return {
3462
- analysis: unknownAnalysis(`analysis returned no parseable JSON: ${truncate$2(raw, 500)}`),
3463
- raw,
3464
- sdkError: false
3465
- };
3466
- }
3467
- function unknownAnalysis(reasoning) {
3468
- return {
3469
- label: "UNKNOWN",
3470
- confidence: 0,
3471
- subDiagnosis: "NONE",
3472
- headline: "",
3473
- recommendation: "",
3474
- evidence: [],
3475
- reasoning
3476
- };
3355
+ return {
3356
+ analysis: unknownAnalysis(`analysis returned no parseable JSON: ${truncate$2(raw, 500)}`),
3357
+ raw,
3358
+ sdkError: false
3359
+ };
3360
+ }
3361
+ function unknownAnalysis(reasoning) {
3362
+ return {
3363
+ label: "UNKNOWN",
3364
+ confidence: 0,
3365
+ subDiagnosis: "NONE",
3366
+ headline: "",
3367
+ recommendation: "",
3368
+ evidence: [],
3369
+ reasoning
3370
+ };
3371
+ }
3372
+ const LABELS = new Set(PREDICTED_LABELS);
3373
+ const SUB_SET = new Set(SUB_DIAGNOSES);
3374
+ /**
3375
+ * Manual, lenient normalisation (mirrors diagnose's normaliseResult): a
3376
+ * missing/extra field should degrade gracefully, not reject the whole
3377
+ * prediction — only an unrecognisable label makes the candidate unusable.
3378
+ */
3379
+ function normaliseFailureAnalysis(parsed) {
3380
+ if (!isObject(parsed)) return null;
3381
+ const label = parsed["label"];
3382
+ if (typeof label !== "string" || !LABELS.has(label)) return null;
3383
+ const confidence = typeof parsed["confidence"] === "number" ? clamp(parsed["confidence"], 0, 1) : 0;
3384
+ const reasoning = typeof parsed["reasoning"] === "string" ? parsed["reasoning"] : "";
3385
+ const headline = typeof parsed["headline"] === "string" ? parsed["headline"] : "";
3386
+ const recommendation = typeof parsed["recommendation"] === "string" ? parsed["recommendation"] : "";
3387
+ const rawSub = parsed["subDiagnosis"];
3388
+ const subDiagnosis = typeof rawSub === "string" && SUB_SET.has(rawSub) ? rawSub : "NONE";
3389
+ const evidence = [];
3390
+ if (Array.isArray(parsed["evidence"])) for (const item of parsed["evidence"]) {
3391
+ if (!isObject(item)) continue;
3392
+ const detail = typeof item["detail"] === "string" ? item["detail"] : null;
3393
+ if (detail === null) continue;
3394
+ const file = typeof item["file"] === "string" ? item["file"] : void 0;
3395
+ evidence.push(file !== void 0 ? {
3396
+ file,
3397
+ detail
3398
+ } : { detail });
3399
+ if (evidence.length >= 3) break;
3400
+ }
3401
+ return {
3402
+ label,
3403
+ confidence,
3404
+ subDiagnosis,
3405
+ headline,
3406
+ recommendation,
3407
+ evidence,
3408
+ reasoning
3409
+ };
3410
+ }
3411
+ //#endregion
3412
+ //#region src/drift/affected.ts
3413
+ const execFileP = promisify(execFile);
3414
+ /**
3415
+ * GITHUB_BASE_REF holds a bare branch name (e.g. "main"); the local checkout
3416
+ * only has it as a remote-tracking ref, so prefix `origin/` unless already
3417
+ * qualified. Shared by `ccqa drift`'s resolveBaseRef and `ccqa run`'s
3418
+ * resolveAnalysisBase so the rule can't drift between them.
3419
+ */
3420
+ function normalizeGithubBaseRef(ref) {
3421
+ return ref.startsWith("origin/") ? ref : `origin/${ref}`;
3422
+ }
3423
+ /**
3424
+ * Resolve the base ref to diff against for `ccqa drift --changed`.
3425
+ * Precedence: explicit override > GITHUB_BASE_REF > origin/main.
3426
+ *
3427
+ * Note: this is the `ccqa drift` rule. `ccqa run` resolves its baseline via
3428
+ * `src/run/git-context.ts` instead, which has no origin/main fallback — see
3429
+ * the rationale there.
3430
+ */
3431
+ function resolveBaseRef(explicit) {
3432
+ if (explicit && explicit.length > 0) return explicit;
3433
+ const ghBase = process.env["GITHUB_BASE_REF"];
3434
+ if (ghBase && ghBase.length > 0) return normalizeGithubBaseRef(ghBase);
3435
+ return "origin/main";
3436
+ }
3437
+ /**
3438
+ * Run `git diff --name-status base...HEAD` from `cwd` and return one entry per
3439
+ * changed file. Renames are reported under their NEW path with status
3440
+ * "renamed" — the OLD path is dropped because the spec mapping is against the
3441
+ * post-rename layout.
3442
+ *
3443
+ * Paths are re-rooted to be relative to `cwd`, not the git repo root. In a
3444
+ * monorepo where `cwd` is a sub-package (e.g. `apps/foo`), git emits paths
3445
+ * relative to the repo root, but specs declare relatedPaths relative to
3446
+ * their own package. Changes outside `cwd` are kept under their repo-root
3447
+ * path and flagged `outsideCwd` — they only scope a spec in when the spec
3448
+ * explicitly declares a repo-root-relative glob, so an unrelated PR can
3449
+ * never accidentally match the app-relative globs.
3450
+ */
3451
+ async function getChangedFiles(base, cwd) {
3452
+ const [{ stdout: rootOut }, { stdout: diffOut }] = await Promise.all([execFileP("git", ["rev-parse", "--show-toplevel"], { cwd }), execFileP("git", [
3453
+ "diff",
3454
+ "--name-status",
3455
+ "-M",
3456
+ `${base}...HEAD`
3457
+ ], {
3458
+ cwd,
3459
+ maxBuffer: 32 * 1024 * 1024
3460
+ })]);
3461
+ return rerootChangedFiles(parseGitDiffOutput(diffOut), rootOut.trim(), cwd);
3462
+ }
3463
+ /**
3464
+ * Convert paths in `entries` from git-repo-root relative to `cwd` relative.
3465
+ * Entries outside `cwd` keep their repo-root path and are flagged
3466
+ * `outsideCwd`. Exported for unit tests.
3467
+ */
3468
+ function rerootChangedFiles(entries, repoRoot, cwd) {
3469
+ const prefix = relative(repoRoot, cwd);
3470
+ if (!prefix) return entries;
3471
+ const out = [];
3472
+ for (const e of entries) {
3473
+ const rel = relative(prefix, e.path);
3474
+ if (rel.startsWith("..") || rel === "") out.push({
3475
+ ...e,
3476
+ outsideCwd: true
3477
+ });
3478
+ else out.push({
3479
+ ...e,
3480
+ path: rel
3481
+ });
3482
+ }
3483
+ return out;
3484
+ }
3485
+ function parseGitDiffOutput(stdout) {
3486
+ const out = [];
3487
+ for (const line of stdout.split("\n")) {
3488
+ if (!line.trim()) continue;
3489
+ const parts = line.split(" ");
3490
+ const code = parts[0];
3491
+ if (!code) continue;
3492
+ if (code.startsWith("R")) {
3493
+ const newPath = parts[2];
3494
+ if (newPath) out.push({
3495
+ path: newPath,
3496
+ status: "renamed"
3497
+ });
3498
+ continue;
3499
+ }
3500
+ if (code.startsWith("C")) {
3501
+ const newPath = parts[2];
3502
+ if (newPath) out.push({
3503
+ path: newPath,
3504
+ status: "added"
3505
+ });
3506
+ continue;
3507
+ }
3508
+ const path = parts[1];
3509
+ if (!path) continue;
3510
+ switch (code[0]) {
3511
+ case "A":
3512
+ out.push({
3513
+ path,
3514
+ status: "added"
3515
+ });
3516
+ break;
3517
+ case "M":
3518
+ case "T":
3519
+ out.push({
3520
+ path,
3521
+ status: "modified"
3522
+ });
3523
+ break;
3524
+ case "D":
3525
+ out.push({
3526
+ path,
3527
+ status: "deleted"
3528
+ });
3529
+ break;
3530
+ default: out.push({
3531
+ path,
3532
+ status: "modified"
3533
+ });
3534
+ }
3535
+ }
3536
+ return out;
3537
+ }
3538
+ /** Normalize a leading `./` away so diff paths and relatedPaths globs compare. */
3539
+ function stripLeadingDotSlash(s) {
3540
+ return s.startsWith("./") ? s.slice(2) : s;
3541
+ }
3542
+ const REGEX_CACHE = /* @__PURE__ */ new Map();
3543
+ /** Compiles `pattern` to a RegExp, memoized so repeated `--changed` matches don't re-build. */
3544
+ function compileGlob(pattern) {
3545
+ const cached = REGEX_CACHE.get(pattern);
3546
+ if (cached) return cached;
3547
+ const compiled = globToRegExp(stripLeadingDotSlash(pattern));
3548
+ REGEX_CACHE.set(pattern, compiled);
3549
+ return compiled;
3550
+ }
3551
+ function globToRegExp(pattern) {
3552
+ let re = "^";
3553
+ let i = 0;
3554
+ while (i < pattern.length) {
3555
+ const ch = pattern[i];
3556
+ if (ch === "?") {
3557
+ re += "[^/]";
3558
+ i++;
3559
+ continue;
3560
+ }
3561
+ if (ch !== "*") {
3562
+ re += /[.+^${}()|[\]\\]/.test(ch) ? "\\" + ch : ch;
3563
+ i++;
3564
+ continue;
3565
+ }
3566
+ if (pattern[i + 1] !== "*") {
3567
+ re += "[^/]*";
3568
+ i++;
3569
+ continue;
3570
+ }
3571
+ const hasLeadingSlash = re.endsWith("/");
3572
+ const hasTrailingSlash = pattern[i + 2] === "/";
3573
+ if (hasLeadingSlash) re = re.slice(0, -1);
3574
+ if (hasLeadingSlash || hasTrailingSlash) re += "(?:/?.*)?";
3575
+ else re += ".*";
3576
+ i += hasTrailingSlash ? 3 : 2;
3577
+ }
3578
+ return new RegExp(re + "$");
3477
3579
  }
3478
- const LABELS = new Set(PREDICTED_LABELS);
3479
- const SUB_SET = new Set(SUB_DIAGNOSES);
3480
3580
  /**
3481
- * Manual, lenient normalisation (mirrors diagnose's normaliseResult): a
3482
- * missing/extra field should degrade gracefully, not reject the whole
3483
- * prediction only an unrecognisable label makes the candidate unusable.
3581
+ * Returns true if `changedPath` is covered by any of `relatedPaths`. An empty
3582
+ * `relatedPaths` returns false callers handle the "unscoped spec" case
3583
+ * separately (treat the spec as always-affected) before calling this.
3484
3584
  */
3485
- function normaliseFailureAnalysis(parsed) {
3486
- if (!isObject(parsed)) return null;
3487
- const label = parsed["label"];
3488
- if (typeof label !== "string" || !LABELS.has(label)) return null;
3489
- const confidence = typeof parsed["confidence"] === "number" ? clamp(parsed["confidence"], 0, 1) : 0;
3490
- const reasoning = typeof parsed["reasoning"] === "string" ? parsed["reasoning"] : "";
3491
- const headline = typeof parsed["headline"] === "string" ? parsed["headline"] : "";
3492
- const recommendation = typeof parsed["recommendation"] === "string" ? parsed["recommendation"] : "";
3493
- const rawSub = parsed["subDiagnosis"];
3494
- const subDiagnosis = typeof rawSub === "string" && SUB_SET.has(rawSub) ? rawSub : "NONE";
3495
- const evidence = [];
3496
- if (Array.isArray(parsed["evidence"])) for (const item of parsed["evidence"]) {
3497
- if (!isObject(item)) continue;
3498
- const detail = typeof item["detail"] === "string" ? item["detail"] : null;
3499
- if (detail === null) continue;
3500
- const file = typeof item["file"] === "string" ? item["file"] : void 0;
3501
- evidence.push(file !== void 0 ? {
3502
- file,
3503
- detail
3504
- } : { detail });
3505
- if (evidence.length >= 3) break;
3506
- }
3507
- return {
3508
- label,
3509
- confidence,
3510
- subDiagnosis,
3511
- headline,
3512
- recommendation,
3513
- evidence,
3514
- reasoning
3515
- };
3585
+ function isPathAffectedBy(changedPath, relatedPaths) {
3586
+ const stripped = stripLeadingDotSlash(changedPath);
3587
+ for (const pattern of relatedPaths) if (compileGlob(pattern).test(stripped)) return true;
3588
+ return false;
3516
3589
  }
3517
3590
  /**
3518
3591
  * Capture the PR diff used as context for failure analysis. `--relative`
@@ -3627,6 +3700,249 @@ function scopePatchForSpec(patch, relatedPaths, caps = {}) {
3627
3700
  return parts.join("\n");
3628
3701
  }
3629
3702
  //#endregion
3703
+ //#region src/run/diff-provider.ts
3704
+ /**
3705
+ * Cap on one on-demand file-diff response. Larger than the inline seed's
3706
+ * per-file cap (the model explicitly asked for this file), but still bounded
3707
+ * so a generated-file hunk can't blow the context — the truncation note
3708
+ * points at Read for the file's full current state.
3709
+ */
3710
+ const FILE_DIFF_RESPONSE_CAP = 16 * 1024;
3711
+ /** Find `path`'s section in a split patch and cap it. Exported for tests. */
3712
+ function lookupFileDiff(sections, path) {
3713
+ const normalized = stripLeadingDotSlash(path);
3714
+ const section = sections.find((s) => s.path === normalized);
3715
+ if (!section) return null;
3716
+ if (section.body.length <= 16384) return section.body;
3717
+ return `${section.body.slice(0, FILE_DIFF_RESPONSE_CAP)}\n[truncated: ${section.body.length - FILE_DIFF_RESPONSE_CAP} more chars — Read the file for its full current state]`;
3718
+ }
3719
+ function createDiffProvider(args) {
3720
+ const { resolveBase, cwd } = args;
3721
+ const captures = /* @__PURE__ */ new Map();
3722
+ let relatedPathsIndex = null;
3723
+ function capture(sha) {
3724
+ const cached = captures.get(sha);
3725
+ if (cached) return cached;
3726
+ const pending = (async () => {
3727
+ const result = await capturePrDiff(sha, cwd);
3728
+ if (!result.ok) return {
3729
+ sections: null,
3730
+ nameStatus: null,
3731
+ error: result.error
3732
+ };
3733
+ const { patch, nameStatus } = result.diff;
3734
+ return {
3735
+ sections: patch.length > 0 ? splitPatchByFile(patch) : [],
3736
+ nameStatus,
3737
+ error: null
3738
+ };
3739
+ })();
3740
+ captures.set(sha, pending);
3741
+ return pending;
3742
+ }
3743
+ /** relatedPaths for every spec, read once from the feature tree. */
3744
+ function relatedPaths() {
3745
+ relatedPathsIndex ??= listFeatureTree(cwd).then((tree) => new Map(tree.flatMap((f) => f.specs.map((s) => [specKey({
3746
+ featureName: f.featureName,
3747
+ specName: s.specName
3748
+ }), s.relatedPaths ?? null]))));
3749
+ return relatedPathsIndex;
3750
+ }
3751
+ return { async forSpec(spec) {
3752
+ const resolved = await resolveBase(spec);
3753
+ if (!resolved.ok) return resolved;
3754
+ const [captured, index] = await Promise.all([capture(resolved.base.sha), relatedPaths()]);
3755
+ const scope = index.get(specKey(spec)) ?? null;
3756
+ const sections = captured.sections;
3757
+ return {
3758
+ ok: true,
3759
+ base: resolved.base,
3760
+ patch: sections ? scopePatchForSpec(sections, scope) : null,
3761
+ nameStatus: captured.nameStatus,
3762
+ error: captured.error,
3763
+ fileDiff: (path) => sections ? lookupFileDiff(sections, path) : null
3764
+ };
3765
+ } };
3766
+ }
3767
+ //#endregion
3768
+ //#region src/run/errors.ts
3769
+ /**
3770
+ * Usage error (bad flag combination, broken profile, failed `git diff`, …)
3771
+ * thrown by the `run` pipeline and the helpers it calls, e.g.
3772
+ * `collectChangedSpecs`. `executeRun` never calls `process.exit`, so each
3773
+ * host maps this itself: the CLI action catches it and exits with
3774
+ * `exitCode`; the hub runner records it as a run-level error.
3775
+ */
3776
+ var RunUsageError = class extends Error {
3777
+ exitCode = 2;
3778
+ constructor(message) {
3779
+ super(message);
3780
+ this.name = "RunUsageError";
3781
+ }
3782
+ };
3783
+ //#endregion
3784
+ //#region src/run/git-context.ts
3785
+ /** The `--failure-analysis` value that selects per-spec hub-ledger baselines. */
3786
+ const LAST_GREEN = "last-green";
3787
+ /** Resolve `ref` to a full commit sha, or null when it does not exist locally. */
3788
+ async function resolveCommitSha(ref, cwd) {
3789
+ try {
3790
+ const { stdout } = await execFileP("git", [
3791
+ "rev-parse",
3792
+ "--verify",
3793
+ "--quiet",
3794
+ `${ref}^{commit}`
3795
+ ], { cwd });
3796
+ return stdout.trim() || null;
3797
+ } catch {
3798
+ return null;
3799
+ }
3800
+ }
3801
+ /**
3802
+ * Resolve a `[base]` flag value (from `--failure-analysis [base]` or
3803
+ * `--changed [base]`) to a verified baseline, failing fast — before any spec
3804
+ * runs — when it cannot be resolved.
3805
+ *
3806
+ * - a string value is an explicit ref;
3807
+ * - bare `true` derives the ref from GITHUB_BASE_REF (pull_request events)
3808
+ * and errors outside that context;
3809
+ * - the ref must resolve to a local commit, so a shallow CI checkout that
3810
+ * never fetched the base surfaces here as an actionable error instead of
3811
+ * an empty diff downstream.
3812
+ *
3813
+ * `flagName` only shapes the error messages.
3814
+ */
3815
+ async function resolveAnalysisBase(flagValue, flagName, cwd) {
3816
+ let ref;
3817
+ let source;
3818
+ 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`);
3819
+ if (typeof flagValue === "string") {
3820
+ ref = flagValue;
3821
+ source = "explicit";
3822
+ } else {
3823
+ const ghBase = process.env["GITHUB_BASE_REF"];
3824
+ 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. ${flagName}=origin/main`);
3825
+ ref = normalizeGithubBaseRef(ghBase);
3826
+ source = "github-base-ref";
3827
+ }
3828
+ const sha = await resolveCommitSha(ref, cwd);
3829
+ if (sha === null) throw new RunUsageError(`${flagName}: '${ref}' is not a resolvable git ref in this checkout. If this is CI, the base may not be fetched (try fetch-depth: 0). If '${ref}' was meant as a spec target, put spec targets before flags or use ${flagName}=<ref>.`);
3830
+ return {
3831
+ ref,
3832
+ sha,
3833
+ source
3834
+ };
3835
+ }
3836
+ //#endregion
3837
+ //#region src/cli/git-branch.ts
3838
+ /** Best-effort current branch: CI env vars first, then git, else null. */
3839
+ async function detectBranch(cwd) {
3840
+ const fromEnv = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME;
3841
+ if (fromEnv) return fromEnv;
3842
+ try {
3843
+ const { stdout } = await execFileP("git", [
3844
+ "rev-parse",
3845
+ "--abbrev-ref",
3846
+ "HEAD"
3847
+ ], { cwd });
3848
+ const branch = stdout.trim();
3849
+ return branch && branch !== "HEAD" ? branch : null;
3850
+ } catch {
3851
+ return null;
3852
+ }
3853
+ }
3854
+ /** Best-effort current commit SHA, or null (e.g. not a git repo). */
3855
+ async function getGitHead(cwd) {
3856
+ try {
3857
+ const { stdout } = await execFileP("git", ["rev-parse", "HEAD"], { cwd });
3858
+ return stdout.trim() || null;
3859
+ } catch {
3860
+ return null;
3861
+ }
3862
+ }
3863
+ //#endregion
3864
+ //#region src/run/last-green.ts
3865
+ /**
3866
+ * The repo's default branch name (e.g. "main"), from origin's HEAD ref.
3867
+ * Falls back to "main" when origin isn't configured — the lookup then just
3868
+ * queries a possibly-empty ledger bucket, which is harmless.
3869
+ */
3870
+ async function detectDefaultBranch(cwd) {
3871
+ try {
3872
+ const { stdout } = await execFileP("git", [
3873
+ "symbolic-ref",
3874
+ "--short",
3875
+ "refs/remotes/origin/HEAD"
3876
+ ], { cwd });
3877
+ const ref = stdout.trim();
3878
+ return ref.startsWith("origin/") ? ref.slice(7) : ref || "main";
3879
+ } catch {
3880
+ return "main";
3881
+ }
3882
+ }
3883
+ /**
3884
+ * Fetch the last-green ledger for this run — one hub round trip, logged as
3885
+ * the run's analysis-base meta line. Fails fast (RunUsageError) when the hub
3886
+ * can't serve it: `--failure-analysis=last-green` explicitly opted into
3887
+ * hub-backed baselines, so a broken hub connection is a usage error, never a
3888
+ * silent no-baseline run.
3889
+ */
3890
+ async function fetchLastGreenLedger(hubCtx, profile, cwd) {
3891
+ const [fallbackBranch, detectedBranch] = await Promise.all([detectDefaultBranch(cwd), detectBranch(cwd)]);
3892
+ const branch = detectedBranch ?? fallbackBranch;
3893
+ let entries;
3894
+ try {
3895
+ entries = await hubCtx.hub.getLastGreen(hubCtx.project, {
3896
+ branch,
3897
+ fallbackBranch,
3898
+ ...profile ? { profile } : {}
3899
+ });
3900
+ } catch (err) {
3901
+ throw new RunUsageError(`--failure-analysis=${LAST_GREEN}: could not fetch the last-green ledger from the hub: ${err instanceof Error ? err.message : String(err)}`);
3902
+ }
3903
+ const n = Object.keys(entries).length;
3904
+ const scope = branch === fallbackBranch ? branch : `${branch} → ${fallbackBranch}`;
3905
+ meta("analysis-base", `${LAST_GREEN} (${n} spec baseline${n === 1 ? "" : "s"}, branch ${scope})`);
3906
+ return entries;
3907
+ }
3908
+ /**
3909
+ * Per-spec baseline resolver for `--failure-analysis=last-green`. A spec
3910
+ * missing from the ledger (never green on a pushed run yet) or whose
3911
+ * baseline commit isn't in this checkout resolves to a skip — the run
3912
+ * continues; only that spec's classification is withheld, with the reason
3913
+ * recorded in its report row. Sha existence checks are memoized per commit.
3914
+ */
3915
+ function createLastGreenResolver(entries, cwd) {
3916
+ const shaChecks = /* @__PURE__ */ new Map();
3917
+ const checkSha = (sha) => {
3918
+ const cached = shaChecks.get(sha);
3919
+ if (cached) return cached;
3920
+ const pending = resolveCommitSha(sha, cwd);
3921
+ shaChecks.set(sha, pending);
3922
+ return pending;
3923
+ };
3924
+ return async (spec) => {
3925
+ const entry = entries[specKey(spec)];
3926
+ if (!entry) return {
3927
+ ok: false,
3928
+ skip: "no last-green baseline for this spec on the hub yet (recorded once the spec passes on a pushed run)"
3929
+ };
3930
+ const sha = await checkSha(entry.gitHead);
3931
+ if (!sha) return {
3932
+ ok: false,
3933
+ skip: `last-green commit ${entry.gitHead.slice(0, 12)} is not in this checkout (shallow clone? try fetch-depth: 0)`
3934
+ };
3935
+ return {
3936
+ ok: true,
3937
+ base: {
3938
+ ref: LAST_GREEN,
3939
+ sha,
3940
+ source: "last-green"
3941
+ }
3942
+ };
3943
+ };
3944
+ }
3945
+ //#endregion
3630
3946
  //#region src/report/github-format.ts
3631
3947
  /**
3632
3948
  * Build GitHub Actions `::error::` annotation lines for every failed spec in
@@ -4584,33 +4900,6 @@ function resolvePromptLocalPath(name, cwd) {
4584
4900
  return join(cwd ?? process.cwd(), PROMPT_LOCAL_PATHS[name]);
4585
4901
  }
4586
4902
  //#endregion
4587
- //#region src/cli/git-branch.ts
4588
- /** Best-effort current branch: CI env vars first, then git, else null. */
4589
- async function detectBranch(cwd) {
4590
- const fromEnv = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME;
4591
- if (fromEnv) return fromEnv;
4592
- try {
4593
- const { stdout } = await execFileP("git", [
4594
- "rev-parse",
4595
- "--abbrev-ref",
4596
- "HEAD"
4597
- ], { cwd });
4598
- const branch = stdout.trim();
4599
- return branch && branch !== "HEAD" ? branch : null;
4600
- } catch {
4601
- return null;
4602
- }
4603
- }
4604
- /** Best-effort current commit SHA, or null (e.g. not a git repo). */
4605
- async function getGitHead(cwd) {
4606
- try {
4607
- const { stdout } = await execFileP("git", ["rev-parse", "HEAD"], { cwd });
4608
- return stdout.trim() || null;
4609
- } catch {
4610
- return null;
4611
- }
4612
- }
4613
- //#endregion
4614
4903
  //#region src/cli/hub.ts
4615
4904
  /**
4616
4905
  * `ccqa hub` — the client side of the ccqa hub (a results/secret control
@@ -5540,22 +5829,14 @@ async function runLiveSpecs(specs, opts) {
5540
5829
  const userPromptBundle = await loadPromptBundleFromHub(opts.hubContext ?? null, "live");
5541
5830
  if (userPromptBundle !== null) meta("prompt", userPromptBundle.loaded.join(" + "));
5542
5831
  const userPromptSuffix = userPromptBundle?.text ?? null;
5543
- const failureAnalysisEnabled = opts.failureAnalysis !== false;
5832
+ const diffProvider = opts.diffProvider ?? null;
5833
+ const failureAnalysisEnabled = diffProvider != null;
5544
5834
  const driftAuditEnabled = failureAnalysisEnabled && opts.driftAudit !== false;
5545
5835
  const auth = failureAnalysisEnabled ? driftAuthAvailable() : {
5546
5836
  ok: false,
5547
5837
  reason: "disabled"
5548
5838
  };
5549
5839
  if (failureAnalysisEnabled && !auth.ok) info(`failure analysis skipped (${auth.reason})`);
5550
- const baseRef = resolveBaseRef(opts.base);
5551
- let diff = {
5552
- ok: false,
5553
- error: "diff not captured"
5554
- };
5555
- if (failureAnalysisEnabled && auth.ok) {
5556
- diff = await capturePrDiff(baseRef, cwd);
5557
- if (!diff.ok) info(`failure analysis: source diff unavailable (${diff.error}) — analyzing without diff context`);
5558
- }
5559
5840
  const reportDir = opts.reportDir ?? ".";
5560
5841
  const concurrency = Math.max(1, opts.concurrency ?? 1);
5561
5842
  const built = await runPool(specs, concurrency, (spec, i) => {
@@ -5577,10 +5858,8 @@ async function runLiveSpecs(specs, opts) {
5577
5858
  };
5578
5859
  const row = await buildLiveReportRow(outcome, {
5579
5860
  auth,
5580
- diff,
5581
- baseRef,
5861
+ diffProvider,
5582
5862
  reportDir,
5583
- failureAnalysisEnabled,
5584
5863
  driftAuditEnabled
5585
5864
  }, opts, cwd);
5586
5865
  await opts.report?.upsert(row);
@@ -5616,26 +5895,29 @@ async function buildLiveReportRow(r, ctx, opts, cwd) {
5616
5895
  reportDir: ctx.reportDir
5617
5896
  });
5618
5897
  const driftForSpec = ctx.driftAuditEnabled && r.result.status === "failed" ? await runDriftAuditOne(r, opts, cwd) : null;
5619
- const analysis = ctx.failureAnalysisEnabled && r.result.status === "failed" ? await analyzeOneLiveFailure(r, ctx.diff, ctx.baseRef, driftForSpec, ctx.auth, opts, cwd) : void 0;
5898
+ const analysis = ctx.diffProvider && r.result.status === "failed" ? await analyzeOneLiveFailure(r, ctx.diffProvider, driftForSpec, ctx.auth, opts, cwd) : void 0;
5620
5899
  return {
5621
5900
  ...base,
5622
5901
  driftIssues: driftForSpec,
5623
- ...analysisFieldsFor(analysis, r.result.status, ctx.failureAnalysisEnabled)
5902
+ ...analysisFieldsFor(analysis, r.result.status)
5624
5903
  };
5625
5904
  }
5626
5905
  /**
5627
5906
  * Merge analysis-related fields into the report row. The unattempted-failure
5628
5907
  * branch exists so the report distinguishes "we tried and gave up" (auth /
5629
- * spec.yaml missing) from "we deliberately did not run the classifier".
5908
+ * spec.yaml missing) from "we deliberately did not run the classifier"
5909
+ * `a` is undefined for a failed spec exactly when analysis was not requested
5910
+ * (no diffProvider), so no separate flag is needed.
5630
5911
  */
5631
- function analysisFieldsFor(a, status, failureAnalysisEnabled) {
5912
+ function analysisFieldsFor(a, status) {
5632
5913
  if (a) return {
5633
5914
  analysis: a.analysis,
5634
5915
  analysisSkipped: a.analysisSkipped,
5635
5916
  failureLogExcerpt: a.failureLogExcerpt,
5636
- diffExcerpt: a.diffExcerpt
5917
+ diffExcerpt: a.diffExcerpt,
5918
+ ...a.analysisBase ? { analysisBase: a.analysisBase } : {}
5637
5919
  };
5638
- if (!failureAnalysisEnabled && status === "failed") return { analysisSkipped: "skipped by --no-failure-analysis" };
5920
+ if (status === "failed") return { analysisSkipped: "skipped: --failure-analysis not enabled" };
5639
5921
  return {};
5640
5922
  }
5641
5923
  /**
@@ -5825,11 +6107,13 @@ function logBatchCost(runs) {
5825
6107
  /**
5826
6108
  * Classify one failed live run via `analyzeFailure` — same prompt as the
5827
6109
  * deterministic path (Issue #47), fed the live transcript instead of the
5828
- * vitest log. `auth`, `diff`, and `baseRef` are hoisted once by the caller and
5829
- * shared across specs. Auth-unavailable / no-failed-step degrade to
5830
- * `analysisSkipped` rather than throwing.
6110
+ * vitest log. `auth` is hoisted once by the caller; the diff comes from the
6111
+ * shared provider, already scoped to this spec's relatedPaths and truncated
6112
+ * (the live path used to feed the whole unscoped patch — in a monorepo that
6113
+ * ballooned the prompt with unrelated changes). Auth-unavailable /
6114
+ * no-failed-step degrade to `analysisSkipped` rather than throwing.
5831
6115
  */
5832
- async function analyzeOneLiveFailure(r, diff, baseRef, driftForSpec, auth, opts, cwd) {
6116
+ async function analyzeOneLiveFailure(r, diffProvider, driftForSpec, auth, opts, cwd) {
5833
6117
  const key = `${r.featureName}/${r.specName}`;
5834
6118
  if (!auth.ok) return {
5835
6119
  analysis: null,
@@ -5845,19 +6129,31 @@ async function analyzeOneLiveFailure(r, diff, baseRef, driftForSpec, auth, opts,
5845
6129
  failureLogExcerpt: null,
5846
6130
  diffExcerpt: null
5847
6131
  };
6132
+ const specDiff = await diffProvider.forSpec({
6133
+ featureName: r.featureName,
6134
+ specName: r.specName
6135
+ });
6136
+ if (!specDiff.ok) return {
6137
+ analysis: null,
6138
+ analysisSkipped: specDiff.skip,
6139
+ failureLogExcerpt: excerpt,
6140
+ diffExcerpt: null
6141
+ };
6142
+ if (specDiff.error) info(`failure analysis: source diff unavailable (${specDiff.error}) — analyzing without diff context`);
5848
6143
  const outcome = await analyzeFailure({
5849
6144
  liveTranscriptExcerpt: excerpt,
5850
6145
  specYaml: r.specYaml,
5851
- diffPatch: diff.ok ? diff.diff.patch : null,
5852
- changedFiles: diff.ok ? diff.diff.nameStatus : null,
5853
- baseRef: diff.ok ? baseRef : null,
6146
+ diffPatch: specDiff.patch,
6147
+ changedFiles: specDiff.nameStatus,
6148
+ baseRef: specDiff.base.ref,
5854
6149
  driftIssues: driftForSpec,
5855
6150
  ...opts.language ? { outputLanguage: opts.language } : {},
5856
6151
  ...opts.triageUserPrompt ? { triageUserPrompt: opts.triageUserPrompt } : {},
5857
6152
  ...opts.customPrompt ? { customPrompt: opts.customPrompt } : {}
5858
6153
  }, {
5859
6154
  ...opts.model ? { model: opts.model } : {},
5860
- cwd
6155
+ cwd,
6156
+ getFileDiff: specDiff.fileDiff
5861
6157
  });
5862
6158
  const pct = Math.round(outcome.analysis.confidence * 100);
5863
6159
  const headline = outcome.analysis.headline.trim() || (outcome.analysis.reasoning.split("\n")[0] ?? "").trim();
@@ -5866,7 +6162,11 @@ async function analyzeOneLiveFailure(r, diff, baseRef, driftForSpec, auth, opts,
5866
6162
  analysis: outcome.analysis,
5867
6163
  analysisSkipped: null,
5868
6164
  failureLogExcerpt: excerpt,
5869
- diffExcerpt: diff.ok ? diff.diff.patch : null
6165
+ diffExcerpt: specDiff.patch,
6166
+ analysisBase: {
6167
+ ref: specDiff.base.ref,
6168
+ sha: specDiff.base.sha
6169
+ }
5870
6170
  };
5871
6171
  }
5872
6172
  function count(steps, target) {
@@ -8937,27 +9237,12 @@ function stripCodeFences(text) {
8937
9237
  return m && m[1] !== void 0 ? m[1] : text;
8938
9238
  }
8939
9239
  //#endregion
8940
- //#region src/run/errors.ts
8941
- /**
8942
- * Usage error (bad flag combination, broken profile, failed `git diff`, …)
8943
- * thrown by the `run` pipeline and the helpers it calls, e.g.
8944
- * `collectChangedSpecs`. `executeRun` never calls `process.exit`, so each
8945
- * host maps this itself: the CLI action catches it and exits with
8946
- * `exitCode`; the hub runner records it as a run-level error.
8947
- */
8948
- var RunUsageError = class extends Error {
8949
- exitCode = 2;
8950
- constructor(message) {
8951
- super(message);
8952
- this.name = "RunUsageError";
8953
- }
8954
- };
8955
- //#endregion
8956
9240
  //#region src/cli/changed-specs.ts
8957
9241
  /**
8958
- * Filter specs to those affected by the git diff against the resolved base
8959
- * ref. Powers `ccqa run --changed`; mirrors `ccqa drift --changed` minus the
8960
- * LLM new-file router (kept off here for predictable CI cost).
9242
+ * Filter specs to those affected by the git diff against the `--changed
9243
+ * [base]` baseline. Powers `ccqa run --changed`; mirrors `ccqa drift
9244
+ * --changed` minus the LLM new-file router (kept off here for predictable CI
9245
+ * cost).
8961
9246
  *
8962
9247
  * A spec is "affected" when it has no `relatedPaths` (conservatively
8963
9248
  * included), any changed file matches one of its `relatedPaths` globs, or it
@@ -8965,14 +9250,14 @@ var RunUsageError = class extends Error {
8965
9250
  */
8966
9251
  async function collectChangedSpecs(specs, opts) {
8967
9252
  const { cwd, base } = opts;
8968
- const baseRef = resolveBaseRef(base);
9253
+ const resolved = await resolveAnalysisBase(base, "--changed", cwd);
8969
9254
  let changed;
8970
9255
  try {
8971
- changed = await getChangedFiles(baseRef, cwd);
9256
+ changed = await getChangedFiles(resolved.sha, cwd);
8972
9257
  } catch (e) {
8973
- throw new RunUsageError(`failed to run 'git diff' against ${baseRef}: ${e.message}`);
9258
+ throw new RunUsageError(`failed to run 'git diff' against ${resolved.ref}: ${e.message}`);
8974
9259
  }
8975
- meta("changed-base", baseRef);
9260
+ meta("changed-base", `${resolved.ref} (${resolved.sha.slice(0, 12)})`);
8976
9261
  meta("changed-files", changed.length);
8977
9262
  return filterAffectedSpecs(specs, changed, cwd);
8978
9263
  }
@@ -9036,6 +9321,27 @@ function dedupeSpecs(specs) {
9036
9321
  async function executeRun(targets, opts) {
9037
9322
  if (opts.changed && targets.length > 0) throw new RunUsageError("--changed and an explicit spec target cannot be combined");
9038
9323
  const cwd = opts.cwd ?? process.cwd();
9324
+ const wantsLastGreen = opts.failureAnalysis === LAST_GREEN;
9325
+ const [head, fixedBase] = await Promise.all([getGitHead(cwd), opts.failureAnalysis && !wantsLastGreen ? resolveAnalysisBase(opts.failureAnalysis, "--failure-analysis", cwd) : null]);
9326
+ const git = {
9327
+ head,
9328
+ base: wantsLastGreen ? {
9329
+ ref: LAST_GREEN,
9330
+ sha: null,
9331
+ source: "last-green"
9332
+ } : fixedBase
9333
+ };
9334
+ let diffProvider = null;
9335
+ if (fixedBase) {
9336
+ diffProvider = createDiffProvider({
9337
+ resolveBase: async () => ({
9338
+ ok: true,
9339
+ base: fixedBase
9340
+ }),
9341
+ cwd
9342
+ });
9343
+ meta("analysis-base", `${fixedBase.ref} (${fixedBase.sha.slice(0, 12)}, ${fixedBase.source})`);
9344
+ }
9039
9345
  let projectForProfile;
9040
9346
  try {
9041
9347
  if (opts.profile !== void 0) {
@@ -9071,7 +9377,17 @@ async function executeRun(targets, opts) {
9071
9377
  } catch {
9072
9378
  hubCtx = null;
9073
9379
  }
9074
- const [customPrompt, triageUserPrompt] = await Promise.all([fetchCustomPrompt(hubCtx), fetchTriageUserPrompt(hubCtx)]);
9380
+ if (wantsLastGreen && hubCtx == null) throw new RunUsageError(`--failure-analysis=${LAST_GREEN} requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)`);
9381
+ const ledgerHub = wantsLastGreen ? hubCtx : null;
9382
+ const [customPrompt, triageUserPrompt, ledgerEntries] = await Promise.all([
9383
+ fetchCustomPrompt(hubCtx),
9384
+ fetchTriageUserPrompt(hubCtx),
9385
+ ledgerHub ? fetchLastGreenLedger(ledgerHub, opts.profile, cwd) : null
9386
+ ]);
9387
+ if (ledgerEntries) diffProvider = createDiffProvider({
9388
+ resolveBase: createLastGreenResolver(ledgerEntries, cwd),
9389
+ cwd
9390
+ });
9075
9391
  const triageUserPromptHash = triageUserPrompt ? hashTriageUserPrompt(triageUserPrompt) : null;
9076
9392
  const enumerateAll = () => listAllSpecsWithSpecFile(cwd);
9077
9393
  let specs = dedupeSpecs((await Promise.all((targets.length ? targets : [void 0]).map((t) => resolveSpecTargets(t, enumerateAll, cwd)))).flat());
@@ -9079,7 +9395,7 @@ async function executeRun(targets, opts) {
9079
9395
  const before = specs.length;
9080
9396
  specs = await collectChangedSpecs(specs, {
9081
9397
  cwd,
9082
- base: opts.base
9398
+ base: opts.changed
9083
9399
  });
9084
9400
  meta("changed-scoped", `${specs.length} of ${before} spec${before === 1 ? "" : "s"}`);
9085
9401
  }
@@ -9121,6 +9437,7 @@ async function executeRun(targets, opts) {
9121
9437
  project: hubCtx.project,
9122
9438
  ...branch ? { branch } : {},
9123
9439
  ...opts.profile ? { profile: opts.profile } : {},
9440
+ ...git.head ? { gitHead: git.head } : {},
9124
9441
  kind: "run"
9125
9442
  });
9126
9443
  hubRunId = opened.id;
@@ -9141,11 +9458,7 @@ async function executeRun(targets, opts) {
9141
9458
  warn(`hub: could not open incremental run (${errMessage(err)}); continuing with local report only`);
9142
9459
  }
9143
9460
  const incrementalReport = createIncrementalReport(reportDir, buildReportEnvelope({
9144
- diff: {
9145
- ok: false,
9146
- error: "diff not yet captured"
9147
- },
9148
- baseRef: null,
9461
+ git,
9149
9462
  customPromptVersion: customPrompt?.customPromptVersion ?? null,
9150
9463
  triageUserPromptHash,
9151
9464
  opts
@@ -9177,13 +9490,12 @@ async function executeRun(targets, opts) {
9177
9490
  ...opts.language ? { language: opts.language } : {},
9178
9491
  ...opts.out && liveSpecs.length === 1 ? { out: opts.out } : {},
9179
9492
  cwd,
9180
- ...opts.base ? { base: opts.base } : {},
9181
9493
  reportDir,
9182
9494
  ...typeof opts.retry === "number" ? { retry: opts.retry } : {},
9183
9495
  concurrency: opts.concurrency ?? 1,
9184
9496
  ...opts.profile ? { profile: opts.profile } : {},
9185
9497
  ...opts.driftAudit !== false ? { driftAudit: true } : {},
9186
- ...opts.failureAnalysis === false ? { failureAnalysis: false } : {},
9498
+ diffProvider,
9187
9499
  hubContext: hubCtx,
9188
9500
  customPrompt,
9189
9501
  triageUserPrompt,
@@ -9195,7 +9507,7 @@ async function executeRun(targets, opts) {
9195
9507
  if (externalRows.some((r) => r.status === "failed")) overallExitCode = 1;
9196
9508
  let report;
9197
9509
  {
9198
- const detReport = await analyzeDeterministicSummaries(det.summaries, opts, cwd, reportDir, customPrompt, triageUserPrompt);
9510
+ const detReport = await analyzeDeterministicSummaries(det.summaries, opts, cwd, reportDir, customPrompt, triageUserPrompt, diffProvider);
9199
9511
  report = await writeUnifiedReport({
9200
9512
  reportDir,
9201
9513
  results: [
@@ -9203,8 +9515,7 @@ async function executeRun(targets, opts) {
9203
9515
  ...externalRows,
9204
9516
  ...live.reportResults
9205
9517
  ],
9206
- diff: detReport.diff,
9207
- baseRef: detReport.baseRef,
9518
+ git,
9208
9519
  customPromptVersion: detReport.customPromptVersion,
9209
9520
  triageUserPromptHash,
9210
9521
  opts
@@ -9213,8 +9524,7 @@ async function executeRun(targets, opts) {
9213
9524
  if (hubRunId) {
9214
9525
  const finalStatus = overallExitCode === 0 ? "passed" : "failed";
9215
9526
  const reportMeta = buildReportEnvelope({
9216
- diff: detReport.diff,
9217
- baseRef: detReport.baseRef,
9527
+ git,
9218
9528
  customPromptVersion: detReport.customPromptVersion,
9219
9529
  triageUserPromptHash,
9220
9530
  opts
@@ -9412,24 +9722,15 @@ function failedSpec(s) {
9412
9722
  * failure analysis when `--report` is on; degrades (no throw) when Claude
9413
9723
  * auth or git diff aren't available. Caller writes the HTML / JSON.
9414
9724
  */
9415
- async function analyzeDeterministicSummaries(summaries, opts, cwd, reportDir, customPrompt, triageUserPrompt) {
9416
- const failureAnalysisEnabled = opts.failureAnalysis !== false;
9725
+ async function analyzeDeterministicSummaries(summaries, opts, cwd, reportDir, customPrompt, triageUserPrompt, diffProvider) {
9726
+ const failureAnalysisEnabled = diffProvider != null;
9417
9727
  const driftAuditEnabled = failureAnalysisEnabled && opts.driftAudit !== false;
9418
- const auth = failureAnalysisEnabled || driftAuditEnabled ? driftAuthAvailable() : {
9728
+ const auth = failureAnalysisEnabled ? driftAuthAvailable() : {
9419
9729
  ok: false,
9420
9730
  reason: "skipped by flags"
9421
9731
  };
9422
9732
  const failed = summaries.filter(failedSpec);
9423
9733
  if (failureAnalysisEnabled && !auth.ok && failed.length > 0) info(`failure analysis skipped (${auth.reason})`);
9424
- const baseRef = resolveBaseRef(opts.base);
9425
- let diff = {
9426
- ok: false,
9427
- error: "diff not captured (no failures)"
9428
- };
9429
- if (failed.length > 0) {
9430
- diff = await capturePrDiff(baseRef, cwd);
9431
- if (!diff.ok) info(`drift-report: source diff unavailable (${diff.error}) — analyzing without diff context`);
9432
- }
9433
9734
  const tree = failed.length > 0 ? await listFeatureTree(cwd) : [];
9434
9735
  const specInfoByKey = new Map(tree.flatMap((f) => f.specs.map((sp) => [`${f.featureName}/${sp.specName}`, sp])));
9435
9736
  const findSpecInfo = (s) => specInfoByKey.get(`${s.featureName}/${s.specName}`) ?? null;
@@ -9456,9 +9757,9 @@ async function analyzeDeterministicSummaries(summaries, opts, cwd, reportDir, cu
9456
9757
  onSpecStart: (t) => info(`drift audit: ${t.featureName}/${t.specName}`)
9457
9758
  });
9458
9759
  }
9459
- const patchSections = diff.ok && diff.diff.patch.length > 0 ? splitPatchByFile(diff.diff.patch) : null;
9460
9760
  const allBlocks = await loadAllBlocks(cwd);
9461
9761
  let printedHeader = false;
9762
+ let warnedDiffUnavailable = false;
9462
9763
  const results = [];
9463
9764
  for (const s of summaries) {
9464
9765
  const assertions = collectAssertions(s);
@@ -9493,14 +9794,23 @@ async function analyzeDeterministicSummaries(summaries, opts, cwd, reportDir, cu
9493
9794
  });
9494
9795
  continue;
9495
9796
  }
9496
- const relatedPaths = findSpecInfo(s)?.relatedPaths ?? null;
9497
- const diffExcerpt = patchSections ? scopePatchForSpec(patchSections, relatedPaths) : null;
9797
+ const specDiffResult = diffProvider ? await diffProvider.forSpec({
9798
+ featureName: s.featureName,
9799
+ specName: s.specName
9800
+ }) : null;
9801
+ const specDiff = specDiffResult?.ok ? specDiffResult : null;
9802
+ if (specDiff?.error && !warnedDiffUnavailable) {
9803
+ warnedDiffUnavailable = true;
9804
+ info(`failure analysis: source diff unavailable (${specDiff.error}) — analyzing without diff context`);
9805
+ }
9806
+ const diffExcerpt = specDiff?.patch ?? null;
9498
9807
  const driftResult = driftResults.find((r) => r.target.featureName === s.featureName && r.target.specName === s.specName);
9499
9808
  const driftIssues = driftResult?.ok ? driftResult.issues : null;
9500
9809
  const failureLog = buildFailureLog(s);
9501
9810
  let analysis = null;
9502
9811
  let analysisSkipped = null;
9503
- if (!failureAnalysisEnabled) analysisSkipped = "skipped by --no-failure-analysis";
9812
+ if (!specDiffResult) analysisSkipped = "skipped: --failure-analysis not enabled";
9813
+ else if (!specDiffResult.ok) analysisSkipped = specDiffResult.skip;
9504
9814
  else if (!auth.ok) analysisSkipped = auth.reason;
9505
9815
  else if (specYaml === null) analysisSkipped = "no spec.yaml found for this spec";
9506
9816
  else {
@@ -9511,15 +9821,16 @@ async function analyzeDeterministicSummaries(summaries, opts, cwd, reportDir, cu
9511
9821
  specYaml,
9512
9822
  failureLog,
9513
9823
  diffPatch: diffExcerpt,
9514
- changedFiles: diff.ok ? diff.diff.nameStatus : null,
9515
- baseRef: diff.ok ? baseRef : null,
9824
+ changedFiles: specDiffResult.nameStatus,
9825
+ baseRef: specDiffResult.base.ref,
9516
9826
  driftIssues,
9517
9827
  ...opts.language ? { outputLanguage: opts.language } : {},
9518
9828
  ...triageUserPrompt ? { triageUserPrompt } : {},
9519
9829
  ...customPrompt ? { customPrompt } : {}
9520
9830
  }, {
9521
9831
  ...opts.model ? { model: opts.model } : {},
9522
- cwd
9832
+ cwd,
9833
+ getFileDiff: specDiffResult.fileDiff
9523
9834
  });
9524
9835
  analysis = outcome.analysis;
9525
9836
  if (!printedHeader) {
@@ -9537,6 +9848,10 @@ async function analyzeDeterministicSummaries(summaries, opts, cwd, reportDir, cu
9537
9848
  status: "failed",
9538
9849
  analysis,
9539
9850
  analysisSkipped,
9851
+ ...specDiff ? { analysisBase: {
9852
+ ref: specDiff.base.ref,
9853
+ sha: specDiff.base.sha
9854
+ } } : {},
9540
9855
  driftIssues,
9541
9856
  failureLogExcerpt: failureLog.length > 0 ? failureLog : null,
9542
9857
  diffExcerpt,
@@ -9546,8 +9861,6 @@ async function analyzeDeterministicSummaries(summaries, opts, cwd, reportDir, cu
9546
9861
  }
9547
9862
  return {
9548
9863
  results,
9549
- diff,
9550
- baseRef,
9551
9864
  customPromptVersion: customPrompt?.customPromptVersion ?? null
9552
9865
  };
9553
9866
  }
@@ -9559,30 +9872,33 @@ async function analyzeDeterministicSummaries(summaries, opts, cwd, reportDir, cu
9559
9872
  * final report.json stays byte-identical (existing e2e goldens compare it).
9560
9873
  */
9561
9874
  function buildReportEnvelope(args) {
9562
- const { diff, baseRef, customPromptVersion, triageUserPromptHash, opts } = args;
9875
+ const { git, customPromptVersion, triageUserPromptHash, opts } = args;
9563
9876
  return {
9564
9877
  schemaVersion: 1,
9565
9878
  kind: "run",
9566
9879
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
9567
9880
  runId: process.env["GITHUB_RUN_ID"] ?? null,
9568
9881
  git: {
9569
- head: diff.ok ? diff.diff.head : null,
9570
- base: diff.ok ? baseRef : null
9882
+ head: git.head,
9883
+ base: git.base?.ref ?? null,
9884
+ ...git.base ? {
9885
+ baseSha: git.base.sha,
9886
+ baseSource: git.base.source
9887
+ } : {}
9571
9888
  },
9572
9889
  model: opts.model ?? null,
9573
9890
  language: opts.language ?? null,
9574
- promptVersion: "4",
9891
+ promptVersion: "5",
9575
9892
  customPromptVersion,
9576
9893
  ...triageUserPromptHash !== null ? { triageUserPromptHash } : {}
9577
9894
  };
9578
9895
  }
9579
9896
  /** Write the unified JSON (+ optional GitHub-annotation) report for one run. Returns the report data. */
9580
9897
  async function writeUnifiedReport(args) {
9581
- const { reportDir, results, diff, baseRef, customPromptVersion, triageUserPromptHash, opts } = args;
9898
+ const { reportDir, results, git, customPromptVersion, triageUserPromptHash, opts } = args;
9582
9899
  const data = {
9583
9900
  ...buildReportEnvelope({
9584
- diff,
9585
- baseRef,
9901
+ git,
9586
9902
  customPromptVersion,
9587
9903
  triageUserPromptHash,
9588
9904
  opts
@@ -9896,7 +10212,7 @@ function installTeardownSignalHandlers(teardown) {
9896
10212
  }
9897
10213
  //#endregion
9898
10214
  //#region src/cli/run.ts
9899
- 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. Each spec's execution mode comes from its spec.yaml `mode:` field (default deterministic; set `mode: live` to have Claude drive agent-browser live per step). Deterministic specs replay the recorded test.spec.ts under vitest. A structured report (report.json + evidence) is always written; use --push-report to also stream it to a hub.").option("--report [dir]", `Directory for the structured run results (report.json + evidence PNGs) that are always written. Default: ${DEFAULT_REPORT_DIR}/. Pass this only to change the location.`).option("--push-report", "Incrementally push the run report to the hub as the run progresses (open → patch per spec → finalize). Requires --hub-url/--hub-token (or CCQA_HUB_URL/CCQA_HUB_TOKEN). Without it, hub credentials are used only to fetch variables/sessions/prompts, not to push.").option("--changed", "Restrict execution to specs whose relatedPaths intersect the git diff against --base (or, in CI, $GITHUB_BASE_REF, else origin/main). Cannot be combined with an explicit spec id.").option("--no-failure-analysis", "Skip the per-failure root-cause classification (TEST_DRIFT / SPEC_CHANGE / PRODUCT_BUG). --report only.").option("--no-drift-audit", "Skip the spec↔code drift audit shown in the report. --report only.").option("--base <ref>", "Base ref the source diff is taken against for failure analysis (default: GITHUB_BASE_REF, then origin/main).").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--format <fmt>", "Additional output format alongside HTML when --report is set: 'text' (default), 'json' (writes report.json), 'github' (GitHub Actions annotations on stdout).", (raw) => {
10215
+ 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. Each spec's execution mode comes from its spec.yaml `mode:` field (default deterministic; set `mode: live` to have Claude drive agent-browser live per step). Deterministic specs replay the recorded test.spec.ts under vitest. A structured report (report.json + evidence) is always written; use --push-report to also stream it to a hub.").option("--report [dir]", `Directory for the structured run results (report.json + evidence PNGs) that are always written. Default: ${DEFAULT_REPORT_DIR}/. Pass this only to change the location.`).option("--push-report", "Incrementally push the run report to the hub as the run progresses (open → patch per spec → finalize). Requires --hub-url/--hub-token (or CCQA_HUB_URL/CCQA_HUB_TOKEN). Without it, hub credentials are used only to fetch variables/sessions/prompts, not to push.").option("--changed [base]", "Restrict execution to specs whose relatedPaths intersect the git diff against [base]. Without a value the base comes from $GITHUB_BASE_REF (pull_request CI); elsewhere pass it explicitly (e.g. --changed=origin/main). Cannot be combined with an explicit spec id.").option("--failure-analysis [base]", "Classify each failure (TEST_DRIFT / SPEC_CHANGE / PRODUCT_BUG) against the source diff since [base]. Without a value the base comes from $GITHUB_BASE_REF (pull_request CI); elsewhere pass it explicitly (e.g. --failure-analysis=origin/main), or pass 'last-green' to diff each spec against the commit where it last passed (per-spec baselines from the hub; requires a hub connection). Off by default — no Claude calls without it.").option("--no-drift-audit", "With --failure-analysis: skip the spec↔code drift audit shown in the report.").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--format <fmt>", "Additional output format alongside HTML when --report is set: 'text' (default), 'json' (writes report.json), 'github' (GitHub Actions annotations on stdout).", (raw) => {
9900
10216
  if (REPORT_FORMATS.includes(raw)) return raw;
9901
10217
  throw new Error(`--format must be one of ${REPORT_FORMATS.join(" | ")}`);
9902
10218
  }, "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) => {
@@ -13390,6 +13706,11 @@ z.object({ error: z.object({
13390
13706
  code: z.string(),
13391
13707
  message: z.string()
13392
13708
  }) });
13709
+ z.object({
13710
+ gitHead: z.string(),
13711
+ runId: z.string(),
13712
+ at: z.string()
13713
+ });
13393
13714
  /**
13394
13715
  * A triage-learning job. Grading failing specs in the hub UI produces the
13395
13716
  * "actual cause" labels this reads; the job turns them into an improved
@@ -13505,6 +13826,7 @@ function createPushRunHandler(config) {
13505
13826
  };
13506
13827
  await config.storage.artifacts.putDir(run.id, dir);
13507
13828
  await config.storage.runs.create(run);
13829
+ await updateLastGreenLedger(config.storage, run, report.results);
13508
13830
  sendJson(ctx.res, 201, run);
13509
13831
  } finally {
13510
13832
  await rm(dir, {
@@ -13515,14 +13837,17 @@ function createPushRunHandler(config) {
13515
13837
  };
13516
13838
  }
13517
13839
  /**
13518
- * POST /api/v1/runs/open?project=&branch=&profile=&kind= — start a "running"
13519
- * run with no report yet. Unlike `POST /runs`, nothing is pushed up front:
13520
- * the caller patches results in as they finish (`PATCH /runs/:id`), so an
13521
- * interrupted run still leaves a partial report on the hub instead of none.
13840
+ * POST /api/v1/runs/open?project=&branch=&profile=&kind=&gitHead= — start a
13841
+ * "running" run with no report yet. Unlike `POST /runs`, nothing is pushed up
13842
+ * front: the caller patches results in as they finish (`PATCH /runs/:id`), so
13843
+ * an interrupted run still leaves a partial report on the hub instead of
13844
+ * none. `gitHead` (optional) attributes the run to a commit from the start —
13845
+ * without it an interrupted run would never learn its commit.
13522
13846
  */
13523
13847
  function createOpenRunHandler(config) {
13524
13848
  return async (ctx) => {
13525
13849
  const { project, branch, profile, kind } = parseRunScope(ctx);
13850
+ const gitHead = ctx.url.searchParams.get("gitHead");
13526
13851
  const now = (/* @__PURE__ */ new Date()).toISOString();
13527
13852
  const run = {
13528
13853
  id: randomUUID(),
@@ -13537,7 +13862,7 @@ function createOpenRunHandler(config) {
13537
13862
  passed: 0,
13538
13863
  failed: 0
13539
13864
  },
13540
- gitHead: null,
13865
+ gitHead: gitHead || null,
13541
13866
  promptVersion: "",
13542
13867
  ciRunId: null,
13543
13868
  reportCreatedAt: now,
@@ -13553,10 +13878,7 @@ const PatchRunRequestSchema = z.object({
13553
13878
  done: z.boolean().optional(),
13554
13879
  finalStatus: z.enum(["passed", "failed"]).optional(),
13555
13880
  reportMeta: z.object({
13556
- git: z.object({
13557
- head: z.string().nullable(),
13558
- base: z.string().nullable()
13559
- }).partial().optional(),
13881
+ git: GitEnvelopeSchema.partial().optional(),
13560
13882
  model: z.string().nullable().optional(),
13561
13883
  language: z.string().nullable().optional(),
13562
13884
  promptVersion: z.string().optional(),
@@ -13580,6 +13902,38 @@ function countSpecs(results) {
13580
13902
  };
13581
13903
  }
13582
13904
  /**
13905
+ * Advance the last-green ledger for every passed spec of a terminal
13906
+ * `kind: "run"` run. Spec-level, not run-level: a run with one chronically
13907
+ * failing spec still moves the baseline of every spec that did pass.
13908
+ * Best-effort — a ledger failure must not fail the push; the ledger is an
13909
+ * accelerator for `--failure-analysis=last-green`, not part of the run
13910
+ * record. Runs without a branch or gitHead can't be placed in the ledger and
13911
+ * are skipped.
13912
+ *
13913
+ * Ordering caveat (known approximation): `at` is the run's reportCreatedAt —
13914
+ * open time for incremental runs, report time for immutable pushes. When two
13915
+ * runs on the same branch+profile overlap, "newest at wins" can pick either
13916
+ * of the two genuinely-green commits, since the hub has no git ancestry to
13917
+ * order them properly. Accepted: CI serializes per branch in practice, and a
13918
+ * baseline can only ever point at a commit where the spec really passed.
13919
+ */
13920
+ async function updateLastGreenLedger(storage, run, results) {
13921
+ const { gitHead, branch } = run;
13922
+ if (run.kind !== "run" || !gitHead || !branch) return;
13923
+ const passed = results.filter((r) => r.status === "passed");
13924
+ if (passed.length === 0) return;
13925
+ const entries = Object.fromEntries(passed.map((r) => [`${r.feature}/${r.spec}`, {
13926
+ gitHead,
13927
+ runId: run.id,
13928
+ at: run.reportCreatedAt
13929
+ }]));
13930
+ try {
13931
+ await storage.lastGreen.merge(run.project, run.profile ?? "default", branch, entries);
13932
+ } catch (err) {
13933
+ console.error(`hub: last-green ledger update failed for run "${run.id}": ${err instanceof Error ? err.message : String(err)}`);
13934
+ }
13935
+ }
13936
+ /**
13583
13937
  * PATCH /api/v1/runs/:id — incrementally add spec results (and evidence) to a
13584
13938
  * "running" run. Once the run is terminal (`done: true` was sent, or it was
13585
13939
  * pushed immutably via `POST /runs`), further patches are rejected with 409.
@@ -13601,6 +13955,7 @@ function createPatchRunHandler(config) {
13601
13955
  if (!parsed.success) throw new HttpError(400, "invalid_body", `request body is invalid: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`);
13602
13956
  const { rows, evidence, done, finalStatus, reportMeta } = parsed.data;
13603
13957
  let specs = run.specs;
13958
+ let mergedResults = [];
13604
13959
  await config.storage.artifacts.updateJsonFile(id, "report.json", (current) => {
13605
13960
  const base = current ?? {
13606
13961
  schemaVersion: 1,
@@ -13620,7 +13975,9 @@ function createPatchRunHandler(config) {
13620
13975
  ...base,
13621
13976
  ...reportMeta?.git ? { git: {
13622
13977
  head: reportMeta.git.head ?? base.git.head,
13623
- base: reportMeta.git.base ?? base.git.base
13978
+ base: reportMeta.git.base ?? base.git.base,
13979
+ baseSha: reportMeta.git.baseSha ?? base.git.baseSha ?? null,
13980
+ baseSource: reportMeta.git.baseSource ?? base.git.baseSource ?? null
13624
13981
  } } : {},
13625
13982
  ...reportMeta?.model !== void 0 ? { model: reportMeta.model } : {},
13626
13983
  ...reportMeta?.language !== void 0 ? { language: reportMeta.language } : {},
@@ -13630,6 +13987,7 @@ function createPatchRunHandler(config) {
13630
13987
  };
13631
13988
  const merged = mergeResults(current?.results ?? [], rows);
13632
13989
  specs = countSpecs(merged);
13990
+ mergedResults = merged;
13633
13991
  return {
13634
13992
  ...envelope,
13635
13993
  results: merged
@@ -13643,6 +14001,7 @@ function createPatchRunHandler(config) {
13643
14001
  ...reportMeta?.promptVersion ? { promptVersion: reportMeta.promptVersion } : {}
13644
14002
  } : { specs };
13645
14003
  const updated = await config.storage.runs.update(id, patch);
14004
+ if (done) await updateLastGreenLedger(config.storage, updated, mergedResults);
13646
14005
  sendJson(ctx.res, 200, updated);
13647
14006
  };
13648
14007
  }
@@ -13759,9 +14118,11 @@ function parseRunScope(ctx) {
13759
14118
  };
13760
14119
  }
13761
14120
  /**
13762
- * A branch is a free-form label (e.g. `feature/foo`), stored verbatim and
13763
- * never used to build a filesystem path, so `/` is allowed — only length is
13764
- * bounded. null when the client didn't send one.
14121
+ * A branch is a free-form label (e.g. `feature/foo`), so `/` is allowed —
14122
+ * only length is bounded (a sanity cap; the last-green ledger separately
14123
+ * hash-truncates long percent-encoded names into a safe filename, see
14124
+ * paths.ts). Run records store it verbatim. null when the client didn't
14125
+ * send one. Exported for the last-green handler.
13765
14126
  */
13766
14127
  function requireBranch(raw) {
13767
14128
  if (raw === null || raw === "") return null;
@@ -14014,6 +14375,32 @@ function createListProfilesHandler(storage) {
14014
14375
  };
14015
14376
  }
14016
14377
  //#endregion
14378
+ //#region src/hub/api/handlers/last-green.ts
14379
+ /**
14380
+ * GET /api/v1/projects/:project/last-green?profile=&branch=&fallbackBranch=
14381
+ *
14382
+ * Returns the last-green ledger entries for one project/profile, keyed by
14383
+ * "feature/spec". `branch` is the caller's current branch; `fallbackBranch`
14384
+ * (optional, typically the default branch) is overlaid *under* it, so a PR
14385
+ * branch with no greens of its own still inherits the default branch's
14386
+ * baselines while its own greens take precedence. One round trip serves the
14387
+ * whole run.
14388
+ */
14389
+ function createGetLastGreenHandler(storage) {
14390
+ return async (ctx) => {
14391
+ const project = requireSafeSegment(ctx.params.project, "project");
14392
+ const profile = requireSafeSegment(ctx.url.searchParams.get("profile") ?? "default", "profile");
14393
+ const branch = requireBranch(ctx.url.searchParams.get("branch"));
14394
+ if (!branch) throw new HttpError(400, "missing_param", "branch query parameter is required");
14395
+ const fallbackBranch = requireBranch(ctx.url.searchParams.get("fallbackBranch"));
14396
+ const [primary, fallback] = await Promise.all([storage.lastGreen.get(project, profile, branch), fallbackBranch && fallbackBranch !== branch ? storage.lastGreen.get(project, profile, fallbackBranch) : Promise.resolve({})]);
14397
+ sendJson(ctx.res, 200, { entries: {
14398
+ ...fallback,
14399
+ ...primary
14400
+ } });
14401
+ };
14402
+ }
14403
+ //#endregion
14017
14404
  //#region src/hub/api/handlers/prompts.ts
14018
14405
  const MAX_PROMPT_BODY_BYTES = 256 * 1024;
14019
14406
  /** Validate the `:project` route param (prompts are project-scoped, not per-profile). */
@@ -18022,7 +18409,7 @@ function createLearningWorker(deps) {
18022
18409
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
18023
18410
  const customPrompt = {
18024
18411
  schemaVersion: 1,
18025
- basePromptVersion: "4",
18412
+ basePromptVersion: "5",
18026
18413
  customPromptVersion: `${generatedAt}-c${cases.length}`,
18027
18414
  generatedAt,
18028
18415
  guidance
@@ -18141,6 +18528,7 @@ function registerRoutes(router, config, queue) {
18141
18528
  router.put("/api/v1/runs/:id/triage/actual-causes", createImportActualCausesHandler(storage));
18142
18529
  router.get("/api/v1/projects", createListProjectsHandler(storage));
18143
18530
  router.get("/api/v1/projects/:project/profiles", createListProfilesHandler(storage));
18531
+ router.get("/api/v1/projects/:project/last-green", createGetLastGreenHandler(storage));
18144
18532
  const sessionConfig = {
18145
18533
  store: storage.sessions,
18146
18534
  encryptionKey: config.encryptionKey
@@ -18373,6 +18761,10 @@ function perspectivesKindDir(root) {
18373
18761
  function perspectivesPath(root, project) {
18374
18762
  return join(perspectivesKindDir(root), `${project}.json`);
18375
18763
  }
18764
+ function lastGreenPath(root, project, profile, branch) {
18765
+ const encoded = encodeURIComponent(branch);
18766
+ return join(root, "last-green", project, profile, `${encoded.length <= 200 ? encoded : `${encoded.slice(0, 64)}-${createHash("sha256").update(branch).digest("hex").slice(0, 32)}`}.json`);
18767
+ }
18376
18768
  //#endregion
18377
18769
  //#region src/hub/core/storage/file/artifact-store.ts
18378
18770
  /**
@@ -18460,6 +18852,25 @@ function createFileJobStore(root) {
18460
18852
  };
18461
18853
  }
18462
18854
  //#endregion
18855
+ //#region src/hub/core/storage/file/last-green-store.ts
18856
+ function createFileLastGreenStore(root) {
18857
+ return {
18858
+ async get(project, profile, branch) {
18859
+ return await readJson(lastGreenPath(root, project, profile, branch)) ?? {};
18860
+ },
18861
+ async merge(project, profile, branch, entries) {
18862
+ await updateJson(lastGreenPath(root, project, profile, branch), (current) => {
18863
+ const out = { ...current ?? {} };
18864
+ for (const [key, entry] of Object.entries(entries)) {
18865
+ const prev = out[key];
18866
+ if (!prev || prev.at <= entry.at) out[key] = entry;
18867
+ }
18868
+ return out;
18869
+ });
18870
+ }
18871
+ };
18872
+ }
18873
+ //#endregion
18463
18874
  //#region src/hub/core/storage/file/perspectives-store.ts
18464
18875
  /**
18465
18876
  * Defense-in-depth path validation: the HTTP layer already checks the project
@@ -18713,7 +19124,8 @@ function createFileHubStorage(dataDir) {
18713
19124
  triage: createFileTriageStore(dataDir),
18714
19125
  prompts: createFilePromptStore(dataDir),
18715
19126
  perspectives: createFilePerspectivesStore(dataDir),
18716
- jobs: createFileJobStore(dataDir)
19127
+ jobs: createFileJobStore(dataDir),
19128
+ lastGreen: createFileLastGreenStore(dataDir)
18717
19129
  };
18718
19130
  }
18719
19131
  //#endregion