ccqa 1.8.2 → 1.9.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
@@ -2252,6 +2252,7 @@ const PerspectiveSpecSchema = z.object({
2252
2252
  testCondition: z.string().optional(),
2253
2253
  preconditions: z.array(z.string().min(1)).optional(),
2254
2254
  relatedPaths: z.array(z.string().min(1)).optional(),
2255
+ relatedPathsUnmatched: z.number().int().nonnegative().optional(),
2255
2256
  status: PerspectiveStatusSchema,
2256
2257
  note: z.string().optional()
2257
2258
  }).strip();
@@ -3080,6 +3081,7 @@ const RunReportDataSchema = z.object({
3080
3081
  promptVersion: z.string(),
3081
3082
  customPromptVersion: z.string().nullable().default(null),
3082
3083
  triageUserPromptHash: z.string().optional(),
3084
+ deployedSha: z.string().optional(),
3083
3085
  results: z.array(ReportSpecResultSchema)
3084
3086
  });
3085
3087
  /** Shape of the "export labels" download produced by the report's client-side JS. */
@@ -3594,7 +3596,7 @@ async function collectSpecArtifacts(args) {
3594
3596
  };
3595
3597
  const dir = specArtifactsDir(args.reportDir, args.feature, args.spec);
3596
3598
  const relPrefix = posix.join(ARTIFACTS_SUBDIR, `${args.feature}__${args.spec}`);
3597
- const relFiles = await walkFiles$1(dir, "");
3599
+ const relFiles = await walkFiles$2(dir, "");
3598
3600
  relFiles.sort((a, b) => a === "output.log" ? -1 : b === "output.log" ? 1 : a.localeCompare(b));
3599
3601
  const kept = [];
3600
3602
  const dropped = [];
@@ -3621,7 +3623,7 @@ async function collectSpecArtifacts(args) {
3621
3623
  return kept;
3622
3624
  }
3623
3625
  /** All files under `dir` as posix paths relative to it; missing dir → []. */
3624
- async function walkFiles$1(dir, relBase) {
3626
+ async function walkFiles$2(dir, relBase) {
3625
3627
  let entries;
3626
3628
  try {
3627
3629
  entries = await readdir(dir, { withFileTypes: true });
@@ -3631,7 +3633,7 @@ async function walkFiles$1(dir, relBase) {
3631
3633
  const out = [];
3632
3634
  for (const entry of entries) {
3633
3635
  const rel = relBase === "" ? entry.name : `${relBase}/${entry.name}`;
3634
- if (entry.isDirectory()) out.push(...await walkFiles$1(join(dir, entry.name), rel));
3636
+ if (entry.isDirectory()) out.push(...await walkFiles$2(join(dir, entry.name), rel));
3635
3637
  else if (entry.isFile()) out.push(rel);
3636
3638
  }
3637
3639
  return out;
@@ -3876,6 +3878,22 @@ function parseGitDiffOutput(stdout) {
3876
3878
  }
3877
3879
  return out;
3878
3880
  }
3881
+ /**
3882
+ * Returns true if `path` matches the glob `pattern`.
3883
+ *
3884
+ * Supports a deliberately small glob language sufficient for relatedPaths:
3885
+ * - `**` matches any number of path segments (including zero)
3886
+ * - `*` matches any run of characters that does NOT include `/`
3887
+ * - `?` matches exactly one character that is not `/`
3888
+ * - leading `./` is stripped from both sides
3889
+ *
3890
+ * Everything else is treated literally. This is intentional — relatedPaths
3891
+ * comes from Claude and we want predictable matching behavior, not full
3892
+ * minimatch semantics.
3893
+ */
3894
+ function matchesGlob(path, pattern) {
3895
+ return compileGlob(pattern).test(stripLeadingDotSlash(path));
3896
+ }
3879
3897
  /** Normalize a leading `./` away so diff paths and relatedPaths globs compare. */
3880
3898
  function stripLeadingDotSlash(s) {
3881
3899
  return s.startsWith("./") ? s.slice(2) : s;
@@ -3948,13 +3966,13 @@ function globBase(pattern) {
3948
3966
  return staticSegments.join("/") || ".";
3949
3967
  }
3950
3968
  /** Recursively collect files under `dirAbs`, skipping node_modules/.git. */
3951
- async function walkFiles(dirAbs) {
3969
+ async function walkFiles$1(dirAbs) {
3952
3970
  const entries = await readdir(dirAbs, { withFileTypes: true });
3953
3971
  const out = [];
3954
3972
  for (const entry of entries) {
3955
3973
  const abs = join(dirAbs, entry.name);
3956
3974
  if (entry.isDirectory()) {
3957
- if (!WALK_SKIP_DIRS.has(entry.name)) out.push(...await walkFiles(abs));
3975
+ if (!WALK_SKIP_DIRS.has(entry.name)) out.push(...await walkFiles$1(abs));
3958
3976
  } else if (entry.isFile()) out.push(abs);
3959
3977
  }
3960
3978
  return out;
@@ -3969,13 +3987,13 @@ async function expandPatternToFiles(cwd, pattern) {
3969
3987
  const abs = resolve(cwd, pattern);
3970
3988
  const st = await stat(abs).catch(() => null);
3971
3989
  if (!st) throw new Error(`"${pattern}" does not exist (resolved to ${abs})`);
3972
- return (st.isDirectory() ? await walkFiles(abs) : [abs]).map((f) => relative(cwd, f)).sort();
3990
+ return (st.isDirectory() ? await walkFiles$1(abs) : [abs]).map((f) => relative(cwd, f)).sort();
3973
3991
  }
3974
3992
  const base = globBase(pattern);
3975
3993
  const baseAbs = resolve(cwd, base);
3976
3994
  if (!(await stat(baseAbs).catch(() => null))?.isDirectory()) throw new Error(`"${pattern}" matches nothing — its base directory ${base} does not exist`);
3977
3995
  const matcher = compileGlob(pattern);
3978
- const matched = (await walkFiles(baseAbs)).map((f) => relative(cwd, f)).filter((rel) => matcher.test(rel)).sort();
3996
+ const matched = (await walkFiles$1(baseAbs)).map((f) => relative(cwd, f)).filter((rel) => matcher.test(rel)).sort();
3979
3997
  if (matched.length === 0) throw new Error(`"${pattern}" matches no files under ${base}`);
3980
3998
  return matched;
3981
3999
  }
@@ -6207,10 +6225,21 @@ var RunUsageError = class extends Error {
6207
6225
  this.name = "RunUsageError";
6208
6226
  }
6209
6227
  };
6228
+ /** An error's message, whatever was thrown. Shared so the run modules report failures alike. */
6229
+ function errMessage(err) {
6230
+ return err instanceof Error ? err.message : String(err);
6231
+ }
6210
6232
  //#endregion
6211
6233
  //#region src/run/git-context.ts
6212
6234
  /** The `--failure-analysis` value that selects per-spec hub-ledger baselines. */
6213
6235
  const LAST_GREEN = "last-green";
6236
+ /**
6237
+ * The `--changed` value that selects specs from the hub's re-run verdicts
6238
+ * (`rerun-selection.ts` acts on it). Kept here beside `LAST_GREEN` so the two
6239
+ * "not a git ref" keywords, and the rules that reject each on the flag it does
6240
+ * not belong to, sit in one place.
6241
+ */
6242
+ const LAST_RUN = "last-run";
6214
6243
  /** Resolve `ref` to a full commit sha, or null when it does not exist locally. */
6215
6244
  async function resolveCommitSha(ref, cwd) {
6216
6245
  try {
@@ -6243,6 +6272,7 @@ async function resolveAnalysisBase(flagValue, flagName, cwd) {
6243
6272
  let ref;
6244
6273
  let source;
6245
6274
  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`);
6275
+ if (flagValue === "last-run") throw new RunUsageError(`${flagName}=${LAST_RUN} is not supported — last-run selects which specs to run and only applies to --changed`);
6246
6276
  if (typeof flagValue === "string") {
6247
6277
  ref = flagValue;
6248
6278
  source = "explicit";
@@ -6370,6 +6400,172 @@ function createLastGreenResolver(entries, cwd) {
6370
6400
  };
6371
6401
  }
6372
6402
  //#endregion
6403
+ //#region src/run/deploy-head.ts
6404
+ /**
6405
+ * The commit a profile's environment is currently running, per the hub's
6406
+ * deploy log — its newest entry, or null when nothing has been recorded.
6407
+ */
6408
+ async function deployHeadSha(hub, project, profile) {
6409
+ const { entries } = await hub.getDeployLog(project, {
6410
+ profile,
6411
+ limit: 1
6412
+ });
6413
+ return entries[entries.length - 1]?.sha ?? null;
6414
+ }
6415
+ /**
6416
+ * The commit the profile's environment was running when this run started.
6417
+ *
6418
+ * Captured before any spec executes and asserted on both push paths
6419
+ * (`?deployedSha=` on `POST /runs` via `ccqa hub push`, and on `POST
6420
+ * /runs/open` for `--push-report`). Left to itself the hub reads its own
6421
+ * deploy-log head when the call lands — after the whole run for a single-shot
6422
+ * push, after the deterministic phase for an incremental one — so a deploy
6423
+ * landing in that window would be recorded as the run's baseline and
6424
+ * under-report what needs re-running later. Asserting the earlier commit errs
6425
+ * the other way: a spec that straddled a deploy is simply selected again.
6426
+ *
6427
+ * Best-effort by design (hence `try`): no hub, no profile, no deploy log, or a
6428
+ * hub too old to serve one all leave the run unattributed, exactly as before.
6429
+ */
6430
+ async function tryDeployHeadSha(hubCtx, profile) {
6431
+ try {
6432
+ return await deployHeadSha(hubCtx.hub, hubCtx.project, profile);
6433
+ } catch {
6434
+ return null;
6435
+ }
6436
+ }
6437
+ //#endregion
6438
+ //#region src/run/dry-run.ts
6439
+ /**
6440
+ * The lines `ccqa run --dry-run` prints: one per selected spec, tagged with
6441
+ * what would have executed it.
6442
+ *
6443
+ * This exists because a selection can be wrong in a way that costs money.
6444
+ * `relatedPaths` accuracy is not yet proven, and both `--changed <ref>` and
6445
+ * `--changed=last-run` decide from it, so a human has to be able to read the
6446
+ * selection back before a live spec spends a Claude budget on it.
6447
+ *
6448
+ * Rows that would not have executed (a generate-only target, an unresolvable
6449
+ * one) are listed too, with their reason: they are part of what the selection
6450
+ * produced, and leaving them out would make the list look shorter than the
6451
+ * run's report will be.
6452
+ */
6453
+ function formatDryRunLines(agentBrowser, routed) {
6454
+ const tagged = [
6455
+ ...agentBrowser.map((s) => ({
6456
+ key: specKey(s),
6457
+ tag: s.mode
6458
+ })),
6459
+ ...routed.external.flatMap((g) => g.specs.map((s) => ({
6460
+ key: specKey(s),
6461
+ tag: g.targetId
6462
+ }))),
6463
+ ...routed.skipped.map((s) => ({
6464
+ key: specKey(s),
6465
+ tag: `skipped — ${s.reason}`
6466
+ })),
6467
+ ...routed.unresolved.map((s) => ({
6468
+ key: specKey(s),
6469
+ tag: `unresolved — ${s.reason}`
6470
+ }))
6471
+ ];
6472
+ const width = Math.max(0, ...tagged.map((t) => t.key.length));
6473
+ return tagged.map((t) => ` ${t.key.padEnd(width)} ${t.tag}`);
6474
+ }
6475
+ //#endregion
6476
+ //#region src/run/rerun-selection.ts
6477
+ /**
6478
+ * `ccqa run --changed=last-run`: select specs from the hub's re-run verdicts
6479
+ * instead of from a git diff (ADR-0010). The baseline is not a ref at all —
6480
+ * it is each spec's own last run, positioned against the deploy log the
6481
+ * consuming deploy job feeds the hub — so this path does no git work.
6482
+ *
6483
+ * Every "I cannot answer" is an error here, never an empty selection: an
6484
+ * unanswerable question that silently runs nothing is the one failure mode
6485
+ * that makes the whole feature dangerous.
6486
+ */
6487
+ /** First ccqa release whose hub serves `GET /projects/:project/rerun`. */
6488
+ const RERUN_MIN_HUB_VERSION = "1.9";
6489
+ /**
6490
+ * The profile `--changed=last-run` asks about. Mandatory: two environments sit
6491
+ * at different commits and the deploy log is per-profile, so "needs re-run"
6492
+ * has no profile-free answer.
6493
+ */
6494
+ function requireRerunProfile(profile) {
6495
+ if (profile === void 0) throw new RunUsageError(`--changed=${LAST_RUN} requires --profile <name>: the deploy log it reads is per-profile, so which specs need a re-run has no answer without one`);
6496
+ return profile;
6497
+ }
6498
+ /**
6499
+ * Ask the hub which specs need a re-run, failing fast — before any spec runs —
6500
+ * on every condition that would otherwise degrade into "select nothing".
6501
+ */
6502
+ async function fetchRerunReport(hubCtx, profile) {
6503
+ let report;
6504
+ try {
6505
+ report = await hubCtx.hub.getRerun(hubCtx.project, { profile });
6506
+ } catch (err) {
6507
+ if (err instanceof HubApiError && err.status === 404) throw new RunUsageError(explainNotFound(hubCtx, err));
6508
+ throw new RunUsageError(`--changed=${LAST_RUN}: could not ask the hub which specs need a re-run: ${errMessage(err)}`);
6509
+ }
6510
+ if (report.deployHead === null) throw new RunUsageError(`--changed=${LAST_RUN}: no deploy has been recorded for profile "${profile}" of project "${hubCtx.project}", so nothing can be compared against. Wire \`ccqa hub deploy record\` into the deploy job, or pass an explicit baseline (--changed=<ref>).`);
6511
+ return {
6512
+ ...report,
6513
+ deployHead: report.deployHead
6514
+ };
6515
+ }
6516
+ /**
6517
+ * Which of the two 404s this was. The handler answers `no_perspectives` when
6518
+ * the route exists but the project has no document; any other code on a 404
6519
+ * means the hub does not serve this route at all.
6520
+ */
6521
+ function explainNotFound(hubCtx, err) {
6522
+ if (err.code === "no_perspectives") return `--changed=${LAST_RUN}: project "${hubCtx.project}" has no perspectives document on the hub, so no spec has \`relatedPaths\` to match a deploy against. Run \`ccqa perspectives\` first.`;
6523
+ return `--changed=${LAST_RUN}: this hub does not serve re-run verdicts — it needs ccqa ${RERUN_MIN_HUB_VERSION} or newer. Upgrade the hub, or pass an explicit baseline (--changed=<ref>).`;
6524
+ }
6525
+ /** States the summary line reports, worst-known-first. */
6526
+ const SUMMARY_ORDER = [
6527
+ "needed",
6528
+ "unknown",
6529
+ "neverRun",
6530
+ "notNeeded",
6531
+ "notEvaluated"
6532
+ ];
6533
+ /** States that mean "the hub has no verdict", as opposed to a verdict of "no". */
6534
+ const UNANSWERABLE = new Set([
6535
+ "unknown",
6536
+ "neverRun",
6537
+ "notEvaluated"
6538
+ ]);
6539
+ /**
6540
+ * Narrow `specs` to the ones the hub says are worth running.
6541
+ *
6542
+ * `needed` is always selected. `unknown` and `neverRun` are "the question
6543
+ * cannot be answered", so they are excluded by default and opted into with
6544
+ * `--include-unknown` — fail-open on request, never silently. `notNeeded` and
6545
+ * `notEvaluated` are never selected.
6546
+ */
6547
+ function selectSpecsNeedingRerun(specs, report, opts) {
6548
+ const selectable = new Set(opts.includeUnknown ? [
6549
+ "needed",
6550
+ "unknown",
6551
+ "neverRun"
6552
+ ] : ["needed"]);
6553
+ const counts = /* @__PURE__ */ new Map();
6554
+ const selected = [];
6555
+ let excludedUnanswerable = 0;
6556
+ for (const spec of specs) {
6557
+ const state = report.specs[specKey(spec)]?.state ?? "unknown";
6558
+ counts.set(state, (counts.get(state) ?? 0) + 1);
6559
+ if (selectable.has(state)) selected.push(spec);
6560
+ else if (UNANSWERABLE.has(state)) excludedUnanswerable++;
6561
+ }
6562
+ return {
6563
+ selected,
6564
+ summary: SUMMARY_ORDER.filter((s) => counts.has(s)).map((s) => `${counts.get(s)} ${s}`).join(", "),
6565
+ excludedUnanswerable
6566
+ };
6567
+ }
6568
+ //#endregion
6373
6569
  //#region src/report/github-format.ts
6374
6570
  /**
6375
6571
  * Build GitHub Actions `::error::` annotation lines for every failed spec in
@@ -6932,6 +7128,68 @@ function verifySessionRestores(statePath, verifyUrl) {
6932
7128
  ]);
6933
7129
  }
6934
7130
  }
7131
+ /**
7132
+ * Non-destructive mid-run health probe of an already-running live session.
7133
+ * Reads the current page URL (`eval location.href` — the same read
7134
+ * {@link verifySessionRestores} makes, and it does NOT navigate, so it never
7135
+ * clobbers the page the model is working on) and detects only the unambiguous
7136
+ * signals that agent-browser's daemon was replaced/wedged mid-run:
7137
+ *
7138
+ * - the probe exits non-zero — the daemon is wedged or was replaced
7139
+ * (`spawnAB`'s hard timeout bounds a hung daemon, so this returns rather
7140
+ * than hanging the run);
7141
+ * - the page is blank/absent (`about:blank`, empty, `chrome://…`) — a
7142
+ * restarted daemon comes up with no page and no in-memory auth-state.
7143
+ *
7144
+ * Deliberately NOT flagged: a non-blank page that left the verify URL's origin.
7145
+ * That can't be told apart from a spec legitimately roaming to another origin
7146
+ * mid-flow (e.g. Slack → a separate admin app it logs into at runtime), and a
7147
+ * false "unhealthy" would re-inject the saved state and wipe auth the spec
7148
+ * acquired live — breaking a spec that was fine. Missing a same-origin-ish
7149
+ * sign-in wall just leaves that step failing as before (no regression), so the
7150
+ * asymmetry favours only firing on a provably dead daemon. `verifyUrl` is still
7151
+ * required (it's the re-anchor target for recovery) but no longer compared here.
7152
+ */
7153
+ function checkLiveSessionHealth(sessionName) {
7154
+ const probe = spawnAB([
7155
+ "--session",
7156
+ sessionName,
7157
+ "eval",
7158
+ "location.href"
7159
+ ]);
7160
+ if (probe.status !== 0) return {
7161
+ healthy: false,
7162
+ reason: (probe.stderr || probe.stdout || `probe exited ${probe.status}`).trim()
7163
+ };
7164
+ const href = unwrapEvalString(probe.stdout);
7165
+ if (!href || href === "about:blank" || href.startsWith("chrome://") || href.startsWith("chrome-error://")) return {
7166
+ healthy: false,
7167
+ reason: `blank/absent page (${href || "empty"})`
7168
+ };
7169
+ return { healthy: true };
7170
+ }
7171
+ /**
7172
+ * Recover a live session whose daemon was replaced mid-run (detected by
7173
+ * {@link checkLiveSessionHealth}). The restart drops the in-memory auth-state
7174
+ * injected at run start, so the session fell to a sign-in wall. Re-boot +
7175
+ * re-attach the saved state ({@link loadStateIntoSession} is idempotent —
7176
+ * `state load` is load-only, never writes back) and then navigate to
7177
+ * `verifyUrl`, a known signed-in page, so the retrying model has an
7178
+ * authenticated anchor to continue from instead of a login screen. Returns the
7179
+ * injection result; the trailing `open` is best-effort (a failed nav still
7180
+ * leaves the state attached for the model's own next navigation).
7181
+ */
7182
+ function recoverLiveSession(sessionName, statePath, verifyUrl) {
7183
+ const injected = loadStateIntoSession(sessionName, statePath);
7184
+ if (!injected.ok) return injected;
7185
+ spawnAB([
7186
+ "--session",
7187
+ sessionName,
7188
+ "open",
7189
+ verifyUrl
7190
+ ]);
7191
+ return { ok: true };
7192
+ }
6935
7193
  /** Take the last non-empty line of `agent-browser eval` stdout and JSON-unquote it. */
6936
7194
  function unwrapEvalString(stdout) {
6937
7195
  const lines = stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
@@ -7025,6 +7283,80 @@ function resolvePromptLocalPath(name, cwd) {
7025
7283
  return join(cwd ?? process.cwd(), PROMPT_LOCAL_PATHS[name]);
7026
7284
  }
7027
7285
  //#endregion
7286
+ //#region src/run/github-run.ts
7287
+ /**
7288
+ * The GitHub Actions run URL for the current job, built from the standard
7289
+ * Actions environment variables. Returns null unless all three are present,
7290
+ * so nothing is ever invented for a local run — the same "only when in CI"
7291
+ * contract the report envelope's `runId` (GITHUB_RUN_ID) already follows.
7292
+ */
7293
+ function githubRunUrl(env = process.env) {
7294
+ const server = env["GITHUB_SERVER_URL"];
7295
+ const repo = env["GITHUB_REPOSITORY"];
7296
+ const runId = githubRunId(env);
7297
+ if (!server || !repo || !runId) return null;
7298
+ return `${server}/${repo}/actions/runs/${runId}`;
7299
+ }
7300
+ /** The current GitHub Actions run id (GITHUB_RUN_ID); null outside Actions. */
7301
+ function githubRunId(env = process.env) {
7302
+ return env["GITHUB_RUN_ID"] ?? null;
7303
+ }
7304
+ //#endregion
7305
+ //#region src/cli/deploy-paths.ts
7306
+ /**
7307
+ * What `ccqa hub deploy record` reports as a deploy's changed paths.
7308
+ *
7309
+ * The hub has no checkout, so this is the one thing only the deploy job can
7310
+ * answer (ADR-0010). Everything here exists to keep that answer honest in the
7311
+ * direction that matters: over-reporting makes a spec re-run once too often,
7312
+ * under-reporting makes it silently skip a real regression.
7313
+ */
7314
+ /**
7315
+ * How many paths one deploy sends. A monorepo-wide refactor can list tens of
7316
+ * thousands, and the hub bounds the request body, so the list is cut here.
7317
+ *
7318
+ * The cap is deliberately far above the hub's own retention bound: whenever it
7319
+ * bites, the hub still receives more paths than it retains and marks the entry
7320
+ * `truncated`, which reads as "touched everything". A cap at or below the
7321
+ * hub's would instead present a cut-down list as a complete one — a confident
7322
+ * "no re-run needed" built on paths that were never sent.
7323
+ */
7324
+ const MAX_SENT_CHANGED_PATHS = 5e3;
7325
+ /** Cut `paths` to `MAX_SENT_CHANGED_PATHS`; see the constant for why the bound is where it is. */
7326
+ function capDeployPaths(paths) {
7327
+ return paths.slice(0, MAX_SENT_CHANGED_PATHS);
7328
+ }
7329
+ /**
7330
+ * The files that differ between two commits, as a deploy must report them.
7331
+ *
7332
+ * **Two-dot** (`git diff A B`), never three-dot: three-dot resolves the merge
7333
+ * base first, so redeploying an ancestor — a rollback — reports an empty diff
7334
+ * and the rollback becomes invisible. `getChangedFiles` in
7335
+ * `src/drift/affected.ts` is three-dot, which is right for the PR question it
7336
+ * answers and wrong for this one.
7337
+ *
7338
+ * `--no-renames` is likewise deliberate: with rename detection a rename is one
7339
+ * entry naming only the destination, so a file moved *out* of a spec's
7340
+ * `relatedPaths` would no longer match it. Off, the rename appears as a delete
7341
+ * plus an add and both paths are reported.
7342
+ *
7343
+ * Paths are re-rooted to `cwd` on the same rule as `--changed`, because
7344
+ * `relatedPaths` are written as the directory hosting `.ccqa/` sees them.
7345
+ */
7346
+ async function changedPathsBetween(previous, sha, cwd) {
7347
+ const [{ stdout: rootOut }, { stdout: diffOut }] = await Promise.all([execFileP("git", ["rev-parse", "--show-toplevel"], { cwd }), execFileP("git", [
7348
+ "diff",
7349
+ "--name-status",
7350
+ "--no-renames",
7351
+ previous,
7352
+ sha
7353
+ ], {
7354
+ cwd,
7355
+ maxBuffer: 32 * 1024 * 1024
7356
+ })]);
7357
+ return rerootChangedFiles(parseGitDiffOutput(diffOut), rootOut.trim(), cwd).map((f) => f.path);
7358
+ }
7359
+ //#endregion
7028
7360
  //#region src/cli/hub.ts
7029
7361
  /**
7030
7362
  * `ccqa hub` — the client side of the ccqa hub (a results/secret control
@@ -7202,6 +7534,45 @@ const promptRm = new Command("rm").description("Delete a prompt from the hub.").
7202
7534
  info(`deleted prompt "${name}" from the hub`);
7203
7535
  }));
7204
7536
  const promptCommand = new Command("prompt").description("Manage prompt assets (per-flow user/agent guidance, triage user guidance, analysis custom prompt) stored on the hub (fetched automatically by `ccqa run` at run time).").addCommand(promptPush).addCommand(promptLs).addCommand(promptRm);
7537
+ const deployRecord = new Command("record").description("Tell the hub what a deploy shipped, so it can answer which specs need a re-run (`ccqa run --changed=last-run`). Run this from the deploy job, after the deploy succeeds. The changed paths are computed locally with a two-dot `git diff <previous> <sha>`; a job that has only curl and git can POST the same body directly (see docs/hub.md).").requiredOption("--profile <name>", "Environment this deploy landed in (e.g. 'stg'). Required: dev and stg sit at different commits, so the deploy log is per-profile.").requiredOption("--sha <sha>", "Commit that was deployed.").option("--previous <sha>", "Commit this deploy replaced. Defaults to the profile's current deploy-log head on the hub. With neither, the deploy is recorded as touching everything.").option("--ref <ref>", "Ref that was deployed (branch or tag). Recorded for display only.").option(...hubUrlOption).option(...hubTokenOption).option("--project <name>", "Project whose deploy log this entry joins. Defaults to the current directory's name.").option("--cwd <path>", "Directory the git diff and the default --project name are resolved against.").action(withHubErrors(async (opts) => {
7538
+ const cwd = resolveCwd(opts.cwd);
7539
+ const project = resolveProject(opts);
7540
+ const hub = connect(opts);
7541
+ const previous = opts.previous ?? await deployHeadSha(hub, project, opts.profile);
7542
+ const runUrl = githubRunUrl();
7543
+ const changedPaths = previous === null ? null : await diffOrTouchEverything(previous, opts.sha, cwd);
7544
+ const entry = await hub.recordDeploy(project, opts.profile, {
7545
+ sha: opts.sha,
7546
+ previousSha: previous,
7547
+ changedPaths,
7548
+ ...opts.ref ? { ref: opts.ref } : {},
7549
+ ...runUrl ? { runUrl } : {}
7550
+ });
7551
+ header("hub deploy record", entry.sha.slice(0, 12));
7552
+ meta("project", project);
7553
+ meta("profile", opts.profile);
7554
+ meta("previous", previous ? previous.slice(0, 12) : "(none — treated as touching everything)");
7555
+ meta("changed paths", changedPaths === null ? "(not reported)" : String(changedPaths.length));
7556
+ if (entry.truncated) meta("truncated", "yes — the hub treats this deploy as touching everything");
7557
+ if (entry.gapBefore) warn("this deploy does not chain onto the log head, so a gap is recorded — specs whose baseline sits behind it report 'unknown' rather than 'not needed'");
7558
+ info(`recorded deploy #${entry.index}`);
7559
+ }));
7560
+ /**
7561
+ * The two-dot diff, or `null` when git can't produce one (a shallow checkout
7562
+ * that never fetched `previous`, a rolled-back sha that isn't local). `null`
7563
+ * makes the hub treat the deploy as touching everything: fail-open and
7564
+ * self-limiting — everything re-runs once, then it settles — whereas silently
7565
+ * sending an empty list would claim the deploy changed nothing.
7566
+ */
7567
+ async function diffOrTouchEverything(previous, sha, cwd) {
7568
+ try {
7569
+ return capDeployPaths(await changedPathsBetween(previous, sha, cwd));
7570
+ } catch (err) {
7571
+ warn(`could not diff ${previous.slice(0, 12)}..${sha.slice(0, 12)} (${errMessage(err)}); recording the deploy as touching everything`);
7572
+ return null;
7573
+ }
7574
+ }
7575
+ const deployCommand = new Command("deploy").description("Report deploys to the hub, the input behind `ccqa run --changed=last-run`.").addCommand(deployRecord);
7205
7576
  const pushCommand = new Command("push").description("Upload the report directory of a finished `ccqa run --report` to the hub as a run. Run this after `ccqa run` (use `if: always()` in CI so failing runs are pushed too).").option("--report <dir>", `Report directory to push. Default: ${DEFAULT_REPORT_DIR}/`).option("--project <name>", "Logical project name for the run. Defaults to the current directory's name.").option("--branch <name>", "Branch label. Defaults to $GITHUB_HEAD_REF / $GITHUB_REF_NAME / current git branch.").option("--profile <name>", "Profile (environment) the run executed against. Recorded for display; runs are not scoped by profile.").option(...hubUrlOption).option(...hubTokenOption).option("--cwd <path>", "Directory the report dir is resolved against (defaults to the current directory).").action(withHubErrors(async (opts) => {
7206
7577
  const cwd = resolveCwd(opts.cwd);
7207
7578
  const reportDir = join(cwd, opts.report ?? "ccqa-report");
@@ -7214,16 +7585,19 @@ const pushCommand = new Command("push").description("Upload the report directory
7214
7585
  hint("run `ccqa run --report` first, then push its report directory");
7215
7586
  process.exit(2);
7216
7587
  }
7217
- if (!RunReportDataSchema.safeParse(report).success) {
7588
+ const parsed = RunReportDataSchema.safeParse(report);
7589
+ if (!parsed.success) {
7218
7590
  error(`report.json in ${reportDir} is not a valid ccqa report`);
7219
7591
  process.exit(2);
7220
7592
  }
7593
+ const deployedSha = parsed.data.deployedSha;
7221
7594
  const branch = opts.branch ?? await detectBranch(cwd);
7222
7595
  const archive = await packDirToTarGz(reportDir);
7223
7596
  const run = await connect(opts).pushRun(archive, {
7224
7597
  project,
7225
7598
  ...branch ? { branch } : {},
7226
- ...opts.profile ? { profile: opts.profile } : {}
7599
+ ...opts.profile ? { profile: opts.profile } : {},
7600
+ ...deployedSha ? { deployedSha } : {}
7227
7601
  });
7228
7602
  header("hub push", run.id);
7229
7603
  meta("project", run.project);
@@ -7233,7 +7607,7 @@ const pushCommand = new Command("push").description("Upload the report directory
7233
7607
  meta("specs", `${run.specs.passed}/${run.specs.total} passed`);
7234
7608
  info(`${resolveBaseUrl(opts)}/#/runs/${run.id}`);
7235
7609
  }));
7236
- const hubCommand = new Command("hub").description("Client for a ccqa hub: push run results and manage sessions/variables/prompts used by `ccqa run`. See docs/hub.md.").addCommand(pushCommand).addCommand(sessionCommand$1).addCommand(varCommand).addCommand(promptCommand);
7610
+ const hubCommand = new Command("hub").description("Client for a ccqa hub: push run results and manage sessions/variables/prompts used by `ccqa run`. See docs/hub.md.").addCommand(pushCommand).addCommand(deployCommand).addCommand(sessionCommand$1).addCommand(varCommand).addCommand(promptCommand);
7237
7611
  /** Loose check that a fetched session is agent-browser storage-state, mirroring loadStorageState. */
7238
7612
  function isStorageStateShape(state) {
7239
7613
  return typeof state === "object" && state !== null && Array.isArray(state.cookies) && Array.isArray(state.origins);
@@ -7514,6 +7888,7 @@ async function runLiveExecutor(input) {
7514
7888
  const stepResults = [];
7515
7889
  let overallFailed = false;
7516
7890
  const statePath = input.statePath ?? null;
7891
+ const verifyUrl = input.verifyUrl ?? null;
7517
7892
  const promptPrefix = buildLiveSystemPromptPrefix({
7518
7893
  title: input.spec.title,
7519
7894
  allSteps: input.steps,
@@ -7545,12 +7920,25 @@ async function runLiveExecutor(input) {
7545
7920
  const systemPrompt = promptPrefix + buildLiveSystemPromptStepSection(step$1) + suffixBlock + langDirective;
7546
7921
  const userPrompt = buildLiveUserPrompt(step$1);
7547
7922
  let attempt = 0;
7923
+ let recoveredOnce = false;
7548
7924
  let lastOutcome = null;
7549
- while (attempt <= retries) {
7550
- if (attempt > 0) info(` retry ${attempt}/${retries} for ${step$1.id}`);
7925
+ for (;;) {
7551
7926
  lastOutcome = await executeStepAttempt(step$1, paths, systemPrompt, userPrompt);
7552
7927
  if (lastOutcome.status === "passed") break;
7928
+ if (!recoveredOnce && statePath && verifyUrl) {
7929
+ const health = checkLiveSessionHealth(input.sessionName);
7930
+ if (!health.healthy) {
7931
+ warn(`session lost mid-step for ${step$1.id} (${health.reason}); re-injecting auth-state and retrying`);
7932
+ const rec = recoverLiveSession(input.sessionName, statePath, verifyUrl);
7933
+ if (!rec.ok) warn(`session recovery failed: ${rec.error}`);
7934
+ recoveredOnce = true;
7935
+ attempt++;
7936
+ continue;
7937
+ }
7938
+ }
7939
+ if (attempt >= retries) break;
7553
7940
  attempt++;
7941
+ info(` retry ${attempt}/${retries} for ${step$1.id}`);
7554
7942
  }
7555
7943
  const outcome = lastOutcome;
7556
7944
  stepResults.push({
@@ -8101,6 +8489,7 @@ async function resolveSessionState(names, hubCtx, profile, verify = verifySessio
8101
8489
  const profileFlag = profile ? ` --profile ${profile}` : "";
8102
8490
  const loaded = [];
8103
8491
  const broken = [];
8492
+ let verifyUrl;
8104
8493
  for (const name of names) {
8105
8494
  let state;
8106
8495
  try {
@@ -8115,6 +8504,7 @@ async function resolveSessionState(names, hubCtx, profile, verify = verifySessio
8115
8504
  }
8116
8505
  const embedded = state[SESSION_VERIFY_URL_KEY];
8117
8506
  if (typeof embedded === "string") {
8507
+ verifyUrl ??= embedded;
8118
8508
  const memoKey = `${resolvedProfile}/${name}`;
8119
8509
  if (!verifiedSessions.has(memoKey)) {
8120
8510
  const tmp = await writeMergedTempState(mergeStorageStates([state]));
@@ -8139,6 +8529,7 @@ async function resolveSessionState(names, hubCtx, profile, verify = verifySessio
8139
8529
  return {
8140
8530
  ok: true,
8141
8531
  statePath,
8532
+ ...verifyUrl ? { verifyUrl } : {},
8142
8533
  cleanup: () => removeTempStateDir(statePath)
8143
8534
  };
8144
8535
  }
@@ -8167,6 +8558,7 @@ async function runOneSpec(args) {
8167
8558
  meta("session", sessionName);
8168
8559
  opts.teardown?.trackSession(sessionName);
8169
8560
  let statePath = null;
8561
+ let verifyUrl = null;
8170
8562
  let cleanupSession = null;
8171
8563
  if (spec.session && spec.session.length > 0) {
8172
8564
  const resolution = await resolveSessionState(spec.session, opts.hubContext ?? null, opts.profile);
@@ -8181,6 +8573,7 @@ async function runOneSpec(args) {
8181
8573
  };
8182
8574
  }
8183
8575
  statePath = resolution.statePath;
8576
+ verifyUrl = resolution.verifyUrl ?? null;
8184
8577
  cleanupSession = resolution.cleanup;
8185
8578
  meta("state", spec.session.join(", "));
8186
8579
  }
@@ -8196,6 +8589,7 @@ async function runOneSpec(args) {
8196
8589
  runDir,
8197
8590
  sessionName,
8198
8591
  statePath,
8592
+ verifyUrl,
8199
8593
  systemPromptSuffix: userPromptSuffix,
8200
8594
  model: opts.model,
8201
8595
  language: opts.language,
@@ -10137,25 +10531,6 @@ function createIncrementalReport(reportDir, envelope, sink) {
10137
10531
  };
10138
10532
  }
10139
10533
  //#endregion
10140
- //#region src/run/github-run.ts
10141
- /**
10142
- * The GitHub Actions run URL for the current job, built from the standard
10143
- * Actions environment variables. Returns null unless all three are present,
10144
- * so nothing is ever invented for a local run — the same "only when in CI"
10145
- * contract the report envelope's `runId` (GITHUB_RUN_ID) already follows.
10146
- */
10147
- function githubRunUrl(env = process.env) {
10148
- const server = env["GITHUB_SERVER_URL"];
10149
- const repo = env["GITHUB_REPOSITORY"];
10150
- const runId = githubRunId(env);
10151
- if (!server || !repo || !runId) return null;
10152
- return `${server}/${repo}/actions/runs/${runId}`;
10153
- }
10154
- /** The current GitHub Actions run id (GITHUB_RUN_ID); null outside Actions. */
10155
- function githubRunId(env = process.env) {
10156
- return env["GITHUB_RUN_ID"] ?? null;
10157
- }
10158
- //#endregion
10159
10534
  //#region src/prompts/agent-update.ts
10160
10535
  /**
10161
10536
  * Build the prompts used by `--update-agent-prompt` to refresh
@@ -10551,9 +10926,12 @@ function dedupeSpecs(specs) {
10551
10926
  */
10552
10927
  async function executeRun(targets, opts) {
10553
10928
  if (opts.changed && targets.length > 0) throw new RunUsageError("--changed and an explicit spec target cannot be combined");
10929
+ const rerunProfile = opts.changed === "last-run" ? requireRerunProfile(opts.profile) : null;
10930
+ if (opts.includeUnknown && rerunProfile === null) warn(`--include-unknown is ignored: it only applies to --changed=${LAST_RUN}`);
10931
+ const forExecution = opts.dryRun !== true;
10554
10932
  const cwd = opts.cwd ?? process.cwd();
10555
10933
  const wantsLastGreen = opts.failureAnalysis === LAST_GREEN;
10556
- const [head, fixedBase] = await Promise.all([getGitHead(cwd), opts.failureAnalysis && !wantsLastGreen ? resolveAnalysisBase(opts.failureAnalysis, "--failure-analysis", cwd) : null]);
10934
+ const [head, fixedBase] = await Promise.all([getGitHead(cwd), forExecution && opts.failureAnalysis && !wantsLastGreen ? resolveAnalysisBase(opts.failureAnalysis, "--failure-analysis", cwd) : null]);
10557
10935
  const git = {
10558
10936
  head,
10559
10937
  base: wantsLastGreen ? {
@@ -10573,19 +10951,16 @@ async function executeRun(targets, opts) {
10573
10951
  });
10574
10952
  meta("analysis-base", `${fixedBase.ref} (${fixedBase.sha.slice(0, 12)}, ${fixedBase.source})`);
10575
10953
  }
10576
- let projectForProfile;
10577
- try {
10578
- if (opts.profile !== void 0) {
10579
- projectForProfile = resolveProjectOrThrow(opts.project, cwd);
10580
- await resolveProfileEnv({
10581
- profile: opts.profile,
10582
- project: projectForProfile,
10583
- cwd,
10584
- hubUrl: opts.hubUrl,
10585
- hubToken: opts.hubToken,
10586
- hubHeader: opts.hubHeader
10587
- });
10588
- } else await resolveProfileEnv({
10954
+ if (forExecution) try {
10955
+ if (opts.profile !== void 0) await resolveProfileEnv({
10956
+ profile: opts.profile,
10957
+ project: resolveProjectOrThrow(opts.project, cwd),
10958
+ cwd,
10959
+ hubUrl: opts.hubUrl,
10960
+ hubToken: opts.hubToken,
10961
+ hubHeader: opts.hubHeader
10962
+ });
10963
+ else await resolveProfileEnv({
10589
10964
  profile: void 0,
10590
10965
  project: "",
10591
10966
  cwd
@@ -10594,7 +10969,7 @@ async function executeRun(targets, opts) {
10594
10969
  if (err instanceof RunUsageError) throw err;
10595
10970
  if (err instanceof ProjectNameError) throw new RunUsageError(err.message);
10596
10971
  if (err instanceof HubConnectionError || err instanceof HubApiError) throw new RunUsageError(err.message);
10597
- throw new RunUsageError(`failed to load profile "${opts.profile}": ${err instanceof Error ? err.message : String(err)}`);
10972
+ throw new RunUsageError(`failed to load profile "${opts.profile}": ${errMessage(err)}`);
10598
10973
  }
10599
10974
  let hubCtx = null;
10600
10975
  try {
@@ -10610,11 +10985,15 @@ async function executeRun(targets, opts) {
10610
10985
  }
10611
10986
  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)`);
10612
10987
  const ledgerHub = wantsLastGreen ? hubCtx : null;
10613
- const [customPrompt, triageUserPrompt, ledgerEntries] = await Promise.all([
10614
- fetchCustomPrompt(hubCtx),
10615
- fetchTriageUserPrompt(hubCtx),
10616
- ledgerHub ? fetchLastGreenLedger(ledgerHub, opts.profile, cwd) : null
10988
+ if (rerunProfile !== null && hubCtx == null) throw new RunUsageError(`--changed=${LAST_RUN} requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)`);
10989
+ const [customPrompt, triageUserPrompt, ledgerEntries, rerunReport, fetchedDeployHead] = await Promise.all([
10990
+ forExecution ? fetchCustomPrompt(hubCtx) : null,
10991
+ forExecution ? fetchTriageUserPrompt(hubCtx) : null,
10992
+ forExecution && ledgerHub ? fetchLastGreenLedger(ledgerHub, opts.profile, cwd) : null,
10993
+ rerunProfile !== null && hubCtx ? fetchRerunReport(hubCtx, rerunProfile) : null,
10994
+ forExecution && hubCtx && opts.profile && rerunProfile === null ? tryDeployHeadSha(hubCtx, opts.profile) : null
10617
10995
  ]);
10996
+ const deployedSha = rerunReport?.deployHead.sha ?? fetchedDeployHead;
10618
10997
  if (ledgerEntries) diffProvider = createDiffProvider({
10619
10998
  resolveBase: createLastGreenResolver(ledgerEntries, cwd),
10620
10999
  cwd
@@ -10638,11 +11017,19 @@ async function executeRun(targets, opts) {
10638
11017
  let specs = dedupeSpecs((await Promise.all((targets.length ? targets : [void 0]).map((t) => resolveSpecTargets(t, enumerateAll, cwd)))).flat());
10639
11018
  if (opts.changed) {
10640
11019
  const before = specs.length;
10641
- specs = await collectChangedSpecs(specs, {
11020
+ let unanswerable = 0;
11021
+ if (rerunReport) {
11022
+ const selection = selectSpecsNeedingRerun(specs, rerunReport, { includeUnknown: opts.includeUnknown === true });
11023
+ specs = selection.selected;
11024
+ unanswerable = selection.excludedUnanswerable;
11025
+ meta("rerun-base", `deploy ${rerunReport.deployHead.sha.slice(0, 12)} (profile ${rerunReport.profile})`);
11026
+ meta("rerun-states", selection.summary);
11027
+ } else specs = await collectChangedSpecs(specs, {
10642
11028
  cwd,
10643
11029
  base: opts.changed
10644
11030
  });
10645
11031
  meta("changed-scoped", `${specs.length} of ${before} spec${before === 1 ? "" : "s"}`);
11032
+ if (specs.length === 0 && unanswerable > 0) hint(`${unanswerable} spec(s) were excluded because the hub could not tell whether they need a re-run; pass --include-unknown to run them anyway`);
10646
11033
  }
10647
11034
  if (specs.length === 0) {
10648
11035
  warn("no specs to run");
@@ -10671,6 +11058,16 @@ async function executeRun(targets, opts) {
10671
11058
  } else if (opts.out && liveSpecs.length > 1) warn("--out is ignored when running multiple live specs");
10672
11059
  if (detSpecs.length === 0 && opts.evidence === false) warn("--no-evidence is ignored: it only applies to agent-browser 'mode: deterministic' specs, and this run has none");
10673
11060
  blank();
11061
+ if (opts.dryRun) {
11062
+ for (const line of formatDryRunLines(withMode, dispatch)) emitRaw(line + "\n");
11063
+ blank();
11064
+ info("dry run: nothing was executed and no report was written");
11065
+ return {
11066
+ exitCode: 0,
11067
+ report: null,
11068
+ reportDir: null
11069
+ };
11070
+ }
10674
11071
  const det = await runDeterministicSpecs(detSpecs, opts, cwd, reportDir);
10675
11072
  if (opts.pushReport && hubCtx == null) warn("--push-report requires --hub-url/--hub-token (or CCQA_HUB_URL/CCQA_HUB_TOKEN); skipping push");
10676
11073
  let hubRunId = null;
@@ -10684,6 +11081,7 @@ async function executeRun(targets, opts) {
10684
11081
  ...branch ? { branch } : {},
10685
11082
  ...opts.profile ? { profile: opts.profile } : {},
10686
11083
  ...git.head ? { gitHead: git.head } : {},
11084
+ ...deployedSha ? { deployedSha } : {},
10687
11085
  ...ciRunId ? { ciRunId } : {},
10688
11086
  ...runUrl ? { runUrl } : {},
10689
11087
  kind: "run"
@@ -10709,6 +11107,7 @@ async function executeRun(targets, opts) {
10709
11107
  git,
10710
11108
  customPromptVersion: customPrompt?.customPromptVersion ?? null,
10711
11109
  triageUserPromptHash,
11110
+ deployedSha,
10712
11111
  opts
10713
11112
  }), hubSink);
10714
11113
  let completedNormally = false;
@@ -10774,6 +11173,7 @@ async function executeRun(targets, opts) {
10774
11173
  git,
10775
11174
  customPromptVersion,
10776
11175
  triageUserPromptHash,
11176
+ deployedSha,
10777
11177
  opts
10778
11178
  });
10779
11179
  completedNormally = true;
@@ -10783,6 +11183,7 @@ async function executeRun(targets, opts) {
10783
11183
  git,
10784
11184
  customPromptVersion,
10785
11185
  triageUserPromptHash,
11186
+ deployedSha,
10786
11187
  opts
10787
11188
  });
10788
11189
  const streamedKeys = new Set(incrementalReport.rows().map((r) => `${r.feature}/${r.spec}`));
@@ -11058,7 +11459,7 @@ async function analyzeDeterministicSummaries(summaries, cwd, reportDir, { pass,
11058
11459
  * final report.json stays byte-identical (existing e2e goldens compare it).
11059
11460
  */
11060
11461
  function buildReportEnvelope(args) {
11061
- const { git, customPromptVersion, triageUserPromptHash, opts } = args;
11462
+ const { git, customPromptVersion, triageUserPromptHash, deployedSha, opts } = args;
11062
11463
  const runUrl = githubRunUrl();
11063
11464
  return {
11064
11465
  schemaVersion: 1,
@@ -11078,17 +11479,19 @@ function buildReportEnvelope(args) {
11078
11479
  language: opts.language ?? null,
11079
11480
  promptVersion: "8",
11080
11481
  customPromptVersion,
11081
- ...triageUserPromptHash !== null ? { triageUserPromptHash } : {}
11482
+ ...triageUserPromptHash !== null ? { triageUserPromptHash } : {},
11483
+ ...deployedSha !== null ? { deployedSha } : {}
11082
11484
  };
11083
11485
  }
11084
11486
  /** Write the unified JSON (+ optional GitHub-annotation) report for one run. Returns the report data. */
11085
11487
  async function writeUnifiedReport(args) {
11086
- const { reportDir, results, git, customPromptVersion, triageUserPromptHash, opts } = args;
11488
+ const { reportDir, results, git, customPromptVersion, triageUserPromptHash, deployedSha, opts } = args;
11087
11489
  const data = {
11088
11490
  ...buildReportEnvelope({
11089
11491
  git,
11090
11492
  customPromptVersion,
11091
11493
  triageUserPromptHash,
11494
+ deployedSha,
11092
11495
  opts
11093
11496
  }),
11094
11497
  results
@@ -11100,9 +11503,6 @@ async function writeUnifiedReport(args) {
11100
11503
  if (opts.format === "github") for (const line of emitGithubAnnotations(data)) emitRaw(line + "\n");
11101
11504
  return data;
11102
11505
  }
11103
- function errMessage(err) {
11104
- return err instanceof Error ? err.message : String(err);
11105
- }
11106
11506
  /**
11107
11507
  * Raw-byte budget for the files inlined in one incremental `PATCH`. Base64
11108
11508
  * inflates by ~4/3 and the rows ride in the same body, so this keeps the
@@ -11343,7 +11743,7 @@ function installTeardownSignalHandlers(teardown) {
11343
11743
  }
11344
11744
  //#endregion
11345
11745
  //#region src/cli/run.ts
11346
- const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("run").argument("[targets...]", "Specs to run, space-separated: each '<feature>/<spec>', '<feature>', or omit for all. Duplicates are de-duped.").description("Run specs, on any target. Agent-browser specs replay the recorded test.spec.ts under vitest (default), or, with spec.yaml `mode: live`, have Claude drive agent-browser live per step. External-target specs (playwright, runn) run through the target's configured `runCommand`. A structured report (report.json + evidence) is always written; use --push-report to also stream it to a hub.").option("--report [dir]", `Directory for the structured run results (report.json + evidence PNGs) that are always written. Default: ${DEFAULT_REPORT_DIR}/. Pass this only to change the location.`).option("--push-report", "Incrementally push the run report to the hub as the run progresses (open → patch per spec → finalize). Requires --hub-url/--hub-token (or CCQA_HUB_URL/CCQA_HUB_TOKEN). Without it, hub credentials are used only to fetch variables/sessions/prompts, not to push.").option("--changed [base]", "Restrict execution to specs 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("--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) => {
11746
+ const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("run").argument("[targets...]", "Specs to run, space-separated: each '<feature>/<spec>', '<feature>', or omit for all. Duplicates are de-duped.").description("Run specs, on any target. Agent-browser specs replay the recorded test.spec.ts under vitest (default), or, with spec.yaml `mode: live`, have Claude drive agent-browser live per step. External-target specs (playwright, runn) run through the target's configured `runCommand`. A structured report (report.json + evidence) is always written; use --push-report to also stream it to a hub.").option("--report [dir]", `Directory for the structured run results (report.json + evidence PNGs) that are always written. Default: ${DEFAULT_REPORT_DIR}/. Pass this only to change the location.`).option("--push-report", "Incrementally push the run report to the hub as the run progresses (open → patch per spec → finalize). Requires --hub-url/--hub-token (or CCQA_HUB_URL/CCQA_HUB_TOKEN). Without it, hub credentials are used only to fetch variables/sessions/prompts, not to push.").option("--changed [base]", "Restrict execution to specs 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), or pass 'last-run' to run the specs the hub says need one — each spec's own last run compared against the deploy log (requires a hub connection and --profile; no git diff involved). Cannot be combined with an explicit spec id.").option("--include-unknown", "(--changed=last-run only) Also run specs whose re-run need the hub cannot answer ('unknown') and specs that have never run ('neverRun'). Off by default: an unanswerable question is reported, not guessed.").option("--dry-run", "Print the specs this invocation would run, then exit 0 without executing anything and without writing a report. Works with every selection mode.").option("--failure-analysis [base]", "Classify each failure (TEST_DRIFT / SPEC_CHANGE / PRODUCT_BUG) against the source diff since [base]. Without a value the base comes from $GITHUB_BASE_REF (pull_request CI); elsewhere pass it explicitly (e.g. --failure-analysis=origin/main), or pass 'last-green' to diff each spec against the commit where it last passed (per-spec baselines from the hub; requires a hub connection). Off by default — no Claude calls without it.").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--format <fmt>", "Additional output format alongside HTML when --report is set: 'text' (default), 'json' (writes report.json), 'github' (GitHub Actions annotations on stdout).", (raw) => {
11347
11747
  if (REPORT_FORMATS.includes(raw)) return raw;
11348
11748
  throw new Error(`--format must be one of ${REPORT_FORMATS.join(" | ")}`);
11349
11749
  }, "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) => {
@@ -11366,6 +11766,7 @@ function parseConcurrency$1(raw) {
11366
11766
  function headerTarget(targets, opts) {
11367
11767
  if (targets.length === 1) return targets[0];
11368
11768
  if (targets.length > 1) return `${targets.length} targets`;
11769
+ if (opts.changed === "last-run") return "(needs re-run)";
11369
11770
  return opts.changed ? "(changed)" : "(all specs)";
11370
11771
  }
11371
11772
  /**
@@ -12863,6 +13264,97 @@ For each test case above, write a 1–2 sentence factual \`summary\` of what it
12863
13264
  `;
12864
13265
  }
12865
13266
  //#endregion
13267
+ //#region src/cli/related-paths-check.ts
13268
+ /**
13269
+ * Data-quality check on `relatedPaths`: how many of a spec's patterns match no
13270
+ * file at all.
13271
+ *
13272
+ * A pattern that matches nothing is the failure mode that makes re-run
13273
+ * selection lie in the *dangerous* direction — too narrow a list produces a
13274
+ * confident "no re-run needed" for a spec whose code did change (ADR-0010).
13275
+ * It costs one file listing and no model call, so `ccqa perspectives` records
13276
+ * the count and the hub UI can warn next to the verdict.
13277
+ *
13278
+ * Patterns are interpreted relative to the directory hosting `.ccqa/`, the
13279
+ * same rule `--changed` applies. A pattern deliberately written repo-root
13280
+ * relative to reach a monorepo sibling package therefore counts as unmatched;
13281
+ * that is a false alarm, which is the harmless direction here.
13282
+ */
13283
+ /** Directories the fallback walk never descends into: enormous, and never what `relatedPaths` target. */
13284
+ const SKIP_DIRS = new Set([".git", "node_modules"]);
13285
+ /**
13286
+ * The files a `relatedPaths` pattern could possibly match, as cwd-relative
13287
+ * posix paths, listed once and shared by every spec.
13288
+ *
13289
+ * Tracked files, via `git ls-files`. That is not just the cheap way (a walk of
13290
+ * a large monorepo takes an order of magnitude longer and drags in `dist/`,
13291
+ * `.next/`, `coverage/`, past report directories) — it is the *correct*
13292
+ * universe: `relatedPaths` are only ever matched against `git diff` output,
13293
+ * both by `--changed` and by a deploy's `changedPaths`, and that output only
13294
+ * ever names tracked files. A pattern whose sole match is an untracked build
13295
+ * artifact can never match a real change, so counting it as matched would be a
13296
+ * miss in precisely the direction this check exists to catch.
13297
+ *
13298
+ * Falls back to a directory walk outside a git checkout, where the question
13299
+ * still has a useful approximate answer.
13300
+ */
13301
+ async function listCheckoutFiles(cwd) {
13302
+ try {
13303
+ const { stdout } = await execFileP("git", ["ls-files", "-z"], {
13304
+ cwd,
13305
+ maxBuffer: 64 * 1024 * 1024
13306
+ });
13307
+ return stdout.split("\0").filter((p) => p.length > 0);
13308
+ } catch {
13309
+ return walkFiles(cwd);
13310
+ }
13311
+ }
13312
+ /**
13313
+ * Every file under `cwd` as a cwd-relative posix path — the non-git fallback
13314
+ * for `listCheckoutFiles`. Symlinks are listed but not followed, so a link loop
13315
+ * cannot hang the walk. An unreadable directory is skipped rather than fatal:
13316
+ * the count is a warning signal, not a gate. Exported for tests, which cannot
13317
+ * otherwise reach this branch deterministically.
13318
+ */
13319
+ async function walkFiles(cwd) {
13320
+ const out = [];
13321
+ const stack = [cwd];
13322
+ while (stack.length > 0) {
13323
+ const dir = stack.pop();
13324
+ let entries;
13325
+ try {
13326
+ entries = await readdir(dir, { withFileTypes: true });
13327
+ } catch {
13328
+ continue;
13329
+ }
13330
+ for (const entry of entries) {
13331
+ const abs = join(dir, entry.name);
13332
+ if (entry.isDirectory()) {
13333
+ if (!SKIP_DIRS.has(entry.name)) stack.push(abs);
13334
+ } else out.push(relative(cwd, abs).split(sep).join("/"));
13335
+ }
13336
+ }
13337
+ return out;
13338
+ }
13339
+ /** How many of `patterns` match none of `files`. */
13340
+ function countUnmatchedPatterns(patterns, files) {
13341
+ return patterns.filter((pattern) => !files.some((file) => matchesGlob(file, pattern))).length;
13342
+ }
13343
+ /**
13344
+ * A perspective entry's `relatedPaths` fields — the list and its zero-match
13345
+ * count — produced together, so the two writers of the document (the full
13346
+ * `ccqa perspectives` build and the per-spec update after `ccqa record`) cannot
13347
+ * record one without the other. Empty for a spec that declares no paths: there
13348
+ * is nothing to check, which is not the same as "checked, all matched".
13349
+ */
13350
+ function relatedPathsFields(paths, files) {
13351
+ if (paths.length === 0) return {};
13352
+ return {
13353
+ relatedPaths: [...paths],
13354
+ relatedPathsUnmatched: countUnmatchedPatterns(paths, files)
13355
+ };
13356
+ }
13357
+ //#endregion
12866
13358
  //#region src/cli/perspectives.ts
12867
13359
  const perspectivesCommand = addHubOptions(addLanguageOption(new Command("perspectives").description("Generate/update the project's perspectives document on the hub — a factual inventory of existing test coverage (no severity, no gap analysis)").option("--instruction <text>", "Hint to steer how summaries are written").option("--apply", "Auto-apply without [y/N] confirmation", false).option("--check", "Verify the hub document still matches the local specs (mechanical fields only) and exit 1 when it is stale. No Claude calls — cheap enough for CI.", false).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID").option("--project <name>", "Hub project to store the document under (default: cwd directory name)"))).action(withHubErrors(async (opts) => {
12868
13360
  if (opts.check) await runPerspectivesCheck(opts);
@@ -12880,7 +13372,8 @@ async function runPerspectivesCheck(opts) {
12880
13372
  const hub = requireHubOrExit(opts);
12881
13373
  const project = resolveProject(opts);
12882
13374
  header("perspectives", `check (project: ${project})`);
12883
- const skeleton = await buildSkeleton(await listFeatureTree());
13375
+ const [tree, checkoutFiles] = await Promise.all([listFeatureTree(), listCheckoutFiles(process.cwd())]);
13376
+ const skeleton = await buildSkeleton(tree, checkoutFiles);
12884
13377
  const localCount = skeleton.reduce((n, f) => n + f.specs.length, 0);
12885
13378
  const existingDoc = await hub.getPerspectives(project);
12886
13379
  if (existingDoc === null) {
@@ -12962,7 +13455,8 @@ async function runPerspectives(opts) {
12962
13455
  const hub = requireHubOrExit(opts);
12963
13456
  const project = resolveProject(opts);
12964
13457
  header("perspectives", `project: ${project}`);
12965
- const skeleton = await buildSkeleton(await listFeatureTree());
13458
+ const [tree, checkoutFiles] = await Promise.all([listFeatureTree(), listCheckoutFiles(process.cwd())]);
13459
+ const skeleton = await buildSkeleton(tree, checkoutFiles);
12966
13460
  const allSpecs = skeleton.flatMap((f) => f.specs);
12967
13461
  if (allSpecs.length === 0) {
12968
13462
  info("no test cases found under .ccqa/features — nothing to inventory.");
@@ -13018,8 +13512,11 @@ async function cleanupLegacyLocalFiles() {
13018
13512
  * relatedPaths transcribed from each spec, status derived mechanically from
13019
13513
  * on-disk artifacts. `summary` is left empty here; Claude fills it later.
13020
13514
  * Specs whose spec.yaml is missing or unparsable are skipped.
13515
+ *
13516
+ * `checkoutFiles` is the one shared file listing every spec's `relatedPaths`
13517
+ * zero-match count is computed against.
13021
13518
  */
13022
- async function buildSkeleton(tree) {
13519
+ async function buildSkeleton(tree, checkoutFiles) {
13023
13520
  const config = await loadProjectConfig(process.cwd()).catch(() => null);
13024
13521
  return (await Promise.all(tree.map(async (feature) => {
13025
13522
  const specs = await Promise.all(feature.specs.filter((s) => s.hasSpecFile).map(async (s) => {
@@ -13027,14 +13524,13 @@ async function buildSkeleton(tree) {
13027
13524
  const meta = readSpecMeta(s.specName, specYaml);
13028
13525
  const plugin = resolveSpecTarget(specYaml, config);
13029
13526
  const status = await deriveStatus(feature.featureName, s.specName, meta.mode, plugin);
13030
- const entry = {
13527
+ return {
13031
13528
  specName: s.specName,
13032
13529
  title: meta.title,
13033
13530
  summary: "",
13531
+ ...relatedPathsFields(s.relatedPaths ?? [], checkoutFiles),
13034
13532
  status
13035
13533
  };
13036
- if (s.relatedPaths) entry.relatedPaths = s.relatedPaths;
13037
- return entry;
13038
13534
  }));
13039
13535
  return {
13040
13536
  featureName: feature.featureName,
@@ -13288,6 +13784,7 @@ async function doSync(ctx, opts) {
13288
13784
  const status = await deriveStatus(featureName, specName, meta$1.mode, plugin);
13289
13785
  const relatedPaths = extractRelatedPaths(specYaml);
13290
13786
  const previous = findSpec(doc, featureName, specName);
13787
+ const checkoutFiles = listCheckoutFiles(process.cwd());
13291
13788
  const written = (await requestSummaries([{
13292
13789
  featureName,
13293
13790
  specName,
@@ -13301,6 +13798,7 @@ async function doSync(ctx, opts) {
13301
13798
  specName,
13302
13799
  title: meta$1.title,
13303
13800
  summary: written?.summary ?? previous?.summary ?? "",
13801
+ ...relatedPathsFields(relatedPaths, await checkoutFiles),
13304
13802
  status
13305
13803
  };
13306
13804
  const startScreen = written?.startScreen ?? previous?.startScreen;
@@ -13309,7 +13807,6 @@ async function doSync(ctx, opts) {
13309
13807
  if (testCondition) entry.testCondition = testCondition;
13310
13808
  const preconditions = written?.preconditions ?? previous?.preconditions;
13311
13809
  if (preconditions && preconditions.length > 0) entry.preconditions = preconditions;
13312
- if (relatedPaths.length > 0) entry.relatedPaths = relatedPaths;
13313
13810
  if (previous?.note) entry.note = previous.note;
13314
13811
  upsertSpec(doc, featureName, entry);
13315
13812
  doc.generatedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -14426,6 +14923,10 @@ const bootstrapCommand = new Command("bootstrap").description("Open a headed bro
14426
14923
  const sessionCommand = new Command("session").description("Manage saved browser sessions (cookies + localStorage) for `session:` specs. Use `ccqa hub session ls` to list sessions stored on the hub.").addCommand(bootstrapCommand);
14427
14924
  //#endregion
14428
14925
  //#region src/hub/api/respond.ts
14926
+ /** The message of an unknown throwable, for a log line or an error body. */
14927
+ function errMsg(err) {
14928
+ return err instanceof Error ? err.message : String(err);
14929
+ }
14429
14930
  var HttpError = class extends Error {
14430
14931
  status;
14431
14932
  code;
@@ -14451,7 +14952,7 @@ function sendError(res, err) {
14451
14952
  }
14452
14953
  sendJson(res, 500, { error: {
14453
14954
  code: "internal_error",
14454
- message: err instanceof Error ? err.message : String(err)
14955
+ message: errMsg(err)
14455
14956
  } });
14456
14957
  }
14457
14958
  function sendBytes(res, status, bytes, contentType) {
@@ -14477,6 +14978,24 @@ function readBody(req, maxBytes) {
14477
14978
  req.on("error", rejectPromise);
14478
14979
  });
14479
14980
  }
14981
+ /**
14982
+ * Read a JSON request body and validate it against `schema`. Both a body that
14983
+ * isn't JSON and one that doesn't fit the schema are the client's fault, so
14984
+ * both are 400 `invalid_body` — a bare `JSON.parse` would surface a malformed
14985
+ * body as a 500. `label` names the body in the message ("deploy body").
14986
+ */
14987
+ async function readJsonBody(req, maxBytes, schema, label) {
14988
+ const raw = await readBody(req, maxBytes);
14989
+ let parsed;
14990
+ try {
14991
+ parsed = JSON.parse(raw.toString("utf8"));
14992
+ } catch {
14993
+ throw new HttpError(400, "invalid_body", `${label} must be valid JSON`);
14994
+ }
14995
+ const result = schema.safeParse(parsed);
14996
+ if (!result.success) throw new HttpError(400, "invalid_body", `${label} is invalid: ${result.error.issues[0]?.message ?? "schema mismatch"}`);
14997
+ return result.data;
14998
+ }
14480
14999
  //#endregion
14481
15000
  //#region src/hub/api/handlers/health.ts
14482
15001
  /** GET /api/v1/health — unauthenticated liveness probe. `queueDepth` is the number of learning jobs waiting. */
@@ -14532,7 +15051,10 @@ z.object({
14532
15051
  ciRunId: z.string().nullable(),
14533
15052
  runUrl: z.string().nullable().optional(),
14534
15053
  reportCreatedAt: z.string(),
14535
- createdAt: z.string()
15054
+ createdAt: z.string(),
15055
+ deployedSha: z.string().nullable().optional(),
15056
+ deployedShaSource: z.enum(["hub-deploy-log", "client"]).nullable().optional(),
15057
+ deployedShaAmbiguous: z.boolean().optional()
14536
15058
  });
14537
15059
  /**
14538
15060
  * One failing spec's triage: the AI's prediction (read-only, sourced from
@@ -14581,10 +15103,129 @@ z.object({ error: z.object({
14581
15103
  code: z.string(),
14582
15104
  message: z.string()
14583
15105
  }) });
14584
- z.object({
15106
+ /**
15107
+ * One spec's record of a single run, as stored in a ledger bucket. Identical
15108
+ * to `LastGreenEntry` plus the commit the environment was running at the time
15109
+ * — without it a bucket entry can be ordered in wall-clock time but not
15110
+ * *positioned* against the deploy log, which is the only ordering re-run
15111
+ * selection may use (ADR-0010).
15112
+ */
15113
+ const SpecLedgerEntrySchema = z.object({
14585
15114
  gitHead: z.string(),
14586
15115
  runId: z.string(),
14587
15116
  at: z.string()
15117
+ }).extend({
15118
+ deployedSha: z.string().nullable().optional(),
15119
+ deployedShaAmbiguous: z.boolean().optional()
15120
+ });
15121
+ z.object({
15122
+ green: z.record(z.string(), SpecLedgerEntrySchema).default({}),
15123
+ run: z.record(z.string(), SpecLedgerEntrySchema).default({}),
15124
+ red: z.record(z.string(), SpecLedgerEntrySchema).default({})
15125
+ });
15126
+ /** One deploy, as the consumer's deploy job reported it (ADR-0010). */
15127
+ const DeployEntrySchema = z.object({
15128
+ index: z.number().int().nonnegative(),
15129
+ sha: z.string(),
15130
+ previousSha: z.string().nullable(),
15131
+ at: z.string(),
15132
+ ref: z.string().optional(),
15133
+ runUrl: z.string().optional(),
15134
+ changedPaths: z.array(z.string()).nullable(),
15135
+ truncated: z.boolean().default(false),
15136
+ gapBefore: z.boolean().default(false)
15137
+ });
15138
+ z.object({
15139
+ nextIndex: z.number().int().nonnegative().default(0),
15140
+ entries: z.array(DeployEntrySchema).default([])
15141
+ });
15142
+ /**
15143
+ * One spec's entry in the derived touch index: the newest deploy known to have
15144
+ * touched it, folded in at write time from the deploy's full `changedPaths` so
15145
+ * that list can be dropped afterwards rather than retained per deploy.
15146
+ *
15147
+ * Folded against the `relatedPaths` in force when the deploy landed, so it can
15148
+ * disagree with a match against a spec's current `relatedPaths`. It is
15149
+ * therefore only consulted where the retained log cannot answer (a truncated
15150
+ * entry), never in place of matching the current paths.
15151
+ */
15152
+ const SpecTouchSchema = z.object({
15153
+ lastTouchedIndex: z.number().int().nonnegative(),
15154
+ lastTouchedSha: z.string(),
15155
+ lastTouchedAt: z.string(),
15156
+ matchedPaths: z.array(z.string())
15157
+ });
15158
+ z.record(z.string(), SpecTouchSchema);
15159
+ /**
15160
+ * Whether a spec's last result is still trustworthy. Deliberately named for
15161
+ * the action rather than for a freshness adjective: "needs re-run" (mechanical,
15162
+ * no model call) is a different question from drift (does the spec still
15163
+ * describe the product), and the two must not be conflated — see ADR-0010.
15164
+ */
15165
+ const RerunStateSchema = z.enum([
15166
+ "needed",
15167
+ "notNeeded",
15168
+ "unknown",
15169
+ "neverRun",
15170
+ "notEvaluated"
15171
+ ]);
15172
+ /**
15173
+ * Why a spec is `unknown`. Always carried, so the view can name the missing
15174
+ * input ("no deploy log for this profile") instead of shrugging. `unknown` is
15175
+ * never rendered as "not needed".
15176
+ */
15177
+ const RerunUnknownReasonSchema = z.enum([
15178
+ "noRelatedPaths",
15179
+ "noDeployLog",
15180
+ "unknownDeployedSha",
15181
+ "ambiguousDeployedSha",
15182
+ "deployedShaNotInLog",
15183
+ "gapInRange",
15184
+ "truncatedInRange"
15185
+ ]);
15186
+ /** Where a deploy sits in a profile's log, as the view names it. */
15187
+ const DeployRefSchema = z.object({
15188
+ index: z.number().int().nonnegative(),
15189
+ sha: z.string(),
15190
+ at: z.string()
15191
+ });
15192
+ /**
15193
+ * One spec's re-run verdict plus the three ledger coordinates the view shows
15194
+ * alongside it. The coordinates are always present (null when the spec has no
15195
+ * such entry); `reason`, `touchedBy` and `touchedByDeploy` appear only in the
15196
+ * states named below.
15197
+ */
15198
+ const SpecRerunSchema = z.object({
15199
+ state: RerunStateSchema,
15200
+ reason: RerunUnknownReasonSchema.optional(),
15201
+ lastRun: SpecLedgerEntrySchema.nullable(),
15202
+ lastGreen: SpecLedgerEntrySchema.nullable(),
15203
+ lastRed: SpecLedgerEntrySchema.nullable(),
15204
+ touchedBy: z.array(z.string()).optional(),
15205
+ touchedByDeploy: DeployRefSchema.nullable().optional()
15206
+ });
15207
+ z.object({
15208
+ project: z.string(),
15209
+ profile: z.string(),
15210
+ deployHead: DeployRefSchema.nullable(),
15211
+ specs: z.record(z.string(), SpecRerunSchema)
15212
+ });
15213
+ /** Body of `POST /projects/:project/deploys?profile=` — what the deploy job shipped. */
15214
+ const RecordDeployRequestSchema = z.object({
15215
+ sha: z.string().min(1),
15216
+ previousSha: z.string().min(1).nullable().optional(),
15217
+ changedPaths: z.array(z.string()).nullable().optional(),
15218
+ ref: z.string().optional(),
15219
+ runUrl: z.string().optional()
15220
+ });
15221
+ z.object({
15222
+ entries: z.array(DeployEntrySchema),
15223
+ nextIndex: z.number().int().nonnegative()
15224
+ });
15225
+ z.object({
15226
+ entries: z.record(z.string(), SpecLedgerEntrySchema),
15227
+ lastRun: z.record(z.string(), SpecLedgerEntrySchema).default({}),
15228
+ lastRed: z.record(z.string(), SpecLedgerEntrySchema).default({})
14588
15229
  });
14589
15230
  /**
14590
15231
  * A triage-learning job. Grading failing specs in the hub UI produces the
@@ -14624,13 +15265,56 @@ const CreateLearningJobRequestSchema = z.object({
14624
15265
  runLimit: z.number().int().positive().max(1e3).optional()
14625
15266
  });
14626
15267
  //#endregion
14627
- //#region src/hub/api/validate.ts
15268
+ //#region src/hub/core/spec-ledger.ts
15269
+ const BUCKET_NAMES = [
15270
+ "green",
15271
+ "run",
15272
+ "red"
15273
+ ];
15274
+ function emptyLedger() {
15275
+ return {
15276
+ green: {},
15277
+ run: {},
15278
+ red: {}
15279
+ };
15280
+ }
14628
15281
  /**
14629
- * Validators for URL path parameters that flow into the storage layer's file
14630
- * path construction (secret store scope/name, artifact relative paths).
14631
- * Router params come from `decodeURIComponent`-ed path segments, so a client
14632
- * can put `..`, `/`, or `\` in them these guards reject anything that
14633
- * could escape the intended directory before it ever reaches disk I/O.
15282
+ * Documents written before the ledger grew `run` and `red` are a flat
15283
+ * `Record<specKey, entry>` of greens. A spec key is "feature/spec" and always
15284
+ * contains a '/', so it can never collide with a bucket name and the two
15285
+ * shapes are unambiguous. The first `merge` rewrites the file in the new
15286
+ * shape; nothing else migrates it.
15287
+ */
15288
+ function toLedger(raw) {
15289
+ if (raw === null || typeof raw !== "object") return emptyLedger();
15290
+ const doc = raw;
15291
+ if (!BUCKET_NAMES.some((name) => name in doc)) return {
15292
+ green: doc,
15293
+ run: {},
15294
+ red: {}
15295
+ };
15296
+ return {
15297
+ green: doc["green"] ?? {},
15298
+ run: doc["run"] ?? {},
15299
+ red: doc["red"] ?? {}
15300
+ };
15301
+ }
15302
+ /** Fold `from` into `into` in place, per bucket and key, newest `at` winning. */
15303
+ function mergeLedgerInto(into, from) {
15304
+ for (const name of BUCKET_NAMES) for (const [key, entry] of Object.entries(from[name])) {
15305
+ const prev = into[name][key];
15306
+ if (!prev || prev.at <= entry.at) into[name][key] = entry;
15307
+ }
15308
+ return into;
15309
+ }
15310
+ //#endregion
15311
+ //#region src/hub/api/validate.ts
15312
+ /**
15313
+ * Validators for URL path parameters that flow into the storage layer's file
15314
+ * path construction (secret store scope/name, artifact relative paths).
15315
+ * Router params come from `decodeURIComponent`-ed path segments, so a client
15316
+ * can put `..`, `/`, or `\` in them — these guards reject anything that
15317
+ * could escape the intended directory before it ever reaches disk I/O.
14634
15318
  */
14635
15319
  const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
14636
15320
  /** Validate a single URL path parameter (e.g. `:profile`, `:name`) as a bare name. Throws 400 if unsafe. */
@@ -14638,6 +15322,13 @@ function requireSafeSegment(value, paramName) {
14638
15322
  if (value.length === 0 || value.length > 128 || !SAFE_SEGMENT.test(value) || value === "." || value === ".." || value.includes("/") || value.includes("\\")) throw new HttpError(400, "invalid_param", `invalid ${paramName}: must be a bare name (letters, digits, '.', '_', '-'; no path separators or '..')`);
14639
15323
  return value;
14640
15324
  }
15325
+ /**
15326
+ * The `?profile=` query param, defaulting to "default". Shared by every
15327
+ * profile-scoped route so the default and the validation rule have one home.
15328
+ */
15329
+ function requireProfileParam(url) {
15330
+ return requireSafeSegment(url.searchParams.get("profile") ?? "default", "profile");
15331
+ }
14641
15332
  /** Validate a `*path`-captured relative path (multiple segments allowed) as safe to join under a root dir. Throws 400 if unsafe. */
14642
15333
  function requireSafeRelPath(relPath, paramName) {
14643
15334
  const segments = relPath.split("/");
@@ -14657,7 +15348,7 @@ const DEFAULT_MAX_PUSH_BYTES = 32 * 1024 * 1024;
14657
15348
  function createPushRunHandler(config) {
14658
15349
  const maxPushBytes = config.maxPushBytes ?? DEFAULT_MAX_PUSH_BYTES;
14659
15350
  return async (ctx) => {
14660
- const { project, branch, profile, kind } = parseRunScope(ctx);
15351
+ const { project, branch, profile, kind, deployedSha } = parseRunScope(ctx);
14661
15352
  const body = await readBody(ctx.req, maxPushBytes);
14662
15353
  const dir = await mkdtemp(join(tmpdir(), "ccqa-hub-push-"));
14663
15354
  try {
@@ -14698,11 +15389,13 @@ function createPushRunHandler(config) {
14698
15389
  ciRunId: report.runId,
14699
15390
  runUrl: report.runUrl ?? null,
14700
15391
  reportCreatedAt: report.createdAt,
14701
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
15392
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
15393
+ ...await resolveDeployedSha(config.storage, project, profile, deployedSha),
15394
+ deployedShaAmbiguous: false
14702
15395
  };
14703
15396
  await config.storage.artifacts.putDir(run.id, dir);
14704
15397
  await config.storage.runs.create(run);
14705
- await updateLastGreenLedger(config.storage, run, report.results);
15398
+ await updateSpecLedger(config.storage, run, report.results);
14706
15399
  sendJson(ctx.res, 201, run);
14707
15400
  } finally {
14708
15401
  await rm(dir, {
@@ -14722,7 +15415,7 @@ function createPushRunHandler(config) {
14722
15415
  */
14723
15416
  function createOpenRunHandler(config) {
14724
15417
  return async (ctx) => {
14725
- const { project, branch, profile, kind } = parseRunScope(ctx);
15418
+ const { project, branch, profile, kind, deployedSha } = parseRunScope(ctx);
14726
15419
  const gitHead = ctx.url.searchParams.get("gitHead");
14727
15420
  const ciRunId = ctx.url.searchParams.get("ciRunId");
14728
15421
  const runUrl = ctx.url.searchParams.get("runUrl");
@@ -14745,7 +15438,9 @@ function createOpenRunHandler(config) {
14745
15438
  ciRunId: ciRunId || null,
14746
15439
  runUrl: runUrl || null,
14747
15440
  reportCreatedAt: now,
14748
- createdAt: now
15441
+ createdAt: now,
15442
+ ...await resolveDeployedSha(config.storage, project, profile, deployedSha),
15443
+ deployedShaAmbiguous: false
14749
15444
  };
14750
15445
  await config.storage.runs.create(run);
14751
15446
  sendJson(ctx.res, 201, run);
@@ -14782,35 +15477,92 @@ function countSpecs(results) {
14782
15477
  };
14783
15478
  }
14784
15479
  /**
14785
- * Advance the last-green ledger for every passed spec of a terminal
14786
- * `kind: "run"` run. Spec-level, not run-level: a run with one chronically
14787
- * failing spec still moves the baseline of every spec that did pass.
15480
+ * Advance the spec ledger's three buckets from a terminal `kind: "run"` run.
15481
+ * Spec-level, not run-level: a run with one chronically failing spec still
15482
+ * moves the baselines of every spec that did pass.
15483
+ *
15484
+ * A skipped row did not execute, so it advances nothing — not even `run`.
15485
+ * Everything else lands in `run` (the "needs re-run" baseline) and in `green`
15486
+ * or `red` (the last outcome), which are orthogonal axes.
15487
+ *
14788
15488
  * Best-effort — a ledger failure must not fail the push; the ledger is an
14789
- * accelerator for `--failure-analysis=last-green`, not part of the run
14790
- * record. Runs without a branch or gitHead can't be placed in the ledger and
14791
- * are skipped.
15489
+ * accelerator for `--failure-analysis=last-green` and re-run selection, not
15490
+ * part of the run record. Runs without a branch or gitHead can't be placed in
15491
+ * the ledger and are skipped.
14792
15492
  *
14793
15493
  * Ordering caveat (known approximation): `at` is the run's reportCreatedAt —
14794
15494
  * open time for incremental runs, report time for immutable pushes. When two
14795
15495
  * runs on the same branch+profile overlap, "newest at wins" can pick either
14796
15496
  * of the two genuinely-green commits, since the hub has no git ancestry to
14797
15497
  * order them properly. Accepted: CI serializes per branch in practice, and a
14798
- * baseline can only ever point at a commit where the spec really passed.
15498
+ * baseline can only ever point at a commit where the spec really ran.
14799
15499
  */
14800
- async function updateLastGreenLedger(storage, run, results) {
15500
+ async function updateSpecLedger(storage, run, results) {
14801
15501
  const { gitHead, branch } = run;
14802
15502
  if (run.kind !== "run" || !gitHead || !branch) return;
14803
- const passed = results.filter((r) => r.status === "passed");
14804
- if (passed.length === 0) return;
14805
- const entries = Object.fromEntries(passed.map((r) => [`${r.feature}/${r.spec}`, {
15503
+ const entry = {
14806
15504
  gitHead,
14807
15505
  runId: run.id,
14808
- at: run.reportCreatedAt
14809
- }]));
15506
+ at: run.reportCreatedAt,
15507
+ deployedSha: run.deployedSha ?? null,
15508
+ deployedShaAmbiguous: run.deployedShaAmbiguous ?? false
15509
+ };
15510
+ const ledger = emptyLedger();
15511
+ for (const row of results) {
15512
+ if (row.status === "skipped") continue;
15513
+ const key = `${row.feature}/${row.spec}`;
15514
+ ledger.run[key] = entry;
15515
+ if (row.status === "passed") ledger.green[key] = entry;
15516
+ else ledger.red[key] = entry;
15517
+ }
15518
+ if (Object.keys(ledger.run).length === 0) return;
15519
+ try {
15520
+ await storage.ledger.merge(run.project, run.profile ?? "default", branch, ledger);
15521
+ } catch (err) {
15522
+ console.error(`hub: spec ledger update failed for run "${run.id}": ${errMsg(err)}`);
15523
+ }
15524
+ }
15525
+ /**
15526
+ * What commit the environment was running for this run. An explicit
15527
+ * `?deployedSha=` wins — ccqa never guesses a baseline, and a caller that
15528
+ * knows what it deployed against is more authoritative than the log head.
15529
+ *
15530
+ * Best-effort: a deploy log the hub can't read leaves the run unattributed
15531
+ * (re-run selection then answers `unknown`) rather than rejecting the run.
15532
+ */
15533
+ async function resolveDeployedSha(storage, project, profile, explicit) {
15534
+ if (explicit) return {
15535
+ deployedSha: explicit,
15536
+ deployedShaSource: "client"
15537
+ };
14810
15538
  try {
14811
- await storage.lastGreen.merge(run.project, run.profile ?? "default", branch, entries);
15539
+ const head = await storage.deploys.head(project, profile ?? "default");
15540
+ if (head) return {
15541
+ deployedSha: head.sha,
15542
+ deployedShaSource: "hub-deploy-log"
15543
+ };
14812
15544
  } catch (err) {
14813
- console.error(`hub: last-green ledger update failed for run "${run.id}": ${err instanceof Error ? err.message : String(err)}`);
15545
+ console.error(`hub: could not read the deploy log for "${project}/${profile ?? "default"}": ${errMsg(err)}`);
15546
+ }
15547
+ return {
15548
+ deployedSha: null,
15549
+ deployedShaSource: null
15550
+ };
15551
+ }
15552
+ /**
15553
+ * True when the deploy-log head moved while the run was open: the run
15554
+ * straddled a deploy, so which commit it exercised is not knowable and re-run
15555
+ * selection must report `unknown` instead of picking one. Only meaningful for
15556
+ * a sha the hub stamped itself — a client-asserted one is the caller's claim
15557
+ * about its own run, not the hub's observation.
15558
+ */
15559
+ async function deployHeadMovedDuringRun(storage, run) {
15560
+ if (run.deployedShaSource !== "hub-deploy-log" || !run.deployedSha) return false;
15561
+ try {
15562
+ const head = await storage.deploys.head(run.project, run.profile ?? "default");
15563
+ return head !== null && head.sha !== run.deployedSha;
15564
+ } catch {
15565
+ return false;
14814
15566
  }
14815
15567
  }
14816
15568
  /**
@@ -14824,16 +15576,7 @@ function createPatchRunHandler(config) {
14824
15576
  const id = ctx.params.id;
14825
15577
  const run = await getRunOr404(config.storage, id);
14826
15578
  if (run.status !== "running") throw new HttpError(409, "conflict", "run is not running (already terminal)");
14827
- const raw = await readBody(ctx.req, maxPushBytes);
14828
- let bodyJson;
14829
- try {
14830
- bodyJson = JSON.parse(raw.toString("utf8"));
14831
- } catch {
14832
- throw new HttpError(400, "invalid_body", "request body is not valid JSON");
14833
- }
14834
- const parsed = PatchRunRequestSchema.safeParse(bodyJson);
14835
- if (!parsed.success) throw new HttpError(400, "invalid_body", `request body is invalid: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`);
14836
- const { rows, evidence, done, finalStatus, reportMeta } = parsed.data;
15579
+ const { rows, evidence, done, finalStatus, reportMeta } = await readJsonBody(ctx.req, maxPushBytes, PatchRunRequestSchema, "request body");
14837
15580
  let specs = run.specs;
14838
15581
  let mergedResults = [];
14839
15582
  await config.storage.artifacts.updateJsonFile(id, "report.json", (current) => {
@@ -14879,10 +15622,11 @@ function createPatchRunHandler(config) {
14879
15622
  status: finalStatus ?? (specs.failed > 0 ? "failed" : "passed"),
14880
15623
  specs,
14881
15624
  ...reportMeta?.git?.head ? { gitHead: reportMeta.git.head } : {},
14882
- ...reportMeta?.promptVersion ? { promptVersion: reportMeta.promptVersion } : {}
15625
+ ...reportMeta?.promptVersion ? { promptVersion: reportMeta.promptVersion } : {},
15626
+ ...await deployHeadMovedDuringRun(config.storage, run) ? { deployedShaAmbiguous: true } : {}
14883
15627
  } : { specs };
14884
15628
  const updated = await config.storage.runs.update(id, patch);
14885
- if (done) await updateLastGreenLedger(config.storage, updated, mergedResults);
15629
+ if (done) await updateSpecLedger(config.storage, updated, mergedResults);
14886
15630
  sendJson(ctx.res, 200, updated);
14887
15631
  };
14888
15632
  }
@@ -14977,10 +15721,11 @@ function summarizeDrift(results) {
14977
15721
  };
14978
15722
  }
14979
15723
  /**
14980
- * Parse the `project`/`branch`/`profile`/`kind` query params shared by
14981
- * `POST /runs` (push) and `POST /runs/open`. `project` is required; `profile`
14982
- * is optional and recorded for display only (runs are not scoped by profile);
14983
- * `kind` defaults to "run".
15724
+ * Parse the `project`/`branch`/`profile`/`kind`/`deployedSha` query params
15725
+ * shared by `POST /runs` (push) and `POST /runs/open`. `project` is required;
15726
+ * `profile` is optional and recorded for display only (runs are not scoped by
15727
+ * profile); `kind` defaults to "run"; `deployedSha` overrides what the hub
15728
+ * would read from the profile's deploy log.
14984
15729
  */
14985
15730
  function parseRunScope(ctx) {
14986
15731
  const projectRaw = ctx.url.searchParams.get("project");
@@ -14991,11 +15736,13 @@ function parseRunScope(ctx) {
14991
15736
  const profile = profileRaw ? requireSafeSegment(profileRaw, "profile") : null;
14992
15737
  const kindRaw = ctx.url.searchParams.get("kind");
14993
15738
  if (kindRaw !== null && kindRaw !== "run" && kindRaw !== "drift") throw new HttpError(400, "invalid_param", `invalid kind: must be "run" or "drift"`);
15739
+ const deployedSha = boundedParam(ctx.url.searchParams.get("deployedSha"), "deployedSha", 64);
14994
15740
  return {
14995
15741
  project,
14996
15742
  branch,
14997
15743
  profile,
14998
- kind: kindRaw ?? "run"
15744
+ kind: kindRaw ?? "run",
15745
+ deployedSha
14999
15746
  };
15000
15747
  }
15001
15748
  /**
@@ -15006,13 +15753,14 @@ function parseRunScope(ctx) {
15006
15753
  * send one. Exported for the last-green handler.
15007
15754
  */
15008
15755
  function requireBranch(raw) {
15756
+ return boundedParam(raw, "branch", 256);
15757
+ }
15758
+ /** An opaque free-form query param: null when absent, otherwise length-capped as a sanity check. */
15759
+ function boundedParam(raw, name, max) {
15009
15760
  if (raw === null || raw === "") return null;
15010
- if (raw.length > 256) throw new HttpError(400, "invalid_param", "branch is too long (max 256 chars)");
15761
+ if (raw.length > max) throw new HttpError(400, "invalid_param", `${name} is too long (max ${max} chars)`);
15011
15762
  return raw;
15012
15763
  }
15013
- function errMsg(err) {
15014
- return err instanceof Error ? err.message : String(err);
15015
- }
15016
15764
  //#endregion
15017
15765
  //#region src/hub/core/crypto.ts
15018
15766
  /**
@@ -15260,25 +16008,326 @@ function createListProfilesHandler(storage) {
15260
16008
  /**
15261
16009
  * GET /api/v1/projects/:project/last-green?profile=&branch=&fallbackBranch=
15262
16010
  *
15263
- * Returns the last-green ledger entries for one project/profile, keyed by
15264
- * "feature/spec". `branch` is the caller's current branch; `fallbackBranch`
15265
- * (optional, typically the default branch) is overlaid *under* it, so a PR
15266
- * branch with no greens of its own still inherits the default branch's
15267
- * baselines while its own greens take precedence. One round trip serves the
15268
- * whole run.
16011
+ * Returns the spec ledger for one project/profile, keyed by "feature/spec".
16012
+ * `branch` is the caller's current branch; `fallbackBranch` (optional,
16013
+ * typically the default branch) is overlaid *under* it, so a PR branch with no
16014
+ * entries of its own still inherits the default branch's baselines while its
16015
+ * own take precedence. One round trip serves the whole run.
16016
+ *
16017
+ * `entries` is the green bucket and keeps that exact meaning — older CLIs read
16018
+ * `entry.gitHead` from it as "the commit this spec last passed at". The
16019
+ * last-run and last-red buckets are siblings, not a redefinition.
15269
16020
  */
15270
16021
  function createGetLastGreenHandler(storage) {
15271
16022
  return async (ctx) => {
15272
16023
  const project = requireSafeSegment(ctx.params.project, "project");
15273
- const profile = requireSafeSegment(ctx.url.searchParams.get("profile") ?? "default", "profile");
16024
+ const profile = requireProfileParam(ctx.url);
15274
16025
  const branch = requireBranch(ctx.url.searchParams.get("branch"));
15275
16026
  if (!branch) throw new HttpError(400, "missing_param", "branch query parameter is required");
15276
16027
  const fallbackBranch = requireBranch(ctx.url.searchParams.get("fallbackBranch"));
15277
- const [primary, fallback] = await Promise.all([storage.lastGreen.get(project, profile, branch), fallbackBranch && fallbackBranch !== branch ? storage.lastGreen.get(project, profile, fallbackBranch) : Promise.resolve({})]);
15278
- sendJson(ctx.res, 200, { entries: {
15279
- ...fallback,
15280
- ...primary
15281
- } });
16028
+ const [primary, fallback] = await Promise.all([storage.ledger.get(project, profile, branch), fallbackBranch && fallbackBranch !== branch ? storage.ledger.get(project, profile, fallbackBranch) : Promise.resolve(emptyLedger())]);
16029
+ sendJson(ctx.res, 200, {
16030
+ entries: {
16031
+ ...fallback.green,
16032
+ ...primary.green
16033
+ },
16034
+ lastRun: {
16035
+ ...fallback.run,
16036
+ ...primary.run
16037
+ },
16038
+ lastRed: {
16039
+ ...fallback.red,
16040
+ ...primary.red
16041
+ }
16042
+ });
16043
+ };
16044
+ }
16045
+ function emptyDeployLog() {
16046
+ return {
16047
+ nextIndex: 0,
16048
+ entries: []
16049
+ };
16050
+ }
16051
+ /** Append `input` to `current`; the appended entry is always the last of `entries`. */
16052
+ function appendDeploy(current, input) {
16053
+ const log = current ?? emptyDeployLog();
16054
+ const head = log.entries[log.entries.length - 1];
16055
+ const gapBefore = head ? head.sha !== input.previousSha : log.nextIndex > 0;
16056
+ const truncated = input.changedPaths !== null && input.changedPaths.length > 500;
16057
+ const entries = [...log.entries, {
16058
+ ...input,
16059
+ index: log.nextIndex,
16060
+ changedPaths: truncated ? input.changedPaths.slice(0, 500) : input.changedPaths,
16061
+ truncated,
16062
+ gapBefore
16063
+ }];
16064
+ if (entries.length > 200) {
16065
+ entries.splice(0, entries.length - 200);
16066
+ entries[0] = {
16067
+ ...entries[0],
16068
+ gapBefore: true
16069
+ };
16070
+ }
16071
+ return {
16072
+ nextIndex: log.nextIndex + 1,
16073
+ entries
16074
+ };
16075
+ }
16076
+ /**
16077
+ * Fold one deploy into the touch index, matched against `changedPaths` as the
16078
+ * deploy job reported them — the log's retained copy may be truncated, and
16079
+ * this is the only moment the full list exists.
16080
+ *
16081
+ * A deploy that reported no paths touches every spec: fail-open, and
16082
+ * self-limiting because it makes everything re-run once and then settles.
16083
+ */
16084
+ function foldTouchIndex(current, entry, changedPaths, targets) {
16085
+ const out = { ...current };
16086
+ for (const target of targets) {
16087
+ if (target.relatedPaths.length === 0) continue;
16088
+ const matched = changedPaths === null ? [] : matchPaths(changedPaths, target.relatedPaths);
16089
+ if (changedPaths !== null && matched.length === 0) continue;
16090
+ out[target.key] = {
16091
+ lastTouchedIndex: entry.index,
16092
+ lastTouchedSha: entry.sha,
16093
+ lastTouchedAt: entry.at,
16094
+ matchedPaths: matched
16095
+ };
16096
+ }
16097
+ return out;
16098
+ }
16099
+ /**
16100
+ * The paths in `changedPaths` covered by `relatedPaths`, up to the sample
16101
+ * size. Uses `isPathAffectedBy`, the same matcher `ccqa drift --changed` and
16102
+ * `ccqa run --changed` use, so the hub's verdict and the CLI's cannot diverge.
16103
+ */
16104
+ function matchPaths(changedPaths, relatedPaths) {
16105
+ const out = [];
16106
+ for (const path of changedPaths) {
16107
+ if (!isPathAffectedBy(path, relatedPaths)) continue;
16108
+ out.push(path);
16109
+ if (out.length >= 10) break;
16110
+ }
16111
+ return out;
16112
+ }
16113
+ //#endregion
16114
+ //#region src/hub/core/perspectives-specs.ts
16115
+ function prop(obj, key) {
16116
+ return obj?.[key];
16117
+ }
16118
+ /**
16119
+ * Pull the spec targets out of a stored perspectives document.
16120
+ *
16121
+ * Hand-parsed rather than run through `PerspectivesSchema`, because the
16122
+ * document on the hub was written by whatever CLI version the consumer runs:
16123
+ * a single malformed entry must cost that one spec, not fail the whole view.
16124
+ */
16125
+ function readSpecTargets(doc) {
16126
+ const features = prop(doc, "features");
16127
+ if (!Array.isArray(features)) return [];
16128
+ const out = [];
16129
+ for (const feature of features) {
16130
+ const featureName = prop(feature, "featureName");
16131
+ const specs = prop(feature, "specs");
16132
+ if (typeof featureName !== "string" || !Array.isArray(specs)) continue;
16133
+ for (const spec of specs) {
16134
+ const specName = prop(spec, "specName");
16135
+ if (typeof specName !== "string") continue;
16136
+ const related = prop(spec, "relatedPaths");
16137
+ out.push({
16138
+ key: `${featureName}/${specName}`,
16139
+ relatedPaths: Array.isArray(related) ? related.filter((p) => typeof p === "string") : []
16140
+ });
16141
+ }
16142
+ }
16143
+ return out;
16144
+ }
16145
+ /**
16146
+ * The project's spec targets as stored on the hub, or null when it has no
16147
+ * perspectives document — the one condition `GET /rerun` answers with a 404,
16148
+ * and the one that leaves a deploy with nothing to fold against.
16149
+ */
16150
+ async function loadSpecTargets(perspectives, project) {
16151
+ const stored = await perspectives.get(project);
16152
+ if (!stored) return null;
16153
+ try {
16154
+ return readSpecTargets(JSON.parse(Buffer.from(stored).toString("utf8")));
16155
+ } catch {
16156
+ return null;
16157
+ }
16158
+ }
16159
+ //#endregion
16160
+ //#region src/hub/api/handlers/deploys.ts
16161
+ /** `changedPaths` for a wide refactor can run to tens of thousands of entries. */
16162
+ const MAX_DEPLOY_BODY_BYTES = 8 * 1024 * 1024;
16163
+ /**
16164
+ * POST /api/v1/projects/:project/deploys?profile=
16165
+ *
16166
+ * The consuming deploy job tells the hub what it shipped. This is the input
16167
+ * that makes "needs re-run" answerable at all: the hub has no checkout and
16168
+ * never calls a git host, so it cannot work out what changed on its own
16169
+ * (ADR-0010). The log is per-profile because two environments sit at
16170
+ * different commits.
16171
+ */
16172
+ function createRecordDeployHandler(storage) {
16173
+ return async (ctx) => {
16174
+ const project = requireSafeSegment(ctx.params.project, "project");
16175
+ const profile = requireProfileParam(ctx.url);
16176
+ const { sha, previousSha, changedPaths, ref, runUrl } = await readJsonBody(ctx.req, MAX_DEPLOY_BODY_BYTES, RecordDeployRequestSchema, "deploy body");
16177
+ const entry = await storage.deploys.append(project, profile, {
16178
+ sha,
16179
+ previousSha: previousSha ?? null,
16180
+ at: (/* @__PURE__ */ new Date()).toISOString(),
16181
+ ...ref ? { ref } : {},
16182
+ ...runUrl ? { runUrl } : {},
16183
+ changedPaths: changedPaths ?? null
16184
+ });
16185
+ await foldIntoTouchIndex(storage, project, profile, entry, changedPaths ?? null);
16186
+ sendJson(ctx.res, 201, entry);
16187
+ };
16188
+ }
16189
+ /**
16190
+ * Record which specs this deploy touched, matched against the full
16191
+ * `changedPaths` before the stored entry's bounded copy is all that is left.
16192
+ *
16193
+ * Best-effort and deliberately after the append: the log is the record of what
16194
+ * shipped, the index is a derived accelerator, and losing the fold costs
16195
+ * precision on truncated entries — not correctness.
16196
+ */
16197
+ async function foldIntoTouchIndex(storage, project, profile, entry, changedPaths) {
16198
+ try {
16199
+ const targets = await loadSpecTargets(storage.perspectives, project);
16200
+ if (!targets || targets.length === 0) return;
16201
+ await storage.deploys.updateTouchIndex(project, profile, (current) => foldTouchIndex(current, entry, changedPaths, targets));
16202
+ } catch (err) {
16203
+ console.error(`hub: touch-index fold failed for deploy "${entry.sha}" of "${project}/${profile}": ${errMsg(err)}`);
16204
+ }
16205
+ }
16206
+ /** GET /api/v1/projects/:project/deploys?profile=&limit= — the retained log, oldest first. */
16207
+ function createGetDeployLogHandler(storage) {
16208
+ return async (ctx) => {
16209
+ const project = requireSafeSegment(ctx.params.project, "project");
16210
+ const log = await storage.deploys.getLog(project, requireProfileParam(ctx.url));
16211
+ const limit = Number(ctx.url.searchParams.get("limit"));
16212
+ const entries = Number.isFinite(limit) && limit > 0 ? log.entries.slice(-Math.floor(limit)) : log.entries;
16213
+ sendJson(ctx.res, 200, {
16214
+ entries,
16215
+ nextIndex: log.nextIndex
16216
+ });
16217
+ };
16218
+ }
16219
+ //#endregion
16220
+ //#region src/hub/core/rerun.ts
16221
+ function computeRerun(input) {
16222
+ const { specs, ledger, log, touchIndex } = input;
16223
+ const notEvaluated = log.entries.length === 0 && Object.keys(ledger.run).length === 0 && Object.keys(ledger.green).length === 0;
16224
+ const positionBySha = /* @__PURE__ */ new Map();
16225
+ log.entries.forEach((entry, i) => {
16226
+ if (!positionBySha.has(entry.sha)) positionBySha.set(entry.sha, i);
16227
+ });
16228
+ const out = {};
16229
+ for (const spec of specs) {
16230
+ const coords = {
16231
+ lastRun: ledger.run[spec.key] ?? null,
16232
+ lastGreen: ledger.green[spec.key] ?? null,
16233
+ lastRed: ledger.red[spec.key] ?? null
16234
+ };
16235
+ out[spec.key] = notEvaluated ? {
16236
+ state: "notEvaluated",
16237
+ ...coords
16238
+ } : {
16239
+ ...verdict(spec, coords.lastRun, log, positionBySha, touchIndex),
16240
+ ...coords
16241
+ };
16242
+ }
16243
+ return out;
16244
+ }
16245
+ function verdict(spec, lastRun, log, positionBySha, touchIndex) {
16246
+ if (!lastRun) return { state: "neverRun" };
16247
+ if (spec.relatedPaths.length === 0) return unknown("noRelatedPaths");
16248
+ if (log.entries.length === 0) return unknown("noDeployLog");
16249
+ if (lastRun.deployedShaAmbiguous) return unknown("ambiguousDeployedSha");
16250
+ const deployedSha = lastRun.deployedSha ?? null;
16251
+ if (!deployedSha) return unknown("unknownDeployedSha");
16252
+ const baselinePos = positionBySha.get(deployedSha);
16253
+ if (baselinePos === void 0) return unknown("deployedShaNotInLog");
16254
+ const baselineIndex = log.entries[baselinePos].index;
16255
+ let sawGap = false;
16256
+ let sawUnknownContents = false;
16257
+ for (let i = log.entries.length - 1; i > baselinePos; i--) {
16258
+ const entry = log.entries[i];
16259
+ if (entry.changedPaths !== null && !entry.truncated) {
16260
+ const matched = matchPaths(entry.changedPaths, spec.relatedPaths);
16261
+ if (matched.length > 0) return {
16262
+ state: "needed",
16263
+ touchedBy: matched,
16264
+ touchedByDeploy: deployRef(entry)
16265
+ };
16266
+ } else sawUnknownContents = true;
16267
+ if (entry.gapBefore) sawGap = true;
16268
+ }
16269
+ if (!sawGap && !sawUnknownContents) return { state: "notNeeded" };
16270
+ const touch = touchIndex[spec.key];
16271
+ if (touch && touch.lastTouchedIndex > baselineIndex) {
16272
+ const entry = log.entries.find((e) => e.index === touch.lastTouchedIndex);
16273
+ return {
16274
+ state: "needed",
16275
+ ...touch.matchedPaths.length > 0 ? { touchedBy: touch.matchedPaths } : {},
16276
+ touchedByDeploy: entry ? deployRef(entry) : null
16277
+ };
16278
+ }
16279
+ return unknown(sawGap ? "gapInRange" : "truncatedInRange");
16280
+ }
16281
+ function deployRef(entry) {
16282
+ return {
16283
+ index: entry.index,
16284
+ sha: entry.sha,
16285
+ at: entry.at
16286
+ };
16287
+ }
16288
+ function unknown(reason) {
16289
+ return {
16290
+ state: "unknown",
16291
+ reason
16292
+ };
16293
+ }
16294
+ //#endregion
16295
+ //#region src/hub/api/handlers/rerun.ts
16296
+ /**
16297
+ * GET /api/v1/projects/:project/rerun?profile=
16298
+ *
16299
+ * Per spec: is its last result still trustworthy? Set arithmetic over the spec
16300
+ * ledger, the profile's deploy log and each spec's `relatedPaths` (ADR-0010).
16301
+ * The ledger is read across every branch: a run exercises the deployed
16302
+ * environment whatever branch its code came from.
16303
+ */
16304
+ function createGetRerunHandler(storage) {
16305
+ return async (ctx) => {
16306
+ const project = requireSafeSegment(ctx.params.project, "project");
16307
+ const profile = requireProfileParam(ctx.url);
16308
+ const [specs, ledger, log, touchIndex] = await Promise.all([
16309
+ loadSpecTargets(storage.perspectives, project),
16310
+ storage.ledger.getMerged(project, profile),
16311
+ storage.deploys.getLog(project, profile),
16312
+ storage.deploys.getTouchIndex(project, profile)
16313
+ ]);
16314
+ if (specs === null) throw new HttpError(404, "no_perspectives", `no perspectives stored for project "${project}" — push one with \`ccqa perspectives\` before asking which specs need a re-run`);
16315
+ const head = log.entries[log.entries.length - 1];
16316
+ sendJson(ctx.res, 200, {
16317
+ project,
16318
+ profile,
16319
+ deployHead: head ? {
16320
+ index: head.index,
16321
+ sha: head.sha,
16322
+ at: head.at
16323
+ } : null,
16324
+ specs: computeRerun({
16325
+ specs,
16326
+ ledger,
16327
+ log,
16328
+ touchIndex
16329
+ })
16330
+ });
15282
16331
  };
15283
16332
  }
15284
16333
  //#endregion
@@ -15899,15 +16948,29 @@ const HTML_BODY = `
15899
16948
  <p id="persp-status" class="empty-note" hidden></p>
15900
16949
  <div id="persp-body" hidden>
15901
16950
  <div class="ov" id="persp-ov"></div>
16951
+ <div class="note info persp-note" id="persp-rerun-note" hidden></div>
15902
16952
  <div class="toolbar">
15903
16953
  <label class="search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg><input id="persp-q" type="search" data-i18n-ph="perspectives.search" aria-label="Search cases"></label>
15904
- <button class="fchip" data-f="all" aria-pressed="true" type="button" data-i18n="perspectives.filter.all">All</button>
15905
- <button class="fchip" data-f="deterministic" aria-pressed="false" type="button" data-i18n="perspectives.filter.deterministic">Deterministic</button>
15906
- <button class="fchip" data-f="live" aria-pressed="false" type="button" data-i18n="perspectives.filter.live">Live</button>
15907
- <button class="fchip" data-f="norec" aria-pressed="false" type="button" data-i18n="perspectives.filter.norec">Not recorded only</button>
16954
+ <!-- Each chip carries the count of what it would leave behind, so
16955
+ data-i18n sits on the inner label span: applyStaticI18n swaps
16956
+ textContent, which on the button would delete the count. -->
16957
+ <button class="fchip" data-f="all" aria-pressed="true" type="button"><span data-i18n="perspectives.filter.all">All</span><span class="fcount"></span></button>
16958
+ <button class="fchip" data-f="deterministic" aria-pressed="false" type="button"><span data-i18n="perspectives.filter.deterministic">Deterministic</span><span class="fcount"></span></button>
16959
+ <button class="fchip" data-f="live" aria-pressed="false" type="button"><span data-i18n="perspectives.filter.live">Live</span><span class="fcount"></span></button>
16960
+ <button class="fchip" data-f="norec" aria-pressed="false" type="button"><span data-i18n="perspectives.filter.norec">Not recorded only</span><span class="fcount"></span></button>
16961
+ <button class="fchip" id="persp-chip-rerun" data-f="rerun" aria-pressed="false" type="button" hidden><span data-i18n="perspectives.filter.rerun">Needs re-run only</span><span class="fcount"></span></button>
16962
+ <div class="spacer"></div>
16963
+ <span class="muted persp-head" id="persp-deploy-head" hidden></span>
16964
+ <div class="sw-wrap" id="persp-profile-wrap">
16965
+ <button class="sw-btn" id="persp-profile-switch" type="button" aria-haspopup="menu" aria-expanded="false">
16966
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
16967
+ <span class="k" data-i18n="app.profile">profile</span> <span class="v" id="persp-profile-current">default</span> <span class="chev">▾</span>
16968
+ </button>
16969
+ <div class="proj-menu right" id="persp-profile-menu" role="menu" hidden></div>
16970
+ </div>
15908
16971
  </div>
15909
16972
  <div class="tblcard"><div class="table-wrap"><table>
15910
- <thead><tr><th data-i18n="perspectives.col.case">Case</th><th data-i18n="perspectives.col.mode">Mode</th><th data-i18n="perspectives.col.status">Status</th><th></th></tr></thead>
16973
+ <thead><tr><th data-i18n="perspectives.col.case">Case</th><th data-i18n="perspectives.col.mode">Mode</th><th data-i18n="perspectives.col.status">Status</th><th id="persp-th-result" data-i18n="perspectives.col.lastResult" hidden>Last result</th><th id="persp-th-rerun" data-i18n="perspectives.col.rerun" hidden>Needs re-run</th><th></th></tr></thead>
15911
16974
  <tbody id="persp-tbody"></tbody>
15912
16975
  </table></div></div>
15913
16976
  <p class="empty-note" id="persp-no-hit" hidden data-i18n="perspectives.noHit">No matching cases.</p>
@@ -16091,6 +17154,11 @@ const CSS = `
16091
17154
  --fail: #dc2626; --fail-bg: #fef2f2; --fail-border: #fecaca;
16092
17155
  --info: #2563eb; --info-bg: #eff6ff; --info-border: #bfdbfe;
16093
17156
  --amber: #a16207; --amber-bg: #fefce8; --amber-border: #fde68a;
17157
+ /* Fill counterpart of --amber. The token above is tuned for *text* on
17158
+ --amber-bg, so at swatch size it reads brown; a filled area needs the
17159
+ actual yellow the badge is understood to mean. Same value in both
17160
+ themes, since a fill has no contrast-on-background constraint. */
17161
+ --amber-fill: #eab308;
16094
17162
  --violet: #7c3aed; --violet-bg: #f5f3ff; --violet-border: #ddd6fe;
16095
17163
  --radius: 10px; --radius-md: 8px; --radius-sm: 6px;
16096
17164
  --shadow: 0 10px 38px -10px rgba(0,0,0,0.20), 0 4px 12px -4px rgba(0,0,0,0.10);
@@ -16108,6 +17176,7 @@ const CSS = `
16108
17176
  --fail: #f87171; --fail-bg: rgba(248,113,113,0.10); --fail-border: rgba(248,113,113,0.25);
16109
17177
  --info: #60a5fa; --info-bg: rgba(96,165,250,0.10); --info-border: rgba(96,165,250,0.25);
16110
17178
  --amber: #eab308; --amber-bg: rgba(234,179,8,0.10); --amber-border: rgba(234,179,8,0.25);
17179
+ --amber-fill: #eab308;
16111
17180
  --violet: #a78bfa; --violet-bg: rgba(167,139,250,0.10); --violet-border: rgba(167,139,250,0.25);
16112
17181
  --shadow: 0 10px 38px -10px rgba(0,0,0,0.6), 0 4px 12px -4px rgba(0,0,0,0.4);
16113
17182
  }
@@ -16551,30 +17620,39 @@ const CSS = `
16551
17620
  .prompt-diff pre { margin: 0; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-2);
16552
17621
  font-family: var(--mono); font-size: 11px; line-height: 1.5; color: var(--fg-dim); white-space: pre-wrap; word-break: break-word; max-height: 480px; overflow-y: auto; }
16553
17622
 
16554
- /* perspectives — coverage strip, filter toolbar, and the one-table-per-project
17623
+ /* perspectives — summary row, filter toolbar, and the one-table-per-project
16555
17624
  view (feature section rows + expandable case detail rows). Reuses the
16556
- existing badge/chip primitives above; --pass/--amber cover the "runnable"
16557
- vs "not recorded" coloring so no new tokens are needed. */
16558
- .ov { display: flex; align-items: center; gap: 28px; padding: 14px 18px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); margin-bottom: 16px; flex-wrap: wrap; }
16559
- .ov .num { display: flex; flex-direction: column; }
16560
- .ov .num b { font-size: 24px; line-height: 1.15; font-weight: 650; font-variant-numeric: tabular-nums; }
16561
- .ov .num span { font-size: 12px; color: var(--muted); }
16562
- .ov .sep { width: 1px; align-self: stretch; background: var(--border); }
16563
- .ov .breakdown { display: flex; gap: 20px; }
16564
- .covwrap { flex: 1; min-width: 220px; display: flex; flex-direction: column; gap: 6px; }
16565
- .covbar { height: 8px; border-radius: 999px; overflow: hidden; display: flex; background: var(--surface-3); }
16566
- .covbar .ok { background: var(--pass); }
16567
- .covbar .no { background: var(--amber); opacity: 0.75; }
16568
- .covleg { display: flex; gap: 16px; font-size: 12px; color: var(--muted); }
16569
- .covleg i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 5px; }
16570
- .covleg .lg-ok i { background: var(--pass); }
16571
- .covleg .lg-no i { background: var(--amber); opacity: 0.75; }
17625
+ existing badge/chip primitives above, so no new tokens are needed.
17626
+
17627
+ The summary row answers the question this tab exists for which cases
17628
+ need re-running as one inventory line plus one bar segmented by re-run
17629
+ state. The mode and recorded-ness counts moved onto the filter chips,
17630
+ which is where a count says something actionable. */
17631
+ .ov { display: flex; flex-direction: column; gap: 10px; padding: 14px 18px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); margin-bottom: 16px; }
17632
+ .ov-inv { font-size: 13px; color: var(--muted); }
17633
+ .ov-inv b { color: var(--fg); font-size: 15px; font-weight: 650; font-variant-numeric: tabular-nums; }
17634
+ .rrbar { height: 8px; border-radius: 999px; overflow: hidden; display: flex; background: var(--surface-3); }
17635
+ .rrleg { display: flex; flex-wrap: wrap; gap: 4px 18px; font-size: 12px; color: var(--muted); }
17636
+ .rrleg span { display: inline-flex; align-items: center; gap: 6px; }
17637
+ .rrleg i { width: 8px; height: 8px; border-radius: 50%; flex: none; }
17638
+ .rrleg b { color: var(--fg); font-weight: 600; font-variant-numeric: tabular-nums; }
17639
+ /* One class per re-run state, worn by both the bar segment and its legend
17640
+ dot. "unknown" takes the info hue: it must never be mistaken for a pass,
17641
+ and the two neutral states are two different greys so adjacent segments
17642
+ stay separable. */
17643
+ .sg-needed { background: var(--amber-fill); }
17644
+ .sg-unknown { background: var(--info); }
17645
+ .sg-notneeded { background: var(--pass); }
17646
+ .sg-neverrun { background: var(--muted-2); }
17647
+ .sg-noteval { background: var(--muted); }
16572
17648
 
16573
17649
  .search { flex: 1; min-width: 200px; max-width: 340px; display: flex; align-items: center; gap: 7px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 0 10px; height: 32px; background: var(--surface); }
16574
17650
  .search svg { width: 15px; height: 15px; flex: none; color: var(--muted-2); }
16575
17651
  .search input { border: none; outline: none; font: inherit; font-size: 13px; width: 100%; background: transparent; color: var(--fg); }
16576
17652
  .fchip { border: 1px solid var(--border-strong); background: var(--surface); border-radius: 999px; padding: 5px 12px; font-size: 12.5px; color: var(--muted); }
16577
17653
  .fchip[aria-pressed="true"] { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
17654
+ .fchip .fcount { margin-left: 6px; font-variant-numeric: tabular-nums; color: var(--muted-2); }
17655
+ .fchip[aria-pressed="true"] .fcount { color: var(--accent-fg); opacity: 0.7; }
16578
17656
 
16579
17657
  .chip.live { background: var(--info-bg); color: var(--info); border-color: var(--info-border); }
16580
17658
  .badge.ok { background: var(--pass-bg); color: var(--pass); border-color: var(--pass-border); }
@@ -16582,27 +17660,73 @@ const CSS = `
16582
17660
  .badge.norec { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
16583
17661
  .badge.norec .d { background: var(--amber); }
16584
17662
 
17663
+ /* "Needs re-run" (ADR-0010). Four distinct looks on purpose: rr-unknown must
17664
+ never be mistaken for rr-notneeded, so it takes the info hue rather than a
17665
+ dimmed green, and every badge is paired with a .cellsub saying what the
17666
+ verdict rests on. */
17667
+ .badge.rr-needed { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
17668
+ .badge.rr-needed .d { background: var(--amber); }
17669
+ .badge.rr-notneeded { background: var(--pass-bg); color: var(--pass); border-color: var(--pass-border); }
17670
+ .badge.rr-notneeded .d { background: var(--pass); }
17671
+ .badge.rr-unknown { background: var(--info-bg); color: var(--info); border-color: var(--info-border); }
17672
+ .badge.rr-unknown .d { background: var(--info); }
17673
+ .badge.rr-none { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
17674
+ .badge.rr-none .d { background: var(--muted); }
17675
+ .cellsub { display: block; margin-top: 3px; max-width: 260px; color: var(--muted); font-size: 11.5px; line-height: 1.45; }
17676
+ .cellsub a { color: var(--muted); text-decoration: none; border-bottom: 1px dotted var(--border-strong); }
17677
+ .cellsub a:hover { color: var(--fg); }
17678
+ .persp-note { margin-bottom: 12px; }
17679
+ .persp-head { font-size: 12px; white-space: nowrap; }
17680
+ /* The Perspectives toolbar carries search + five chips + the profile
17681
+ selector, so it wraps instead of overflowing on a narrow window. */
17682
+ #view-perspectives .toolbar { flex-wrap: wrap; }
17683
+ .proj-menu.right { left: auto; right: 0; }
17684
+ .d-note { margin-top: 12px; max-width: 900px; }
17685
+
16585
17686
  .tblcard { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
17687
+ /* Badges across a case row must land on one line. Some of these cells carry
17688
+ only a badge, others a badge plus a sub-line (sha · when) or a two-line
17689
+ explanation, so middle-aligning the cells put each badge at a different
17690
+ height. Top-aligning them fixes where the first line starts; a 24px first
17691
+ line in the cell and a 24px badge box, both centred, fix where the text
17692
+ inside it sits — so a badge's Y offset no longer depends on what follows
17693
+ it. Sub-lines re-declare their own tighter leading. */
17694
+ #persp-tbody td { vertical-align: top; }
17695
+ #persp-tbody tr.row > td { line-height: 24px; }
17696
+ #persp-tbody tr.row > td .chip, #persp-tbody tr.row > td .badge { vertical-align: top; min-height: 24px; line-height: 18px; }
16586
17697
  /* Feature section rows must read as headings, not as just another data row —
16587
17698
  larger, darker, extra padding, and a strong top rule marking the break. */
16588
17699
  tr.grp td { background: var(--surface-2); border-top: 2px solid var(--border-strong); border-bottom: 1px solid var(--border); padding: 12px 12px 10px; font-family: var(--mono); font-size: 15px; font-weight: 700; color: var(--fg); }
16589
17700
  tr.grp td .gcount { color: var(--muted); font-weight: 500; font-size: 12px; font-family: var(--font); margin-left: 10px; }
16590
17701
  td.c-title { font-weight: 500; max-width: 460px; }
16591
- td.c-title .csum { display: block; font-weight: 400; color: var(--muted); font-size: 12.5px; margin-top: 1px; }
17702
+ td.c-title .csum { display: block; font-weight: 400; color: var(--muted); font-size: 12.5px; line-height: 1.45; margin-top: 1px; }
16592
17703
  td.c-chev { width: 28px; color: var(--muted-2); text-align: right; }
16593
17704
  .chev-i { display: inline-block; transition: transform 0.15s; font-size: 11px; }
16594
17705
  tr.row[aria-expanded="true"] .chev-i { transform: rotate(90deg); }
16595
17706
  tr.detail { display: none; }
16596
17707
  tr.detail.open { display: table-row; }
16597
- tr.detail > td { background: var(--surface-2); padding: 14px 16px 16px; }
16598
-
16599
- .d-grid { display: grid; grid-template-columns: 120px 1fr; gap: 7px 14px; font-size: 13px; max-width: 860px; }
17708
+ /* The panel is the row continuing, not a card under it: same surface, and no
17709
+ rule between a case and its own panel. The rule below the panel stays —
17710
+ that one separates this case from the next. The hover tint is dropped
17711
+ while open for the same reason; tinting only the top half would split the
17712
+ two apart again. */
17713
+ tr.detail > td { background: var(--surface); padding: 2px 16px 18px; }
17714
+ #persp-tbody tr.row[aria-expanded="true"] > td { border-bottom: 0; }
17715
+ #persp-tbody tr.row[aria-expanded="true"]:hover { background: var(--surface); }
17716
+
17717
+ .d-grid { display: grid; grid-template-columns: 156px 1fr; gap: 9px 14px; font-size: 13px; max-width: 900px; }
16600
17718
  .d-grid dt { color: var(--muted); font-size: 12px; padding-top: 1px; }
16601
17719
  .d-grid dd { color: var(--fg-dim); }
16602
17720
  .d-grid dd ul { list-style: none; display: flex; flex-direction: column; gap: 3px; margin: 0; padding: 0; }
16603
17721
  .d-grid dd li::before { content: "\\2022 "; color: var(--muted-2); }
16604
- .d-grid code { font-size: 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; }
16605
- .notebox { margin-top: 12px; max-width: 860px; }
17722
+ .d-grid code { font-size: 12px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; }
17723
+ /* Prose gets a measure so it stops wrapping mid-phrase in a narrow column;
17724
+ paths wrap as whole chips, never inside a path. */
17725
+ .d-prose { max-width: 62ch; line-height: 1.5; }
17726
+ .d-paths { display: flex; flex-wrap: wrap; gap: 6px; }
17727
+ .d-paths code { white-space: nowrap; }
17728
+ .d-prose + .d-paths { margin-top: 6px; }
17729
+ .notebox { margin-top: 14px; max-width: 900px; }
16606
17730
  .notebox .nlabel { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
16607
17731
  .notebox textarea { width: 100%; min-height: 54px; resize: vertical; font: inherit; font-size: 13px; color: var(--fg-dim); background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 8px 10px; }
16608
17732
  .notebox .nact { margin-top: 6px; display: flex; align-items: center; gap: 8px; }
@@ -16684,6 +17808,8 @@ const CLIENT_JS = `
16684
17808
  "perspectives.search": "Search cases…",
16685
17809
  "perspectives.filter.all": "All", "perspectives.filter.deterministic": "Deterministic",
16686
17810
  "perspectives.filter.live": "Live", "perspectives.filter.norec": "Not recorded only",
17811
+ "perspectives.filter.rerun": "Needs re-run only",
17812
+ "perspectives.col.lastResult": "Last result", "perspectives.col.rerun": "Needs re-run",
16687
17813
  "perspectives.col.case": "Case", "perspectives.col.mode": "Mode", "perspectives.col.status": "Status",
16688
17814
  "perspectives.noHit": "No matching cases.",
16689
17815
  "perspectives.updated": "Last updated:",
@@ -16691,9 +17817,7 @@ const CLIENT_JS = `
16691
17817
  "perspectives.loadFailed": "Loading perspectives failed",
16692
17818
  "perspectives.mode.deterministic": "deterministic", "perspectives.mode.live": "live",
16693
17819
  "perspectives.status.runnable": "runnable", "perspectives.status.notRecorded": "not recorded",
16694
- "perspectives.metric.features": "Features", "perspectives.metric.cases": "Test cases",
16695
- "perspectives.metric.deterministic": "Deterministic", "perspectives.metric.live": "Live",
16696
- "perspectives.cov.runnable": "runnable", "perspectives.cov.notRecorded": "not recorded",
17820
+ "perspectives.ov.cases": "cases", "perspectives.ov.features": "features",
16697
17821
  "perspectives.d.preconditions": "Preconditions", "perspectives.d.startScreen": "Start screen",
16698
17822
  "perspectives.d.testCondition": "Condition", "perspectives.d.spec": "spec",
16699
17823
  "perspectives.d.relatedPaths": "Related code",
@@ -16701,6 +17825,47 @@ const CLIENT_JS = `
16701
17825
  "perspectives.note.placeholder": "Notes about this case…",
16702
17826
  "perspectives.note.saved": "Saved",
16703
17827
  "perspectives.note.error": "Could not save — retry",
17828
+ "perspectives.d.lastRed": "Most recent failure",
17829
+ "perspectives.d.changedSince": "Changes since the last run",
17830
+ "perspectives.d.cannotJudge": "Why this cannot be judged",
17831
+ "perspectives.result.never": "never run",
17832
+ "perspectives.result.openRun": "Open this run in the hub",
17833
+ "perspectives.result.ci": "CI",
17834
+ "perspectives.rerun.state.needed": "Re-run needed",
17835
+ "perspectives.rerun.state.notNeeded": "Not needed",
17836
+ "perspectives.rerun.state.unknown": "Unknown",
17837
+ "perspectives.rerun.state.neverRun": "Never run",
17838
+ "perspectives.rerun.state.notEvaluated": "Not evaluated",
17839
+ "perspectives.rerun.vsDeploy": "judged against deploy",
17840
+ "perspectives.rerun.noDeployHead": "no deploy recorded for this profile",
17841
+ "perspectives.rerun.changedByDeploy": "deploy {sha} changed its related code",
17842
+ "perspectives.rerun.changesSome": "yes (as of deploy {sha})",
17843
+ "perspectives.rerun.changesNone": "none (as of deploy {sha})",
17844
+ "perspectives.rerun.touchedCount": "{n} deployed path(s) matched its related code",
17845
+ "perspectives.rerun.touchedUnknown": "a deploy since the last run touched its related code",
17846
+ "perspectives.rerun.neverRunHint": "no result recorded for this profile yet",
17847
+ "perspectives.rerun.notEvaluatedHint": "no run and no deploy has ever been recorded for this profile",
17848
+ "perspectives.rerun.why.noRelatedPaths": "no related code declared",
17849
+ "perspectives.rerun.why.noDeployLog": "no deploy log for this profile",
17850
+ "perspectives.rerun.why.unknownDeployedSha": "the last run's deployed commit is unknown",
17851
+ "perspectives.rerun.why.ambiguousDeployedSha": "a deploy landed while the last run was executing",
17852
+ "perspectives.rerun.why.deployedShaNotInLog": "the last run's commit predates the retained deploy log",
17853
+ "perspectives.rerun.why.gapInRange": "deploys are missing from the range",
17854
+ "perspectives.rerun.why.truncatedInRange": "a deploy in range did not report what it changed",
17855
+ "perspectives.rerun.why.unrecognized": "this hub reported a reason this UI does not recognise",
17856
+ "perspectives.rerun.fix.noRelatedPaths": "This case declares no related code, so no deploy can be matched against it. Add relatedPaths to its spec.yaml.",
17857
+ "perspectives.rerun.fix.noDeployLog": "Nothing has been recorded in this profile's deploy log. Wire ccqa hub deploy record into the deploy job for this environment so ccqa knows what shipped.",
17858
+ "perspectives.rerun.fix.unknownDeployedSha": "The last run did not record which commit the environment was running, so it cannot be positioned in the deploy log. Runs record it once this profile has a deploy log.",
17859
+ "perspectives.rerun.fix.ambiguousDeployedSha": "A deploy landed while the last run was executing, so which commit it exercised is not knowable. Re-run this case to get a clean baseline.",
17860
+ "perspectives.rerun.fix.deployedShaNotInLog": "The last run's deployed commit is older than the retained deploy log, so its position is lost. Re-run this case to re-anchor it.",
17861
+ "perspectives.rerun.fix.gapInRange": "A deploy in range did not chain onto its predecessor, so deploys are missing from the range. Have the deploy job report the commit it replaced.",
17862
+ "perspectives.rerun.fix.truncatedInRange": "A deploy in range did not report what it changed, so its contents are not knowable. Have the deploy job send the changed paths of a two-dot diff.",
17863
+ "perspectives.rerun.fix.unrecognized": "This hub reported a reason this UI does not recognise. Upgrade the UI to see what it means.",
17864
+ "perspectives.rerun.unsupported": "This hub does not report which cases need a re-run. Upgrade the hub to enable it.",
17865
+ "perspectives.rerun.loadFailed": "Loading re-run data failed",
17866
+ "perspectives.rerun.noDeployLogBanner": "No deploy has been recorded for profile {profile}, so no case can be judged. Wire ccqa hub deploy record into the deploy job for this environment.",
17867
+ "perspectives.rerun.deployHead": "deploy head",
17868
+ "perspectives.dq.unmatched": "{n} of this case's related-code patterns matched no file when perspectives were generated — a not-needed verdict may rest on paths that no longer exist.",
16704
17869
  "prompt.card.record": "Recording browser actions",
16705
17870
  "prompt.card.live": "Live run (AI-driven)",
16706
17871
  "prompt.card.playwright": "Playwright test generation",
@@ -16782,6 +17947,8 @@ const CLIENT_JS = `
16782
17947
  "perspectives.search": "ケースを検索…",
16783
17948
  "perspectives.filter.all": "すべて", "perspectives.filter.deterministic": "決定的",
16784
17949
  "perspectives.filter.live": "ライブ", "perspectives.filter.norec": "未recordのみ",
17950
+ "perspectives.filter.rerun": "要再実行のみ",
17951
+ "perspectives.col.lastResult": "前回結果", "perspectives.col.rerun": "再実行の要否",
16785
17952
  "perspectives.col.case": "ケース", "perspectives.col.mode": "モード", "perspectives.col.status": "状態",
16786
17953
  "perspectives.noHit": "該当するケースがありません。",
16787
17954
  "perspectives.updated": "最終更新:",
@@ -16789,9 +17956,7 @@ const CLIENT_JS = `
16789
17956
  "perspectives.loadFailed": "テスト観点の読み込みに失敗しました",
16790
17957
  "perspectives.mode.deterministic": "決定的", "perspectives.mode.live": "ライブ",
16791
17958
  "perspectives.status.runnable": "実行可能", "perspectives.status.notRecorded": "未record",
16792
- "perspectives.metric.features": "機能", "perspectives.metric.cases": "テストケース",
16793
- "perspectives.metric.deterministic": "決定的", "perspectives.metric.live": "ライブ",
16794
- "perspectives.cov.runnable": "実行可能", "perspectives.cov.notRecorded": "未record",
17959
+ "perspectives.ov.cases": "ケース", "perspectives.ov.features": "機能",
16795
17960
  "perspectives.d.preconditions": "前提条件", "perspectives.d.startScreen": "開始画面",
16796
17961
  "perspectives.d.testCondition": "実行条件", "perspectives.d.spec": "spec",
16797
17962
  "perspectives.d.relatedPaths": "関連コード",
@@ -16799,6 +17964,47 @@ const CLIENT_JS = `
16799
17964
  "perspectives.note.placeholder": "このケースについてのメモ…",
16800
17965
  "perspectives.note.saved": "保存しました",
16801
17966
  "perspectives.note.error": "保存に失敗しました — 再試行してください",
17967
+ "perspectives.d.lastRed": "直近の失敗",
17968
+ "perspectives.d.changedSince": "前回実行以降の変更",
17969
+ "perspectives.d.cannotJudge": "判定できない理由",
17970
+ "perspectives.result.never": "未実行",
17971
+ "perspectives.result.openRun": "ハブでこの実行を開く",
17972
+ "perspectives.result.ci": "CI",
17973
+ "perspectives.rerun.state.needed": "要再実行",
17974
+ "perspectives.rerun.state.notNeeded": "不要",
17975
+ "perspectives.rerun.state.unknown": "不明",
17976
+ "perspectives.rerun.state.neverRun": "未実行",
17977
+ "perspectives.rerun.state.notEvaluated": "未評価",
17978
+ "perspectives.rerun.vsDeploy": "判定基準: デプロイ",
17979
+ "perspectives.rerun.noDeployHead": "このプロファイルにはデプロイの記録がありません",
17980
+ "perspectives.rerun.changedByDeploy": "デプロイ {sha} が関連コードを変更",
17981
+ "perspectives.rerun.changesSome": "あり(デプロイ {sha} 時点)",
17982
+ "perspectives.rerun.changesNone": "なし(デプロイ {sha} 時点)",
17983
+ "perspectives.rerun.touchedCount": "関連コードに一致したデプロイ差分 {n} 件",
17984
+ "perspectives.rerun.touchedUnknown": "前回実行以降のデプロイが関連コードを変更しています",
17985
+ "perspectives.rerun.neverRunHint": "このプロファイルでの実行記録がまだありません",
17986
+ "perspectives.rerun.notEvaluatedHint": "このプロファイルには実行もデプロイも記録がありません",
17987
+ "perspectives.rerun.why.noRelatedPaths": "関連コードが未宣言です",
17988
+ "perspectives.rerun.why.noDeployLog": "このプロファイルのデプロイ記録がありません",
17989
+ "perspectives.rerun.why.unknownDeployedSha": "前回実行時にデプロイされていたcommitが不明です",
17990
+ "perspectives.rerun.why.ambiguousDeployedSha": "前回実行の途中でデプロイが発生しました",
17991
+ "perspectives.rerun.why.deployedShaNotInLog": "前回実行のcommitが保持中のデプロイログより古いです",
17992
+ "perspectives.rerun.why.gapInRange": "対象範囲のデプロイ記録が欠けています",
17993
+ "perspectives.rerun.why.truncatedInRange": "対象範囲に変更内容を報告していないデプロイがあります",
17994
+ "perspectives.rerun.why.unrecognized": "このUIが認識できない理由がハブから返されました",
17995
+ "perspectives.rerun.fix.noRelatedPaths": "このケースは関連コードを宣言していないため、デプロイと突き合わせられません。spec.yaml に relatedPaths を追加してください。",
17996
+ "perspectives.rerun.fix.noDeployLog": "このプロファイルのデプロイログに記録がありません。何がデプロイされたかをccqaに伝えるため、この環境のデプロイジョブに ccqa hub deploy record を組み込んでください。",
17997
+ "perspectives.rerun.fix.unknownDeployedSha": "前回実行は環境で動いていたcommitを記録していないため、デプロイログ上の位置を決められません。このプロファイルにデプロイログができれば、以降の実行では記録されます。",
17998
+ "perspectives.rerun.fix.ambiguousDeployedSha": "前回実行の途中でデプロイが発生したため、どのcommitを検証したのか確定できません。基準を取り直すには再実行してください。",
17999
+ "perspectives.rerun.fix.deployedShaNotInLog": "前回実行のデプロイcommitが保持中のデプロイログより古く、位置を特定できません。再実行して基準を取り直してください。",
18000
+ "perspectives.rerun.fix.gapInRange": "対象範囲のデプロイが直前のデプロイと連結しておらず、記録が欠けています。デプロイジョブから置き換え前のcommitも送ってください。",
18001
+ "perspectives.rerun.fix.truncatedInRange": "対象範囲に変更内容を報告していないデプロイがあり、何が変わったのか確定できません。デプロイジョブから two-dot diff の変更パスを送ってください。",
18002
+ "perspectives.rerun.fix.unrecognized": "このUIが認識できない理由がハブから返されました。内容を表示するにはUIを更新してください。",
18003
+ "perspectives.rerun.unsupported": "このハブは再実行の要否を返しません。利用するにはハブを更新してください。",
18004
+ "perspectives.rerun.loadFailed": "再実行の要否の読み込みに失敗しました",
18005
+ "perspectives.rerun.noDeployLogBanner": "プロファイル {profile} にデプロイの記録がないため、どのケースも判定できません。この環境のデプロイジョブに ccqa hub deploy record を組み込んでください。",
18006
+ "perspectives.rerun.deployHead": "最新デプロイ",
18007
+ "perspectives.dq.unmatched": "このケースの関連コードのうち {n} 件のパターンが、テスト観点の生成時にどのファイルにも一致しませんでした。不要という判定が、すでに存在しないパスに基づいている可能性があります。",
16802
18008
  "prompt.card.record": "ブラウザ操作の記録",
16803
18009
  "prompt.card.live": "ライブ実行(AI操作)",
16804
18010
  "prompt.card.playwright": "Playwrightテスト生成",
@@ -17015,6 +18221,10 @@ const CLIENT_JS = `
17015
18221
  return days + "d ago";
17016
18222
  }
17017
18223
 
18224
+ function shortSha(sha) {
18225
+ return sha ? String(sha).slice(0, 7) : "";
18226
+ }
18227
+
17018
18228
  function statusBadge(status) {
17019
18229
  var span = el("span", "badge " + status);
17020
18230
  span.appendChild(el("span", "d"));
@@ -17071,6 +18281,41 @@ const CLIENT_JS = `
17071
18281
  return svg;
17072
18282
  }
17073
18283
 
18284
+ // Round caps so the "i"/"!" dot (a zero-length segment) actually paints as a
18285
+ // filled dot instead of vanishing under a butt cap at small sizes.
18286
+ function svgRounded() {
18287
+ var svg = svgIcon();
18288
+ svg.setAttribute("stroke-linecap", "round");
18289
+ svg.setAttribute("stroke-linejoin", "round");
18290
+ return svg;
18291
+ }
18292
+
18293
+ // The two note glyphs, matching the inline SVGs the static .note markup uses.
18294
+ function svgInfo() {
18295
+ var svg = svgRounded();
18296
+ var c = document.createElementNS(SVG_NS, "circle");
18297
+ c.setAttribute("cx", "12"); c.setAttribute("cy", "12"); c.setAttribute("r", "10");
18298
+ svg.appendChild(c);
18299
+ svg.appendChild(svgPath("M12 16v-4M12 8h.01"));
18300
+ return svg;
18301
+ }
18302
+ function svgWarn() {
18303
+ var svg = svgRounded();
18304
+ svg.appendChild(svgPath("M12 9v4M12 17h.01"));
18305
+ svg.appendChild(svgPath("M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"));
18306
+ return svg;
18307
+ }
18308
+
18309
+ // Fill an element as a .note box (the static ones live in HTML_BODY). kind is
18310
+ // "info" or "warn"; the text is always textContent, never innerHTML.
18311
+ function fillNote(box, kind, text, extraClass) {
18312
+ clear(box);
18313
+ box.className = "note " + kind + (extraClass ? " " + extraClass : "");
18314
+ box.appendChild(kind === "warn" ? svgWarn() : svgInfo());
18315
+ box.appendChild(el("span", null, text));
18316
+ return box;
18317
+ }
18318
+
17074
18319
  // ── view routing ────────────────────────────────────────────────────
17075
18320
 
17076
18321
  var VIEWS = ["projects", "runs", "detail", "perspectives", "secrets", "prompts", "jobs"];
@@ -18384,16 +19629,7 @@ const CLIENT_JS = `
18384
19629
  var span = el("span", "info");
18385
19630
  span.tabIndex = 0;
18386
19631
  span.setAttribute("role", "note");
18387
- var svg = svgIcon();
18388
- // Round caps so the "i" dot (a zero-length segment) actually paints as a
18389
- // filled dot instead of vanishing under a butt cap at small sizes.
18390
- svg.setAttribute("stroke-linecap", "round");
18391
- svg.setAttribute("stroke-linejoin", "round");
18392
- var c = document.createElementNS(SVG_NS, "circle");
18393
- c.setAttribute("cx", "12"); c.setAttribute("cy", "12"); c.setAttribute("r", "10");
18394
- svg.appendChild(c);
18395
- svg.appendChild(svgPath("M12 16v-4M12 8h.01"));
18396
- span.appendChild(svg);
19632
+ span.appendChild(svgInfo());
18397
19633
  span.appendChild(el("span", "tip", hintText));
18398
19634
  return span;
18399
19635
  }
@@ -18467,7 +19703,16 @@ const CLIENT_JS = `
18467
19703
  // whole document is fetched once per view-open and filtered/rendered
18468
19704
  // client-side — small enough that there is no pagination.
18469
19705
 
18470
- var perspState = { doc: null, q: "", f: "all" };
19706
+ // "rerun" is the RerunReport for the currently selected profile, or null when
19707
+ // this hub can't answer (older hub, or the fetch failed) — in which case the
19708
+ // two re-run columns are dropped rather than filled with blanks.
19709
+ // "rerunSupported" is tri-state: null until the first answer, then whether
19710
+ // this hub answers at all. Chip visibility follows it rather than the report,
19711
+ // so switching profile doesn't drop the filter while the next one loads.
19712
+ var perspState = {
19713
+ doc: null, q: "", f: "all",
19714
+ rerun: null, rerunSupported: null, runUrls: {}, rerunProfiles: []
19715
+ };
18471
19716
 
18472
19717
  function perspectivesPath() {
18473
19718
  return "/api/v1/projects/" + encodeURIComponent(state.project) + "/perspectives";
@@ -18484,6 +19729,272 @@ const CLIENT_JS = `
18484
19729
  }, function () { throw new Error("Network unreachable — check the hub URL and your connection"); });
18485
19730
  }
18486
19731
 
19732
+ // ── perspectives: needs re-run (ADR-0010) ─────────────────────────────
19733
+ // "Is this case's last result still trustworthy?" — mechanical, no model
19734
+ // call, and a different question from drift ("does the case still describe
19735
+ // the product"). The two vocabularies stay apart on purpose: this column
19736
+ // never says stale or fresh, and never borrows drift's wording.
19737
+ //
19738
+ // The verdict is per (project, profile): two environments sit at different
19739
+ // commits, so it has no profile-free answer — hence the profile selector in
19740
+ // this tab's toolbar.
19741
+
19742
+ function rerunPath() {
19743
+ return "/api/v1/projects/" + encodeURIComponent(state.project) +
19744
+ "/rerun?profile=" + encodeURIComponent(state.profile);
19745
+ }
19746
+
19747
+ // Resolves { report } or { note } and never rejects: a hub that predates
19748
+ // this endpoint costs the two extra columns, not the whole tab. A 404 here
19749
+ // can only mean "no such route" — the endpoint's own 404 is "the project has
19750
+ // no perspectives document", and this runs only after that document loaded.
19751
+ function fetchRerun() {
19752
+ return fetch(rerunPath(), { headers: { Authorization: "Bearer " + state.token } }).then(function (res) {
19753
+ if (res.status === 404) return { note: t("perspectives.rerun.unsupported"), kind: "info" };
19754
+ if (!res.ok) return { note: t("perspectives.rerun.loadFailed") + ": " + res.status + " " + res.statusText, kind: "warn" };
19755
+ return res.json().then(function (report) { return { report: report }; }, function () {
19756
+ return { note: t("perspectives.rerun.loadFailed"), kind: "warn" };
19757
+ });
19758
+ }, function () {
19759
+ return { note: t("perspectives.rerun.loadFailed"), kind: "warn" };
19760
+ });
19761
+ }
19762
+
19763
+ // The ledger records a runId but no link, and the profile list the Secrets
19764
+ // tab keeps is the wrong set here (a profile that only has variables has no
19765
+ // runs and no deploys to judge). One runs page answers both: runId -> CI URL,
19766
+ // and the profiles a run was actually recorded under. A run pushed without a
19767
+ // profile lands in "default", exactly as the ledger stores it.
19768
+ function fetchRunIndex() {
19769
+ return apiFetch("/api/v1/runs?project=" + encodeURIComponent(state.project) + "&limit=200")
19770
+ .then(function (data) {
19771
+ var urls = {};
19772
+ var profiles = [];
19773
+ ((data && data.runs) || []).forEach(function (r) {
19774
+ if (r.runUrl) urls[r.id] = r.runUrl;
19775
+ var p = r.profile || "default";
19776
+ if (profiles.indexOf(p) === -1) profiles.push(p);
19777
+ });
19778
+ return { urls: urls, profiles: profiles.sort() };
19779
+ })
19780
+ .catch(function () { return { urls: {}, profiles: [] }; });
19781
+ }
19782
+
19783
+ // Profiles worth offering in this tab's selector: only those a run has been
19784
+ // recorded under. The current one is always included so the menu can show
19785
+ // what is selected even before any run exists.
19786
+ function perspProfileNames() {
19787
+ var names = perspState.rerunProfiles.slice();
19788
+ if (names.indexOf(state.profile) === -1) names.push(state.profile);
19789
+ return names.sort();
19790
+ }
19791
+
19792
+ function perspSpecKey(feature, spec) {
19793
+ return feature.featureName + "/" + spec.specName;
19794
+ }
19795
+
19796
+ function rerunFor(feature, spec) {
19797
+ var report = perspState.rerun;
19798
+ return report && report.specs ? (report.specs[perspSpecKey(feature, spec)] || null) : null;
19799
+ }
19800
+
19801
+ // A reason a newer hub added but this UI has no wording for must still say
19802
+ // something honest instead of printing a raw i18n key (t() returns the key
19803
+ // when it has no entry).
19804
+ function rerunReasonText(prefix, reason) {
19805
+ var text = t(prefix + reason);
19806
+ return text === prefix + reason ? t(prefix + "unrecognized") : text;
19807
+ }
19808
+
19809
+ // Why the question cannot be answered, in the actionable phrasing the detail
19810
+ // panel wants: name the missing input and how to supply it. Only "unknown"
19811
+ // carries a machine-readable reason (ADR-0010); the other two are states of
19812
+ // the ledger, not gaps in the inputs.
19813
+ function rerunCannotJudge(rr) {
19814
+ if (rr.state === "unknown") return rerunReasonText("perspectives.rerun.fix.", rr.reason || "");
19815
+ if (rr.state === "neverRun") return t("perspectives.rerun.neverRunHint");
19816
+ if (rr.state === "notEvaluated") return t("perspectives.rerun.notEvaluatedHint");
19817
+ // A state a newer hub invented: say the UI cannot read it rather than leave
19818
+ // the row blank, which would look like missing data.
19819
+ return rerunReasonText("perspectives.rerun.fix.", rr.state);
19820
+ }
19821
+
19822
+ // The short justification a table cell carries under its badge. Nothing here
19823
+ // may collapse to a bare "up to date" — notNeeded names the deploy it was
19824
+ // judged against, and unknown names the missing input.
19825
+ function rerunCellWhy(rr) {
19826
+ var head = perspState.rerun && perspState.rerun.deployHead;
19827
+ if (rr.state === "needed") {
19828
+ if (!rr.touchedBy || !rr.touchedBy.length) return t("perspectives.rerun.touchedUnknown");
19829
+ return t("perspectives.rerun.touchedCount").replace("{n}", String(rr.touchedBy.length));
19830
+ }
19831
+ if (rr.state === "notNeeded") {
19832
+ if (!head) return t("perspectives.rerun.noDeployHead");
19833
+ return t("perspectives.rerun.vsDeploy") + " " + shortSha(head.sha) + " · " + relTime(head.at);
19834
+ }
19835
+ if (rr.state === "unknown") return rerunReasonText("perspectives.rerun.why.", rr.reason || "");
19836
+ return rerunCannotJudge(rr);
19837
+ }
19838
+
19839
+ var RERUN_BADGE_CLASS = {
19840
+ needed: "rr-needed", notNeeded: "rr-notneeded", unknown: "rr-unknown",
19841
+ neverRun: "rr-none", notEvaluated: "rr-none"
19842
+ };
19843
+
19844
+ // --- pure: rerun composition ---------------------------------------------
19845
+ // Self-contained on purpose: no DOM, no closures. rerun-view.test.ts lifts
19846
+ // this region out of the rendered page and runs it, because the summary bar
19847
+ // is where an overstatement would do the most damage and the suite has no
19848
+ // browser to click through.
19849
+
19850
+ // Bar segments in drawing order: what to act on first, then what needs no
19851
+ // action, then what was never measured. "unknown" keeps its own place and
19852
+ // its own colour — folding it into "notNeeded" would turn "we cannot say"
19853
+ // into "all clear", which is the one thing ADR-0010 forbids.
19854
+ var RERUN_ORDER = ["needed", "unknown", "notNeeded", "neverRun", "notEvaluated"];
19855
+ var RERUN_SEG_CLASS = {
19856
+ needed: "sg-needed", unknown: "sg-unknown", notNeeded: "sg-notneeded",
19857
+ neverRun: "sg-neverrun", notEvaluated: "sg-noteval"
19858
+ };
19859
+
19860
+ // One verdict per case, bucketed. A case with no verdict at all — this hub
19861
+ // does not answer the question, the fetch failed, or the case was added
19862
+ // after the report was computed — is not evaluated, never "not needed". A
19863
+ // state this UI does not know reads as unknown for the same reason: an
19864
+ // answer we cannot interpret is not evidence that nothing is needed.
19865
+ function rerunComposition(verdicts) {
19866
+ var counts = { needed: 0, unknown: 0, notNeeded: 0, neverRun: 0, notEvaluated: 0 };
19867
+ verdicts.forEach(function (rr) {
19868
+ if (!rr || !rr.state) { counts.notEvaluated += 1; return; }
19869
+ var known = Object.prototype.hasOwnProperty.call(counts, rr.state);
19870
+ counts[known ? rr.state : "unknown"] += 1;
19871
+ });
19872
+ return counts;
19873
+ }
19874
+
19875
+ // Only states with cases in them get drawn, which keeps the legend short and
19876
+ // stops an empty state from reading as a verdict: a "needs re-run 0" printed
19877
+ // for a profile nothing has been evaluated on is exactly that misreading.
19878
+ function rerunSegments(counts) {
19879
+ var out = [];
19880
+ RERUN_ORDER.forEach(function (key) {
19881
+ if (counts[key] > 0) out.push({ state: key, count: counts[key], cls: RERUN_SEG_CLASS[key] });
19882
+ });
19883
+ return out;
19884
+ }
19885
+ // --- end pure: rerun composition -----------------------------------------
19886
+
19887
+ // NB: the parameter is not named "state" — that would shadow the app-wide
19888
+ // state object this scope closes over.
19889
+ function rerunBadge(rerunState) {
19890
+ var span = el("span", "badge " + (RERUN_BADGE_CLASS[rerunState] || "rr-none"));
19891
+ span.appendChild(el("span", "d"));
19892
+ span.appendChild(document.createTextNode(" " + t("perspectives.rerun.state." + rerunState)));
19893
+ return span;
19894
+ }
19895
+
19896
+ // One ledger entry as "<short sha> · <when>", linking to the hub's run detail
19897
+ // and, when that run recorded one, to the CI run. Clicks must not bubble: the
19898
+ // table row itself toggles the detail panel.
19899
+ function ledgerLine(entry) {
19900
+ var wrap = el("span");
19901
+ var link = el("a", null, shortSha(entry.gitHead) || String(entry.runId).slice(0, 8));
19902
+ link.href = "#/runs/" + encodeURIComponent(entry.runId);
19903
+ link.title = t("perspectives.result.openRun");
19904
+ link.addEventListener("click", function (e) { e.stopPropagation(); });
19905
+ wrap.appendChild(link);
19906
+ wrap.appendChild(document.createTextNode(" · " + relTime(entry.at)));
19907
+ var ciUrl = perspState.runUrls[entry.runId];
19908
+ if (ciUrl) {
19909
+ wrap.appendChild(document.createTextNode(" · "));
19910
+ var ci = el("a", null, t("perspectives.result.ci"));
19911
+ ci.href = ciUrl;
19912
+ ci.target = "_blank";
19913
+ ci.rel = "noopener";
19914
+ ci.addEventListener("click", function (e) { e.stopPropagation(); });
19915
+ wrap.appendChild(ci);
19916
+ }
19917
+ return wrap;
19918
+ }
19919
+
19920
+ // The last recorded outcome, as { status, entry }. The ledger advances "run"
19921
+ // on every non-skipped result and "green"/"red" on the matching one, so the
19922
+ // last run is whichever of those two carries the same runId.
19923
+ //
19924
+ // A ledger written before the "run" bucket existed carries greens only, and
19925
+ // migrates that way — so with no "run" entry, fall back to the newer of
19926
+ // green/red. Both are real results; ignoring them would print "never run"
19927
+ // for a case whose last-passed coordinate is right there in the detail row.
19928
+ function lastResult(rr) {
19929
+ if (!rr) return null;
19930
+ if (rr.lastRun) {
19931
+ // "" when neither bucket carries this runId: the run is recorded but its
19932
+ // outcome is not, so the cell shows the coordinate without a verdict.
19933
+ var status = "";
19934
+ if (rr.lastGreen && rr.lastGreen.runId === rr.lastRun.runId) status = "passed";
19935
+ else if (rr.lastRed && rr.lastRed.runId === rr.lastRun.runId) status = "failed";
19936
+ return { status: status, entry: rr.lastRun };
19937
+ }
19938
+ if (rr.lastGreen && rr.lastRed) {
19939
+ return rr.lastGreen.at >= rr.lastRed.at
19940
+ ? { status: "passed", entry: rr.lastGreen }
19941
+ : { status: "failed", entry: rr.lastRed };
19942
+ }
19943
+ if (rr.lastGreen) return { status: "passed", entry: rr.lastGreen };
19944
+ if (rr.lastRed) return { status: "failed", entry: rr.lastRed };
19945
+ return null;
19946
+ }
19947
+
19948
+ function perspResultCell(rr) {
19949
+ var td = el("td");
19950
+ // No verdict at all: this case is in the document but not in the report
19951
+ // (added since it was computed). Not the same statement as "never run".
19952
+ if (!rr) {
19953
+ td.appendChild(el("span", "muted", "—"));
19954
+ return td;
19955
+ }
19956
+ var last = lastResult(rr);
19957
+ if (!last) {
19958
+ td.appendChild(el("span", "muted", t("perspectives.result.never")));
19959
+ return td;
19960
+ }
19961
+ td.appendChild(last.status ? statusBadge(last.status) : el("span", "muted", "—"));
19962
+ var sub = el("span", "cellsub");
19963
+ sub.appendChild(ledgerLine(last.entry));
19964
+ td.appendChild(sub);
19965
+ return td;
19966
+ }
19967
+
19968
+ function perspRerunCell(rr) {
19969
+ var td = el("td");
19970
+ if (!rr) {
19971
+ td.appendChild(el("span", "muted", "—"));
19972
+ return td;
19973
+ }
19974
+ td.appendChild(rerunBadge(rr.state));
19975
+ var why = rerunCellWhy(rr);
19976
+ if (why) td.appendChild(el("span", "cellsub", why));
19977
+ return td;
19978
+ }
19979
+
19980
+ // A list of paths/globs as <code> chips. A flex row, so a list that does not
19981
+ // fit wraps between chips instead of breaking inside a path.
19982
+ function pathCodes(paths) {
19983
+ var wrap = el("span", "d-paths");
19984
+ paths.forEach(function (p) { wrap.appendChild(el("code", null, p)); });
19985
+ return wrap;
19986
+ }
19987
+
19988
+ // How many of this case's related-code patterns matched no file when the
19989
+ // document was written. ADDITIVE field: a document from a CLI older than
19990
+ // ADR-0010 simply has no measurement, which is not the same as a measured
19991
+ // zero — so anything that is not a real count reads as "not measured" and
19992
+ // shows nothing, rather than as a clean bill of health.
19993
+ function unmatchedRelatedPathCount(spec) {
19994
+ var raw = spec.relatedPathsUnmatched;
19995
+ return typeof raw === "number" && isFinite(raw) && raw >= 0 ? raw : null;
19996
+ }
19997
+
18487
19998
  // The execution mode lives inside the mechanically-derived status object
18488
19999
  // (spec.status.mode), not at the top level of a spec entry.
18489
20000
  function perspMode(spec) {
@@ -18514,64 +20025,61 @@ const CLIENT_JS = `
18514
20025
  return span;
18515
20026
  }
18516
20027
 
18517
- // Overview strip: feature/case counts, deterministic/live breakdown, and a
18518
- // runnable-vs-not-recorded coverage bar.
20028
+ // Summary row: the inventory as one line, then one bar segmented by re-run
20029
+ // state the question this tab is opened to answer. The mode and
20030
+ // recorded-ness counts are not lost; they moved onto the filter chips, where
20031
+ // a count states what that filter would leave behind.
20032
+ //
20033
+ // With no re-run data (an older hub, a failed fetch, or a profile nothing
20034
+ // has been recorded on) every case is "not evaluated" and the bar says so in
20035
+ // one neutral segment, rather than showing a composition that reads as
20036
+ // "nothing to do".
18519
20037
  function renderPerspOverview(doc) {
18520
20038
  var host = document.getElementById("persp-ov");
18521
20039
  clear(host);
18522
- var allSpecs = doc.features.reduce(function (acc, f) { return acc.concat(f.specs); }, []);
18523
- var total = allSpecs.length;
18524
- var ok = allSpecs.filter(perspRunnable).length;
18525
- var no = total - ok;
18526
- var det = allSpecs.filter(function (s) { return perspMode(s) === "deterministic"; }).length;
18527
- var live = total - det;
18528
-
18529
- function numBlock(value, labelKey) {
18530
- var box = el("div", "num");
18531
- box.appendChild(el("b", null, String(value)));
18532
- box.appendChild(el("span", null, t(labelKey)));
18533
- return box;
18534
- }
20040
+ var verdicts = [];
20041
+ doc.features.forEach(function (feature) {
20042
+ feature.specs.forEach(function (spec) { verdicts.push(rerunFor(feature, spec)); });
20043
+ });
18535
20044
 
18536
- host.appendChild(numBlock(doc.features.length, "perspectives.metric.features"));
18537
- host.appendChild(el("div", "sep"));
18538
- host.appendChild(numBlock(total, "perspectives.metric.cases"));
18539
- host.appendChild(el("div", "sep"));
18540
- var breakdown = el("div", "breakdown");
18541
- breakdown.appendChild(numBlock(det, "perspectives.metric.deterministic"));
18542
- breakdown.appendChild(numBlock(live, "perspectives.metric.live"));
18543
- host.appendChild(breakdown);
18544
-
18545
- var covwrap = el("div", "covwrap");
18546
- var covbar = el("div", "covbar");
18547
- if (total > 0) {
18548
- var okBar = el("div", "ok");
18549
- okBar.style.width = (ok / total) * 100 + "%";
18550
- var noBar = el("div", "no");
18551
- noBar.style.width = (no / total) * 100 + "%";
18552
- covbar.appendChild(okBar);
18553
- covbar.appendChild(noBar);
18554
- }
18555
- covwrap.appendChild(covbar);
18556
- var covleg = el("div", "covleg");
18557
- var legOk = el("span", "lg-ok");
18558
- legOk.appendChild(el("i"));
18559
- legOk.appendChild(document.createTextNode(t("perspectives.cov.runnable") + " " + ok));
18560
- covleg.appendChild(legOk);
18561
- if (no > 0) {
18562
- var legNo = el("span", "lg-no");
18563
- legNo.appendChild(el("i"));
18564
- legNo.appendChild(document.createTextNode(t("perspectives.cov.notRecorded") + " " + no));
18565
- covleg.appendChild(legNo);
18566
- }
18567
- covwrap.appendChild(covleg);
18568
- host.appendChild(covwrap);
20045
+ var inv = el("div", "ov-inv");
20046
+ inv.appendChild(el("b", null, String(verdicts.length)));
20047
+ inv.appendChild(document.createTextNode(" " + t("perspectives.ov.cases") + " / "));
20048
+ inv.appendChild(el("b", null, String(doc.features.length)));
20049
+ inv.appendChild(document.createTextNode(" " + t("perspectives.ov.features")));
20050
+ host.appendChild(inv);
20051
+ if (!verdicts.length) return;
20052
+
20053
+ var bar = el("div", "rrbar");
20054
+ var leg = el("div", "rrleg");
20055
+ rerunSegments(rerunComposition(verdicts)).forEach(function (seg) {
20056
+ var fill = el("div", seg.cls);
20057
+ fill.style.width = (seg.count / verdicts.length) * 100 + "%";
20058
+ bar.appendChild(fill);
20059
+ var item = el("span");
20060
+ item.appendChild(el("i", seg.cls));
20061
+ item.appendChild(document.createTextNode(t("perspectives.rerun.state." + seg.state)));
20062
+ item.appendChild(el("b", null, String(seg.count)));
20063
+ leg.appendChild(item);
20064
+ });
20065
+ host.appendChild(bar);
20066
+ host.appendChild(leg);
18569
20067
  }
18570
20068
 
18571
- function perspMatches(spec) {
18572
- if (perspState.f === "deterministic" && perspMode(spec) !== "deterministic") return false;
18573
- if (perspState.f === "live" && perspMode(spec) !== "live") return false;
18574
- if (perspState.f === "norec" && perspRunnable(spec)) return false;
20069
+ // The filter is passed in rather than read from perspState so the same
20070
+ // predicate can answer "what would this chip yield?" for every chip. The
20071
+ // search text always applies: a chip's count has to be the number of rows
20072
+ // clicking it actually leaves.
20073
+ function perspMatches(feature, spec, f) {
20074
+ if (f === "deterministic" && perspMode(spec) !== "deterministic") return false;
20075
+ if (f === "live" && perspMode(spec) !== "live") return false;
20076
+ if (f === "norec" && perspRunnable(spec)) return false;
20077
+ if (f === "rerun") {
20078
+ var rr = rerunFor(feature, spec);
20079
+ // Only "needed": "unknown" is not a weaker "probably needed", and
20080
+ // folding it in here would be exactly the overstatement ADR-0010 forbids.
20081
+ if (!rr || rr.state !== "needed") return false;
20082
+ }
18575
20083
  if (perspState.q) {
18576
20084
  var hay = (spec.title + " " + (spec.summary || "") + " " + spec.specName).toLowerCase();
18577
20085
  if (hay.indexOf(perspState.q) === -1) return false;
@@ -18579,16 +20087,99 @@ const CLIENT_JS = `
18579
20087
  return true;
18580
20088
  }
18581
20089
 
20090
+ function perspFilterCount(f) {
20091
+ var doc = perspState.doc;
20092
+ if (!doc) return 0;
20093
+ var n = 0;
20094
+ doc.features.forEach(function (feature) {
20095
+ feature.specs.forEach(function (spec) { if (perspMatches(feature, spec, f)) n += 1; });
20096
+ });
20097
+ return n;
20098
+ }
20099
+
20100
+ // --- pure: rerun detail labels -------------------------------------------
20101
+ // Self-contained on purpose (no DOM, no closures) so rerun-view.test.ts can
20102
+ // lift this region out of the rendered page and run it: which label the
20103
+ // panel's evidence row wears, and whether it has a failure row at all.
20104
+
20105
+ // needed/notNeeded put evidence in that row, so it is labelled by the
20106
+ // timeframe the evidence covers. The other three have no evidence to show,
20107
+ // only a missing input to name — a different kind of content, and forcing one
20108
+ // label over both would make one of the two read as a lie.
20109
+ function rerunEvidenceLabelKey(rerunState) {
20110
+ return rerunState === "needed" || rerunState === "notNeeded"
20111
+ ? "perspectives.d.changedSince"
20112
+ : "perspectives.d.cannotJudge";
20113
+ }
20114
+
20115
+ // The failure row points at a run. With no failure there is nothing to point
20116
+ // at, so the row is omitted rather than filled with "never failed" — the row
20117
+ // above already carries the last result.
20118
+ function rerunHasFailure(rr) {
20119
+ return !!(rr && rr.lastRed);
20120
+ }
20121
+
20122
+ // Which deploy the evidence line names, and how. A "needed" verdict carries
20123
+ // the deploy that caused it (touchedByDeploy) when the hub could confirm one,
20124
+ // and that is the deploy a reader wants — so it is named, with when it
20125
+ // landed. Without it (an older hub, or an entry the log no longer retains)
20126
+ // the only deploy coordinate on hand is the report's head, which is the point
20127
+ // the judgement was made at and not a cause: it keeps the weaker wording.
20128
+ // "at" is set only when the line names a cause, so the caller knows whether a
20129
+ // timestamp belongs on it.
20130
+ function rerunChangeLine(rr, deployHead) {
20131
+ var cause = rr.state === "needed" && rr.touchedByDeploy ? rr.touchedByDeploy : null;
20132
+ if (cause && cause.sha) return { key: "perspectives.rerun.changedByDeploy", sha: cause.sha, at: cause.at };
20133
+ if (!deployHead) return { key: "perspectives.rerun.noDeployHead", sha: null, at: null };
20134
+ return {
20135
+ key: rr.state === "needed" ? "perspectives.rerun.changesSome" : "perspectives.rerun.changesNone",
20136
+ sha: deployHead.sha,
20137
+ at: null,
20138
+ };
20139
+ }
20140
+ // --- end pure: rerun detail labels ----------------------------------------
20141
+
20142
+ // The evidence behind the verdict, as the value of whichever row
20143
+ // rerunEvidenceLabelKey chose. For needed/notNeeded that is what the deploy
20144
+ // log holds since this case last ran, named by rerunChangeLine.
20145
+ // The label already states the timeframe, so the value never repeats it.
20146
+ function rerunEvidenceValue(rr) {
20147
+ var wrap = el("div");
20148
+ if (rr.state !== "needed" && rr.state !== "notNeeded") {
20149
+ wrap.appendChild(el("div", "d-prose", rerunCannotJudge(rr)));
20150
+ return wrap;
20151
+ }
20152
+ // Both states require a non-empty deploy log, so a head-less report
20153
+ // contradicts itself; rerunChangeLine then names what is missing rather
20154
+ // than inventing a baseline.
20155
+ var line = rerunChangeLine(rr, perspState.rerun && perspState.rerun.deployHead);
20156
+ var text = t(line.key).replace("{sha}", shortSha(line.sha));
20157
+ if (line.at) text += " · " + relTime(line.at);
20158
+ wrap.appendChild(el("div", "d-prose", text));
20159
+ // A touch the index proved but cannot enumerate leaves no paths to list;
20160
+ // the line above still says a change landed, which is all that is known.
20161
+ if (rr.state === "needed" && rr.touchedBy && rr.touchedBy.length) {
20162
+ wrap.appendChild(pathCodes(rr.touchedBy));
20163
+ }
20164
+ return wrap;
20165
+ }
20166
+
18582
20167
  // Detail row: a definition list of the case's fields plus the note editor.
18583
20168
  // Built with createElement/textContent throughout — every field here is
18584
20169
  // API-derived, so none of it may go through innerHTML.
20170
+ //
20171
+ // The panel shows only what the table row cannot. The row already carries the
20172
+ // title, mode, recorded state, last result and the re-run verdict, so none of
20173
+ // those is repeated: what is left is the case's definition, the evidence the
20174
+ // verdict rests on, its related code, and the note.
18585
20175
  function perspDetailContent(feature, spec) {
18586
20176
  var frag = document.createDocumentFragment();
18587
20177
  var dl = el("dl", "d-grid");
18588
20178
  function row(labelKey, valueNode) {
18589
20179
  dl.appendChild(el("dt", null, t(labelKey)));
18590
20180
  var dd = el("dd");
18591
- if (typeof valueNode === "string") dd.textContent = valueNode;
20181
+ // Prose gets a measure; a node brings its own layout.
20182
+ if (typeof valueNode === "string") dd.appendChild(el("div", "d-prose", valueNode));
18592
20183
  else dd.appendChild(valueNode);
18593
20184
  dl.appendChild(dd);
18594
20185
  }
@@ -18599,18 +20190,28 @@ const CLIENT_JS = `
18599
20190
  }
18600
20191
  if (spec.startScreen) row("perspectives.d.startScreen", spec.startScreen);
18601
20192
  if (spec.testCondition) row("perspectives.d.testCondition", spec.testCondition);
18602
- var specCode = el("code", null, spec.specName);
18603
- row("perspectives.d.spec", specCode);
20193
+ // The spec id stays: it is what a user types to re-run this case, and the
20194
+ // table shows the title, never the id.
20195
+ row("perspectives.d.spec", el("code", null, spec.specName));
20196
+
20197
+ var rr = rerunFor(feature, spec);
20198
+ if (rr) {
20199
+ row(rerunEvidenceLabelKey(rr.state), rerunEvidenceValue(rr));
20200
+ if (rerunHasFailure(rr)) row("perspectives.d.lastRed", ledgerLine(rr.lastRed));
20201
+ }
18604
20202
  if (spec.relatedPaths && spec.relatedPaths.length) {
18605
- var pathsWrap = el("span");
18606
- spec.relatedPaths.forEach(function (p, i) {
18607
- if (i > 0) pathsWrap.appendChild(document.createTextNode(" "));
18608
- pathsWrap.appendChild(el("code", null, p));
18609
- });
18610
- row("perspectives.d.relatedPaths", pathsWrap);
20203
+ row("perspectives.d.relatedPaths", pathCodes(spec.relatedPaths));
18611
20204
  }
18612
20205
  frag.appendChild(dl);
18613
20206
 
20207
+ // Too-narrow relatedPaths produce a confident "not needed" — the dangerous
20208
+ // direction — so an unmatched pattern is flagged under the paths it is about.
20209
+ var unmatched = unmatchedRelatedPathCount(spec);
20210
+ if (unmatched) {
20211
+ frag.appendChild(fillNote(el("div"), "warn",
20212
+ t("perspectives.dq.unmatched").replace("{n}", String(unmatched)), "d-note"));
20213
+ }
20214
+
18614
20215
  var notebox = el("div", "notebox");
18615
20216
  notebox.appendChild(el("div", "nlabel", t("perspectives.note.label")));
18616
20217
  var ta = el("textarea");
@@ -18650,17 +20251,23 @@ const CLIENT_JS = `
18650
20251
  function renderPerspTable(doc) {
18651
20252
  var tbody = document.getElementById("persp-tbody");
18652
20253
  clear(tbody);
20254
+ // Hiding the two <th>s (rather than emitting empty cells) leaves the table
20255
+ // exactly as it was on a hub that cannot answer the re-run question.
20256
+ var showRerun = perspState.rerun != null;
20257
+ document.getElementById("persp-th-result").hidden = !showRerun;
20258
+ document.getElementById("persp-th-rerun").hidden = !showRerun;
20259
+ var cols = showRerun ? 6 : 4;
18653
20260
  var hits = 0;
18654
20261
  doc.features.forEach(function (feature) {
18655
- var specs = feature.specs.filter(perspMatches);
20262
+ var specs = feature.specs.filter(function (s) { return perspMatches(feature, s, perspState.f); });
18656
20263
  if (!specs.length) return;
18657
20264
  hits += specs.length;
18658
20265
 
18659
20266
  var grpRow = el("tr", "grp");
18660
20267
  var grpTd = el("td");
18661
- grpTd.colSpan = 4;
20268
+ grpTd.colSpan = cols;
18662
20269
  grpTd.appendChild(document.createTextNode(feature.featureName));
18663
- grpTd.appendChild(el("span", "gcount", specs.length + " " + t("perspectives.metric.cases").toLowerCase()));
20270
+ grpTd.appendChild(el("span", "gcount", specs.length + " " + t("perspectives.ov.cases")));
18664
20271
  grpRow.appendChild(grpTd);
18665
20272
  tbody.appendChild(grpRow);
18666
20273
 
@@ -18682,13 +20289,19 @@ const CLIENT_JS = `
18682
20289
  statusTd.appendChild(perspStatusBadge(spec));
18683
20290
  row.appendChild(statusTd);
18684
20291
 
20292
+ if (showRerun) {
20293
+ var rr = rerunFor(feature, spec);
20294
+ row.appendChild(perspResultCell(rr));
20295
+ row.appendChild(perspRerunCell(rr));
20296
+ }
20297
+
18685
20298
  var chevTd = el("td", "c-chev");
18686
20299
  chevTd.appendChild(el("span", "chev-i", "\\u25b6"));
18687
20300
  row.appendChild(chevTd);
18688
20301
 
18689
20302
  var detailRow = el("tr", "detail");
18690
20303
  var detailTd = el("td");
18691
- detailTd.colSpan = 4;
20304
+ detailTd.colSpan = cols;
18692
20305
  detailRow.appendChild(detailTd);
18693
20306
  var built = false;
18694
20307
 
@@ -18718,10 +20331,44 @@ const CLIENT_JS = `
18718
20331
  function renderPerspectives() {
18719
20332
  var doc = perspState.doc;
18720
20333
  if (!doc) return;
20334
+ syncPerspChips();
18721
20335
  renderPerspOverview(doc);
18722
20336
  renderPerspTable(doc);
18723
20337
  }
18724
20338
 
20339
+ // The needs-re-run chip only exists while the hub answers the question;
20340
+ // otherwise it would filter everything away. Drop back to "all" if it was
20341
+ // the active filter when the answer came back "not supported".
20342
+ //
20343
+ // Each chip also carries what it would yield — the mode breakdown the
20344
+ // summary row used to spend four tiles on.
20345
+ function syncPerspChips() {
20346
+ var chip = document.getElementById("persp-chip-rerun");
20347
+ chip.hidden = perspState.rerunSupported !== true;
20348
+ if (perspState.rerunSupported === false && perspState.f === "rerun") perspState.f = "all";
20349
+ document.querySelectorAll("#view-perspectives .fchip").forEach(function (b) {
20350
+ var f = b.getAttribute("data-f");
20351
+ b.setAttribute("aria-pressed", String(f === perspState.f));
20352
+ b.querySelector(".fcount").textContent = perspState.doc ? String(perspFilterCount(f)) : "";
20353
+ });
20354
+ }
20355
+
20356
+ function setPerspRerunNote(text, kind) {
20357
+ var box = document.getElementById("persp-rerun-note");
20358
+ box.hidden = !text;
20359
+ if (!text) { clear(box); return; }
20360
+ fillNote(box, kind || "info", text, "persp-note");
20361
+ }
20362
+
20363
+ function setPerspDeployHead(report) {
20364
+ var span = document.getElementById("persp-deploy-head");
20365
+ var head = report && report.deployHead;
20366
+ span.hidden = !head;
20367
+ span.textContent = head
20368
+ ? t("perspectives.rerun.deployHead") + " " + shortSha(head.sha) + " · " + relTime(head.at)
20369
+ : "";
20370
+ }
20371
+
18725
20372
  function setPerspUpdated(doc) {
18726
20373
  var span = document.getElementById("persp-updated");
18727
20374
  if (doc && doc.generatedAt) {
@@ -18737,13 +20384,26 @@ const CLIENT_JS = `
18737
20384
  setPerspStatus("");
18738
20385
  document.getElementById("persp-body").hidden = true;
18739
20386
  setPerspUpdated(null);
20387
+ setPerspRerunNote("");
20388
+ setPerspDeployHead(null);
20389
+ perspState.rerun = null;
20390
+ perspState.rerunSupported = null;
20391
+ syncPerspChips();
18740
20392
  fetchPerspectives()
18741
20393
  .then(function (doc) {
18742
20394
  perspState.doc = doc;
18743
20395
  if (!doc) { setPerspStatus(t("perspectives.empty")); return; }
18744
20396
  setPerspUpdated(doc);
18745
20397
  document.getElementById("persp-body").hidden = false;
20398
+ // The inventory renders first: re-run data is additive to it, and a
20399
+ // slow or absent /rerun must never hold up the table.
18746
20400
  renderPerspectives();
20401
+ // Scoped to its own failure message: a fault here costs the two extra
20402
+ // columns, and reporting it as "loading perspectives failed" would
20403
+ // point at the inventory that in fact loaded fine.
20404
+ return loadRerun().catch(function (err) {
20405
+ setPerspRerunNote(t("perspectives.rerun.loadFailed") + ": " + err.message, "warn");
20406
+ });
18747
20407
  })
18748
20408
  .catch(function (err) {
18749
20409
  perspState.doc = null;
@@ -18751,12 +20411,48 @@ const CLIENT_JS = `
18751
20411
  });
18752
20412
  }
18753
20413
 
20414
+ function rerunScope() {
20415
+ return state.project + "/" + state.profile;
20416
+ }
20417
+
20418
+ function loadRerun() {
20419
+ var scope = rerunScope();
20420
+ return Promise.all([fetchRerun(), fetchRunIndex()]).then(function (results) {
20421
+ // A second profile pick can land while this one is still in flight; the
20422
+ // older response must not overwrite the newer scope's verdicts.
20423
+ if (scope !== rerunScope()) return;
20424
+ var rerun = results[0];
20425
+ perspState.runUrls = results[1].urls;
20426
+ perspState.rerunProfiles = results[1].profiles;
20427
+ perspState.rerun = rerun.report || null;
20428
+ perspState.rerunSupported = perspState.rerun != null;
20429
+ setPerspDeployHead(perspState.rerun);
20430
+ if (rerun.note) {
20431
+ setPerspRerunNote(rerun.note, rerun.kind);
20432
+ } else if (perspState.rerun && !perspState.rerun.deployHead) {
20433
+ // Every case is "unknown" in this state, so say once, at the top, what
20434
+ // is missing and how to supply it, rather than only per row.
20435
+ setPerspRerunNote(t("perspectives.rerun.noDeployLogBanner").replace("{profile}", state.profile), "warn");
20436
+ } else {
20437
+ setPerspRerunNote("");
20438
+ }
20439
+ renderPerspectives();
20440
+ });
20441
+ }
20442
+
20443
+ // Switching profile re-asks only the profile-scoped question: the
20444
+ // perspectives document itself is project-scoped and does not change.
20445
+ function reloadRerun() {
20446
+ perspState.rerun = null;
20447
+ setPerspRerunNote("");
20448
+ setPerspDeployHead(null);
20449
+ renderPerspectives();
20450
+ return loadRerun();
20451
+ }
20452
+
18754
20453
  function openPerspectives() {
18755
20454
  showView("perspectives");
18756
20455
  document.getElementById("persp-q").value = perspState.q;
18757
- document.querySelectorAll("#view-perspectives .fchip").forEach(function (b) {
18758
- b.setAttribute("aria-pressed", String(b.getAttribute("data-f") === perspState.f));
18759
- });
18760
20456
  loadPerspectives();
18761
20457
  }
18762
20458
 
@@ -18944,22 +20640,41 @@ const CLIENT_JS = `
18944
20640
  document.getElementById("project-menu").hidden ? openProjectMenu() : closeProjectMenu();
18945
20641
  }
18946
20642
 
18947
- // ── profile switching (Secrets-tab dropdown) ───────────────────────────
18948
- // Profiles scope ONLY variables + sessions (a profile is a set of env vars,
18949
- // like .ccqa/profiles/<name>.env). Prompts are project-wide and runs are
18950
- // cross-profile, so the selector lives inside the Secrets tab, not the header.
20643
+ // ── profile switching (per-tab dropdowns) ──────────────────────────────
20644
+ // Profiles scope variables + sessions (a profile is a set of env vars, like
20645
+ // .ccqa/profiles/<name>.env) and, since ADR-0010, the needs-re-run verdict:
20646
+ // two environments sit at different commits, so that question has no
20647
+ // profile-free answer. Prompts are project-wide and runs are cross-profile,
20648
+ // so there is still no header-level selector — Secrets and Perspectives each
20649
+ // carry their own, sharing state.profile and differing only in which names
20650
+ // they offer and what a pick reloads.
20651
+
20652
+ var PROFILE_MENUS = {
20653
+ secrets: {
20654
+ switchId: "sec-profile-switch", menuId: "sec-profile-menu", withNew: true,
20655
+ names: function () { return knownProfiles; },
20656
+ pick: function (p) { chooseProfile(p, loadSecrets); }
20657
+ },
20658
+ perspectives: {
20659
+ switchId: "persp-profile-switch", menuId: "persp-profile-menu", withNew: false,
20660
+ names: perspProfileNames,
20661
+ pick: function (p) { chooseProfile(p, reloadRerun); }
20662
+ }
20663
+ };
18951
20664
 
18952
20665
  function setProfile(p) {
18953
20666
  state.profile = p || "default";
18954
- var cur = document.getElementById("sec-profile-current");
18955
- if (cur) cur.textContent = state.profile;
20667
+ ["sec-profile-current", "persp-profile-current"].forEach(function (id) {
20668
+ var cur = document.getElementById(id);
20669
+ if (cur) cur.textContent = state.profile;
20670
+ });
18956
20671
  }
18957
20672
 
18958
- // Switch profile and reload the Secrets tab under the new scope.
18959
- function chooseProfile(p) {
20673
+ // Switch profile and reload the tab that asked, under the new scope.
20674
+ function chooseProfile(p, reload) {
18960
20675
  setProfile(p);
18961
20676
  storeProfileForProject(state.project, state.profile);
18962
- loadSecrets();
20677
+ reload();
18963
20678
  }
18964
20679
 
18965
20680
  // Fetch the profiles for the current project. "default" is always available
@@ -18973,17 +20688,20 @@ const CLIENT_JS = `
18973
20688
  }).catch(function () { knownProfiles = ["default"]; setProfile("default"); });
18974
20689
  }
18975
20690
 
18976
- function buildProfileMenu() {
18977
- var menu = document.getElementById("sec-profile-menu");
20691
+ function buildProfileMenu(menuSpec) {
20692
+ var menu = document.getElementById(menuSpec.menuId);
18978
20693
  clear(menu);
18979
- knownProfiles.forEach(function (p) {
20694
+ menuSpec.names().forEach(function (p) {
18980
20695
  var mi = el("button", "mi" + (p === state.profile ? " current" : ""));
18981
20696
  mi.type = "button";
18982
20697
  mi.setAttribute("role", "menuitem");
18983
20698
  mi.appendChild(el("span", "name", p));
18984
- mi.addEventListener("click", function () { closeProfileMenu(); chooseProfile(p); });
20699
+ mi.addEventListener("click", function () { closeProfileMenu(); menuSpec.pick(p); });
18985
20700
  menu.appendChild(mi);
18986
20701
  });
20702
+ // Only the Secrets menu can create: a profile with no secrets is a usable
20703
+ // secrets scope, but a profile with no runs has nothing to judge.
20704
+ if (!menuSpec.withNew) return;
18987
20705
  menu.appendChild(el("div", "sep"));
18988
20706
  var newItem = el("button", "mi action");
18989
20707
  newItem.type = "button";
@@ -18994,20 +20712,26 @@ const CLIENT_JS = `
18994
20712
  menu.appendChild(newItem);
18995
20713
  }
18996
20714
 
18997
- function openProfileMenu() {
20715
+ function openProfileMenu(which) {
18998
20716
  if (!state.token || !state.project) return;
18999
- buildProfileMenu();
19000
- document.getElementById("sec-profile-menu").hidden = false;
19001
- document.getElementById("sec-profile-switch").setAttribute("aria-expanded", "true");
20717
+ var menuSpec = PROFILE_MENUS[which];
20718
+ buildProfileMenu(menuSpec);
20719
+ document.getElementById(menuSpec.menuId).hidden = false;
20720
+ document.getElementById(menuSpec.switchId).setAttribute("aria-expanded", "true");
19002
20721
  }
20722
+ // Closes both, so an outside click or Escape needs no idea which is open.
19003
20723
  function closeProfileMenu() {
19004
- var menu = document.getElementById("sec-profile-menu");
19005
- if (!menu) return;
19006
- menu.hidden = true;
19007
- document.getElementById("sec-profile-switch").setAttribute("aria-expanded", "false");
20724
+ Object.keys(PROFILE_MENUS).forEach(function (which) {
20725
+ var menuSpec = PROFILE_MENUS[which];
20726
+ var menu = document.getElementById(menuSpec.menuId);
20727
+ if (!menu) return;
20728
+ menu.hidden = true;
20729
+ document.getElementById(menuSpec.switchId).setAttribute("aria-expanded", "false");
20730
+ });
19008
20731
  }
19009
- function toggleProfileMenu() {
19010
- document.getElementById("sec-profile-menu").hidden ? openProfileMenu() : closeProfileMenu();
20732
+ function toggleProfileMenu(which) {
20733
+ var menu = document.getElementById(PROFILE_MENUS[which].menuId);
20734
+ if (menu.hidden) openProfileMenu(which); else closeProfileMenu();
19011
20735
  }
19012
20736
 
19013
20737
  // ── new-project dialog (centered modal; shares #scrim with the sheet) ──
@@ -19051,7 +20775,9 @@ const CLIENT_JS = `
19051
20775
  // Profiles are implicit like projects — created for real on the first
19052
20776
  // secret/prompt stored under them. Just add to the list and select it.
19053
20777
  if (knownProfiles.indexOf(name) === -1) { knownProfiles.push(name); knownProfiles.sort(); }
19054
- chooseProfile(name);
20778
+ // The "new profile" item only exists in the Secrets menu, so that is the
20779
+ // tab to reload.
20780
+ chooseProfile(name, loadSecrets);
19055
20781
  } else {
19056
20782
  if (knownProjects.indexOf(name) === -1) { knownProjects.push(name); knownProjects.sort(); }
19057
20783
  chooseProject(name);
@@ -19138,13 +20864,16 @@ const CLIENT_JS = `
19138
20864
  });
19139
20865
  // Keep clicks inside the menu from bubbling to the document close-handler.
19140
20866
  document.getElementById("project-menu").addEventListener("click", function (e) { e.stopPropagation(); });
19141
- // Secrets-tab profile dropdown
19142
- document.getElementById("sec-profile-switch").addEventListener("click", function (e) {
19143
- e.stopPropagation();
19144
- closeProjectMenu();
19145
- toggleProfileMenu();
20867
+ // Per-tab profile dropdowns (Secrets, Perspectives)
20868
+ Object.keys(PROFILE_MENUS).forEach(function (which) {
20869
+ var menuSpec = PROFILE_MENUS[which];
20870
+ document.getElementById(menuSpec.switchId).addEventListener("click", function (e) {
20871
+ e.stopPropagation();
20872
+ closeProjectMenu();
20873
+ toggleProfileMenu(which);
20874
+ });
20875
+ document.getElementById(menuSpec.menuId).addEventListener("click", function (e) { e.stopPropagation(); });
19146
20876
  });
19147
- document.getElementById("sec-profile-menu").addEventListener("click", function (e) { e.stopPropagation(); });
19148
20877
  // Outside click closes both menus.
19149
20878
  document.addEventListener("click", function () { closeProjectMenu(); closeProfileMenu(); });
19150
20879
 
@@ -19175,10 +20904,9 @@ const CLIENT_JS = `
19175
20904
  });
19176
20905
  document.querySelectorAll("#view-perspectives .fchip").forEach(function (b) {
19177
20906
  b.addEventListener("click", function () {
20907
+ // renderPerspectives -> syncPerspChips repaints aria-pressed from
20908
+ // perspState.f, so the handler only has to record the choice.
19178
20909
  perspState.f = b.getAttribute("data-f");
19179
- document.querySelectorAll("#view-perspectives .fchip").forEach(function (x) {
19180
- x.setAttribute("aria-pressed", x === b ? "true" : "false");
19181
- });
19182
20910
  renderPerspectives();
19183
20911
  });
19184
20912
  });
@@ -19613,6 +21341,9 @@ function registerRoutes(router, config, queue) {
19613
21341
  router.get("/api/v1/projects", createListProjectsHandler(storage));
19614
21342
  router.get("/api/v1/projects/:project/profiles", createListProfilesHandler(storage));
19615
21343
  router.get("/api/v1/projects/:project/last-green", createGetLastGreenHandler(storage));
21344
+ router.post("/api/v1/projects/:project/deploys", createRecordDeployHandler(storage));
21345
+ router.get("/api/v1/projects/:project/deploys", createGetDeployLogHandler(storage));
21346
+ router.get("/api/v1/projects/:project/rerun", createGetRerunHandler(storage));
19616
21347
  const sessionConfig = {
19617
21348
  store: storage.sessions,
19618
21349
  encryptionKey: config.encryptionKey
@@ -19782,6 +21513,9 @@ function isNotFound(err) {
19782
21513
  * variables/<project>/<profile>/<name>.meta.json
19783
21514
  * triage/<runId>.json (TriageRecord[])
19784
21515
  * jobs/<id>/job.json (LearningJob record, mutated as it runs)
21516
+ * last-green/<project>/<profile>/<branch>.json (SpecLedger: green/run/red buckets)
21517
+ * deploys/<project>/<profile>/log.json (DeployLog, ring-buffered)
21518
+ * deploys/<project>/<profile>/touch.json (SpecTouchIndex derived from the log)
19785
21519
  *
19786
21520
  * IDs and names are validated by their callers (run ids are server-minted
19787
21521
  * UUIDs; project/profile/name come from validated request params) before
@@ -19845,9 +21579,22 @@ function perspectivesKindDir(root) {
19845
21579
  function perspectivesPath(root, project) {
19846
21580
  return join(perspectivesKindDir(root), `${project}.json`);
19847
21581
  }
19848
- function lastGreenPath(root, project, profile, branch) {
21582
+ function ledgerProfileDir(root, project, profile) {
21583
+ return join(root, "last-green", project, profile);
21584
+ }
21585
+ function ledgerPath(root, project, profile, branch) {
19849
21586
  const encoded = encodeURIComponent(branch);
19850
- 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`);
21587
+ const name = encoded.length <= 200 ? encoded : `${encoded.slice(0, 64)}-${createHash("sha256").update(branch).digest("hex").slice(0, 32)}`;
21588
+ return join(ledgerProfileDir(root, project, profile), `${name}.json`);
21589
+ }
21590
+ function deployScopeDir(root, project, profile) {
21591
+ return join(root, "deploys", project, profile);
21592
+ }
21593
+ function deployLogPath(root, project, profile) {
21594
+ return join(deployScopeDir(root, project, profile), "log.json");
21595
+ }
21596
+ function deployTouchIndexPath(root, project, profile) {
21597
+ return join(deployScopeDir(root, project, profile), "touch.json");
19851
21598
  }
19852
21599
  //#endregion
19853
21600
  //#region src/hub/core/storage/file/artifact-store.ts
@@ -19889,6 +21636,28 @@ function createFileArtifactStore(root) {
19889
21636
  };
19890
21637
  }
19891
21638
  //#endregion
21639
+ //#region src/hub/core/storage/file/deploy-store.ts
21640
+ function createFileDeployStore(root) {
21641
+ const readLog = async (project, profile) => await readJson(deployLogPath(root, project, profile)) ?? emptyDeployLog();
21642
+ return {
21643
+ async append(project, profile, input) {
21644
+ const log = await updateJson(deployLogPath(root, project, profile), (current) => appendDeploy(current, input));
21645
+ return log.entries[log.entries.length - 1];
21646
+ },
21647
+ getLog: readLog,
21648
+ async head(project, profile) {
21649
+ const { entries } = await readLog(project, profile);
21650
+ return entries[entries.length - 1] ?? null;
21651
+ },
21652
+ async getTouchIndex(project, profile) {
21653
+ return await readJson(deployTouchIndexPath(root, project, profile)) ?? {};
21654
+ },
21655
+ async updateTouchIndex(project, profile, mutate) {
21656
+ await updateJson(deployTouchIndexPath(root, project, profile), (current) => mutate(current ?? {}));
21657
+ }
21658
+ };
21659
+ }
21660
+ //#endregion
19892
21661
  //#region src/hub/core/storage/file/job-store.ts
19893
21662
  /**
19894
21663
  * Read one job record for a list scan, tolerating a bad entry: a missing
@@ -19936,21 +21705,19 @@ function createFileJobStore(root) {
19936
21705
  };
19937
21706
  }
19938
21707
  //#endregion
19939
- //#region src/hub/core/storage/file/last-green-store.ts
19940
- function createFileLastGreenStore(root) {
21708
+ //#region src/hub/core/storage/file/ledger-store.ts
21709
+ function createFileSpecLedgerStore(root) {
19941
21710
  return {
19942
21711
  async get(project, profile, branch) {
19943
- return await readJson(lastGreenPath(root, project, profile, branch)) ?? {};
21712
+ return toLedger(await readJson(ledgerPath(root, project, profile, branch)));
19944
21713
  },
19945
- async merge(project, profile, branch, entries) {
19946
- await updateJson(lastGreenPath(root, project, profile, branch), (current) => {
19947
- const out = { ...current ?? {} };
19948
- for (const [key, entry] of Object.entries(entries)) {
19949
- const prev = out[key];
19950
- if (!prev || prev.at <= entry.at) out[key] = entry;
19951
- }
19952
- return out;
19953
- });
21714
+ async getMerged(project, profile) {
21715
+ const dir = ledgerProfileDir(root, project, profile);
21716
+ const files = (await listDirOrEmpty(dir)).filter((name) => name.endsWith(".json"));
21717
+ return (await Promise.all(files.map((name) => readJson(join(dir, name))))).reduce((acc, doc) => mergeLedgerInto(acc, toLedger(doc)), emptyLedger());
21718
+ },
21719
+ async merge(project, profile, branch, ledger) {
21720
+ await updateJson(ledgerPath(root, project, profile, branch), (current) => mergeLedgerInto(toLedger(current), ledger));
19954
21721
  }
19955
21722
  };
19956
21723
  }
@@ -20209,7 +21976,8 @@ function createFileHubStorage(dataDir) {
20209
21976
  prompts: createFilePromptStore(dataDir),
20210
21977
  perspectives: createFilePerspectivesStore(dataDir),
20211
21978
  jobs: createFileJobStore(dataDir),
20212
- lastGreen: createFileLastGreenStore(dataDir)
21979
+ ledger: createFileSpecLedgerStore(dataDir),
21980
+ deploys: createFileDeployStore(dataDir)
20213
21981
  };
20214
21982
  }
20215
21983
  //#endregion