ccqa 1.33.0 → 1.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -197,6 +197,23 @@ tenant, an account, a role — not an environment: ccqa tracks one
197
197
  verification environment
198
198
  ([ADR-0013](./docs/adr/0013-one-verification-environment.md)).
199
199
 
200
+ ## Agent skills
201
+
202
+ `skills/` ships guides that let a coding agent (e.g. Claude Code) drive ccqa
203
+ end-to-end on its own:
204
+
205
+ - **ccqa-record** — create a new test case: pin down the behavior, write the
206
+ spec, choose deterministic or live mode, record, run to green.
207
+ - **ccqa-rerecord** — bring a flagged or failing test case back to green:
208
+ read the hub's finding, decide what went stale, repair that, re-record.
209
+
210
+ Install them with the [skills CLI](https://github.com/vercel-labs/skills)
211
+ into a consuming project (or `-g` for all projects):
212
+
213
+ ```bash
214
+ npx skills add <this-repo> --skill ccqa-record --skill ccqa-rerecord
215
+ ```
216
+
200
217
  ## Documentation
201
218
 
202
219
  | I want to… | Read |
package/dist/bin/ccqa.mjs CHANGED
@@ -89,17 +89,19 @@ function* iterEnvRefNames(value) {
89
89
  }
90
90
  }
91
91
  /**
92
- * Resolve every `$VAR` / `${VAR}` reference against the current process env.
92
+ * Resolve every `$VAR` / `${VAR}` reference against `overrides`, then the
93
+ * current process env. `overrides` carries values an invoker injects into a
94
+ * child process (e.g. CCQA_RUN_ID), which beat the parent env there.
93
95
  *
94
96
  * Missing variables expand to the empty string, mirroring `sh` behaviour.
95
97
  * Throwing would force ccqa to be invoked with every var set even for
96
98
  * unused blocks, which is more user-hostile than letting the test fail
97
99
  * downstream with a clearer message ("login form rejected: empty password").
98
100
  */
99
- function resolveEnvRefs(value) {
101
+ function resolveEnvRefs(value, overrides = {}) {
100
102
  return value.replace(ENV_VAR_RE, (_, braced, plain) => {
101
103
  const name = braced ?? plain ?? "";
102
- return process.env[name] ?? "";
104
+ return overrides[name] ?? process.env[name] ?? "";
103
105
  });
104
106
  }
105
107
  /**
@@ -408,6 +410,7 @@ function collectIncludedBlockNames(spec) {
408
410
  const CCQA_DIR = ".ccqa";
409
411
  const SPEC_FILE = "spec.yaml";
410
412
  const RECORDING_FILE = "ir.json";
413
+ const FAILED_RECORDING_FILE = "ir.failed.json";
411
414
  const PERSPECTIVES_FILE = "perspectives.yaml";
412
415
  const PERSPECTIVES_MD_FILE = "perspectives.md";
413
416
  function getCcqaDir(cwd = process.cwd()) {
@@ -476,9 +479,22 @@ async function saveRecording(featureName, specName, actions, cwd) {
476
479
  await mkdir(specDir, { recursive: true });
477
480
  const recordingPath = join(specDir, RECORDING_FILE);
478
481
  await writeFile(recordingPath, JSON.stringify(actions, null, 2), "utf-8");
479
- await Promise.all(LEGACY_RECORDING_FILES.map((f) => unlink(join(specDir, f)).catch(() => {})));
482
+ await Promise.all([...LEGACY_RECORDING_FILES, FAILED_RECORDING_FILE].map((f) => unlink(join(specDir, f)).catch(() => {})));
480
483
  return recordingPath;
481
484
  }
485
+ /**
486
+ * Persist the actions of a trace that FAILED. They go to a side file so a
487
+ * recording that did not demonstrate the spec can never replace one that
488
+ * did — `ir.json` and the generated code stay whatever they were. The next
489
+ * successful {@link saveRecording} deletes the file.
490
+ */
491
+ async function saveFailedRecording(featureName, specName, actions, cwd) {
492
+ const specDir = getSpecDir(featureName, specName, cwd);
493
+ await mkdir(specDir, { recursive: true });
494
+ const path = join(specDir, FAILED_RECORDING_FILE);
495
+ await writeFile(path, JSON.stringify(actions, null, 2), "utf-8");
496
+ return path;
497
+ }
482
498
  function getBlocksDir(cwd) {
483
499
  return join(getCcqaDir(cwd), "blocks");
484
500
  }
@@ -1326,10 +1342,12 @@ function opt(key, value) {
1326
1342
  * `instruction` / `expected` may *also* contain `${ENV}` refs that
1327
1343
  * don't go through include params.
1328
1344
  *
1329
- * Only refs whose env value is currently non-empty land in the map
1330
- * scrubbing against an empty string would corrupt unrelated empty strings
1331
- * in the action stream. Names whose env is unset are returned via
1332
- * `unresolved` so the caller can warn the user.
1345
+ * Each ref resolves against `overrides` first, then `process.env`
1346
+ * `overrides` carries values the invoker injects into the child process,
1347
+ * which beat the parent env there. Only refs that resolve non-empty land in
1348
+ * the map scrubbing against an empty string would corrupt unrelated empty
1349
+ * strings in the action stream; the rest are returned via `unresolved` so
1350
+ * the caller can warn the user.
1333
1351
  *
1334
1352
  * Longer values sort first so a `${SHORT}` whose value is a substring of a
1335
1353
  * `${LONG}` value doesn't clobber the longer one.
@@ -1337,7 +1355,7 @@ function opt(key, value) {
1337
1355
  * `title` is deliberately NOT scanned — it never reaches the recorded action
1338
1356
  * stream.
1339
1357
  */
1340
- function buildSpecEnvScrub(spec, expanded) {
1358
+ function buildSpecEnvScrub(spec, expanded, overrides = {}) {
1341
1359
  const refNames = /* @__PURE__ */ new Set();
1342
1360
  for (const step of spec.steps) if (isIncludeStep(step)) for (const v of Object.values(step.params ?? {})) collect(v, refNames);
1343
1361
  else {
@@ -1351,7 +1369,7 @@ function buildSpecEnvScrub(spec, expanded) {
1351
1369
  const map = [];
1352
1370
  const unresolved = [];
1353
1371
  for (const name of refNames) {
1354
- const value = process.env[name];
1372
+ const value = overrides[name] ?? process.env[name];
1355
1373
  if (typeof value === "string" && value.length > 0) map.push([value, "${" + name + "}"]);
1356
1374
  else unresolved.push(name);
1357
1375
  }
@@ -1383,8 +1401,8 @@ const COMMON_PROSE_VALUES = new Set([
1383
1401
  * command log too, trading that short-value coverage for not building a
1384
1402
  * second map.
1385
1403
  */
1386
- function buildProseEnvScrubMap(spec, expanded) {
1387
- return buildSpecEnvScrub(spec, expanded).map.filter(([value]) => value.length >= MIN_PROSE_SCRUB_LENGTH && !COMMON_PROSE_VALUES.has(value.toLowerCase()));
1404
+ function buildProseEnvScrubMap(spec, expanded, overrides = {}) {
1405
+ return buildSpecEnvScrub(spec, expanded, overrides).map.filter(([value]) => value.length >= MIN_PROSE_SCRUB_LENGTH && !COMMON_PROSE_VALUES.has(value.toLowerCase()));
1388
1406
  }
1389
1407
  /**
1390
1408
  * Replace every occurrence of an env value with its `${VAR}` placeholder in
@@ -1524,16 +1542,21 @@ const ENDPOINT_ENV_KEYS = [
1524
1542
  "CLAUDE_CODE_OAUTH_TOKEN"
1525
1543
  ];
1526
1544
  /**
1545
+ * When both credentials are present the OAuth token wins and the API key is
1546
+ * dropped. Left to the CLI the API key would win, which makes "switch a CI
1547
+ * job to the subscription token" require unwiring the key everywhere; with
1548
+ * this rule, adding the one variable is the whole switch, and removing it is
1549
+ * the whole rollback. The one place the rule lives — both the resolved view
1550
+ * and the env the SDK receives apply it through here.
1551
+ */
1552
+ function preferOauthToken(env) {
1553
+ if (env["CLAUDE_CODE_OAUTH_TOKEN"]) delete env["ANTHROPIC_API_KEY"];
1554
+ }
1555
+ /**
1527
1556
  * Collects the endpoint/auth variables set in the current process environment
1528
1557
  * so they can be forwarded, verbatim, to every Claude Code invocation. Returns
1529
1558
  * only the keys that are actually set (non-empty), so unset variables never
1530
- * override the SDK's own defaults.
1531
- *
1532
- * When both credentials are present the OAuth token wins and the API key is
1533
- * not forwarded. Left to the CLI the API key would win, which makes "switch a
1534
- * CI job to the subscription token" require unwiring the key everywhere; with
1535
- * the precedence here, adding the one variable is the whole switch, and
1536
- * removing it is the whole rollback.
1559
+ * override the SDK's own defaults. Credential precedence per preferOauthToken.
1537
1560
  */
1538
1561
  function resolveEndpointEnv() {
1539
1562
  const endpointEnv = {};
@@ -1541,7 +1564,7 @@ function resolveEndpointEnv() {
1541
1564
  const value = process.env[key];
1542
1565
  if (value && value.length > 0) endpointEnv[key] = value;
1543
1566
  }
1544
- if (endpointEnv["CLAUDE_CODE_OAUTH_TOKEN"]) delete endpointEnv["ANTHROPIC_API_KEY"];
1567
+ preferOauthToken(endpointEnv);
1545
1568
  return endpointEnv;
1546
1569
  }
1547
1570
  /**
@@ -1555,6 +1578,30 @@ function withoutEmptyEndpointVars(env) {
1555
1578
  for (const key of ENDPOINT_ENV_KEYS) if (out[key] === "") delete out[key];
1556
1579
  return out;
1557
1580
  }
1581
+ /**
1582
+ * The environment actually handed to the Claude Code process: the full process
1583
+ * environment with the caller's overrides on top, empty endpoint variables
1584
+ * dropped, and — when both credentials survive the merge — the API key removed
1585
+ * so the OAuth token wins.
1586
+ *
1587
+ * That removal MUST happen on the env the SDK receives, not only on the
1588
+ * resolved view: left to the CLI the API key would win, silently moving every
1589
+ * call from the subscription to metered billing when a CI job wires both
1590
+ * (which is exactly what happened before this function existed).
1591
+ *
1592
+ * Returns undefined when no endpoint variable is set and the caller passes no
1593
+ * env, so the SDK keeps its own default environment.
1594
+ */
1595
+ function buildInvocationEnv(env) {
1596
+ const hasEndpointEnv = Object.keys(resolveEndpointEnv()).length > 0;
1597
+ if (!env && !hasEndpointEnv) return void 0;
1598
+ const merged = withoutEmptyEndpointVars({
1599
+ ...process.env,
1600
+ ...env
1601
+ });
1602
+ preferOauthToken(merged);
1603
+ return merged;
1604
+ }
1558
1605
  let nativeBinaryWarned = false;
1559
1606
  /**
1560
1607
  * Warn once per process when the SDK's per-platform native binary is missing:
@@ -1570,11 +1617,7 @@ function warnOnceIfNativeBinaryMissing() {
1570
1617
  async function invokeClaudeStreaming(options, onEvent) {
1571
1618
  const { prompt, systemPrompt, allowedTools, disableBuiltinTools = false, disableThinking = false, mcpServers, maxTurns, env, model, cwd, onAbAction, onAbActionFailed, silenceBashLog = false, envScrubMap = [], relaxAbConstraints = false } = options;
1572
1619
  const resolvedModel = resolveModel(model);
1573
- const hasEndpointEnv = Object.keys(resolveEndpointEnv()).length > 0;
1574
- const mergedEnv = env || hasEndpointEnv ? withoutEmptyEndpointVars({
1575
- ...process.env,
1576
- ...env
1577
- }) : void 0;
1620
+ const mergedEnv = buildInvocationEnv(env);
1578
1621
  let lastAbToolUseId = null;
1579
1622
  const claimAbToolUse = (toolUseId) => {
1580
1623
  if (toolUseId !== lastAbToolUseId) return false;
@@ -10545,7 +10588,6 @@ async function runOneSpec(args) {
10545
10588
  }
10546
10589
  const spec = parseTestSpec(specContent);
10547
10590
  const expanded = expandSpec(spec, { blocks: await loadAllBlocks(cwd) });
10548
- const envScrubMap = buildProseEnvScrubMap(spec, expanded);
10549
10591
  meta("spec", spec.title);
10550
10592
  meta("steps", expanded.length);
10551
10593
  const includes = collectIncludedBlockNames(spec);
@@ -10575,6 +10617,7 @@ async function runOneSpec(args) {
10575
10617
  }
10576
10618
  try {
10577
10619
  const runId = buildRunId();
10620
+ const envScrubMap = buildProseEnvScrubMap(spec, expanded, { CCQA_RUN_ID: runId });
10578
10621
  const runDir = opts.out ?? join(specDir, "runs", runId);
10579
10622
  await mkdir(runDir, { recursive: true });
10580
10623
  meta("runDir", runDir);
@@ -11810,10 +11853,11 @@ async function runVitest(scriptPath, agentBrowserSession) {
11810
11853
  "--config",
11811
11854
  bundledVitestConfigPath(),
11812
11855
  scriptPath
11813
- ], agentBrowserSession ? { env: {
11856
+ ], { env: {
11814
11857
  ...process.env,
11815
- AGENT_BROWSER_SESSION: agentBrowserSession
11816
- } } : {});
11858
+ CCQA_RUN_ID: buildRunId(),
11859
+ ...agentBrowserSession ? { AGENT_BROWSER_SESSION: agentBrowserSession } : {}
11860
+ } });
11817
11861
  const currentScript = await readFile(scriptPath, "utf8");
11818
11862
  return {
11819
11863
  exitCode,
@@ -14005,6 +14049,10 @@ function createRunTeardown() {
14005
14049
  untrackSession(name) {
14006
14050
  sessions.delete(name);
14007
14051
  },
14052
+ async closeTracked(name) {
14053
+ await closeSession(name);
14054
+ sessions.delete(name);
14055
+ },
14008
14056
  onFinalize(fn) {
14009
14057
  finalizers.push(fn);
14010
14058
  },
@@ -14279,6 +14327,7 @@ CCQA_STEP=<step-id> agent-browser --session SESSION upload "<input[type=file] se
14279
14327
  - \`@ref\` / \`@e1\` / \`e14\` — reference IDs are session-specific and change every run.
14280
14328
  - **Bare tag selectors**: \`button\`, \`a\`, \`div\`, \`td\`, \`tr\`, \`main a\`, \`table tbody tr:nth-child(N)\`. These match every element of that tag and are non-deterministic on replay. **This includes the inner selector inside \`find first/last/nth\`** — see the \`find\` rules below.
14281
14329
  - \`[role='button']\` or \`[type='checkbox']\` alone — matches too many elements.
14330
+ - **Playwright-only pseudo-classes**: \`:has-text()\`, \`:text-is()\`, \`:text-matches()\`, \`:visible\`. agent-browser's CSS engine does not implement them — they match nothing and every command using them fails. Use \`text=...\` or plain CSS instead.
14282
14331
  - JavaScript execution (\`eval\`, \`js\`) — blocked by the hook layer.
14283
14332
 
14284
14333
  ### \`find\` subset (fallback when no ALLOWED CSS uniquely targets the element)
@@ -14660,9 +14709,9 @@ const ASSERT_TIMEOUT_MS = 1e4;
14660
14709
  * has no side effect; assert types whose codegen forms aren't directly
14661
14710
  * verifiable here fall through to the caller's `unverifiable` fallback).
14662
14711
  */
14663
- function actionToAbArgs(action, sessionName) {
14712
+ function actionToAbArgs(action, sessionName, envOverrides = {}) {
14664
14713
  const base = ["--session", sessionName];
14665
- const sub = (s) => s === void 0 ? "" : resolveEnvRefs(s);
14714
+ const sub = (s) => s === void 0 ? "" : resolveEnvRefs(s, envOverrides);
14666
14715
  switch (action.action) {
14667
14716
  case "snapshot": return null;
14668
14717
  case "assert": return assertToAbArgs(action, sub, sessionName);
@@ -14747,8 +14796,8 @@ const NO_STEP_ID = "__no_step__";
14747
14796
  * `wait <selector>`); everything else spawns the agent-browser argv. A single
14748
14797
  * hard-timeout (SIGTERM) retry covers the daemon's occasional under-load drop.
14749
14798
  */
14750
- function runValidationAction(action, sessionName) {
14751
- const built = actionToAbArgs(action, sessionName);
14799
+ function runValidationAction(action, sessionName, envOverrides = {}) {
14800
+ const built = actionToAbArgs(action, sessionName, envOverrides);
14752
14801
  if (built === null) return {
14753
14802
  skipped: true,
14754
14803
  ok: false,
@@ -14792,7 +14841,7 @@ function validateActions(actions, opts) {
14792
14841
  });
14793
14842
  continue;
14794
14843
  }
14795
- const outcome = runValidationAction(action, opts.sessionName);
14844
+ const outcome = runValidationAction(action, opts.sessionName, opts.envOverrides);
14796
14845
  if (outcome.skipped) {
14797
14846
  kept.push(action);
14798
14847
  continue;
@@ -14869,7 +14918,7 @@ function rescueLostSteps(actions, kept, dropped, opts) {
14869
14918
  for (const [stepId, drops] of lostStepDrops.entries()) {
14870
14919
  let anyForThisStep = false;
14871
14920
  for (const d of drops) {
14872
- const outcome = runValidationAction(d.action, opts.sessionName);
14921
+ const outcome = runValidationAction(d.action, opts.sessionName, opts.envOverrides);
14873
14922
  if (outcome.skipped) continue;
14874
14923
  if (outcome.ok) {
14875
14924
  rescuedIndices.add(d.index);
@@ -15051,12 +15100,22 @@ function formatUnstableDrop(drop) {
15051
15100
  }
15052
15101
  //#endregion
15053
15102
  //#region src/cli/trace.ts
15103
+ /**
15104
+ * Step ids (in spec order) whose kept actions include no assertion. A step
15105
+ * with none produced a test that performs the step but verifies nothing
15106
+ * about its `expected` — surfaced as a per-step warning after validation.
15107
+ */
15108
+ function stepsWithoutAsserts(stepIds, actions) {
15109
+ const withAssert = new Set(actions.filter((a) => a.action === "assert" && a.stepId !== void 0).map((a) => a.stepId));
15110
+ return stepIds.filter((id) => !withAssert.has(id));
15111
+ }
15054
15112
  async function runTrace(featureName, specName, model, validationMode = "lenient", language, opts = {}) {
15055
15113
  header("trace", `${featureName}/${specName}`);
15056
15114
  await preflightAgentBrowserCommand();
15057
15115
  const spec = parseTestSpec(await readSpecFile(featureName, specName, opts.cwd));
15058
15116
  const expanded = expandSpec(spec, { blocks: await loadAllBlocks(opts.cwd) });
15059
- const envScrub = buildSpecEnvScrub(spec, expanded);
15117
+ const sessionName = generateSessionName();
15118
+ const envScrub = buildSpecEnvScrub(spec, expanded, { CCQA_RUN_ID: sessionName });
15060
15119
  const envScrubMap = envScrub.map;
15061
15120
  if (envScrub.unresolved.length > 0) {
15062
15121
  warn(`spec references env var(s) that are unset at record time: ${envScrub.unresolved.join(", ")}`);
@@ -15068,7 +15127,7 @@ async function runTrace(featureName, specName, model, validationMode = "lenient"
15068
15127
  const includes = collectIncludedBlockNames(spec);
15069
15128
  if (includes.length > 0) meta("blocks", includes.join(", "));
15070
15129
  blank();
15071
- const sessionName = generateSessionName();
15130
+ opts.teardown?.trackSession(sessionName);
15072
15131
  const baseSystemPrompt = buildTraceSystemPrompt({
15073
15132
  title: spec.title,
15074
15133
  steps: expanded,
@@ -15151,13 +15210,18 @@ async function runTrace(featureName, specName, model, validationMode = "lenient"
15151
15210
  }
15152
15211
  });
15153
15212
  if (isError) overallStatus = "failed";
15154
- const validatedActions = validateAndReport(dedupAndReport(scrubAndReport(traceActions)), validationMode);
15155
- const recordingPath = await saveRecording(featureName, specName, validatedActions, opts.cwd);
15213
+ const dedupedActions = dedupAndReport(scrubAndReport(traceActions));
15214
+ const validatedActions = overallStatus === "passed" || opts.validateFailedTrace === true ? validateAndReport(dedupedActions, validationMode, { CCQA_RUN_ID: sessionName }, opts.teardown) : dedupedActions;
15215
+ opts.teardown?.closeTracked(sessionName) ?? closeSession(sessionName);
15216
+ const recordingPath = overallStatus === "passed" ? await saveRecording(featureName, specName, validatedActions, opts.cwd) : await saveFailedRecording(featureName, specName, validatedActions, opts.cwd);
15156
15217
  blank();
15157
15218
  meta("saved", recordingPath);
15158
15219
  meta("actions", validatedActions.length);
15159
15220
  meta("status", overallStatus.toUpperCase());
15160
- hint(`run 'ccqa generate ${featureName}/${specName}' to generate a test script`);
15221
+ if (overallStatus === "passed") {
15222
+ for (const stepId of stepsWithoutAsserts(expanded.map((s) => s.id), validatedActions)) warn(`${stepId} recorded no assertion — nothing in the generated test verifies its 'expected'`);
15223
+ hint(`run 'ccqa generate ${featureName}/${specName}' to generate a test script`);
15224
+ } else warn("trace FAILED — the recorded actions were saved beside the spec for diagnosis; the previous ir.json and generated code are left untouched");
15161
15225
  return {
15162
15226
  status: overallStatus,
15163
15227
  statusLines,
@@ -15298,26 +15362,30 @@ function isAdjacentDuplicate(a, b) {
15298
15362
  }
15299
15363
  /**
15300
15364
  * Run the post-trace replay validation and emit user-visible drop reports.
15301
- * Splitting this out keeps `runTrace` readable; the function is pure aside
15302
- * from `log.*` and the agent-browser invocations inside `validateActions`.
15365
+ * Splitting this out keeps `runTrace` readable; side effects are `log.*`,
15366
+ * the agent-browser invocations inside `validateActions`, and the replay
15367
+ * session's registration/close on `teardown`.
15303
15368
  *
15304
15369
  * In lenient mode (the default) failing actions are NOT removed — they're
15305
15370
  * tagged with `replayUnstable: true` and merged back into the output stream
15306
15371
  * in their original order so codegen can still emit them (with a `// [warn]`
15307
15372
  * comment) and let the auto-fix loop decide what to do.
15308
15373
  */
15309
- function validateAndReport(actions, mode) {
15374
+ function validateAndReport(actions, mode, envOverrides, teardown) {
15310
15375
  if (actions.length === 0) return actions;
15311
15376
  const sessionName = `${generateSessionName()}-validate`;
15377
+ teardown?.trackSession(sessionName);
15312
15378
  blank();
15313
15379
  info(`post-trace validation in ${mode} mode (replaying ${actions.length} recorded action(s))...`);
15314
15380
  const { kept, unstable, dropped, rescuedSteps = [] } = validateActions(actions, {
15315
15381
  sessionName,
15316
15382
  mode,
15383
+ envOverrides,
15317
15384
  onProgress: (i, total, action) => {
15318
15385
  progress(i, total, validationProgressLabel(action));
15319
15386
  }
15320
15387
  });
15388
+ teardown?.closeTracked(sessionName) ?? closeSession(sessionName);
15321
15389
  progressEnd();
15322
15390
  if (rescuedSteps.length > 0) info(`rescued ${rescuedSteps.length} step(s) that had lost every action: ${rescuedSteps.join(", ")}`);
15323
15391
  if (mode === "lenient") {
@@ -15540,6 +15608,10 @@ function resolveTargetOrExit(resolve) {
15540
15608
  async function runGenerateLocked(featureName, specName, opts, cwd) {
15541
15609
  const specYaml = await readSpecFile(featureName, specName, cwd);
15542
15610
  const spec = parseTestSpec(specYaml);
15611
+ if (spec.mode === "live") {
15612
+ error(`this spec is 'mode: live' — a live spec runs without generated code. Run 'ccqa run ${featureName}/${specName}' instead`);
15613
+ process.exit(2);
15614
+ }
15543
15615
  const config = await loadProjectConfig(cwd);
15544
15616
  const target = resolveTargetOrExit(() => opts.targetOverride !== void 0 ? resolveTargetOverride(spec, opts.targetOverride) : resolveTarget(spec, config));
15545
15617
  meta("target", target.id + (opts.targetOverride !== void 0 ? " (--target override)" : ""));
@@ -15712,6 +15784,10 @@ async function runRecord(specPath, opts) {
15712
15784
  error(`target "${target.id}" does not use a browser recording — run 'ccqa generate ${featureName}/${specName}' instead`);
15713
15785
  process.exit(2);
15714
15786
  }
15787
+ if (spec.mode === "live") {
15788
+ error(`this spec is 'mode: live' — a live spec runs without a recording. Run 'ccqa run ${featureName}/${specName}' instead`);
15789
+ process.exit(2);
15790
+ }
15715
15791
  const project = opts.hubProfile !== void 0 ? resolveProject(opts) : void 0;
15716
15792
  if (opts.hubProfile !== void 0) await applyProfileFromOption({
15717
15793
  profile: opts.hubProfile,
@@ -15779,6 +15855,8 @@ async function runRecord(specPath, opts) {
15779
15855
  const traceResult = await runTrace(featureName, specName, opts.model, opts.traceValidation ?? "lenient", language, {
15780
15856
  cwd: cwdForProfile,
15781
15857
  hubContext,
15858
+ teardown,
15859
+ validateFailedTrace: opts.learnHubTracePrompt === true,
15782
15860
  ...opts.instruction ? { instruction: opts.instruction } : {},
15783
15861
  onStep: (stepId) => {
15784
15862
  tracingStep = stepId;
@@ -15796,7 +15874,7 @@ async function runRecord(specPath, opts) {
15796
15874
  ...opts.model ? { model: opts.model } : {},
15797
15875
  ...language ? { language } : {}
15798
15876
  });
15799
- if (!opts.traceOnly) generated = (await runGenerate(featureName, specName, {
15877
+ if (!opts.traceOnly && traceResult.status === "passed") generated = (await runGenerate(featureName, specName, {
15800
15878
  maxRetries: parseInt(opts.autoFixMaxRetries ?? "3", 10),
15801
15879
  fixMode: toFixMode(opts.autoFix ?? "interactive"),
15802
15880
  force: opts.overwrite ?? false,
@@ -21079,7 +21157,7 @@ const CSS = `
21079
21157
  .d-grid dt { color: var(--muted); font-size: 12px; padding-top: 1px; }
21080
21158
  .d-grid dd { color: var(--fg-dim); }
21081
21159
  .d-grid dd ul { list-style: none; display: flex; flex-direction: column; gap: 3px; margin: 0; padding: 0; }
21082
- .d-grid dd li::before { content: "\\2022 "; color: var(--muted-2); }
21160
+ .d-grid dd ul li::before { content: "\\2022 "; color: var(--muted-2); }
21083
21161
  .d-grid code { font-size: 12px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; }
21084
21162
  /* Prose gets a measure so it stops wrapping mid-phrase in a narrow column;
21085
21163
  paths wrap as whole chips, never inside a path. */
@@ -21088,17 +21166,20 @@ const CSS = `
21088
21166
  .d-paths code { white-space: nowrap; }
21089
21167
  .d-prose + .d-paths { margin-top: 6px; }
21090
21168
  .manual-attest { margin-top: 14px; }
21091
- .steps-box { margin-top: 14px; max-width: 900px; }
21092
- .steps-box .slabel { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
21093
21169
  .d-steps { margin: 4px 0 0; padding-left: 18px; display: flex; flex-direction: column; gap: 10px; font-size: 13px; color: var(--fg-dim); white-space: pre-line; }
21094
21170
  .d-steps .step-expected { display: block; margin-top: 2px; font-size: 12.5px; }
21095
- .notebox { margin-top: 14px; max-width: 900px; }
21096
- .notebox .nlabel { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
21097
21171
  .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; }
21098
- .notebox .nact { margin-top: 6px; display: flex; align-items: center; gap: 8px; }
21172
+ .notebox .nact { margin-top: 6px; display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
21099
21173
  .notebox .nstatus { font-size: 12px; color: var(--muted); }
21100
21174
  .notebox .nstatus.ok { color: var(--pass); }
21101
21175
  .notebox .nstatus.err { color: var(--fail); }
21176
+ /* ── detail panel: one card stack — reason, contents, note ── */
21177
+ .c-title .c-id { display: block; font-family: var(--mono); font-size: 11px; color: var(--muted-2); margin-top: 2px; }
21178
+ .p-sect { margin-top: 16px; max-width: 900px; }
21179
+ .p-sect:first-child { margin-top: 12px; }
21180
+ .p-slabel { font-size: 13px; font-weight: 600; color: var(--fg); margin-bottom: 6px; }
21181
+ /* The one-line state note beside the finding chip: what happens next. */
21182
+ .p-head-note { margin-left: auto; font-size: 12.5px; color: var(--muted); }
21102
21183
  /* The inline form an audit-dismissal or environment-attestation button
21103
21184
  expands into, in place of the two window.prompt() calls this replaces. */
21104
21185
  .override-form { margin-top: 10px; max-width: 480px; display: flex; flex-direction: column; gap: 10px; }
@@ -21226,16 +21307,15 @@ const CLIENT_JS = `
21226
21307
  "perspectives.mode.deterministic": "deterministic", "perspectives.mode.live": "live",
21227
21308
  "perspectives.ov.cases": "cases", "perspectives.ov.features": "features",
21228
21309
  "perspectives.d.preconditions": "Preconditions", "perspectives.d.startScreen": "Start screen",
21229
- "perspectives.d.testCondition": "Condition", "perspectives.d.spec": "spec",
21230
- "perspectives.d.steps": "Steps", "perspectives.d.stepInclude": "Include: {name}",
21310
+ "perspectives.d.testCondition": "Condition", "perspectives.d.steps": "Steps", "perspectives.d.stepInclude": "Include: {name}",
21231
21311
  "perspectives.d.stepExpected": "Expected:",
21232
21312
  "perspectives.note.label": "Note",
21233
21313
  "perspectives.note.placeholder": "Notes about this case…",
21234
21314
  "perspectives.note.saved": "Saved",
21235
21315
  "perspectives.note.error": "Could not save — retry",
21236
- "perspectives.d.lastRed": "Most recent failure",
21237
- "perspectives.d.changedSince": "Changes since the last run",
21238
21316
  "perspectives.d.whyVerdict": "Why this verdict",
21317
+ "perspectives.d.contents": "What this case does",
21318
+ "perspectives.finding.loadFailed": "Could not load the finding's detail \u2014 open the run to read it",
21239
21319
  "perspectives.result.openRun": "Open this run in the hub",
21240
21320
  "perspectives.result.ci": "CI",
21241
21321
  "perspectives.rerun.state.needsRepair": "Needs repair",
@@ -21246,26 +21326,26 @@ const CLIENT_JS = `
21246
21326
  "perspectives.rerun.vsDeploy": "judged against deploy",
21247
21327
  "perspectives.rerun.noDeployHead": "no deploy recorded for this profile",
21248
21328
  "perspectives.rerun.changedByDeploy": "deploy {sha} changed files matched to this case",
21249
- "perspectives.rerun.changesSome": "yes (as of deploy {sha})",
21250
- "perspectives.rerun.changesNone": "none (as of deploy {sha})",
21329
+ "perspectives.rerun.changesSome": "changes matched to this case since the last run (as of deploy {sha})",
21330
+ "perspectives.rerun.changesNone": "no changes matched to this case since the last run (as of deploy {sha})",
21251
21331
  "perspectives.rerun.touchedCount": "{n} deployed path(s) matched this case",
21252
21332
  "perspectives.rerun.touchedUnknown": "a deploy since the last run matched this case",
21253
21333
  "perspectives.rerun.inProgressHint": "an audit or a run is still going, or the audit has not caught up with the deploy",
21254
- "perspectives.rerun.heldHint": "another job already holds this specacting on it now would race that job",
21255
- "perspectives.rerun.repair.testDrift": "the generated test no longer matches the code — re-record it",
21256
- "perspectives.rerun.repair.specChange": "the spec describes something the code no longer doesa human decides",
21257
- "perspectives.rerun.repair.auditUndecided": "the audit read the code and could not decidea human looks",
21258
- "perspectives.rerun.repair.runFailed": "the last run failed — re-running it changes nothing until the cause is fixed",
21259
- "perspectives.rerun.why.noSelectionInRange": "a deploy in range was recorded without a spec selection",
21260
- "perspectives.rerun.why.selectionUnknown": "the selector could not tell whether this case was affected",
21334
+ "perspectives.rerun.heldHint": "an audit, auto-fix or run job is working on this casedismissing or attesting is unavailable until it finishes",
21335
+ "perspectives.rerun.repair.testDrift": "the audit judged the generated test code older than the implementationauto-fix re-records it, so nothing is needed yet",
21336
+ "perspectives.rerun.repair.specChange": "the audit judged that the behaviour this test assumes is gone from the code fix or delete the test, or dismiss the finding via “{dismissButton}” if it is wrong",
21337
+ "perspectives.rerun.repair.auditUndecided": "the audit could not tell whether the test has drifted review the finding, then fix the test or dismiss it via “{dismissButton}”",
21338
+ "perspectives.rerun.repair.runFailed": "the latest run failed — this verdict will not change until the failure's cause is addressed",
21339
+ "perspectives.rerun.why.noSelectionInRange": "a deploy in range carries no impact judgement, so this case runs to be safe",
21340
+ "perspectives.rerun.why.selectionUnknown": "the latest deploy could not be judged as affecting this case or not, so it runs to be safe",
21261
21341
  "perspectives.rerun.why.noDeployLog": "no deploy log for this profile",
21262
21342
  "perspectives.rerun.why.unknownDeployedSha": "the last run's deployed commit is unknown",
21263
21343
  "perspectives.rerun.why.ambiguousDeployedSha": "a deploy landed while the last run was executing",
21264
21344
  "perspectives.rerun.why.deployedShaNotInLog": "the last run's commit predates the retained deploy log",
21265
21345
  "perspectives.rerun.why.gapInRange": "deploys are missing from the range",
21266
21346
  "perspectives.rerun.why.unrecognized": "this hub reported a reason this UI does not recognise",
21267
- "perspectives.rerun.fix.noSelectionInRange": "A deploy in range was recorded without a spec selection, so nothing says whether it affected this case. Run ccqa select-specs in the deploy job and send its verdict with the deploy.",
21268
- "perspectives.rerun.fix.selectionUnknown": "A deploy in range was judged, but the selector could not decide this case. Re-run it to get a clean baseline.",
21347
+ "perspectives.rerun.fix.noSelectionInRange": "A deploy was recorded without an impact judgement, so whether it affected this case is unknown. This happens when the judgement did not finish in time, or was disabled, at record. The next run and audit retake the result.",
21348
+ "perspectives.rerun.fix.selectionUnknown": "A deploy could not be judged as affecting this case or not, so the next run retakes the result.",
21269
21349
  "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.",
21270
21350
  "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.",
21271
21351
  "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.",
@@ -21298,8 +21378,8 @@ const CLIENT_JS = `
21298
21378
  "perspectives.override.noteLabel": "What was resolved, and how you checked (required)",
21299
21379
  "perspectives.override.submit": "Record",
21300
21380
  "perspectives.override.cancel": "Never mind",
21301
- "perspectives.override.dismissHint": "Overrides the audit finding and closes the case. The verdict moves to “re-run needed”, and the next run settles it.",
21302
- "perspectives.override.envHint": "The failure stays on record, but the verdict becomes “manually verified without waiting for a re-run. It lapses once a deploy reaches this spec.",
21381
+ "perspectives.override.dismissHint": "Withdraws the audit finding and puts this case back among the runnable. The verdict moves to “re-run needed”, and the next run's result settles it.",
21382
+ "perspectives.override.envHint": "The failure stays on record; only the verdict becomes “manually verified”. It lapses once a deploy reaches this case, and normal runs resume.",
21303
21383
  "prompt.card.record": "Recording browser actions",
21304
21384
  "prompt.card.live": "Live run (AI-driven)",
21305
21385
  "prompt.card.playwright": "Playwright test generation",
@@ -21416,16 +21496,15 @@ const CLIENT_JS = `
21416
21496
  "perspectives.mode.deterministic": "決定的", "perspectives.mode.live": "ライブ",
21417
21497
  "perspectives.ov.cases": "ケース", "perspectives.ov.features": "機能",
21418
21498
  "perspectives.d.preconditions": "前提条件", "perspectives.d.startScreen": "開始画面",
21419
- "perspectives.d.testCondition": "実行条件", "perspectives.d.spec": "spec",
21420
- "perspectives.d.steps": "手順", "perspectives.d.stepInclude": "ブロック: {name}",
21499
+ "perspectives.d.testCondition": "実行条件", "perspectives.d.steps": "手順", "perspectives.d.stepInclude": "ブロック: {name}",
21421
21500
  "perspectives.d.stepExpected": "期待結果:",
21422
- "perspectives.note.label": "note",
21501
+ "perspectives.note.label": "メモ",
21423
21502
  "perspectives.note.placeholder": "このケースについてのメモ…",
21424
21503
  "perspectives.note.saved": "保存しました",
21425
21504
  "perspectives.note.error": "保存に失敗しました — 再試行してください",
21426
- "perspectives.d.lastRed": "直近の失敗",
21427
- "perspectives.d.changedSince": "前回実行以降の変更",
21428
21505
  "perspectives.d.whyVerdict": "この判定の理由",
21506
+ "perspectives.d.contents": "テストの内容",
21507
+ "perspectives.finding.loadFailed": "詳細を読み込めませんでした。実行のページで確認してください",
21429
21508
  "perspectives.result.openRun": "ハブでこの実行を開く",
21430
21509
  "perspectives.result.ci": "CI",
21431
21510
  "perspectives.rerun.state.needsRepair": "修正待ち",
@@ -21435,27 +21514,27 @@ const CLIENT_JS = `
21435
21514
  "perspectives.rerun.state.verified": "検証済み",
21436
21515
  "perspectives.rerun.vsDeploy": "判定基準: デプロイ",
21437
21516
  "perspectives.rerun.noDeployHead": "このプロファイルにはデプロイの記録がありません",
21438
- "perspectives.rerun.changedByDeploy": "デプロイ {sha} がこのケースに一致するファイルを変更",
21439
- "perspectives.rerun.changesSome": "あり(デプロイ {sha} 時点)",
21440
- "perspectives.rerun.changesNone": "なし(デプロイ {sha} 時点)",
21441
- "perspectives.rerun.touchedCount": "このケースに一致したデプロイ差分 {n} ",
21442
- "perspectives.rerun.touchedUnknown": "前回実行以降のデプロイがこのケースに一致する変更を行っています",
21443
- "perspectives.rerun.inProgressHint": "監査か実行がまだ走っているか、監査がデプロイに追いついていません",
21444
- "perspectives.rerun.heldHint": "このスペックは既に別のジョブが保持しています。今操作するとそのジョブと競合します",
21445
- "perspectives.rerun.repair.testDrift": "生成されたテストが古くなっています。録り直してください",
21446
- "perspectives.rerun.repair.specChange": "spec がコードのやめた動作を書いています。人が判断します",
21447
- "perspectives.rerun.repair.auditUndecided": "監査がコードを読んだうえで判定できませんでした。人が見ます",
21448
- "perspectives.rerun.repair.runFailed": "最後の実行が落ちています。原因を直すまで再実行しても変わりません",
21449
- "perspectives.rerun.why.noSelectionInRange": "対象範囲に判定を伴わないデプロイがあります",
21450
- "perspectives.rerun.why.selectionUnknown": "影響の有無を判定できませんでした",
21517
+ "perspectives.rerun.changedByDeploy": "デプロイ {sha} が、このケースに関係するファイルを変更しています",
21518
+ "perspectives.rerun.changesSome": "前回の実行より後に、このケースに関係する変更があります(デプロイ {sha} 時点)",
21519
+ "perspectives.rerun.changesNone": "前回の実行より後に、このケースに関係する変更はありません(デプロイ {sha} 時点)",
21520
+ "perspectives.rerun.touchedCount": "前回の実行より後のデプロイが、このケースに関係する変更を {n} 件含んでいます",
21521
+ "perspectives.rerun.touchedUnknown": "前回の実行より後のデプロイが、このケースに関係する変更を含んでいます",
21522
+ "perspectives.rerun.inProgressHint": "監査または実行のジョブが作業中か、最新デプロイに対する監査がまだ走っていません",
21523
+ "perspectives.rerun.heldHint": "監査・自動修正・実行のいずれかのジョブが、このケースを処理中です。終わるまで、このケースへの操作(棄却・手動確認)はできません",
21524
+ "perspectives.rerun.repair.testDrift": "監査が、生成済みのテストコードは実装より古いと判定しました。自動修正が録り直すので、まず対応は不要です",
21525
+ "perspectives.rerun.repair.specChange": "監査が、このテストの前提とする振る舞いは実装から無くなったと判定しました。テストを直すか削除するかを決めてください。指摘のほうが誤りなら、「{dismissButton}」から棄却できます",
21526
+ "perspectives.rerun.repair.auditUndecided": "テストが実装とズレているかどうか、監査では判断できませんでした。指摘の内容を確認して、テストを直すか、「{dismissButton}」から棄却してください",
21527
+ "perspectives.rerun.repair.runFailed": "直近の実行が失敗しています。失敗の原因に対処するまで、この判定は変わりません",
21528
+ "perspectives.rerun.why.noSelectionInRange": "影響判定なしで記録されたデプロイがあるため、念のため実行対象になっています",
21529
+ "perspectives.rerun.why.selectionUnknown": "直近のデプロイがこのケースに影響するかどうか判断できなかったため、念のため実行対象になっています",
21451
21530
  "perspectives.rerun.why.noDeployLog": "このプロファイルのデプロイ記録がありません",
21452
21531
  "perspectives.rerun.why.unknownDeployedSha": "前回実行時にデプロイされていたcommitが不明です",
21453
21532
  "perspectives.rerun.why.ambiguousDeployedSha": "前回実行の途中でデプロイが発生しました",
21454
- "perspectives.rerun.why.deployedShaNotInLog": "前回実行のcommitが保持中のデプロイログより古いです",
21533
+ "perspectives.rerun.why.deployedShaNotInLog": "前回の実行が古く、どのデプロイに対して実行した結果か特定できません",
21455
21534
  "perspectives.rerun.why.gapInRange": "対象範囲のデプロイ記録が欠けています",
21456
21535
  "perspectives.rerun.why.unrecognized": "このUIが認識できない理由がハブから返されました",
21457
- "perspectives.rerun.fix.noSelectionInRange": "対象範囲に判定を伴わないデプロイがあり、このケースに影響したかどうかを示すものがありません。デプロイジョブで ccqa select-specs を実行し、判定をデプロイと一緒に送ってください。",
21458
- "perspectives.rerun.fix.selectionUnknown": "対象範囲のデプロイは判定されましたが、このケースについては判断がつきませんでした。再実行して基準を取り直してください。",
21536
+ "perspectives.rerun.fix.noSelectionInRange": "影響判定なしで記録されたデプロイがあり、このケースに影響したかどうか分かりません。デプロイの記録時に影響判定が時間内に終わらなかったか、無効化されていたときに起きます。次の実行と監査で結果を取り直します。",
21537
+ "perspectives.rerun.fix.selectionUnknown": "デプロイがこのケースに影響するかどうか判断できなかったため、次の実行で結果を取り直します。",
21459
21538
  "perspectives.rerun.fix.noDeployLog": "このプロファイルのデプロイログに記録がありません。何がデプロイされたかをccqaに伝えるため、この環境のデプロイジョブに ccqa hub deploy record を組み込んでください。",
21460
21539
  "perspectives.rerun.fix.unknownDeployedSha": "前回実行は環境で動いていたcommitを記録していないため、デプロイログ上の位置を決められません。このプロファイルにデプロイログができれば、以降の実行では記録されます。",
21461
21540
  "perspectives.rerun.fix.ambiguousDeployedSha": "前回実行の途中でデプロイが発生したため、どのcommitを検証したのか確定できません。基準を取り直すには再実行してください。",
@@ -21471,25 +21550,25 @@ const CLIENT_JS = `
21471
21550
  "perspectives.manual.envButton": "環境要因が解消した場合はこちら",
21472
21551
  "perspectives.manual.confirmRevoke": "このケースの手動確認を取り消しますか?",
21473
21552
  "perspectives.manual.error": "保存に失敗しました — 再試行してください",
21474
- "perspectives.manual.verifiedBy": "{by}さんが手動確認({at})",
21475
- "perspectives.manual.lapsed.deployReached": "手動確認はデプロイの到達により失効",
21476
- "perspectives.manual.lapsed.deployReachedNamed": "手動確認はデプロイ {sha}({at})の到達により失効",
21477
- "perspectives.manual.lapsed.cannotPlace": "手動確認は基準デプロイをログで特定できず失効",
21478
- "perspectives.manual.lapsed.specEdited": "手動確認はspecの編集により失効",
21479
- "perspectives.manual.lapsed.newerRed": "手動確認は直後の実行失敗により失効",
21480
- "perspectives.manual.lapsed.unrecognized": "このUIが認識できない理由により手動確認が失効",
21553
+ "perspectives.manual.verifiedBy": "{by} さんが手動で動作確認しました({at})",
21554
+ "perspectives.manual.lapsed.deployReached": "デプロイがこのケースに届いたため、手動確認は失効しました",
21555
+ "perspectives.manual.lapsed.deployReachedNamed": "デプロイ {sha}({at})がこのケースに届いたため、手動確認は失効しました",
21556
+ "perspectives.manual.lapsed.cannotPlace": "基準のデプロイをログで特定できなくなったため、手動確認は失効しました",
21557
+ "perspectives.manual.lapsed.specEdited": "spec が編集されたため、手動確認は失効しました",
21558
+ "perspectives.manual.lapsed.newerRed": "手動確認より後の実行が失敗したため、手動確認は失効しました",
21559
+ "perspectives.manual.lapsed.unrecognized": "この UI が認識できない理由により、手動確認は失効しました",
21481
21560
  "perspectives.dismiss.offerButton": "テスト仕様に問題がなかった場合はこちら",
21482
21561
  "perspectives.dismiss.revokeButton": "棄却を取り消す",
21483
21562
  "perspectives.dismiss.confirmRevoke": "このケースの棄却を取り消しますか?",
21484
- "perspectives.dismiss.activeNote": "監査指摘「{headline}」は{by}が誤検知として棄却({at})—「{note}」。次の実行が裁定します。",
21485
- "perspectives.dismiss.priorNote": "前回この指摘は{by}が棄却しています({at})—「{note}",
21563
+ "perspectives.dismiss.activeNote": "{by} さんが監査指摘「{headline}」を誤検知として棄却しました({at}・理由: {note})。次の実行結果が正否を決めます。",
21564
+ "perspectives.dismiss.priorNote": "この指摘は、以前 {by} さんが棄却しています({at}・理由: {note}",
21486
21565
  "perspectives.override.byLabel": "確認した人",
21487
21566
  "perspectives.override.reasonLabel": "理由(必須)",
21488
21567
  "perspectives.override.noteLabel": "解消と確認の内容(必須)",
21489
21568
  "perspectives.override.submit": "記録する",
21490
21569
  "perspectives.override.cancel": "やめる",
21491
- "perspectives.override.dismissHint": "監査の指摘を上書きして台帳を閉じます。判定は「要再実行」に移り、次の実行が正否を裁定します。",
21492
- "perspectives.override.envHint": "失敗の記録は残したまま、判定は再実行を待たず「手動確認済み」になります。次のデプロイがこの spec に届くと失効します。",
21570
+ "perspectives.override.dismissHint": "監査の指摘を取り下げて、このケースを実行対象に戻します。判定は「要再実行」になり、次の実行結果が正否を決めます。",
21571
+ "perspectives.override.envHint": "失敗の記録は残したまま、判定だけが「手動確認済み」になります。次のデプロイがこのケースに届くと失効し、通常の実行に戻ります。",
21493
21572
  "prompt.card.record": "ブラウザ操作の記録",
21494
21573
  "prompt.card.live": "ライブ実行(AI操作)",
21495
21574
  "prompt.card.playwright": "Playwrightテスト生成",
@@ -21786,7 +21865,6 @@ const CLIENT_JS = `
21786
21865
  return span;
21787
21866
  }
21788
21867
 
21789
-
21790
21868
  // Shared by the runs-list row and the run-detail header — the one place
21791
21869
  // both decide whether a run's own status badge speaks drift's vocabulary.
21792
21870
  function runStatusBadge(run) {
@@ -22396,15 +22474,13 @@ const CLIENT_JS = `
22396
22474
 
22397
22475
  // ── run detail: spec cards ──────────────────────────────────────────
22398
22476
 
22399
- // The diagnosis card: one surface for everything about a failure's cause.
22400
- // Verdict (label + confidence), then the cause→fix pair as labelled rows —
22401
- // headline and recommendation are one causal unit, so they read as one.
22402
- // subDiagnosis is deliberately NOT shown: it is a machine vocabulary for
22403
- // accuracy stratification and learning, not for humans. The caller appends
22404
- // the evidence/reasoning accordions and the grading zone into this box.
22405
- function analysisSection(runId, r) {
22406
- var wrap = el("div", "analysis-box");
22407
- var a = r.analysis;
22477
+ // The diagnosis card's two halves, shared by the run view (analysisSection)
22478
+ // and the perspectives reason card so the two renderings cannot drift:
22479
+ // verdict head (label chip + confidence), then the cause→fix pair as
22480
+ // labelled rows headline and recommendation are one causal unit, so they
22481
+ // read as one. subDiagnosis is deliberately NOT shown: it is a machine
22482
+ // vocabulary for accuracy stratification and learning, not for humans.
22483
+ function diagnosisHead(a) {
22408
22484
  var head = el("div", "analysis-head");
22409
22485
  head.appendChild(labelChip(a.label));
22410
22486
  // Which repair a SPEC_CHANGE needs — delete the spec, or rewrite it. The
@@ -22414,7 +22490,10 @@ const CLIENT_JS = `
22414
22490
  head.appendChild(el("span", "chip spec-change-chip", t("diag.specChangeKind." + a.specChangeKind)));
22415
22491
  }
22416
22492
  head.appendChild(el("span", "conf", Math.round(a.confidence * 100) + "%"));
22417
- wrap.appendChild(head);
22493
+ return head;
22494
+ }
22495
+
22496
+ function diagnosisKv(a) {
22418
22497
  var kv = el("div", "analysis-kv");
22419
22498
  // Set only when the verdict blames the test case (TEST_DRIFT/SPEC_CHANGE),
22420
22499
  // on both kinds of row — it names the half that has to be repaired.
@@ -22430,7 +22509,14 @@ const CLIENT_JS = `
22430
22509
  kv.appendChild(el("div", "k", t("diag.fix")));
22431
22510
  kv.appendChild(el("div", "v", a.recommendation));
22432
22511
  }
22433
- if (kv.childNodes.length > 0) wrap.appendChild(kv);
22512
+ return kv.childNodes.length > 0 ? kv : null;
22513
+ }
22514
+
22515
+ function analysisSection(runId, r) {
22516
+ var wrap = el("div", "analysis-box");
22517
+ wrap.appendChild(diagnosisHead(r.analysis));
22518
+ var kv = diagnosisKv(r.analysis);
22519
+ if (kv) wrap.appendChild(kv);
22434
22520
  return wrap;
22435
22521
  }
22436
22522
 
@@ -23543,9 +23629,10 @@ const CLIENT_JS = `
23543
23629
  // this hub answers at all. Chip visibility follows it rather than the report,
23544
23630
  // so switching profile doesn't drop the filter while the next one loads.
23545
23631
  // "drift" is the DriftLedgerResponse, or null when unanswered (older hub, or
23546
- // a failed fetch) — not profile-scoped, so it does not reset when the
23547
- // profile switcher changes (unlike "rerun" above). Only feeds the audit
23548
- // column's "audited at" line now; its own finding is superseded by rr.audit.
23632
+ // a failed fetch) — not profile-scoped. It backs the audit column's
23633
+ // "audited at" line and the reason card's finding (runId/label/headline),
23634
+ // so reloadRerun refetches it alongside the rerun report to keep the two
23635
+ // reports of one card equally fresh.
23549
23636
  var perspState = {
23550
23637
  doc: null, q: "", f: "all",
23551
23638
  rerun: null, rerunSupported: null, runUrls: {}, rerunProfiles: [],
@@ -23613,10 +23700,10 @@ const CLIENT_JS = `
23613
23700
 
23614
23701
  // ── perspectives: drift ledger ────────────────────────────────────────
23615
23702
  // Not profile-scoped (see perspState.drift above), so unlike rerunPath this
23616
- // takes no ?profile=. Its own finding is superseded by the audit axis in
23617
- // the /rerun report (ADR-0014); what survives into the view is only its
23618
- // coordinate when a spec was last audited — folded into the audit
23619
- // column's evidence line (perspAuditCell).
23703
+ // takes no ?profile=. The audit AXIS is answered by the /rerun report
23704
+ // (ADR-0014); this ledger supplies what that report does not carry the
23705
+ // finding's own coordinate and words (runId/at/label/headline), shown in
23706
+ // the audit column's evidence line and the reason card.
23620
23707
 
23621
23708
  function driftPath() {
23622
23709
  return "/api/v1/projects/" + encodeURIComponent(state.project) + "/drift";
@@ -23669,7 +23756,10 @@ const CLIENT_JS = `
23669
23756
  // when it has no entry).
23670
23757
  function rerunReasonText(prefix, reason) {
23671
23758
  var text = t(prefix + reason);
23672
- return text === prefix + reason ? t(prefix + "unrecognized") : text;
23759
+ if (text === prefix + reason) text = t(prefix + "unrecognized");
23760
+ // Wordings that point at the dismiss control name it by its own label, so
23761
+ // renaming the button cannot silently strand four strings.
23762
+ return text.replace("{dismissButton}", t("perspectives.dismiss.offerButton"));
23673
23763
  }
23674
23764
 
23675
23765
  // Who attested and when, plus their note if they left one — the whole
@@ -23760,7 +23850,6 @@ const CLIENT_JS = `
23760
23850
  return lapse ? why + " · " + lapse : why;
23761
23851
  }
23762
23852
 
23763
-
23764
23853
  // --- pure: rerun composition ---------------------------------------------
23765
23854
  // Self-contained on purpose: no DOM, no closures. rerun-view.test.ts lifts
23766
23855
  // this region out of the rendered page and runs it, because the summary bar
@@ -24202,8 +24291,8 @@ const CLIENT_JS = `
24202
24291
 
24203
24292
  // --- pure: rerun detail labels -------------------------------------------
24204
24293
  // Self-contained on purpose (no DOM, no closures) so rerun-view.test.ts can
24205
- // lift this region out of the rendered page and run it: which label the
24206
- // panel's evidence row wears, and whether it has a failure row at all.
24294
+ // lift this region out of the rendered page and run it: whether the deploy
24295
+ // log answered for this case, and which deploy the reason line names.
24207
24296
 
24208
24297
  // The deploy log answered for this case: the row can show what it holds.
24209
24298
  // A case the log could not place has no evidence to show even though its
@@ -24213,20 +24302,6 @@ const CLIENT_JS = `
24213
24302
  return rr.verdict === "rerunNeeded" && !rr.executionAssumedReached;
24214
24303
  }
24215
24304
 
24216
- // Evidence is labelled by the timeframe it covers; everything else names why
24217
- // the verdict landed — a different kind of content, and forcing one label
24218
- // over both would make one of the two read as a lie.
24219
- function rerunEvidenceLabelKey(rr) {
24220
- return rerunHasEvidence(rr) ? "perspectives.d.changedSince" : "perspectives.d.whyVerdict";
24221
- }
24222
-
24223
- // The failure row points at a run. With no failure there is nothing to point
24224
- // at, so the row is omitted rather than filled with "never failed" — the row
24225
- // above already carries the last result.
24226
- function rerunHasFailure(rr) {
24227
- return !!(rr && rr.lastRed);
24228
- }
24229
-
24230
24305
  // Which deploy the evidence line names, and how. A "needed" verdict carries
24231
24306
  // the deploy that caused it (touchedByDeploy) when the hub could confirm one,
24232
24307
  // and that is the deploy a reader wants — so it is named, with when it
@@ -24293,13 +24368,10 @@ const CLIENT_JS = `
24293
24368
  return null;
24294
24369
  }
24295
24370
 
24296
- // The evidence behind the verdict, as the value of whichever row
24297
- // rerunEvidenceLabelKey chose. For needed/notNeeded that is what the deploy
24298
- // log holds since this case last ran, named by rerunChangeLine.
24299
- // The label already states the timeframe, so the value never repeats it.
24300
- // A dismissal (active or superseded by a later finding) is appended below
24301
- // whichever of those this case has, rather than replacing it — see
24302
- // rerunDismissalLine.
24371
+ // The reason card's body when no run-recorded finding is shown: what the
24372
+ // deploy log holds since this case last ran (rerunChangeLine), or why the
24373
+ // verdict landed. The dismissal and lapse notes are the card's own to
24374
+ // append (perspReasonCard), not this value's.
24303
24375
  function rerunEvidenceValue(rr) {
24304
24376
  var wrap = el("div");
24305
24377
  if (!rerunHasEvidence(rr)) {
@@ -24319,29 +24391,12 @@ const CLIENT_JS = `
24319
24391
  wrap.appendChild(pathCodes(rr.touchedBy));
24320
24392
  }
24321
24393
  }
24322
- var dismissLine = rerunDismissalLine(rr);
24323
- if (dismissLine) wrap.appendChild(el("div", "d-prose" + (dismissLine.muted ? " muted" : ""), dismissLine.text));
24324
- return wrap;
24325
- }
24326
-
24327
- // The failure: which run it was, then what the analysis concluded. The
24328
- // headline is model output, already localized server-side, so it is shown as
24329
- // written. A run made without failure analysis carries neither field, and the
24330
- // row is then the coordinate alone — what it has always been.
24331
- function rerunFailureValue(entry) {
24332
- var wrap = el("div");
24333
- wrap.appendChild(ledgerLine(entry));
24334
- if (entry.label) {
24335
- var line = el("div", "d-prose", labelText(entry.label));
24336
- if (entry.headline) line.appendChild(document.createTextNode(" · " + entry.headline));
24337
- wrap.appendChild(line);
24338
- }
24339
24394
  return wrap;
24340
24395
  }
24341
24396
 
24342
24397
  // Lets a person's own check stand in for the machine's verdict. reloadRerun()
24343
- // is the same profile-scoped refresh a profile switch uses — it re-renders
24344
- // the whole table, so an open detail panel closes along with it.
24398
+ // is the same refresh a profile switch uses — it re-renders the whole
24399
+ // table, so an open detail panel closes along with it.
24345
24400
  function submitAttestation(method, body) {
24346
24401
  apiFetch(attestationsPath(), {
24347
24402
  method: method,
@@ -24458,7 +24513,10 @@ const CLIENT_JS = `
24458
24513
  // dismissal that is currently the reason the axis reads clean. At most one
24459
24514
  // of the two ever shows — a finding the axis
24460
24515
  // itself has cleared, dismissed or not, offers nothing here.
24516
+ // While a job holds the spec, no control shows at all: heldHint promises
24517
+ // the reader that overrides wait for the job, so the buttons must too.
24461
24518
  function auditOverrideBox(feature, spec, rr) {
24519
+ if (rr.heldBy) return null;
24462
24520
  if (auditDismissalActive(rr)) {
24463
24521
  var box = el("div", "manual-attest");
24464
24522
  box.appendChild(auditDismissalRevokeButton(feature, spec));
@@ -24473,6 +24531,7 @@ const CLIENT_JS = `
24473
24531
  // no open finding of its own, which is auditOverrideBox's problem to answer,
24474
24532
  // not this one's.
24475
24533
  function executionOverrideBox(feature, spec, rr) {
24534
+ if (rr.heldBy) return null;
24476
24535
  if (rr.manual) {
24477
24536
  var box = el("div", "manual-attest");
24478
24537
  if (rr.verdict !== "manuallyVerified") box.appendChild(el("div", "d-prose", manualAttestationText(rr.manual)));
@@ -24487,16 +24546,133 @@ const CLIENT_JS = `
24487
24546
  return null;
24488
24547
  }
24489
24548
 
24490
- // Detail row: a definition list of the case's fields plus the note editor.
24491
- // Built with createElement/textContent throughout every field here is
24492
- // API-derived, so none of it may go through innerHTML.
24493
- //
24494
- // The panel shows only what the table row cannot. The row already carries the
24495
- // title, mode, recorded state, last result and the re-run verdict, so none of
24496
- // those is repeated: what is left is the case's definition, the evidence the
24497
- // verdict rests on, and the note.
24549
+ // ── the reason card ──────────────────────────────────────────────────────
24550
+ // One card that answers "why is the verdict what it is". When an axis
24551
+ // stands on a run-recorded finding (an open audit finding, or the failure
24552
+ // the execution axis reports), the card carries that finding in full,
24553
+ // fetched from the run's own report where cause, fix and evidence
24554
+ // already live. Everything a person may do about the state (dismiss,
24555
+ // attest, revoke) sits in the same card, beside the reason it answers.
24556
+
24557
+ var runReportCache = {};
24558
+ function fetchRunReport(runId) {
24559
+ if (!runReportCache[runId]) {
24560
+ // Same endpoint (and so the same HTTP cache entry) the run view reads.
24561
+ // A failure is not cached: a transient 502 costs one refetch on the
24562
+ // next expand instead of pinning the fallback for the session.
24563
+ runReportCache[runId] = apiFetch("/api/v1/runs/" + encodeURIComponent(runId) + "/report")
24564
+ .catch(function () { delete runReportCache[runId]; return null; });
24565
+ }
24566
+ return runReportCache[runId];
24567
+ }
24568
+
24569
+ function reportRowFor(report, key) {
24570
+ var rows = (report && report.results) || [];
24571
+ for (var i = 0; i < rows.length; i++) {
24572
+ if (rows[i].feature + "/" + rows[i].spec === key) return rows[i];
24573
+ }
24574
+ return null;
24575
+ }
24576
+
24577
+ // Which run-recorded finding the card shows, if any: an open audit finding
24578
+ // wins (it is why nothing runs), else the failure the execution axis stands
24579
+ // on. The ledger's own label/headline are the instant fallback while the
24580
+ // report loads — and the whole content if it never arrives.
24581
+ function reasonFindingSource(rr, driftEntry) {
24582
+ if (auditOpen(rr) && driftEntry && driftEntry.runId && driftEntry.label) return driftEntry;
24583
+ if (rr.execution === "failed" && rr.lastRed && rr.lastRed.runId && rr.lastRed.label) return rr.lastRed;
24584
+ return null;
24585
+ }
24586
+
24587
+ function perspReasonCard(feature, spec, rr, driftEntry) {
24588
+ var card = el("div", "analysis-box");
24589
+ var source = reasonFindingSource(rr, driftEntry);
24590
+
24591
+ if (source) {
24592
+ // The ledger's label/headline render at once; the run's own report
24593
+ // replaces them with the full diagnosis (the same head/kv the run view
24594
+ // builds) when — and if — it arrives.
24595
+ var note = el("span", "p-head-note", rerunWhyVerdict(rr));
24596
+ var slot = el("div");
24597
+ var head = el("div", "analysis-head");
24598
+ head.appendChild(labelChip(source.label));
24599
+ head.appendChild(note);
24600
+ slot.appendChild(head);
24601
+ if (source.headline) {
24602
+ var kv = diagnosisKv({ headline: source.headline });
24603
+ if (kv) slot.appendChild(kv);
24604
+ }
24605
+ card.appendChild(slot);
24606
+
24607
+ // A graded finding is the human's word, not the model's: the ledger
24608
+ // carries the corrected label/headline, while the run's report still
24609
+ // holds the original prediction. Upgrading would show the guess the
24610
+ // person explicitly overwrote, so the card keeps the ledger's version.
24611
+ if (source.graded) return finishReasonCard(card, feature, spec, rr);
24612
+
24613
+ fetchRunReport(source.runId).then(function (report) {
24614
+ var reportRow = reportRowFor(report, perspSpecKey(feature, spec));
24615
+ var a = reportRow && reportRow.analysis;
24616
+ if (!a) {
24617
+ if (!source.headline) slot.appendChild(el("div", "d-prose muted", t("perspectives.finding.loadFailed")));
24618
+ return;
24619
+ }
24620
+ // The ledger's one-liner stands in when the report row lost its own.
24621
+ if (!a.headline) a.headline = source.headline || "";
24622
+ clear(slot);
24623
+ var fullHead = diagnosisHead(a);
24624
+ fullHead.appendChild(note);
24625
+ slot.appendChild(fullHead);
24626
+ var fullKv = diagnosisKv(a);
24627
+ if (fullKv) slot.appendChild(fullKv);
24628
+ var evi = analysisEvidenceSection(reportRow);
24629
+ if (evi.count) slot.appendChild(detailsBlock(t("acc.evidence"), evi.count, evi.node));
24630
+ });
24631
+ } else {
24632
+ // No finding to show: the reason is the deploy-log answer, or the
24633
+ // verdict's own wording.
24634
+ card.appendChild(rerunEvidenceValue(rr));
24635
+ }
24636
+
24637
+ return finishReasonCard(card, feature, spec, rr);
24638
+ }
24639
+
24640
+ // The card's shared tail: the dismissal/lapse notes and the person's
24641
+ // controls, appended after whichever body the card ended up with.
24642
+ function finishReasonCard(card, feature, spec, rr) {
24643
+ var dline = rerunDismissalLine(rr);
24644
+ if (dline) card.appendChild(el("div", "d-prose" + (dline.muted ? " muted" : ""), dline.text));
24645
+ var lapse = rerunManualLapseText(rr);
24646
+ if (lapse) card.appendChild(el("div", "d-prose muted", lapse));
24647
+ var auditBox = auditOverrideBox(feature, spec, rr);
24648
+ if (auditBox) card.appendChild(auditBox);
24649
+ var execBox = executionOverrideBox(feature, spec, rr);
24650
+ if (execBox) card.appendChild(execBox);
24651
+ return card;
24652
+ }
24653
+
24654
+ // Detail row: the case's current state first, then why the verdict is what
24655
+ // it is, then what the case does, then the note — a stack of cards in the
24656
+ // order a reader asks the questions. Built with createElement/textContent
24657
+ // throughout — every field here is API-derived, so none of it may go
24658
+ // through innerHTML.
24498
24659
  function perspDetailContent(feature, spec) {
24499
24660
  var frag = document.createDocumentFragment();
24661
+ var rr = ledgerEntryFor(perspState.rerun, feature, spec);
24662
+ var driftEntry = ledgerEntryFor(perspState.drift, feature, spec);
24663
+
24664
+ // The two axis states stay in the table row only — the panel answers why,
24665
+ // not what, so it opens straight on the reason.
24666
+ if (rr) {
24667
+ var reason = el("div", "p-sect");
24668
+ reason.appendChild(el("div", "p-slabel", t("perspectives.d.whyVerdict")));
24669
+ reason.appendChild(perspReasonCard(feature, spec, rr, driftEntry));
24670
+ frag.appendChild(reason);
24671
+ }
24672
+
24673
+ var contents = el("div", "p-sect");
24674
+ contents.appendChild(el("div", "p-slabel", t("perspectives.d.contents")));
24675
+ var ccard = el("div", "analysis-box");
24500
24676
  var dl = el("dl", "d-grid");
24501
24677
  function row(labelKey, valueNode) {
24502
24678
  dl.appendChild(el("dt", null, t(labelKey)));
@@ -24513,20 +24689,7 @@ const CLIENT_JS = `
24513
24689
  }
24514
24690
  if (spec.startScreen) row("perspectives.d.startScreen", spec.startScreen);
24515
24691
  if (spec.testCondition) row("perspectives.d.testCondition", spec.testCondition);
24516
- // The spec id stays: it is what a user types to re-run this case, and the
24517
- // table shows the title, never the id.
24518
- row("perspectives.d.spec", el("code", null, spec.specName));
24519
-
24520
- var rr = ledgerEntryFor(perspState.rerun, feature, spec);
24521
- if (rr) {
24522
- row(rerunEvidenceLabelKey(rr), rerunEvidenceValue(rr));
24523
- if (rerunHasFailure(rr)) row("perspectives.d.lastRed", rerunFailureValue(rr.lastRed));
24524
- }
24525
- frag.appendChild(dl);
24526
-
24527
24692
  if (spec.steps && spec.steps.length) {
24528
- var stepsBox = el("div", "steps-box");
24529
- stepsBox.appendChild(el("div", "slabel", t("perspectives.d.steps")));
24530
24693
  var stepsList = el("ol", "d-steps");
24531
24694
  spec.steps.forEach(function (step) {
24532
24695
  var li = el("li");
@@ -24540,22 +24703,14 @@ const CLIENT_JS = `
24540
24703
  }
24541
24704
  stepsList.appendChild(li);
24542
24705
  });
24543
- stepsBox.appendChild(stepsList);
24544
- frag.appendChild(stepsBox);
24706
+ row("perspectives.d.steps", stepsList);
24545
24707
  }
24708
+ ccard.appendChild(dl);
24709
+ contents.appendChild(ccard);
24710
+ frag.appendChild(contents);
24546
24711
 
24547
- // A person's override, always at most one control per axis: which finding
24548
- // is open decides whether that slot offers a new override or revokes a
24549
- // standing one (auditOverrideBox / executionOverrideBox).
24550
- if (rr) {
24551
- var auditBox = auditOverrideBox(feature, spec, rr);
24552
- if (auditBox) frag.appendChild(auditBox);
24553
- var execBox = executionOverrideBox(feature, spec, rr);
24554
- if (execBox) frag.appendChild(execBox);
24555
- }
24556
-
24557
- var notebox = el("div", "notebox");
24558
- notebox.appendChild(el("div", "nlabel", t("perspectives.note.label")));
24712
+ var notebox = el("div", "notebox p-sect");
24713
+ notebox.appendChild(el("div", "p-slabel", t("perspectives.note.label")));
24559
24714
  var ta = el("textarea");
24560
24715
  ta.placeholder = t("perspectives.note.placeholder");
24561
24716
  ta.value = spec.note || "";
@@ -24564,8 +24719,8 @@ const CLIENT_JS = `
24564
24719
  var saveBtn = el("button", "btn primary", t("common.save"));
24565
24720
  saveBtn.type = "button";
24566
24721
  var statusEl = el("span", "nstatus");
24567
- nact.appendChild(saveBtn);
24568
24722
  nact.appendChild(statusEl);
24723
+ nact.appendChild(saveBtn);
24569
24724
  notebox.appendChild(nact);
24570
24725
  frag.appendChild(notebox);
24571
24726
 
@@ -24621,6 +24776,7 @@ const CLIENT_JS = `
24621
24776
 
24622
24777
  var titleTd = el("td", "c-title");
24623
24778
  titleTd.appendChild(document.createTextNode(spec.title));
24779
+ titleTd.appendChild(el("span", "c-id", perspSpecKey(feature, spec)));
24624
24780
  if (spec.summary) titleTd.appendChild(el("span", "csum", spec.summary));
24625
24781
  row.appendChild(titleTd);
24626
24782
 
@@ -24753,8 +24909,8 @@ const CLIENT_JS = `
24753
24909
  loadRerun().catch(function (err) {
24754
24910
  setPerspNote("persp-rerun-note", t("perspectives.rerun.loadFailed") + ": " + err.message, "warn");
24755
24911
  }),
24756
- // Evidence-only (the "audited at" line in the audit column) — a
24757
- // failed or unsupported fetch just omits that line, no banner.
24912
+ // A failed or unsupported fetch just omits the "audited at" line
24913
+ // and the reason card's finding detail, no banner.
24758
24914
  loadDrift().catch(function () {}),
24759
24915
  ]);
24760
24916
  })
@@ -24893,29 +25049,32 @@ const CLIENT_JS = `
24893
25049
  });
24894
25050
  }
24895
25051
 
24896
- // Loaded once per project open never re-run on a profile switch, since
24897
- // drift carries no profile (unlike loadRerun/reloadRerun below). Only the
24898
- // ledger's own coordinate (when a spec was last audited) survives into the
24899
- // view now its finding is superseded by the fresher, deploy-aware audit
24900
- // axis in the /rerun report — so there is nothing here worth a banner on
24901
- // an older or unreachable hub.
25052
+ // Loaded on project open and again on every reloadRerun: the reason card
25053
+ // joins rr.audit against this ledger's entry (runId/headline), so the two
25054
+ // must not drift apart after a dismissal or attestation. An older or
25055
+ // unreachable hub degrades to the axis alone not worth a banner.
24902
25056
  function loadDrift() {
24903
25057
  return fetch(driftPath(), { headers: { Authorization: "Bearer " + state.token } })
24904
25058
  .then(function (res) { return res.ok ? res.json() : null; }, function () { return null; })
24905
25059
  .then(function (report) {
24906
- perspState.drift = report || null;
25060
+ // A transient failure keeps the copy already loaded — blanking it
25061
+ // would drop the audit coordinates and the reason card's finding
25062
+ // for the rest of the session.
25063
+ if (report) perspState.drift = report;
24907
25064
  renderPerspectives();
24908
25065
  });
24909
25066
  }
24910
25067
 
24911
- // Switching profile re-asks only the profile-scoped question: the
24912
- // perspectives document itself is project-scoped and does not change, and
24913
- // neither does the run index loadRerun used to (wastefully) re-fetch.
25068
+ // Re-asks the rerun question and refreshes the drift ledger beside it (the
25069
+ // reason card reads both; see perspState.drift). The perspectives document
25070
+ // itself is project-scoped and does not change, and neither does the run
25071
+ // index loadRerun used to (wastefully) re-fetch.
24914
25072
  function reloadRerun() {
24915
25073
  perspState.rerun = null;
24916
25074
  setPerspNote("persp-rerun-note", "");
24917
25075
  setPerspDeployHead(null);
24918
25076
  renderPerspectives();
25077
+ loadDrift().catch(function () {});
24919
25078
  return loadRerun();
24920
25079
  }
24921
25080
 
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.33.0",
3
+ "version": "1.35.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.33.0",
3
+ "version": "1.35.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {