ccqa 1.32.0 → 1.34.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
@@ -1524,16 +1524,21 @@ const ENDPOINT_ENV_KEYS = [
1524
1524
  "CLAUDE_CODE_OAUTH_TOKEN"
1525
1525
  ];
1526
1526
  /**
1527
+ * When both credentials are present the OAuth token wins and the API key is
1528
+ * dropped. Left to the CLI the API key would win, which makes "switch a CI
1529
+ * job to the subscription token" require unwiring the key everywhere; with
1530
+ * this rule, adding the one variable is the whole switch, and removing it is
1531
+ * the whole rollback. The one place the rule lives — both the resolved view
1532
+ * and the env the SDK receives apply it through here.
1533
+ */
1534
+ function preferOauthToken(env) {
1535
+ if (env["CLAUDE_CODE_OAUTH_TOKEN"]) delete env["ANTHROPIC_API_KEY"];
1536
+ }
1537
+ /**
1527
1538
  * Collects the endpoint/auth variables set in the current process environment
1528
1539
  * so they can be forwarded, verbatim, to every Claude Code invocation. Returns
1529
1540
  * 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.
1541
+ * override the SDK's own defaults. Credential precedence per preferOauthToken.
1537
1542
  */
1538
1543
  function resolveEndpointEnv() {
1539
1544
  const endpointEnv = {};
@@ -1541,7 +1546,7 @@ function resolveEndpointEnv() {
1541
1546
  const value = process.env[key];
1542
1547
  if (value && value.length > 0) endpointEnv[key] = value;
1543
1548
  }
1544
- if (endpointEnv["CLAUDE_CODE_OAUTH_TOKEN"]) delete endpointEnv["ANTHROPIC_API_KEY"];
1549
+ preferOauthToken(endpointEnv);
1545
1550
  return endpointEnv;
1546
1551
  }
1547
1552
  /**
@@ -1555,6 +1560,30 @@ function withoutEmptyEndpointVars(env) {
1555
1560
  for (const key of ENDPOINT_ENV_KEYS) if (out[key] === "") delete out[key];
1556
1561
  return out;
1557
1562
  }
1563
+ /**
1564
+ * The environment actually handed to the Claude Code process: the full process
1565
+ * environment with the caller's overrides on top, empty endpoint variables
1566
+ * dropped, and — when both credentials survive the merge — the API key removed
1567
+ * so the OAuth token wins.
1568
+ *
1569
+ * That removal MUST happen on the env the SDK receives, not only on the
1570
+ * resolved view: left to the CLI the API key would win, silently moving every
1571
+ * call from the subscription to metered billing when a CI job wires both
1572
+ * (which is exactly what happened before this function existed).
1573
+ *
1574
+ * Returns undefined when no endpoint variable is set and the caller passes no
1575
+ * env, so the SDK keeps its own default environment.
1576
+ */
1577
+ function buildInvocationEnv(env) {
1578
+ const hasEndpointEnv = Object.keys(resolveEndpointEnv()).length > 0;
1579
+ if (!env && !hasEndpointEnv) return void 0;
1580
+ const merged = withoutEmptyEndpointVars({
1581
+ ...process.env,
1582
+ ...env
1583
+ });
1584
+ preferOauthToken(merged);
1585
+ return merged;
1586
+ }
1558
1587
  let nativeBinaryWarned = false;
1559
1588
  /**
1560
1589
  * Warn once per process when the SDK's per-platform native binary is missing:
@@ -1570,11 +1599,7 @@ function warnOnceIfNativeBinaryMissing() {
1570
1599
  async function invokeClaudeStreaming(options, onEvent) {
1571
1600
  const { prompt, systemPrompt, allowedTools, disableBuiltinTools = false, disableThinking = false, mcpServers, maxTurns, env, model, cwd, onAbAction, onAbActionFailed, silenceBashLog = false, envScrubMap = [], relaxAbConstraints = false } = options;
1572
1601
  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;
1602
+ const mergedEnv = buildInvocationEnv(env);
1578
1603
  let lastAbToolUseId = null;
1579
1604
  const claimAbToolUse = (toolUseId) => {
1580
1605
  if (toolUseId !== lastAbToolUseId) return false;
@@ -7518,6 +7543,46 @@ z.object({
7518
7543
  specs: z.record(z.string(), AttestationSchema)
7519
7544
  });
7520
7545
  /**
7546
+ * A person's answer to one audit finding: the spec describes the code fine,
7547
+ * and the finding is wrong. Where an attestation speaks about the product,
7548
+ * this speaks about the *audit* — so it settles the audit axis rather than
7549
+ * the verdict, and the spec goes back to being run like any other.
7550
+ *
7551
+ * Pinned to the audit run whose finding it answers. A later audit is a new
7552
+ * observation of newer code, so it produces a new run and this stops
7553
+ * applying: the machine gets to raise the finding again, and the record of
7554
+ * the last dismissal is shown beside it rather than silently suppressing it.
7555
+ * No profile — an audit finding is about the repository, not an environment
7556
+ * (ADR-0013), which is also why this is scoped per project alone.
7557
+ */
7558
+ const AuditDismissalSchema = z.object({
7559
+ by: z.string().min(1),
7560
+ at: z.string(),
7561
+ note: z.string().min(1),
7562
+ auditRunId: z.string(),
7563
+ label: DriftLabelSchema,
7564
+ headline: z.string()
7565
+ });
7566
+ /** The per-project dismissal document: "feature/spec" → the last dismissal. */
7567
+ const AuditDismissalsSchema = z.object({ specs: z.record(z.string(), AuditDismissalSchema).default({}) });
7568
+ /** Body of `PUT /projects/:project/audit-dismissals`. */
7569
+ const PutAuditDismissalRequestSchema = z.object({
7570
+ spec: z.string().min(1).max(512),
7571
+ by: z.string().min(1).max(256),
7572
+ note: z.string().min(1).max(4e3)
7573
+ });
7574
+ /** Body of `DELETE /projects/:project/audit-dismissals`. */
7575
+ const DeleteAuditDismissalRequestSchema = z.object({ spec: z.string().min(1).max(512) });
7576
+ z.object({
7577
+ project: z.string(),
7578
+ spec: z.string(),
7579
+ dismissal: AuditDismissalSchema
7580
+ });
7581
+ z.object({
7582
+ project: z.string(),
7583
+ specs: z.record(z.string(), AuditDismissalSchema)
7584
+ });
7585
+ /**
7521
7586
  * One spec's verdict, the two axes it was derived from, and the three ledger
7522
7587
  * coordinates the view shows alongside them. The coordinates are always
7523
7588
  * present (null when the spec has no such entry); the optional fields appear
@@ -7533,6 +7598,8 @@ const SpecRerunSchema = z.object({
7533
7598
  execution: ExecutionStateSchema,
7534
7599
  driftLabel: DriftLabelSchema.exclude(["UNKNOWN"]).optional(),
7535
7600
  auditAssumedReached: RerunUnknownReasonSchema.optional(),
7601
+ auditDismissed: AuditDismissalSchema.optional(),
7602
+ auditDismissalApplied: z.boolean().optional(),
7536
7603
  executionAssumedReached: RerunUnknownReasonSchema.optional(),
7537
7604
  specChangedSince: z.string().optional(),
7538
7605
  manual: AttestationSchema.optional(),
@@ -9297,6 +9364,20 @@ function connect(opts) {
9297
9364
  error("hub token is required (--hub-token or CCQA_HUB_TOKEN)");
9298
9365
  process.exit(2);
9299
9366
  }
9367
+ /**
9368
+ * The canonical "feature/spec" key for a CLI argument. Hub records are stored
9369
+ * and looked up under exactly this form, so an alias accepted here but kept
9370
+ * verbatim would silently never match.
9371
+ */
9372
+ function requireSpecId(rawSpecId) {
9373
+ try {
9374
+ const parsed = parseSpecPath(rawSpecId);
9375
+ return `${parsed.featureName}/${parsed.specName}`;
9376
+ } catch (err) {
9377
+ error(errMessage(err));
9378
+ process.exit(2);
9379
+ }
9380
+ }
9300
9381
  function validateSessionName(name) {
9301
9382
  const parsed = SessionNameSchema.safeParse(name);
9302
9383
  if (!parsed.success) {
@@ -9594,14 +9675,7 @@ const pushCommand = new Command("push").description("Upload the report directory
9594
9675
  const attestCommand = new Command("attest").argument("<feature/spec>", "Spec id, e.g. checkout/happy-path").description("Record that a person checked a spec's behaviour by hand against the deployed environment. The verdict answers manuallyVerified instead of asking a person for what a person already did — the drift ledger is untouched, so the repair loop keeps its reason to fix the test. The attestation lapses on its own when a deploy reaches the spec or the spec is edited.").requiredOption("--profile <name>", "Environment that was checked (e.g. 'stg'). The attestation is anchored to its current deploy head.").option("--by <name>", "Who checked. Required unless --revoke.").option("--note <text>", "What was checked and how — the reader deciding whether to trust it sees this.").option("--revoke", "Withdraw the spec's attestation instead of recording one.").option(...hubUrlOption).option(...hubTokenOption).option("--project <name>", "Hub project. Defaults to the current directory's name.").option("--cwd <path>", "Directory the default --project name is resolved against.").action(withHubErrors(async (rawSpecId, opts) => {
9595
9676
  const project = resolveProject(opts);
9596
9677
  const hub = connect(opts);
9597
- let specId;
9598
- try {
9599
- const parsed = parseSpecPath(rawSpecId);
9600
- specId = `${parsed.featureName}/${parsed.specName}`;
9601
- } catch (err) {
9602
- error(errMessage(err));
9603
- process.exit(2);
9604
- }
9678
+ const specId = requireSpecId(rawSpecId);
9605
9679
  if (opts.revoke) {
9606
9680
  await hub.deleteAttestation(project, { profile: opts.profile }, specId);
9607
9681
  header("hub attest", `${specId} revoked`);
@@ -9621,7 +9695,30 @@ const attestCommand = new Command("attest").argument("<feature/spec>", "Spec id,
9621
9695
  meta("anchored to deploy", res.attestation.deployedSha ?? "(no deploy log)");
9622
9696
  info("the verdict answers manuallyVerified until a deploy reaches this spec or the spec is edited");
9623
9697
  }));
9624
- 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(costCommand).addCommand(sessionCommand).addCommand(varCommand).addCommand(promptCommand).addCommand(attestCommand);
9698
+ const dismissCommand = new Command("dismiss").argument("<feature/spec>", "Spec id, e.g. checkout/happy-path").description("Record that a person judged the spec's current audit finding wrong: the spec describes the code fine. This settles the audit axis rather than the verdict — the spec goes back to being run like any other, and the next run says whether the person was right. The dismissal is pinned to the audit run that raised the finding, so a later audit can raise it again. No --profile: a finding is about the repository, not an environment.").option("--by <name>", "Who judged it wrong. Required unless --revoke.").option("--reason <text>", "Why the finding is wrong. Required unless --revoke — this is what a mis-firing audit learns from.").option("--revoke", "Withdraw the dismissal, putting the finding back in force.").option(...hubUrlOption).option(...hubTokenOption).option("--project <name>", "Hub project. Defaults to the current directory's name.").option("--cwd <path>", "Directory the default --project name is resolved against.").action(withHubErrors(async (rawSpecId, opts) => {
9699
+ const project = resolveProject(opts);
9700
+ const hub = connect(opts);
9701
+ const specId = requireSpecId(rawSpecId);
9702
+ if (opts.revoke) {
9703
+ await hub.deleteAuditDismissal(project, specId);
9704
+ header("hub dismiss", `${specId} revoked`);
9705
+ return;
9706
+ }
9707
+ if (!opts.by || !opts.reason) {
9708
+ error("--by <name> and --reason <text> are both required: a dismissal is a person's correction, and it needs the person and the correction");
9709
+ process.exit(2);
9710
+ }
9711
+ const res = await hub.putAuditDismissal(project, {
9712
+ spec: specId,
9713
+ by: opts.by,
9714
+ note: opts.reason
9715
+ });
9716
+ header("hub dismiss", specId);
9717
+ meta("by", res.dismissal.by);
9718
+ meta("dismissed", `${res.dismissal.label} — ${res.dismissal.headline || "(no headline)"}`);
9719
+ info("this finding no longer holds the spec back; a later audit can raise one of its own");
9720
+ }));
9721
+ 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(costCommand).addCommand(sessionCommand).addCommand(varCommand).addCommand(promptCommand).addCommand(attestCommand).addCommand(dismissCommand);
9625
9722
  /** Loose check that a fetched session is agent-browser storage-state, mirroring loadStorageState. */
9626
9723
  function isStorageStateShape(state) {
9627
9724
  return typeof state === "object" && state !== null && Array.isArray(state.cookies) && Array.isArray(state.origins);
@@ -18929,7 +19026,7 @@ function createGetAuditNeedHandler(storage) {
18929
19026
  //#endregion
18930
19027
  //#region src/hub/api/handlers/locks.ts
18931
19028
  /** A spec-key list and three short strings; nothing here should approach this. */
18932
- const MAX_BODY_BYTES$4 = 1024 * 1024;
19029
+ const MAX_BODY_BYTES$5 = 1024 * 1024;
18933
19030
  /**
18934
19031
  * POST /api/v1/projects/:project/locks?profile=
18935
19032
  *
@@ -18943,7 +19040,7 @@ function createAcquireLocksHandler(storage) {
18943
19040
  return async (ctx) => {
18944
19041
  const project = requireSafeSegment(ctx.params.project, "project");
18945
19042
  const profile = requireProfileParam(ctx.url);
18946
- const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$4, AcquireLocksRequestSchema, "lock request");
19043
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$5, AcquireLocksRequestSchema, "lock request");
18947
19044
  let result = {
18948
19045
  granted: [],
18949
19046
  denied: []
@@ -18972,7 +19069,7 @@ function createReleaseLocksHandler(storage) {
18972
19069
  return async (ctx) => {
18973
19070
  const project = requireSafeSegment(ctx.params.project, "project");
18974
19071
  const profile = requireProfileParam(ctx.url);
18975
- const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$4, ReleaseLocksRequestSchema, "release request");
19072
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$5, ReleaseLocksRequestSchema, "release request");
18976
19073
  await storage.locks.update(project, profile, (current) => releaseAll(current, body.holder));
18977
19074
  ctx.res.writeHead(204).end();
18978
19075
  };
@@ -18985,7 +19082,7 @@ function createReleaseLocksHandler(storage) {
18985
19082
  * 5000 keys of 256 `\uXXXX`-escaped characters — so a conforming client is
18986
19083
  * never answered 413 by a limit the documented bounds don't mention.
18987
19084
  */
18988
- const MAX_BODY_BYTES$3 = 8 * 1024 * 1024;
19085
+ const MAX_BODY_BYTES$4 = 8 * 1024 * 1024;
18989
19086
  function requireAckKey(ctx) {
18990
19087
  return {
18991
19088
  project: requireSafeSegment(ctx.params.project, "project"),
@@ -19012,7 +19109,7 @@ function createGetAckHandler(storage) {
19012
19109
  function createPutAckHandler(storage) {
19013
19110
  return async (ctx) => {
19014
19111
  const key = requireAckKey(ctx);
19015
- const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$3, PutAckRequestSchema, "ack body");
19112
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$4, PutAckRequestSchema, "ack body");
19016
19113
  const ack = await storage.acks.put(key.project, key.profile, key.name, body.keys);
19017
19114
  sendJson(ctx.res, 200, {
19018
19115
  ...key,
@@ -19023,7 +19120,7 @@ function createPutAckHandler(storage) {
19023
19120
  //#endregion
19024
19121
  //#region src/hub/api/handlers/attestations.ts
19025
19122
  /** Far above the largest body `PutAttestationRequestSchema`'s bounds admit. */
19026
- const MAX_BODY_BYTES$2 = 64 * 1024;
19123
+ const MAX_BODY_BYTES$3 = 64 * 1024;
19027
19124
  function requireScope(ctx) {
19028
19125
  return {
19029
19126
  project: requireSafeSegment(ctx.params.project, "project"),
@@ -19056,7 +19153,7 @@ function createPutAttestationHandler(storage) {
19056
19153
  return async (ctx) => {
19057
19154
  const scope = requireScope(ctx);
19058
19155
  const [body, head, targets] = await Promise.all([
19059
- readJsonBody(ctx.req, MAX_BODY_BYTES$2, PutAttestationRequestSchema, "attestation body"),
19156
+ readJsonBody(ctx.req, MAX_BODY_BYTES$3, PutAttestationRequestSchema, "attestation body"),
19060
19157
  storage.deploys.head(scope.project, scope.profile),
19061
19158
  requireSpecTargets(storage.perspectives, scope.project, "what can be attested")
19062
19159
  ]);
@@ -19086,7 +19183,7 @@ function createPutAttestationHandler(storage) {
19086
19183
  function createDeleteAttestationHandler(storage) {
19087
19184
  return async (ctx) => {
19088
19185
  const scope = requireScope(ctx);
19089
- const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$2, DeleteAttestationRequestSchema, "attestation body");
19186
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$3, DeleteAttestationRequestSchema, "attestation body");
19090
19187
  await storage.attestations.update(scope.project, scope.profile, (current) => {
19091
19188
  const { [body.spec]: _, ...rest } = current.specs;
19092
19189
  return { specs: rest };
@@ -19095,6 +19192,83 @@ function createDeleteAttestationHandler(storage) {
19095
19192
  };
19096
19193
  }
19097
19194
  //#endregion
19195
+ //#region src/hub/api/handlers/audit-dismissals.ts
19196
+ /** Far above the largest body `PutAuditDismissalRequestSchema`'s bounds admit. */
19197
+ const MAX_BODY_BYTES$2 = 64 * 1024;
19198
+ /**
19199
+ * GET /api/v1/projects/:project/audit-dismissals — the raw document,
19200
+ * whether or not each entry still answers the spec's current finding. No
19201
+ * `?profile=`: a finding is about the repository (see `AuditDismissalSchema`).
19202
+ */
19203
+ function createGetAuditDismissalsHandler(storage) {
19204
+ return async (ctx) => {
19205
+ const project = requireSafeSegment(ctx.params.project, "project");
19206
+ const doc = await storage.auditDismissals.get(project);
19207
+ sendJson(ctx.res, 200, {
19208
+ project,
19209
+ specs: doc.specs
19210
+ });
19211
+ };
19212
+ }
19213
+ /**
19214
+ * PUT /api/v1/projects/:project/audit-dismissals — record that a person
19215
+ * judged the spec's current audit finding wrong.
19216
+ *
19217
+ * The finding being answered is read from the ledger rather than taken from
19218
+ * the caller: a dismissal must name the run and the label it answers, and
19219
+ * only the hub knows which finding is current. A spec with no open finding is
19220
+ * a 400 — there is nothing to dismiss, and accepting it would write a record
19221
+ * that never applies to anything.
19222
+ *
19223
+ * The guard stops there on purpose. `/rerun` applies a dismissal only while
19224
+ * the audit is also *current* for the profile being asked about, and that is
19225
+ * a per-profile question this endpoint has no profile to ask it of (a finding
19226
+ * is about the repository, so the dismissal is project-scoped). A dismissal
19227
+ * written while a deploy has overtaken the audit is harmless: the next audit
19228
+ * supersedes the finding, and the record with it.
19229
+ */
19230
+ function createPutAuditDismissalHandler(storage) {
19231
+ return async (ctx) => {
19232
+ const project = requireSafeSegment(ctx.params.project, "project");
19233
+ const [body, ledger] = await Promise.all([readJsonBody(ctx.req, MAX_BODY_BYTES$2, PutAuditDismissalRequestSchema, "dismissal body"), storage.driftLedger.getMerged(project)]);
19234
+ const entry = ledger.specs[body.spec];
19235
+ if (!entry || entry.label === null) throw new HttpError(400, "no_open_finding", `spec "${body.spec}" has no open audit finding in project "${project}" — there is nothing to dismiss`);
19236
+ const dismissal = {
19237
+ by: body.by,
19238
+ at: (/* @__PURE__ */ new Date()).toISOString(),
19239
+ note: body.note,
19240
+ auditRunId: entry.runId,
19241
+ label: entry.label,
19242
+ headline: entry.headline ?? ""
19243
+ };
19244
+ await storage.auditDismissals.update(project, (current) => ({ specs: {
19245
+ ...current.specs,
19246
+ [body.spec]: dismissal
19247
+ } }));
19248
+ sendJson(ctx.res, 200, {
19249
+ project,
19250
+ spec: body.spec,
19251
+ dismissal
19252
+ });
19253
+ };
19254
+ }
19255
+ /**
19256
+ * DELETE /api/v1/projects/:project/audit-dismissals — withdraw a dismissal,
19257
+ * putting the audit's finding back in force. Deleting one that does not exist
19258
+ * is 200: the caller asked for its absence, and it is absent.
19259
+ */
19260
+ function createDeleteAuditDismissalHandler(storage) {
19261
+ return async (ctx) => {
19262
+ const project = requireSafeSegment(ctx.params.project, "project");
19263
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$2, DeleteAuditDismissalRequestSchema, "dismissal body");
19264
+ await storage.auditDismissals.update(project, (current) => {
19265
+ const { [body.spec]: _, ...rest } = current.specs;
19266
+ return { specs: rest };
19267
+ });
19268
+ sendJson(ctx.res, 200, { removed: body.spec });
19269
+ };
19270
+ }
19271
+ //#endregion
19098
19272
  //#region src/hub/api/handlers/spend.ts
19099
19273
  /** One entry is a handful of short fields; anything larger is a malformed client. */
19100
19274
  const MAX_BODY_BYTES$1 = 4 * 1024;
@@ -19167,7 +19341,7 @@ function specMovedSince(changedAt, baselineSha, baselineAt, deployTimes) {
19167
19341
  return changedAt > (baselineSha && deployTimes.get(baselineSha) || baselineAt) ? changedAt : null;
19168
19342
  }
19169
19343
  function computeRerun(input) {
19170
- const { specs, ledger, log, touchIndex, drift, locks, attestations, now } = input;
19344
+ const { specs, ledger, log, touchIndex, drift, locks, attestations, dismissals, now } = input;
19171
19345
  const range = buildRange(log, touchIndex);
19172
19346
  const deployTimes = deployedAt(log);
19173
19347
  const out = {};
@@ -19180,6 +19354,9 @@ function computeRerun(input) {
19180
19354
  let audit = auditState(drift, spec.key, range);
19181
19355
  let execution = executionState(coords, (sha) => freshness(sha, spec.key, range));
19182
19356
  const driftEntry = drift.specs[spec.key];
19357
+ const dismissal = dismissals.specs[spec.key];
19358
+ const dismissed = dismissal !== void 0 && driftEntry !== void 0 && dismissal.auditRunId === driftEntry.runId && dismissal.label === driftEntry.label && (audit.audit === "drifted" || audit.audit === "undecided");
19359
+ if (dismissed) audit = { audit: "clean" };
19183
19360
  const auditMoved = specMovedSince(spec.changedAt, driftEntry?.gitHead ?? null, driftEntry?.at ?? "", deployTimes);
19184
19361
  const runMoved = specMovedSince(spec.changedAt, coords.lastRun?.deployedSha ?? null, coords.lastRun?.at ?? "", deployTimes);
19185
19362
  if (auditMoved && audit.audit !== "due") audit = { audit: "due" };
@@ -19193,6 +19370,10 @@ function computeRerun(input) {
19193
19370
  ...auditMoved || runMoved ? { specChangedSince: auditMoved ?? runMoved } : {},
19194
19371
  ...audit,
19195
19372
  ...execution,
19373
+ ...dismissal ? {
19374
+ auditDismissed: dismissal,
19375
+ auditDismissalApplied: dismissed
19376
+ } : {},
19196
19377
  ...manualState?.kind === "covers" ? { manual: manualState.attest } : {},
19197
19378
  ...manualState?.kind === "lapsed" ? {
19198
19379
  manualLapsed: {
@@ -19356,14 +19537,15 @@ function createGetRerunHandler(storage) {
19356
19537
  return async (ctx) => {
19357
19538
  const project = requireSafeSegment(ctx.params.project, "project");
19358
19539
  const profile = requireProfileParam(ctx.url);
19359
- const [specs, ledger, log, touchIndex, drift, locks, attestations] = await Promise.all([
19540
+ const [specs, ledger, log, touchIndex, drift, locks, attestations, dismissals] = await Promise.all([
19360
19541
  requireSpecTargets(storage.perspectives, project, "which specs need a re-run"),
19361
19542
  storage.ledger.getMerged(project, profile),
19362
19543
  storage.deploys.getLog(project, profile),
19363
19544
  storage.deploys.getTouchIndex(project, profile),
19364
19545
  storage.driftLedger.getMerged(project),
19365
19546
  storage.locks.get(project, profile),
19366
- storage.attestations.get(project, profile)
19547
+ storage.attestations.get(project, profile),
19548
+ storage.auditDismissals.get(project)
19367
19549
  ]);
19368
19550
  const head = log.entries[log.entries.length - 1];
19369
19551
  sendJson(ctx.res, 200, {
@@ -19378,6 +19560,7 @@ function createGetRerunHandler(storage) {
19378
19560
  drift,
19379
19561
  locks,
19380
19562
  attestations,
19563
+ dismissals,
19381
19564
  now: /* @__PURE__ */ new Date()
19382
19565
  })
19383
19566
  });
@@ -20921,7 +21104,7 @@ const CSS = `
20921
21104
  .d-grid dt { color: var(--muted); font-size: 12px; padding-top: 1px; }
20922
21105
  .d-grid dd { color: var(--fg-dim); }
20923
21106
  .d-grid dd ul { list-style: none; display: flex; flex-direction: column; gap: 3px; margin: 0; padding: 0; }
20924
- .d-grid dd li::before { content: "\\2022 "; color: var(--muted-2); }
21107
+ .d-grid dd ul li::before { content: "\\2022 "; color: var(--muted-2); }
20925
21108
  .d-grid code { font-size: 12px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; }
20926
21109
  /* Prose gets a measure so it stops wrapping mid-phrase in a narrow column;
20927
21110
  paths wrap as whole chips, never inside a path. */
@@ -20930,17 +21113,26 @@ const CSS = `
20930
21113
  .d-paths code { white-space: nowrap; }
20931
21114
  .d-prose + .d-paths { margin-top: 6px; }
20932
21115
  .manual-attest { margin-top: 14px; }
20933
- .steps-box { margin-top: 14px; max-width: 900px; }
20934
- .steps-box .slabel { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
20935
21116
  .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; }
20936
21117
  .d-steps .step-expected { display: block; margin-top: 2px; font-size: 12.5px; }
20937
- .notebox { margin-top: 14px; max-width: 900px; }
20938
- .notebox .nlabel { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
20939
21118
  .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; }
20940
- .notebox .nact { margin-top: 6px; display: flex; align-items: center; gap: 8px; }
21119
+ .notebox .nact { margin-top: 6px; display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
20941
21120
  .notebox .nstatus { font-size: 12px; color: var(--muted); }
20942
21121
  .notebox .nstatus.ok { color: var(--pass); }
20943
21122
  .notebox .nstatus.err { color: var(--fail); }
21123
+ /* ── detail panel: one card stack — reason, contents, note ── */
21124
+ .c-title .c-id { display: block; font-family: var(--mono); font-size: 11px; color: var(--muted-2); margin-top: 2px; }
21125
+ .p-sect { margin-top: 16px; max-width: 900px; }
21126
+ .p-sect:first-child { margin-top: 12px; }
21127
+ .p-slabel { font-size: 13px; font-weight: 600; color: var(--fg); margin-bottom: 6px; }
21128
+ /* The one-line state note beside the finding chip: what happens next. */
21129
+ .p-head-note { margin-left: auto; font-size: 12.5px; color: var(--muted); }
21130
+ /* The inline form an audit-dismissal or environment-attestation button
21131
+ expands into, in place of the two window.prompt() calls this replaces. */
21132
+ .override-form { margin-top: 10px; max-width: 480px; display: flex; flex-direction: column; gap: 10px; }
21133
+ .override-form 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; }
21134
+ .override-form .of-act { display: flex; align-items: center; gap: 8px; }
21135
+ .override-form .of-hint { font-size: 12px; color: var(--muted); max-width: 62ch; line-height: 1.5; }
20944
21136
 
20945
21137
  @media (max-width: 900px) { .app { grid-template-columns: 1fr; } .sidebar { display: none; } .logo .wm { display: none; } .split { grid-template-columns: 1fr; } .rd-head .meta { margin-left: 0; } }
20946
21138
  @media (max-width: 700px) { .prompt-grid { grid-template-columns: 1fr; } .prompt-diff { grid-template-columns: 1fr; } }
@@ -21062,16 +21254,15 @@ const CLIENT_JS = `
21062
21254
  "perspectives.mode.deterministic": "deterministic", "perspectives.mode.live": "live",
21063
21255
  "perspectives.ov.cases": "cases", "perspectives.ov.features": "features",
21064
21256
  "perspectives.d.preconditions": "Preconditions", "perspectives.d.startScreen": "Start screen",
21065
- "perspectives.d.testCondition": "Condition", "perspectives.d.spec": "spec",
21066
- "perspectives.d.steps": "Steps", "perspectives.d.stepInclude": "Include: {name}",
21257
+ "perspectives.d.testCondition": "Condition", "perspectives.d.steps": "Steps", "perspectives.d.stepInclude": "Include: {name}",
21067
21258
  "perspectives.d.stepExpected": "Expected:",
21068
21259
  "perspectives.note.label": "Note",
21069
21260
  "perspectives.note.placeholder": "Notes about this case…",
21070
21261
  "perspectives.note.saved": "Saved",
21071
21262
  "perspectives.note.error": "Could not save — retry",
21072
- "perspectives.d.lastRed": "Most recent failure",
21073
- "perspectives.d.changedSince": "Changes since the last run",
21074
21263
  "perspectives.d.whyVerdict": "Why this verdict",
21264
+ "perspectives.d.contents": "What this case does",
21265
+ "perspectives.finding.loadFailed": "Could not load the finding's detail \u2014 open the run to read it",
21075
21266
  "perspectives.result.openRun": "Open this run in the hub",
21076
21267
  "perspectives.result.ci": "CI",
21077
21268
  "perspectives.rerun.state.needsRepair": "Needs repair",
@@ -21082,26 +21273,26 @@ const CLIENT_JS = `
21082
21273
  "perspectives.rerun.vsDeploy": "judged against deploy",
21083
21274
  "perspectives.rerun.noDeployHead": "no deploy recorded for this profile",
21084
21275
  "perspectives.rerun.changedByDeploy": "deploy {sha} changed files matched to this case",
21085
- "perspectives.rerun.changesSome": "yes (as of deploy {sha})",
21086
- "perspectives.rerun.changesNone": "none (as of deploy {sha})",
21276
+ "perspectives.rerun.changesSome": "changes matched to this case since the last run (as of deploy {sha})",
21277
+ "perspectives.rerun.changesNone": "no changes matched to this case since the last run (as of deploy {sha})",
21087
21278
  "perspectives.rerun.touchedCount": "{n} deployed path(s) matched this case",
21088
21279
  "perspectives.rerun.touchedUnknown": "a deploy since the last run matched this case",
21089
21280
  "perspectives.rerun.inProgressHint": "an audit or a run is still going, or the audit has not caught up with the deploy",
21090
- "perspectives.rerun.heldHint": "another job already holds this specacting on it now would race that job",
21091
- "perspectives.rerun.repair.testDrift": "the generated test no longer matches the code — re-record it",
21092
- "perspectives.rerun.repair.specChange": "the spec describes something the code no longer doesa human decides",
21093
- "perspectives.rerun.repair.auditUndecided": "the audit read the code and could not decidea human looks",
21094
- "perspectives.rerun.repair.runFailed": "the last run failed — re-running it changes nothing until the cause is fixed",
21095
- "perspectives.rerun.why.noSelectionInRange": "a deploy in range was recorded without a spec selection",
21096
- "perspectives.rerun.why.selectionUnknown": "the selector could not tell whether this case was affected",
21281
+ "perspectives.rerun.heldHint": "an audit, auto-fix or run job is working on this casedismissing or attesting is unavailable until it finishes",
21282
+ "perspectives.rerun.repair.testDrift": "the audit judged the generated test code older than the implementationauto-fix re-records it, so nothing is needed yet",
21283
+ "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",
21284
+ "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}”",
21285
+ "perspectives.rerun.repair.runFailed": "the latest run failed — this verdict will not change until the failure's cause is addressed",
21286
+ "perspectives.rerun.why.noSelectionInRange": "a deploy in range carries no impact judgement, so this case runs to be safe",
21287
+ "perspectives.rerun.why.selectionUnknown": "the latest deploy could not be judged as affecting this case or not, so it runs to be safe",
21097
21288
  "perspectives.rerun.why.noDeployLog": "no deploy log for this profile",
21098
21289
  "perspectives.rerun.why.unknownDeployedSha": "the last run's deployed commit is unknown",
21099
21290
  "perspectives.rerun.why.ambiguousDeployedSha": "a deploy landed while the last run was executing",
21100
21291
  "perspectives.rerun.why.deployedShaNotInLog": "the last run's commit predates the retained deploy log",
21101
21292
  "perspectives.rerun.why.gapInRange": "deploys are missing from the range",
21102
21293
  "perspectives.rerun.why.unrecognized": "this hub reported a reason this UI does not recognise",
21103
- "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.",
21104
- "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.",
21294
+ "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.",
21295
+ "perspectives.rerun.fix.selectionUnknown": "A deploy could not be judged as affecting this case or not, so the next run retakes the result.",
21105
21296
  "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.",
21106
21297
  "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.",
21107
21298
  "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.",
@@ -21113,10 +21304,8 @@ const CLIENT_JS = `
21113
21304
  "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.",
21114
21305
  "perspectives.rerun.deployHead": "deploy head",
21115
21306
  "perspectives.drift.graded": "confirmed",
21116
- "perspectives.manual.attestButton": "Mark as manually verified",
21117
21307
  "perspectives.manual.revokeButton": "Revoke manual verification",
21118
- "perspectives.manual.promptBy": "Who is verifying this?",
21119
- "perspectives.manual.promptNote": "Note (optional)",
21308
+ "perspectives.manual.envButton": "If the environment issue is resolved, use this",
21120
21309
  "perspectives.manual.confirmRevoke": "Revoke the manual verification for this case?",
21121
21310
  "perspectives.manual.error": "Could not save — retry",
21122
21311
  "perspectives.manual.verifiedBy": "{by} manually verified this ({at})",
@@ -21126,6 +21315,18 @@ const CLIENT_JS = `
21126
21315
  "perspectives.manual.lapsed.specEdited": "the manual verification lapsed when the spec was edited",
21127
21316
  "perspectives.manual.lapsed.newerRed": "the manual verification lapsed after a later run failed",
21128
21317
  "perspectives.manual.lapsed.unrecognized": "the manual verification lapsed for a reason this UI does not recognise",
21318
+ "perspectives.dismiss.offerButton": "If the test spec was fine, use this",
21319
+ "perspectives.dismiss.revokeButton": "Undo the dismissal",
21320
+ "perspectives.dismiss.confirmRevoke": "Undo the dismissal for this case?",
21321
+ "perspectives.dismiss.activeNote": "Audit finding “{headline}” was dismissed by {by} as a false positive ({at}) — “{note}”. The next run will settle it.",
21322
+ "perspectives.dismiss.priorNote": "This finding was previously dismissed by {by} ({at}) — “{note}”.",
21323
+ "perspectives.override.byLabel": "Verified by",
21324
+ "perspectives.override.reasonLabel": "Reason (required)",
21325
+ "perspectives.override.noteLabel": "What was resolved, and how you checked (required)",
21326
+ "perspectives.override.submit": "Record",
21327
+ "perspectives.override.cancel": "Never mind",
21328
+ "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.",
21329
+ "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.",
21129
21330
  "prompt.card.record": "Recording browser actions",
21130
21331
  "prompt.card.live": "Live run (AI-driven)",
21131
21332
  "prompt.card.playwright": "Playwright test generation",
@@ -21242,16 +21443,15 @@ const CLIENT_JS = `
21242
21443
  "perspectives.mode.deterministic": "決定的", "perspectives.mode.live": "ライブ",
21243
21444
  "perspectives.ov.cases": "ケース", "perspectives.ov.features": "機能",
21244
21445
  "perspectives.d.preconditions": "前提条件", "perspectives.d.startScreen": "開始画面",
21245
- "perspectives.d.testCondition": "実行条件", "perspectives.d.spec": "spec",
21246
- "perspectives.d.steps": "手順", "perspectives.d.stepInclude": "ブロック: {name}",
21446
+ "perspectives.d.testCondition": "実行条件", "perspectives.d.steps": "手順", "perspectives.d.stepInclude": "ブロック: {name}",
21247
21447
  "perspectives.d.stepExpected": "期待結果:",
21248
- "perspectives.note.label": "note",
21448
+ "perspectives.note.label": "メモ",
21249
21449
  "perspectives.note.placeholder": "このケースについてのメモ…",
21250
21450
  "perspectives.note.saved": "保存しました",
21251
21451
  "perspectives.note.error": "保存に失敗しました — 再試行してください",
21252
- "perspectives.d.lastRed": "直近の失敗",
21253
- "perspectives.d.changedSince": "前回実行以降の変更",
21254
21452
  "perspectives.d.whyVerdict": "この判定の理由",
21453
+ "perspectives.d.contents": "テストの内容",
21454
+ "perspectives.finding.loadFailed": "詳細を読み込めませんでした。実行のページで確認してください",
21255
21455
  "perspectives.result.openRun": "ハブでこの実行を開く",
21256
21456
  "perspectives.result.ci": "CI",
21257
21457
  "perspectives.rerun.state.needsRepair": "修正待ち",
@@ -21261,27 +21461,27 @@ const CLIENT_JS = `
21261
21461
  "perspectives.rerun.state.verified": "検証済み",
21262
21462
  "perspectives.rerun.vsDeploy": "判定基準: デプロイ",
21263
21463
  "perspectives.rerun.noDeployHead": "このプロファイルにはデプロイの記録がありません",
21264
- "perspectives.rerun.changedByDeploy": "デプロイ {sha} がこのケースに一致するファイルを変更",
21265
- "perspectives.rerun.changesSome": "あり(デプロイ {sha} 時点)",
21266
- "perspectives.rerun.changesNone": "なし(デプロイ {sha} 時点)",
21267
- "perspectives.rerun.touchedCount": "このケースに一致したデプロイ差分 {n} ",
21268
- "perspectives.rerun.touchedUnknown": "前回実行以降のデプロイがこのケースに一致する変更を行っています",
21269
- "perspectives.rerun.inProgressHint": "監査か実行がまだ走っているか、監査がデプロイに追いついていません",
21270
- "perspectives.rerun.heldHint": "このスペックは既に別のジョブが保持しています。今操作するとそのジョブと競合します",
21271
- "perspectives.rerun.repair.testDrift": "生成されたテストが古くなっています。録り直してください",
21272
- "perspectives.rerun.repair.specChange": "spec がコードのやめた動作を書いています。人が判断します",
21273
- "perspectives.rerun.repair.auditUndecided": "監査がコードを読んだうえで判定できませんでした。人が見ます",
21274
- "perspectives.rerun.repair.runFailed": "最後の実行が落ちています。原因を直すまで再実行しても変わりません",
21275
- "perspectives.rerun.why.noSelectionInRange": "対象範囲に判定を伴わないデプロイがあります",
21276
- "perspectives.rerun.why.selectionUnknown": "影響の有無を判定できませんでした",
21464
+ "perspectives.rerun.changedByDeploy": "デプロイ {sha} が、このケースに関係するファイルを変更しています",
21465
+ "perspectives.rerun.changesSome": "前回の実行より後に、このケースに関係する変更があります(デプロイ {sha} 時点)",
21466
+ "perspectives.rerun.changesNone": "前回の実行より後に、このケースに関係する変更はありません(デプロイ {sha} 時点)",
21467
+ "perspectives.rerun.touchedCount": "前回の実行より後のデプロイが、このケースに関係する変更を {n} 件含んでいます",
21468
+ "perspectives.rerun.touchedUnknown": "前回の実行より後のデプロイが、このケースに関係する変更を含んでいます",
21469
+ "perspectives.rerun.inProgressHint": "監査または実行のジョブが作業中か、最新デプロイに対する監査がまだ走っていません",
21470
+ "perspectives.rerun.heldHint": "監査・自動修正・実行のいずれかのジョブが、このケースを処理中です。終わるまで、このケースへの操作(棄却・手動確認)はできません",
21471
+ "perspectives.rerun.repair.testDrift": "監査が、生成済みのテストコードは実装より古いと判定しました。自動修正が録り直すので、まず対応は不要です",
21472
+ "perspectives.rerun.repair.specChange": "監査が、このテストの前提とする振る舞いは実装から無くなったと判定しました。テストを直すか削除するかを決めてください。指摘のほうが誤りなら、「{dismissButton}」から棄却できます",
21473
+ "perspectives.rerun.repair.auditUndecided": "テストが実装とズレているかどうか、監査では判断できませんでした。指摘の内容を確認して、テストを直すか、「{dismissButton}」から棄却してください",
21474
+ "perspectives.rerun.repair.runFailed": "直近の実行が失敗しています。失敗の原因に対処するまで、この判定は変わりません",
21475
+ "perspectives.rerun.why.noSelectionInRange": "影響判定なしで記録されたデプロイがあるため、念のため実行対象になっています",
21476
+ "perspectives.rerun.why.selectionUnknown": "直近のデプロイがこのケースに影響するかどうか判断できなかったため、念のため実行対象になっています",
21277
21477
  "perspectives.rerun.why.noDeployLog": "このプロファイルのデプロイ記録がありません",
21278
21478
  "perspectives.rerun.why.unknownDeployedSha": "前回実行時にデプロイされていたcommitが不明です",
21279
21479
  "perspectives.rerun.why.ambiguousDeployedSha": "前回実行の途中でデプロイが発生しました",
21280
- "perspectives.rerun.why.deployedShaNotInLog": "前回実行のcommitが保持中のデプロイログより古いです",
21480
+ "perspectives.rerun.why.deployedShaNotInLog": "前回の実行が古く、どのデプロイに対して実行した結果か特定できません",
21281
21481
  "perspectives.rerun.why.gapInRange": "対象範囲のデプロイ記録が欠けています",
21282
21482
  "perspectives.rerun.why.unrecognized": "このUIが認識できない理由がハブから返されました",
21283
- "perspectives.rerun.fix.noSelectionInRange": "対象範囲に判定を伴わないデプロイがあり、このケースに影響したかどうかを示すものがありません。デプロイジョブで ccqa select-specs を実行し、判定をデプロイと一緒に送ってください。",
21284
- "perspectives.rerun.fix.selectionUnknown": "対象範囲のデプロイは判定されましたが、このケースについては判断がつきませんでした。再実行して基準を取り直してください。",
21483
+ "perspectives.rerun.fix.noSelectionInRange": "影響判定なしで記録されたデプロイがあり、このケースに影響したかどうか分かりません。デプロイの記録時に影響判定が時間内に終わらなかったか、無効化されていたときに起きます。次の実行と監査で結果を取り直します。",
21484
+ "perspectives.rerun.fix.selectionUnknown": "デプロイがこのケースに影響するかどうか判断できなかったため、次の実行で結果を取り直します。",
21285
21485
  "perspectives.rerun.fix.noDeployLog": "このプロファイルのデプロイログに記録がありません。何がデプロイされたかをccqaに伝えるため、この環境のデプロイジョブに ccqa hub deploy record を組み込んでください。",
21286
21486
  "perspectives.rerun.fix.unknownDeployedSha": "前回実行は環境で動いていたcommitを記録していないため、デプロイログ上の位置を決められません。このプロファイルにデプロイログができれば、以降の実行では記録されます。",
21287
21487
  "perspectives.rerun.fix.ambiguousDeployedSha": "前回実行の途中でデプロイが発生したため、どのcommitを検証したのか確定できません。基準を取り直すには再実行してください。",
@@ -21293,19 +21493,29 @@ const CLIENT_JS = `
21293
21493
  "perspectives.rerun.noDeployLogBanner": "プロファイル {profile} にデプロイの記録がないため、どのケースも判定できません。この環境のデプロイジョブに ccqa hub deploy record を組み込んでください。",
21294
21494
  "perspectives.rerun.deployHead": "最新デプロイ",
21295
21495
  "perspectives.drift.graded": "人が確認",
21296
- "perspectives.manual.attestButton": "手動で確認した",
21297
21496
  "perspectives.manual.revokeButton": "手動確認を取り消す",
21298
- "perspectives.manual.promptBy": "確認者名を入力してください",
21299
- "perspectives.manual.promptNote": "メモ(任意)",
21497
+ "perspectives.manual.envButton": "環境要因が解消した場合はこちら",
21300
21498
  "perspectives.manual.confirmRevoke": "このケースの手動確認を取り消しますか?",
21301
21499
  "perspectives.manual.error": "保存に失敗しました — 再試行してください",
21302
- "perspectives.manual.verifiedBy": "{by}さんが手動確認({at})",
21303
- "perspectives.manual.lapsed.deployReached": "手動確認はデプロイの到達により失効",
21304
- "perspectives.manual.lapsed.deployReachedNamed": "手動確認はデプロイ {sha}({at})の到達により失効",
21305
- "perspectives.manual.lapsed.cannotPlace": "手動確認は基準デプロイをログで特定できず失効",
21306
- "perspectives.manual.lapsed.specEdited": "手動確認はspecの編集により失効",
21307
- "perspectives.manual.lapsed.newerRed": "手動確認は直後の実行失敗により失効",
21308
- "perspectives.manual.lapsed.unrecognized": "このUIが認識できない理由により手動確認が失効",
21500
+ "perspectives.manual.verifiedBy": "{by} さんが手動で動作確認しました({at})",
21501
+ "perspectives.manual.lapsed.deployReached": "デプロイがこのケースに届いたため、手動確認は失効しました",
21502
+ "perspectives.manual.lapsed.deployReachedNamed": "デプロイ {sha}({at})がこのケースに届いたため、手動確認は失効しました",
21503
+ "perspectives.manual.lapsed.cannotPlace": "基準のデプロイをログで特定できなくなったため、手動確認は失効しました",
21504
+ "perspectives.manual.lapsed.specEdited": "spec が編集されたため、手動確認は失効しました",
21505
+ "perspectives.manual.lapsed.newerRed": "手動確認より後の実行が失敗したため、手動確認は失効しました",
21506
+ "perspectives.manual.lapsed.unrecognized": "この UI が認識できない理由により、手動確認は失効しました",
21507
+ "perspectives.dismiss.offerButton": "テスト仕様に問題がなかった場合はこちら",
21508
+ "perspectives.dismiss.revokeButton": "棄却を取り消す",
21509
+ "perspectives.dismiss.confirmRevoke": "このケースの棄却を取り消しますか?",
21510
+ "perspectives.dismiss.activeNote": "{by} さんが監査指摘「{headline}」を誤検知として棄却しました({at}・理由: {note})。次の実行結果が正否を決めます。",
21511
+ "perspectives.dismiss.priorNote": "この指摘は、以前 {by} さんが棄却しています({at}・理由: {note})",
21512
+ "perspectives.override.byLabel": "確認した人",
21513
+ "perspectives.override.reasonLabel": "理由(必須)",
21514
+ "perspectives.override.noteLabel": "解消と確認の内容(必須)",
21515
+ "perspectives.override.submit": "記録する",
21516
+ "perspectives.override.cancel": "やめる",
21517
+ "perspectives.override.dismissHint": "監査の指摘を取り下げて、このケースを実行対象に戻します。判定は「要再実行」になり、次の実行結果が正否を決めます。",
21518
+ "perspectives.override.envHint": "失敗の記録は残したまま、判定だけが「手動確認済み」になります。次のデプロイがこのケースに届くと失効し、通常の実行に戻ります。",
21309
21519
  "prompt.card.record": "ブラウザ操作の記録",
21310
21520
  "prompt.card.live": "ライブ実行(AI操作)",
21311
21521
  "prompt.card.playwright": "Playwrightテスト生成",
@@ -21602,7 +21812,6 @@ const CLIENT_JS = `
21602
21812
  return span;
21603
21813
  }
21604
21814
 
21605
-
21606
21815
  // Shared by the runs-list row and the run-detail header — the one place
21607
21816
  // both decide whether a run's own status badge speaks drift's vocabulary.
21608
21817
  function runStatusBadge(run) {
@@ -22212,15 +22421,13 @@ const CLIENT_JS = `
22212
22421
 
22213
22422
  // ── run detail: spec cards ──────────────────────────────────────────
22214
22423
 
22215
- // The diagnosis card: one surface for everything about a failure's cause.
22216
- // Verdict (label + confidence), then the cause→fix pair as labelled rows —
22217
- // headline and recommendation are one causal unit, so they read as one.
22218
- // subDiagnosis is deliberately NOT shown: it is a machine vocabulary for
22219
- // accuracy stratification and learning, not for humans. The caller appends
22220
- // the evidence/reasoning accordions and the grading zone into this box.
22221
- function analysisSection(runId, r) {
22222
- var wrap = el("div", "analysis-box");
22223
- var a = r.analysis;
22424
+ // The diagnosis card's two halves, shared by the run view (analysisSection)
22425
+ // and the perspectives reason card so the two renderings cannot drift:
22426
+ // verdict head (label chip + confidence), then the cause→fix pair as
22427
+ // labelled rows headline and recommendation are one causal unit, so they
22428
+ // read as one. subDiagnosis is deliberately NOT shown: it is a machine
22429
+ // vocabulary for accuracy stratification and learning, not for humans.
22430
+ function diagnosisHead(a) {
22224
22431
  var head = el("div", "analysis-head");
22225
22432
  head.appendChild(labelChip(a.label));
22226
22433
  // Which repair a SPEC_CHANGE needs — delete the spec, or rewrite it. The
@@ -22230,7 +22437,10 @@ const CLIENT_JS = `
22230
22437
  head.appendChild(el("span", "chip spec-change-chip", t("diag.specChangeKind." + a.specChangeKind)));
22231
22438
  }
22232
22439
  head.appendChild(el("span", "conf", Math.round(a.confidence * 100) + "%"));
22233
- wrap.appendChild(head);
22440
+ return head;
22441
+ }
22442
+
22443
+ function diagnosisKv(a) {
22234
22444
  var kv = el("div", "analysis-kv");
22235
22445
  // Set only when the verdict blames the test case (TEST_DRIFT/SPEC_CHANGE),
22236
22446
  // on both kinds of row — it names the half that has to be repaired.
@@ -22246,7 +22456,14 @@ const CLIENT_JS = `
22246
22456
  kv.appendChild(el("div", "k", t("diag.fix")));
22247
22457
  kv.appendChild(el("div", "v", a.recommendation));
22248
22458
  }
22249
- if (kv.childNodes.length > 0) wrap.appendChild(kv);
22459
+ return kv.childNodes.length > 0 ? kv : null;
22460
+ }
22461
+
22462
+ function analysisSection(runId, r) {
22463
+ var wrap = el("div", "analysis-box");
22464
+ wrap.appendChild(diagnosisHead(r.analysis));
22465
+ var kv = diagnosisKv(r.analysis);
22466
+ if (kv) wrap.appendChild(kv);
22250
22467
  return wrap;
22251
22468
  }
22252
22469
 
@@ -23359,9 +23576,10 @@ const CLIENT_JS = `
23359
23576
  // this hub answers at all. Chip visibility follows it rather than the report,
23360
23577
  // so switching profile doesn't drop the filter while the next one loads.
23361
23578
  // "drift" is the DriftLedgerResponse, or null when unanswered (older hub, or
23362
- // a failed fetch) — not profile-scoped, so it does not reset when the
23363
- // profile switcher changes (unlike "rerun" above). Only feeds the audit
23364
- // column's "audited at" line now; its own finding is superseded by rr.audit.
23579
+ // a failed fetch) — not profile-scoped. It backs the audit column's
23580
+ // "audited at" line and the reason card's finding (runId/label/headline),
23581
+ // so reloadRerun refetches it alongside the rerun report to keep the two
23582
+ // reports of one card equally fresh.
23365
23583
  var perspState = {
23366
23584
  doc: null, q: "", f: "all",
23367
23585
  rerun: null, rerunSupported: null, runUrls: {}, rerunProfiles: [],
@@ -23403,6 +23621,13 @@ const CLIENT_JS = `
23403
23621
  "/attestations?profile=" + encodeURIComponent(state.profile);
23404
23622
  }
23405
23623
 
23624
+ // A person's answer to an audit finding, not an environment: the finding is
23625
+ // about the repository, not a deployed profile, so this carries no
23626
+ // ?profile= (unlike attestationsPath above).
23627
+ function auditDismissalsPath() {
23628
+ return "/api/v1/projects/" + encodeURIComponent(state.project) + "/audit-dismissals";
23629
+ }
23630
+
23406
23631
  // Resolves { report } or { note } and never rejects: a hub that predates
23407
23632
  // the endpoint costs only the columns it feeds, not the whole tab. A 404
23408
23633
  // here can only mean "no such route" — the endpoint's own 404 is "the
@@ -23422,10 +23647,10 @@ const CLIENT_JS = `
23422
23647
 
23423
23648
  // ── perspectives: drift ledger ────────────────────────────────────────
23424
23649
  // Not profile-scoped (see perspState.drift above), so unlike rerunPath this
23425
- // takes no ?profile=. Its own finding is superseded by the audit axis in
23426
- // the /rerun report (ADR-0014); what survives into the view is only its
23427
- // coordinate when a spec was last audited — folded into the audit
23428
- // column's evidence line (perspAuditCell).
23650
+ // takes no ?profile=. The audit AXIS is answered by the /rerun report
23651
+ // (ADR-0014); this ledger supplies what that report does not carry the
23652
+ // finding's own coordinate and words (runId/at/label/headline), shown in
23653
+ // the audit column's evidence line and the reason card.
23429
23654
 
23430
23655
  function driftPath() {
23431
23656
  return "/api/v1/projects/" + encodeURIComponent(state.project) + "/drift";
@@ -23478,7 +23703,10 @@ const CLIENT_JS = `
23478
23703
  // when it has no entry).
23479
23704
  function rerunReasonText(prefix, reason) {
23480
23705
  var text = t(prefix + reason);
23481
- return text === prefix + reason ? t(prefix + "unrecognized") : text;
23706
+ if (text === prefix + reason) text = t(prefix + "unrecognized");
23707
+ // Wordings that point at the dismiss control name it by its own label, so
23708
+ // renaming the button cannot silently strand four strings.
23709
+ return text.replace("{dismissButton}", t("perspectives.dismiss.offerButton"));
23482
23710
  }
23483
23711
 
23484
23712
  // Who attested and when, plus their note if they left one — the whole
@@ -23569,7 +23797,6 @@ const CLIENT_JS = `
23569
23797
  return lapse ? why + " · " + lapse : why;
23570
23798
  }
23571
23799
 
23572
-
23573
23800
  // --- pure: rerun composition ---------------------------------------------
23574
23801
  // Self-contained on purpose: no DOM, no closures. rerun-view.test.ts lifts
23575
23802
  // this region out of the rendered page and runs it, because the summary bar
@@ -24011,8 +24238,8 @@ const CLIENT_JS = `
24011
24238
 
24012
24239
  // --- pure: rerun detail labels -------------------------------------------
24013
24240
  // Self-contained on purpose (no DOM, no closures) so rerun-view.test.ts can
24014
- // lift this region out of the rendered page and run it: which label the
24015
- // panel's evidence row wears, and whether it has a failure row at all.
24241
+ // lift this region out of the rendered page and run it: whether the deploy
24242
+ // log answered for this case, and which deploy the reason line names.
24016
24243
 
24017
24244
  // The deploy log answered for this case: the row can show what it holds.
24018
24245
  // A case the log could not place has no evidence to show even though its
@@ -24022,20 +24249,6 @@ const CLIENT_JS = `
24022
24249
  return rr.verdict === "rerunNeeded" && !rr.executionAssumedReached;
24023
24250
  }
24024
24251
 
24025
- // Evidence is labelled by the timeframe it covers; everything else names why
24026
- // the verdict landed — a different kind of content, and forcing one label
24027
- // over both would make one of the two read as a lie.
24028
- function rerunEvidenceLabelKey(rr) {
24029
- return rerunHasEvidence(rr) ? "perspectives.d.changedSince" : "perspectives.d.whyVerdict";
24030
- }
24031
-
24032
- // The failure row points at a run. With no failure there is nothing to point
24033
- // at, so the row is omitted rather than filled with "never failed" — the row
24034
- // above already carries the last result.
24035
- function rerunHasFailure(rr) {
24036
- return !!(rr && rr.lastRed);
24037
- }
24038
-
24039
24252
  // Which deploy the evidence line names, and how. A "needed" verdict carries
24040
24253
  // the deploy that caused it (touchedByDeploy) when the hub could confirm one,
24041
24254
  // and that is the deploy a reader wants — so it is named, with when it
@@ -24056,51 +24269,81 @@ const CLIENT_JS = `
24056
24269
  }
24057
24270
  // --- end pure: rerun detail labels ----------------------------------------
24058
24271
 
24059
- // The evidence behind the verdict, as the value of whichever row
24060
- // rerunEvidenceLabelKey chose. For needed/notNeeded that is what the deploy
24061
- // log holds since this case last ran, named by rerunChangeLine.
24062
- // The label already states the timeframe, so the value never repeats it.
24063
- function rerunEvidenceValue(rr) {
24064
- var wrap = el("div");
24065
- if (!rerunHasEvidence(rr)) {
24066
- wrap.appendChild(el("div", "d-prose", rerunWhyVerdict(rr)));
24067
- return wrap;
24272
+ // --- pure: audit dismissal reading ----------------------------------------
24273
+ // Self-contained (no DOM, no closures) for the same reason as the regions
24274
+ // above: read rr.auditDismissed against rr.audit, per the schema's own
24275
+ // comment on the field. "clean" means the dismissal is what is holding the
24276
+ // axis there; "drifted"/"undecided" means a later audit re-raised what it
24277
+ // answered, so the old dismissal no longer covers it.
24278
+ // The audit has something outstanding on this spec. Both values mean the
24279
+ // same thing to a reader deciding whether to answer it: the audit read the
24280
+ // code and did not clear the spec (ADR-0019).
24281
+ function auditOpen(rr) {
24282
+ return !!rr && (rr.audit === "drifted" || rr.audit === "undecided");
24283
+ }
24284
+ function auditDismissalActive(rr) {
24285
+ // The hub says whether the dismissal settled the axis. Inferring it from
24286
+ // "clean" would credit the person for a later audit clearing the spec on
24287
+ // its own, which reads identically here.
24288
+ return !!(rr && rr.auditDismissed && rr.auditDismissalApplied);
24289
+ }
24290
+ function auditDismissalReflagged(rr) {
24291
+ return !!(rr && rr.auditDismissed && auditOpen(rr));
24292
+ }
24293
+ // --- end pure: audit dismissal reading -------------------------------------
24294
+
24295
+ // The dismissal's own words, read against the current audit state: active,
24296
+ // it explains why the axis reads clean; re-flagged, it is a fact worth
24297
+ // keeping visible beside the finding that reopened it.
24298
+ function rerunDismissalLine(rr) {
24299
+ if (!rr || !rr.auditDismissed) return null;
24300
+ var d = rr.auditDismissed;
24301
+ if (auditDismissalActive(rr)) {
24302
+ return {
24303
+ muted: false,
24304
+ text: t("perspectives.dismiss.activeNote")
24305
+ .replace("{headline}", d.headline).replace("{by}", d.by).replace("{at}", relTime(d.at)).replace("{note}", d.note),
24306
+ };
24068
24307
  }
24069
- // Both states require a non-empty deploy log, so a head-less report
24070
- // contradicts itself; rerunChangeLine then names what is missing rather
24071
- // than inventing a baseline.
24072
- var line = rerunChangeLine(rr, perspState.rerun && perspState.rerun.deployHead);
24073
- var text = t(line.key).replace("{sha}", shortSha(line.sha));
24074
- if (line.at) text += " · " + relTime(line.at);
24075
- wrap.appendChild(el("div", "d-prose", text));
24076
- // A touch the index proved but cannot enumerate leaves no paths to list;
24077
- // the line above still says a change landed, which is all that is known.
24078
- if (rr.verdict === "rerunNeeded" && rr.touchedBy && rr.touchedBy.length) {
24079
- wrap.appendChild(pathCodes(rr.touchedBy));
24308
+ if (auditDismissalReflagged(rr)) {
24309
+ return {
24310
+ muted: true,
24311
+ text: t("perspectives.dismiss.priorNote")
24312
+ .replace("{by}", d.by).replace("{at}", relTime(d.at)).replace("{note}", d.note),
24313
+ };
24080
24314
  }
24081
- return wrap;
24315
+ return null;
24082
24316
  }
24083
24317
 
24084
- // The failure: which run it was, then what the analysis concluded. The
24085
- // headline is model output, already localized server-side, so it is shown as
24086
- // written. A run made without failure analysis carries neither field, and the
24087
- // row is then the coordinate alone — what it has always been.
24088
- function rerunFailureValue(entry) {
24318
+ // The reason card's body when no run-recorded finding is shown: what the
24319
+ // deploy log holds since this case last ran (rerunChangeLine), or why the
24320
+ // verdict landed. The dismissal and lapse notes are the card's own to
24321
+ // append (perspReasonCard), not this value's.
24322
+ function rerunEvidenceValue(rr) {
24089
24323
  var wrap = el("div");
24090
- wrap.appendChild(ledgerLine(entry));
24091
- if (entry.label) {
24092
- var line = el("div", "d-prose", labelText(entry.label));
24093
- if (entry.headline) line.appendChild(document.createTextNode(" · " + entry.headline));
24094
- wrap.appendChild(line);
24324
+ if (!rerunHasEvidence(rr)) {
24325
+ wrap.appendChild(el("div", "d-prose", rerunWhyVerdict(rr)));
24326
+ } else {
24327
+ // Both states require a non-empty deploy log, so a head-less report
24328
+ // contradicts itself; rerunChangeLine then names what is missing rather
24329
+ // than inventing a baseline.
24330
+ var line = rerunChangeLine(rr, perspState.rerun && perspState.rerun.deployHead);
24331
+ var text = t(line.key).replace("{sha}", shortSha(line.sha));
24332
+ if (line.at) text += " · " + relTime(line.at);
24333
+ wrap.appendChild(el("div", "d-prose", text));
24334
+ // A touch the index proved but cannot enumerate leaves no paths to
24335
+ // list; the line above still says a change landed, which is all that
24336
+ // is known.
24337
+ if (rr.verdict === "rerunNeeded" && rr.touchedBy && rr.touchedBy.length) {
24338
+ wrap.appendChild(pathCodes(rr.touchedBy));
24339
+ }
24095
24340
  }
24096
24341
  return wrap;
24097
24342
  }
24098
24343
 
24099
- // Lets a person's own check stand in for the machine's verdict. prompt()/
24100
- // confirm() rather than a form: the action is rare and the two fields it
24101
- // needs are short, so a modal would outweigh what it does. reloadRerun()
24102
- // is the same profile-scoped refresh a profile switch uses — it re-renders
24103
- // the whole table, so an open detail panel closes along with it.
24344
+ // Lets a person's own check stand in for the machine's verdict. reloadRerun()
24345
+ // is the same refresh a profile switch uses it re-renders the whole
24346
+ // table, so an open detail panel closes along with it.
24104
24347
  function submitAttestation(method, body) {
24105
24348
  apiFetch(attestationsPath(), {
24106
24349
  method: method,
@@ -24110,17 +24353,16 @@ const CLIENT_JS = `
24110
24353
  .catch(function (err) { window.alert(t("perspectives.manual.error") + ": " + err.message); });
24111
24354
  }
24112
24355
 
24113
- function manualAttestButton(feature, spec) {
24114
- var btn = el("button", "btn sm primary", t("perspectives.manual.attestButton"));
24115
- btn.type = "button";
24116
- btn.addEventListener("click", function () {
24117
- var by = window.prompt(t("perspectives.manual.promptBy"), loadAttestBy());
24118
- if (!by) return;
24119
- storeAttestBy(by);
24120
- var note = window.prompt(t("perspectives.manual.promptNote"), "");
24121
- submitAttestation("PUT", { spec: perspSpecKey(feature, spec), by: by, note: note || undefined });
24122
- });
24123
- return btn;
24356
+ // Lets a person say an audit finding was wrong. Same reload contract as
24357
+ // submitAttestation above; a different endpoint (no ?profile=, ADR: a
24358
+ // finding is about the repository).
24359
+ function submitAuditDismissal(method, body) {
24360
+ apiFetch(auditDismissalsPath(), {
24361
+ method: method,
24362
+ headers: { "Content-Type": "application/json" },
24363
+ body: JSON.stringify(body),
24364
+ }).then(function () { reloadRerun(); })
24365
+ .catch(function (err) { window.alert(t("perspectives.manual.error") + ": " + err.message); });
24124
24366
  }
24125
24367
 
24126
24368
  function manualRevokeButton(feature, spec) {
@@ -24133,16 +24375,251 @@ const CLIENT_JS = `
24133
24375
  return btn;
24134
24376
  }
24135
24377
 
24136
- // Detail row: a definition list of the case's fields plus the note editor.
24137
- // Built with createElement/textContent throughout every field here is
24138
- // API-derived, so none of it may go through innerHTML.
24139
- //
24140
- // The panel shows only what the table row cannot. The row already carries the
24141
- // title, mode, recorded state, last result and the re-run verdict, so none of
24142
- // those is repeated: what is left is the case's definition, the evidence the
24143
- // verdict rests on, and the note.
24378
+ function auditDismissalRevokeButton(feature, spec) {
24379
+ var btn = el("button", "btn ghost sm del", t("perspectives.dismiss.revokeButton"));
24380
+ btn.type = "button";
24381
+ btn.addEventListener("click", function () {
24382
+ if (!window.confirm(t("perspectives.dismiss.confirmRevoke"))) return;
24383
+ submitAuditDismissal("DELETE", { spec: perspSpecKey(feature, spec) });
24384
+ });
24385
+ return btn;
24386
+ }
24387
+
24388
+ // The inline form a "dismiss" or "environment" offer button expands into,
24389
+ // in place of the two window.prompt() calls this replaces. Both kinds ask
24390
+ // for the same two things — who, and why — and differ only in wording and
24391
+ // which endpoint the answer goes to.
24392
+ function buildOverrideForm(kind, feature, spec, onCancel) {
24393
+ var wrap = el("div", "override-form");
24394
+
24395
+ var byRow = el("div", "form-row");
24396
+ byRow.appendChild(el("label", null, t("perspectives.override.byLabel")));
24397
+ var byInput = el("input", "input");
24398
+ byInput.type = "text";
24399
+ byInput.value = loadAttestBy();
24400
+ byRow.appendChild(byInput);
24401
+ wrap.appendChild(byRow);
24402
+
24403
+ var noteRow = el("div", "form-row");
24404
+ noteRow.appendChild(el("label", null, t(kind === "dismiss" ? "perspectives.override.reasonLabel" : "perspectives.override.noteLabel")));
24405
+ var noteInput = el("textarea");
24406
+ noteRow.appendChild(noteInput);
24407
+ wrap.appendChild(noteRow);
24408
+
24409
+ var act = el("div", "of-act");
24410
+ var submitBtn = el("button", "btn sm primary", t("perspectives.override.submit"));
24411
+ submitBtn.type = "button";
24412
+ submitBtn.disabled = true;
24413
+ var cancelBtn = el("button", "btn ghost sm", t("perspectives.override.cancel"));
24414
+ cancelBtn.type = "button";
24415
+ act.appendChild(submitBtn);
24416
+ act.appendChild(cancelBtn);
24417
+ wrap.appendChild(act);
24418
+
24419
+ wrap.appendChild(el("div", "of-hint", t(kind === "dismiss" ? "perspectives.override.dismissHint" : "perspectives.override.envHint")));
24420
+
24421
+ function syncEnabled() {
24422
+ submitBtn.disabled = !(byInput.value.trim() && noteInput.value.trim());
24423
+ }
24424
+ byInput.addEventListener("input", syncEnabled);
24425
+ noteInput.addEventListener("input", syncEnabled);
24426
+ cancelBtn.addEventListener("click", onCancel);
24427
+
24428
+ submitBtn.addEventListener("click", function () {
24429
+ var by = byInput.value.trim();
24430
+ var note = noteInput.value.trim();
24431
+ if (!by || !note) return;
24432
+ storeAttestBy(by);
24433
+ if (kind === "dismiss") submitAuditDismissal("PUT", { spec: perspSpecKey(feature, spec), by: by, note: note });
24434
+ else submitAttestation("PUT", { spec: perspSpecKey(feature, spec), by: by, note: note });
24435
+ });
24436
+
24437
+ return wrap;
24438
+ }
24439
+
24440
+ // A button that expands into buildOverrideForm above in place, rather than
24441
+ // a modal — the action is rare enough that swapping the button for its own
24442
+ // form reads fine without one.
24443
+ function buildOverrideOffer(kind, feature, spec) {
24444
+ var box = el("div", "manual-attest");
24445
+ var openBtn = el("button", "btn sm primary", t(kind === "dismiss" ? "perspectives.dismiss.offerButton" : "perspectives.manual.envButton"));
24446
+ openBtn.type = "button";
24447
+ box.appendChild(openBtn);
24448
+ openBtn.addEventListener("click", function () {
24449
+ box.removeChild(openBtn);
24450
+ var form = buildOverrideForm(kind, feature, spec, function () {
24451
+ box.removeChild(form);
24452
+ box.appendChild(openBtn);
24453
+ });
24454
+ box.appendChild(form);
24455
+ });
24456
+ return box;
24457
+ }
24458
+
24459
+ // The audit-axis override slot: dismiss an open finding, or revoke a
24460
+ // dismissal that is currently the reason the axis reads clean. At most one
24461
+ // of the two ever shows — a finding the axis
24462
+ // itself has cleared, dismissed or not, offers nothing here.
24463
+ // While a job holds the spec, no control shows at all: heldHint promises
24464
+ // the reader that overrides wait for the job, so the buttons must too.
24465
+ function auditOverrideBox(feature, spec, rr) {
24466
+ if (rr.heldBy) return null;
24467
+ if (auditDismissalActive(rr)) {
24468
+ var box = el("div", "manual-attest");
24469
+ box.appendChild(auditDismissalRevokeButton(feature, spec));
24470
+ return box;
24471
+ }
24472
+ if (auditOpen(rr)) return buildOverrideOffer("dismiss", feature, spec);
24473
+ return null;
24474
+ }
24475
+
24476
+ // The execution-axis override slot: revoke a standing attestation, or offer
24477
+ // one for an environment-caused failure — but only when the audit axis has
24478
+ // no open finding of its own, which is auditOverrideBox's problem to answer,
24479
+ // not this one's.
24480
+ function executionOverrideBox(feature, spec, rr) {
24481
+ if (rr.heldBy) return null;
24482
+ if (rr.manual) {
24483
+ var box = el("div", "manual-attest");
24484
+ if (rr.verdict !== "manuallyVerified") box.appendChild(el("div", "d-prose", manualAttestationText(rr.manual)));
24485
+ box.appendChild(manualRevokeButton(feature, spec));
24486
+ return box;
24487
+ }
24488
+ // Only once the audit has actually cleared the spec: while it is still
24489
+ // due, nobody knows yet whether the environment was the only thing wrong.
24490
+ if (rr.audit === "clean" && rr.execution === "failed" && rr.lastRed && rr.lastRed.label === "ENVIRONMENT") {
24491
+ return buildOverrideOffer("environment", feature, spec);
24492
+ }
24493
+ return null;
24494
+ }
24495
+
24496
+ // ── the reason card ──────────────────────────────────────────────────────
24497
+ // One card that answers "why is the verdict what it is". When an axis
24498
+ // stands on a run-recorded finding (an open audit finding, or the failure
24499
+ // the execution axis reports), the card carries that finding in full,
24500
+ // fetched from the run's own report — where cause, fix and evidence
24501
+ // already live. Everything a person may do about the state (dismiss,
24502
+ // attest, revoke) sits in the same card, beside the reason it answers.
24503
+
24504
+ var runReportCache = {};
24505
+ function fetchRunReport(runId) {
24506
+ if (!runReportCache[runId]) {
24507
+ // Same endpoint (and so the same HTTP cache entry) the run view reads.
24508
+ // A failure is not cached: a transient 502 costs one refetch on the
24509
+ // next expand instead of pinning the fallback for the session.
24510
+ runReportCache[runId] = apiFetch("/api/v1/runs/" + encodeURIComponent(runId) + "/report")
24511
+ .catch(function () { delete runReportCache[runId]; return null; });
24512
+ }
24513
+ return runReportCache[runId];
24514
+ }
24515
+
24516
+ function reportRowFor(report, key) {
24517
+ var rows = (report && report.results) || [];
24518
+ for (var i = 0; i < rows.length; i++) {
24519
+ if (rows[i].feature + "/" + rows[i].spec === key) return rows[i];
24520
+ }
24521
+ return null;
24522
+ }
24523
+
24524
+ // Which run-recorded finding the card shows, if any: an open audit finding
24525
+ // wins (it is why nothing runs), else the failure the execution axis stands
24526
+ // on. The ledger's own label/headline are the instant fallback while the
24527
+ // report loads — and the whole content if it never arrives.
24528
+ function reasonFindingSource(rr, driftEntry) {
24529
+ if (auditOpen(rr) && driftEntry && driftEntry.runId && driftEntry.label) return driftEntry;
24530
+ if (rr.execution === "failed" && rr.lastRed && rr.lastRed.runId && rr.lastRed.label) return rr.lastRed;
24531
+ return null;
24532
+ }
24533
+
24534
+ function perspReasonCard(feature, spec, rr, driftEntry) {
24535
+ var card = el("div", "analysis-box");
24536
+ var source = reasonFindingSource(rr, driftEntry);
24537
+
24538
+ if (source) {
24539
+ // The ledger's label/headline render at once; the run's own report
24540
+ // replaces them with the full diagnosis (the same head/kv the run view
24541
+ // builds) when — and if — it arrives.
24542
+ var note = el("span", "p-head-note", rerunWhyVerdict(rr));
24543
+ var slot = el("div");
24544
+ var head = el("div", "analysis-head");
24545
+ head.appendChild(labelChip(source.label));
24546
+ head.appendChild(note);
24547
+ slot.appendChild(head);
24548
+ if (source.headline) {
24549
+ var kv = diagnosisKv({ headline: source.headline });
24550
+ if (kv) slot.appendChild(kv);
24551
+ }
24552
+ card.appendChild(slot);
24553
+
24554
+ // A graded finding is the human's word, not the model's: the ledger
24555
+ // carries the corrected label/headline, while the run's report still
24556
+ // holds the original prediction. Upgrading would show the guess the
24557
+ // person explicitly overwrote, so the card keeps the ledger's version.
24558
+ if (source.graded) return finishReasonCard(card, feature, spec, rr);
24559
+
24560
+ fetchRunReport(source.runId).then(function (report) {
24561
+ var reportRow = reportRowFor(report, perspSpecKey(feature, spec));
24562
+ var a = reportRow && reportRow.analysis;
24563
+ if (!a) {
24564
+ if (!source.headline) slot.appendChild(el("div", "d-prose muted", t("perspectives.finding.loadFailed")));
24565
+ return;
24566
+ }
24567
+ // The ledger's one-liner stands in when the report row lost its own.
24568
+ if (!a.headline) a.headline = source.headline || "";
24569
+ clear(slot);
24570
+ var fullHead = diagnosisHead(a);
24571
+ fullHead.appendChild(note);
24572
+ slot.appendChild(fullHead);
24573
+ var fullKv = diagnosisKv(a);
24574
+ if (fullKv) slot.appendChild(fullKv);
24575
+ var evi = analysisEvidenceSection(reportRow);
24576
+ if (evi.count) slot.appendChild(detailsBlock(t("acc.evidence"), evi.count, evi.node));
24577
+ });
24578
+ } else {
24579
+ // No finding to show: the reason is the deploy-log answer, or the
24580
+ // verdict's own wording.
24581
+ card.appendChild(rerunEvidenceValue(rr));
24582
+ }
24583
+
24584
+ return finishReasonCard(card, feature, spec, rr);
24585
+ }
24586
+
24587
+ // The card's shared tail: the dismissal/lapse notes and the person's
24588
+ // controls, appended after whichever body the card ended up with.
24589
+ function finishReasonCard(card, feature, spec, rr) {
24590
+ var dline = rerunDismissalLine(rr);
24591
+ if (dline) card.appendChild(el("div", "d-prose" + (dline.muted ? " muted" : ""), dline.text));
24592
+ var lapse = rerunManualLapseText(rr);
24593
+ if (lapse) card.appendChild(el("div", "d-prose muted", lapse));
24594
+ var auditBox = auditOverrideBox(feature, spec, rr);
24595
+ if (auditBox) card.appendChild(auditBox);
24596
+ var execBox = executionOverrideBox(feature, spec, rr);
24597
+ if (execBox) card.appendChild(execBox);
24598
+ return card;
24599
+ }
24600
+
24601
+ // Detail row: the case's current state first, then why the verdict is what
24602
+ // it is, then what the case does, then the note — a stack of cards in the
24603
+ // order a reader asks the questions. Built with createElement/textContent
24604
+ // throughout — every field here is API-derived, so none of it may go
24605
+ // through innerHTML.
24144
24606
  function perspDetailContent(feature, spec) {
24145
24607
  var frag = document.createDocumentFragment();
24608
+ var rr = ledgerEntryFor(perspState.rerun, feature, spec);
24609
+ var driftEntry = ledgerEntryFor(perspState.drift, feature, spec);
24610
+
24611
+ // The two axis states stay in the table row only — the panel answers why,
24612
+ // not what, so it opens straight on the reason.
24613
+ if (rr) {
24614
+ var reason = el("div", "p-sect");
24615
+ reason.appendChild(el("div", "p-slabel", t("perspectives.d.whyVerdict")));
24616
+ reason.appendChild(perspReasonCard(feature, spec, rr, driftEntry));
24617
+ frag.appendChild(reason);
24618
+ }
24619
+
24620
+ var contents = el("div", "p-sect");
24621
+ contents.appendChild(el("div", "p-slabel", t("perspectives.d.contents")));
24622
+ var ccard = el("div", "analysis-box");
24146
24623
  var dl = el("dl", "d-grid");
24147
24624
  function row(labelKey, valueNode) {
24148
24625
  dl.appendChild(el("dt", null, t(labelKey)));
@@ -24159,20 +24636,7 @@ const CLIENT_JS = `
24159
24636
  }
24160
24637
  if (spec.startScreen) row("perspectives.d.startScreen", spec.startScreen);
24161
24638
  if (spec.testCondition) row("perspectives.d.testCondition", spec.testCondition);
24162
- // The spec id stays: it is what a user types to re-run this case, and the
24163
- // table shows the title, never the id.
24164
- row("perspectives.d.spec", el("code", null, spec.specName));
24165
-
24166
- var rr = ledgerEntryFor(perspState.rerun, feature, spec);
24167
- if (rr) {
24168
- row(rerunEvidenceLabelKey(rr), rerunEvidenceValue(rr));
24169
- if (rerunHasFailure(rr)) row("perspectives.d.lastRed", rerunFailureValue(rr.lastRed));
24170
- }
24171
- frag.appendChild(dl);
24172
-
24173
24639
  if (spec.steps && spec.steps.length) {
24174
- var stepsBox = el("div", "steps-box");
24175
- stepsBox.appendChild(el("div", "slabel", t("perspectives.d.steps")));
24176
24640
  var stepsList = el("ol", "d-steps");
24177
24641
  spec.steps.forEach(function (step) {
24178
24642
  var li = el("li");
@@ -24186,30 +24650,14 @@ const CLIENT_JS = `
24186
24650
  }
24187
24651
  stepsList.appendChild(li);
24188
24652
  });
24189
- stepsBox.appendChild(stepsList);
24190
- frag.appendChild(stepsBox);
24191
- }
24192
-
24193
- // A standing attestation always offers revoke — even when it changed
24194
- // nothing (a held or machine-verified spec), it exists and must stay
24195
- // findable. A lapsed one is named right here, beside the attest button
24196
- // the reader is deciding whether to press.
24197
- var manualBtn = null;
24198
- if (rr && rr.manual) manualBtn = manualRevokeButton(feature, spec);
24199
- else if (rr && (rr.verdict === "needsRepair" || rr.verdict === "rerunNeeded")) manualBtn = manualAttestButton(feature, spec);
24200
- if (manualBtn) {
24201
- var manualBox = el("div", "manual-attest");
24202
- if (rr.manual && rr.verdict !== "manuallyVerified") {
24203
- manualBox.appendChild(el("div", "d-prose", manualAttestationText(rr.manual)));
24204
- }
24205
- var lapseLine = rerunManualLapseText(rr);
24206
- if (lapseLine) manualBox.appendChild(el("div", "d-prose", lapseLine));
24207
- manualBox.appendChild(manualBtn);
24208
- frag.appendChild(manualBox);
24653
+ row("perspectives.d.steps", stepsList);
24209
24654
  }
24655
+ ccard.appendChild(dl);
24656
+ contents.appendChild(ccard);
24657
+ frag.appendChild(contents);
24210
24658
 
24211
- var notebox = el("div", "notebox");
24212
- notebox.appendChild(el("div", "nlabel", t("perspectives.note.label")));
24659
+ var notebox = el("div", "notebox p-sect");
24660
+ notebox.appendChild(el("div", "p-slabel", t("perspectives.note.label")));
24213
24661
  var ta = el("textarea");
24214
24662
  ta.placeholder = t("perspectives.note.placeholder");
24215
24663
  ta.value = spec.note || "";
@@ -24218,8 +24666,8 @@ const CLIENT_JS = `
24218
24666
  var saveBtn = el("button", "btn primary", t("common.save"));
24219
24667
  saveBtn.type = "button";
24220
24668
  var statusEl = el("span", "nstatus");
24221
- nact.appendChild(saveBtn);
24222
24669
  nact.appendChild(statusEl);
24670
+ nact.appendChild(saveBtn);
24223
24671
  notebox.appendChild(nact);
24224
24672
  frag.appendChild(notebox);
24225
24673
 
@@ -24275,6 +24723,7 @@ const CLIENT_JS = `
24275
24723
 
24276
24724
  var titleTd = el("td", "c-title");
24277
24725
  titleTd.appendChild(document.createTextNode(spec.title));
24726
+ titleTd.appendChild(el("span", "c-id", perspSpecKey(feature, spec)));
24278
24727
  if (spec.summary) titleTd.appendChild(el("span", "csum", spec.summary));
24279
24728
  row.appendChild(titleTd);
24280
24729
 
@@ -24407,8 +24856,8 @@ const CLIENT_JS = `
24407
24856
  loadRerun().catch(function (err) {
24408
24857
  setPerspNote("persp-rerun-note", t("perspectives.rerun.loadFailed") + ": " + err.message, "warn");
24409
24858
  }),
24410
- // Evidence-only (the "audited at" line in the audit column) — a
24411
- // failed or unsupported fetch just omits that line, no banner.
24859
+ // A failed or unsupported fetch just omits the "audited at" line
24860
+ // and the reason card's finding detail, no banner.
24412
24861
  loadDrift().catch(function () {}),
24413
24862
  ]);
24414
24863
  })
@@ -24547,29 +24996,32 @@ const CLIENT_JS = `
24547
24996
  });
24548
24997
  }
24549
24998
 
24550
- // Loaded once per project open never re-run on a profile switch, since
24551
- // drift carries no profile (unlike loadRerun/reloadRerun below). Only the
24552
- // ledger's own coordinate (when a spec was last audited) survives into the
24553
- // view now its finding is superseded by the fresher, deploy-aware audit
24554
- // axis in the /rerun report — so there is nothing here worth a banner on
24555
- // an older or unreachable hub.
24999
+ // Loaded on project open and again on every reloadRerun: the reason card
25000
+ // joins rr.audit against this ledger's entry (runId/headline), so the two
25001
+ // must not drift apart after a dismissal or attestation. An older or
25002
+ // unreachable hub degrades to the axis alone not worth a banner.
24556
25003
  function loadDrift() {
24557
25004
  return fetch(driftPath(), { headers: { Authorization: "Bearer " + state.token } })
24558
25005
  .then(function (res) { return res.ok ? res.json() : null; }, function () { return null; })
24559
25006
  .then(function (report) {
24560
- perspState.drift = report || null;
25007
+ // A transient failure keeps the copy already loaded — blanking it
25008
+ // would drop the audit coordinates and the reason card's finding
25009
+ // for the rest of the session.
25010
+ if (report) perspState.drift = report;
24561
25011
  renderPerspectives();
24562
25012
  });
24563
25013
  }
24564
25014
 
24565
- // Switching profile re-asks only the profile-scoped question: the
24566
- // perspectives document itself is project-scoped and does not change, and
24567
- // neither does the run index loadRerun used to (wastefully) re-fetch.
25015
+ // Re-asks the rerun question and refreshes the drift ledger beside it (the
25016
+ // reason card reads both; see perspState.drift). The perspectives document
25017
+ // itself is project-scoped and does not change, and neither does the run
25018
+ // index loadRerun used to (wastefully) re-fetch.
24568
25019
  function reloadRerun() {
24569
25020
  perspState.rerun = null;
24570
25021
  setPerspNote("persp-rerun-note", "");
24571
25022
  setPerspDeployHead(null);
24572
25023
  renderPerspectives();
25024
+ loadDrift().catch(function () {});
24573
25025
  return loadRerun();
24574
25026
  }
24575
25027
 
@@ -25502,6 +25954,9 @@ function registerRoutes(router, config, queue) {
25502
25954
  router.get("/api/v1/projects/:project/attestations", createGetAttestationsHandler(storage));
25503
25955
  router.put("/api/v1/projects/:project/attestations", createPutAttestationHandler(storage));
25504
25956
  router.delete("/api/v1/projects/:project/attestations", createDeleteAttestationHandler(storage));
25957
+ router.get("/api/v1/projects/:project/audit-dismissals", createGetAuditDismissalsHandler(storage));
25958
+ router.put("/api/v1/projects/:project/audit-dismissals", createPutAuditDismissalHandler(storage));
25959
+ router.delete("/api/v1/projects/:project/audit-dismissals", createDeleteAuditDismissalHandler(storage));
25505
25960
  router.get("/api/v1/projects/:project/acks/:name", createGetAckHandler(storage));
25506
25961
  router.put("/api/v1/projects/:project/acks/:name", createPutAckHandler(storage));
25507
25962
  router.post("/api/v1/projects/:project/spend", createRecordSpendHandler(storage));
@@ -25794,6 +26249,9 @@ function specLocksPath(root, project, profile) {
25794
26249
  function attestationsPath(root, project, profile) {
25795
26250
  return join(root, "attestations", project, profile, "attestations.json");
25796
26251
  }
26252
+ function auditDismissalsPath(root, project) {
26253
+ return join(root, "audit-dismissals", `${project}.json`);
26254
+ }
25797
26255
  function ackPath(root, project, profile, name) {
25798
26256
  return join(root, "acks", project, profile, `${name}.json`);
25799
26257
  }
@@ -25851,6 +26309,22 @@ function createFileAttestationStore(root) {
25851
26309
  };
25852
26310
  }
25853
26311
  //#endregion
26312
+ //#region src/hub/core/storage/file/audit-dismissal-store.ts
26313
+ function toDismissals(doc) {
26314
+ const parsed = AuditDismissalsSchema.safeParse(doc);
26315
+ return parsed.success ? parsed.data : { specs: {} };
26316
+ }
26317
+ function createFileAuditDismissalStore(root) {
26318
+ return {
26319
+ async get(project) {
26320
+ return toDismissals(await readJson(auditDismissalsPath(root, project)));
26321
+ },
26322
+ async update(project, mutate) {
26323
+ return updateJson(auditDismissalsPath(root, project), (current) => mutate(toDismissals(current)));
26324
+ }
26325
+ };
26326
+ }
26327
+ //#endregion
25854
26328
  //#region src/hub/core/storage/file/artifact-store.ts
25855
26329
  /**
25856
26330
  * Defense-in-depth: `relPath` is expected to already be validated by the
@@ -26335,7 +26809,8 @@ function createFileHubStorage(dataDir) {
26335
26809
  locks: createFileLockStore(dataDir),
26336
26810
  acks: createFileAckStore(dataDir),
26337
26811
  spend: createFileSpendStore(dataDir),
26338
- attestations: createFileAttestationStore(dataDir)
26812
+ attestations: createFileAttestationStore(dataDir),
26813
+ auditDismissals: createFileAuditDismissalStore(dataDir)
26339
26814
  };
26340
26815
  }
26341
26816
  //#endregion