ccqa 1.32.0 → 1.33.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
@@ -7518,6 +7518,46 @@ z.object({
7518
7518
  specs: z.record(z.string(), AttestationSchema)
7519
7519
  });
7520
7520
  /**
7521
+ * A person's answer to one audit finding: the spec describes the code fine,
7522
+ * and the finding is wrong. Where an attestation speaks about the product,
7523
+ * this speaks about the *audit* — so it settles the audit axis rather than
7524
+ * the verdict, and the spec goes back to being run like any other.
7525
+ *
7526
+ * Pinned to the audit run whose finding it answers. A later audit is a new
7527
+ * observation of newer code, so it produces a new run and this stops
7528
+ * applying: the machine gets to raise the finding again, and the record of
7529
+ * the last dismissal is shown beside it rather than silently suppressing it.
7530
+ * No profile — an audit finding is about the repository, not an environment
7531
+ * (ADR-0013), which is also why this is scoped per project alone.
7532
+ */
7533
+ const AuditDismissalSchema = z.object({
7534
+ by: z.string().min(1),
7535
+ at: z.string(),
7536
+ note: z.string().min(1),
7537
+ auditRunId: z.string(),
7538
+ label: DriftLabelSchema,
7539
+ headline: z.string()
7540
+ });
7541
+ /** The per-project dismissal document: "feature/spec" → the last dismissal. */
7542
+ const AuditDismissalsSchema = z.object({ specs: z.record(z.string(), AuditDismissalSchema).default({}) });
7543
+ /** Body of `PUT /projects/:project/audit-dismissals`. */
7544
+ const PutAuditDismissalRequestSchema = z.object({
7545
+ spec: z.string().min(1).max(512),
7546
+ by: z.string().min(1).max(256),
7547
+ note: z.string().min(1).max(4e3)
7548
+ });
7549
+ /** Body of `DELETE /projects/:project/audit-dismissals`. */
7550
+ const DeleteAuditDismissalRequestSchema = z.object({ spec: z.string().min(1).max(512) });
7551
+ z.object({
7552
+ project: z.string(),
7553
+ spec: z.string(),
7554
+ dismissal: AuditDismissalSchema
7555
+ });
7556
+ z.object({
7557
+ project: z.string(),
7558
+ specs: z.record(z.string(), AuditDismissalSchema)
7559
+ });
7560
+ /**
7521
7561
  * One spec's verdict, the two axes it was derived from, and the three ledger
7522
7562
  * coordinates the view shows alongside them. The coordinates are always
7523
7563
  * present (null when the spec has no such entry); the optional fields appear
@@ -7533,6 +7573,8 @@ const SpecRerunSchema = z.object({
7533
7573
  execution: ExecutionStateSchema,
7534
7574
  driftLabel: DriftLabelSchema.exclude(["UNKNOWN"]).optional(),
7535
7575
  auditAssumedReached: RerunUnknownReasonSchema.optional(),
7576
+ auditDismissed: AuditDismissalSchema.optional(),
7577
+ auditDismissalApplied: z.boolean().optional(),
7536
7578
  executionAssumedReached: RerunUnknownReasonSchema.optional(),
7537
7579
  specChangedSince: z.string().optional(),
7538
7580
  manual: AttestationSchema.optional(),
@@ -9297,6 +9339,20 @@ function connect(opts) {
9297
9339
  error("hub token is required (--hub-token or CCQA_HUB_TOKEN)");
9298
9340
  process.exit(2);
9299
9341
  }
9342
+ /**
9343
+ * The canonical "feature/spec" key for a CLI argument. Hub records are stored
9344
+ * and looked up under exactly this form, so an alias accepted here but kept
9345
+ * verbatim would silently never match.
9346
+ */
9347
+ function requireSpecId(rawSpecId) {
9348
+ try {
9349
+ const parsed = parseSpecPath(rawSpecId);
9350
+ return `${parsed.featureName}/${parsed.specName}`;
9351
+ } catch (err) {
9352
+ error(errMessage(err));
9353
+ process.exit(2);
9354
+ }
9355
+ }
9300
9356
  function validateSessionName(name) {
9301
9357
  const parsed = SessionNameSchema.safeParse(name);
9302
9358
  if (!parsed.success) {
@@ -9594,14 +9650,7 @@ const pushCommand = new Command("push").description("Upload the report directory
9594
9650
  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
9651
  const project = resolveProject(opts);
9596
9652
  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
- }
9653
+ const specId = requireSpecId(rawSpecId);
9605
9654
  if (opts.revoke) {
9606
9655
  await hub.deleteAttestation(project, { profile: opts.profile }, specId);
9607
9656
  header("hub attest", `${specId} revoked`);
@@ -9621,7 +9670,30 @@ const attestCommand = new Command("attest").argument("<feature/spec>", "Spec id,
9621
9670
  meta("anchored to deploy", res.attestation.deployedSha ?? "(no deploy log)");
9622
9671
  info("the verdict answers manuallyVerified until a deploy reaches this spec or the spec is edited");
9623
9672
  }));
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);
9673
+ 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) => {
9674
+ const project = resolveProject(opts);
9675
+ const hub = connect(opts);
9676
+ const specId = requireSpecId(rawSpecId);
9677
+ if (opts.revoke) {
9678
+ await hub.deleteAuditDismissal(project, specId);
9679
+ header("hub dismiss", `${specId} revoked`);
9680
+ return;
9681
+ }
9682
+ if (!opts.by || !opts.reason) {
9683
+ error("--by <name> and --reason <text> are both required: a dismissal is a person's correction, and it needs the person and the correction");
9684
+ process.exit(2);
9685
+ }
9686
+ const res = await hub.putAuditDismissal(project, {
9687
+ spec: specId,
9688
+ by: opts.by,
9689
+ note: opts.reason
9690
+ });
9691
+ header("hub dismiss", specId);
9692
+ meta("by", res.dismissal.by);
9693
+ meta("dismissed", `${res.dismissal.label} — ${res.dismissal.headline || "(no headline)"}`);
9694
+ info("this finding no longer holds the spec back; a later audit can raise one of its own");
9695
+ }));
9696
+ 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
9697
  /** Loose check that a fetched session is agent-browser storage-state, mirroring loadStorageState. */
9626
9698
  function isStorageStateShape(state) {
9627
9699
  return typeof state === "object" && state !== null && Array.isArray(state.cookies) && Array.isArray(state.origins);
@@ -18929,7 +19001,7 @@ function createGetAuditNeedHandler(storage) {
18929
19001
  //#endregion
18930
19002
  //#region src/hub/api/handlers/locks.ts
18931
19003
  /** A spec-key list and three short strings; nothing here should approach this. */
18932
- const MAX_BODY_BYTES$4 = 1024 * 1024;
19004
+ const MAX_BODY_BYTES$5 = 1024 * 1024;
18933
19005
  /**
18934
19006
  * POST /api/v1/projects/:project/locks?profile=
18935
19007
  *
@@ -18943,7 +19015,7 @@ function createAcquireLocksHandler(storage) {
18943
19015
  return async (ctx) => {
18944
19016
  const project = requireSafeSegment(ctx.params.project, "project");
18945
19017
  const profile = requireProfileParam(ctx.url);
18946
- const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$4, AcquireLocksRequestSchema, "lock request");
19018
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$5, AcquireLocksRequestSchema, "lock request");
18947
19019
  let result = {
18948
19020
  granted: [],
18949
19021
  denied: []
@@ -18972,7 +19044,7 @@ function createReleaseLocksHandler(storage) {
18972
19044
  return async (ctx) => {
18973
19045
  const project = requireSafeSegment(ctx.params.project, "project");
18974
19046
  const profile = requireProfileParam(ctx.url);
18975
- const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$4, ReleaseLocksRequestSchema, "release request");
19047
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$5, ReleaseLocksRequestSchema, "release request");
18976
19048
  await storage.locks.update(project, profile, (current) => releaseAll(current, body.holder));
18977
19049
  ctx.res.writeHead(204).end();
18978
19050
  };
@@ -18985,7 +19057,7 @@ function createReleaseLocksHandler(storage) {
18985
19057
  * 5000 keys of 256 `\uXXXX`-escaped characters — so a conforming client is
18986
19058
  * never answered 413 by a limit the documented bounds don't mention.
18987
19059
  */
18988
- const MAX_BODY_BYTES$3 = 8 * 1024 * 1024;
19060
+ const MAX_BODY_BYTES$4 = 8 * 1024 * 1024;
18989
19061
  function requireAckKey(ctx) {
18990
19062
  return {
18991
19063
  project: requireSafeSegment(ctx.params.project, "project"),
@@ -19012,7 +19084,7 @@ function createGetAckHandler(storage) {
19012
19084
  function createPutAckHandler(storage) {
19013
19085
  return async (ctx) => {
19014
19086
  const key = requireAckKey(ctx);
19015
- const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$3, PutAckRequestSchema, "ack body");
19087
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$4, PutAckRequestSchema, "ack body");
19016
19088
  const ack = await storage.acks.put(key.project, key.profile, key.name, body.keys);
19017
19089
  sendJson(ctx.res, 200, {
19018
19090
  ...key,
@@ -19023,7 +19095,7 @@ function createPutAckHandler(storage) {
19023
19095
  //#endregion
19024
19096
  //#region src/hub/api/handlers/attestations.ts
19025
19097
  /** Far above the largest body `PutAttestationRequestSchema`'s bounds admit. */
19026
- const MAX_BODY_BYTES$2 = 64 * 1024;
19098
+ const MAX_BODY_BYTES$3 = 64 * 1024;
19027
19099
  function requireScope(ctx) {
19028
19100
  return {
19029
19101
  project: requireSafeSegment(ctx.params.project, "project"),
@@ -19056,7 +19128,7 @@ function createPutAttestationHandler(storage) {
19056
19128
  return async (ctx) => {
19057
19129
  const scope = requireScope(ctx);
19058
19130
  const [body, head, targets] = await Promise.all([
19059
- readJsonBody(ctx.req, MAX_BODY_BYTES$2, PutAttestationRequestSchema, "attestation body"),
19131
+ readJsonBody(ctx.req, MAX_BODY_BYTES$3, PutAttestationRequestSchema, "attestation body"),
19060
19132
  storage.deploys.head(scope.project, scope.profile),
19061
19133
  requireSpecTargets(storage.perspectives, scope.project, "what can be attested")
19062
19134
  ]);
@@ -19086,7 +19158,7 @@ function createPutAttestationHandler(storage) {
19086
19158
  function createDeleteAttestationHandler(storage) {
19087
19159
  return async (ctx) => {
19088
19160
  const scope = requireScope(ctx);
19089
- const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$2, DeleteAttestationRequestSchema, "attestation body");
19161
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$3, DeleteAttestationRequestSchema, "attestation body");
19090
19162
  await storage.attestations.update(scope.project, scope.profile, (current) => {
19091
19163
  const { [body.spec]: _, ...rest } = current.specs;
19092
19164
  return { specs: rest };
@@ -19095,6 +19167,83 @@ function createDeleteAttestationHandler(storage) {
19095
19167
  };
19096
19168
  }
19097
19169
  //#endregion
19170
+ //#region src/hub/api/handlers/audit-dismissals.ts
19171
+ /** Far above the largest body `PutAuditDismissalRequestSchema`'s bounds admit. */
19172
+ const MAX_BODY_BYTES$2 = 64 * 1024;
19173
+ /**
19174
+ * GET /api/v1/projects/:project/audit-dismissals — the raw document,
19175
+ * whether or not each entry still answers the spec's current finding. No
19176
+ * `?profile=`: a finding is about the repository (see `AuditDismissalSchema`).
19177
+ */
19178
+ function createGetAuditDismissalsHandler(storage) {
19179
+ return async (ctx) => {
19180
+ const project = requireSafeSegment(ctx.params.project, "project");
19181
+ const doc = await storage.auditDismissals.get(project);
19182
+ sendJson(ctx.res, 200, {
19183
+ project,
19184
+ specs: doc.specs
19185
+ });
19186
+ };
19187
+ }
19188
+ /**
19189
+ * PUT /api/v1/projects/:project/audit-dismissals — record that a person
19190
+ * judged the spec's current audit finding wrong.
19191
+ *
19192
+ * The finding being answered is read from the ledger rather than taken from
19193
+ * the caller: a dismissal must name the run and the label it answers, and
19194
+ * only the hub knows which finding is current. A spec with no open finding is
19195
+ * a 400 — there is nothing to dismiss, and accepting it would write a record
19196
+ * that never applies to anything.
19197
+ *
19198
+ * The guard stops there on purpose. `/rerun` applies a dismissal only while
19199
+ * the audit is also *current* for the profile being asked about, and that is
19200
+ * a per-profile question this endpoint has no profile to ask it of (a finding
19201
+ * is about the repository, so the dismissal is project-scoped). A dismissal
19202
+ * written while a deploy has overtaken the audit is harmless: the next audit
19203
+ * supersedes the finding, and the record with it.
19204
+ */
19205
+ function createPutAuditDismissalHandler(storage) {
19206
+ return async (ctx) => {
19207
+ const project = requireSafeSegment(ctx.params.project, "project");
19208
+ const [body, ledger] = await Promise.all([readJsonBody(ctx.req, MAX_BODY_BYTES$2, PutAuditDismissalRequestSchema, "dismissal body"), storage.driftLedger.getMerged(project)]);
19209
+ const entry = ledger.specs[body.spec];
19210
+ 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`);
19211
+ const dismissal = {
19212
+ by: body.by,
19213
+ at: (/* @__PURE__ */ new Date()).toISOString(),
19214
+ note: body.note,
19215
+ auditRunId: entry.runId,
19216
+ label: entry.label,
19217
+ headline: entry.headline ?? ""
19218
+ };
19219
+ await storage.auditDismissals.update(project, (current) => ({ specs: {
19220
+ ...current.specs,
19221
+ [body.spec]: dismissal
19222
+ } }));
19223
+ sendJson(ctx.res, 200, {
19224
+ project,
19225
+ spec: body.spec,
19226
+ dismissal
19227
+ });
19228
+ };
19229
+ }
19230
+ /**
19231
+ * DELETE /api/v1/projects/:project/audit-dismissals — withdraw a dismissal,
19232
+ * putting the audit's finding back in force. Deleting one that does not exist
19233
+ * is 200: the caller asked for its absence, and it is absent.
19234
+ */
19235
+ function createDeleteAuditDismissalHandler(storage) {
19236
+ return async (ctx) => {
19237
+ const project = requireSafeSegment(ctx.params.project, "project");
19238
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$2, DeleteAuditDismissalRequestSchema, "dismissal body");
19239
+ await storage.auditDismissals.update(project, (current) => {
19240
+ const { [body.spec]: _, ...rest } = current.specs;
19241
+ return { specs: rest };
19242
+ });
19243
+ sendJson(ctx.res, 200, { removed: body.spec });
19244
+ };
19245
+ }
19246
+ //#endregion
19098
19247
  //#region src/hub/api/handlers/spend.ts
19099
19248
  /** One entry is a handful of short fields; anything larger is a malformed client. */
19100
19249
  const MAX_BODY_BYTES$1 = 4 * 1024;
@@ -19167,7 +19316,7 @@ function specMovedSince(changedAt, baselineSha, baselineAt, deployTimes) {
19167
19316
  return changedAt > (baselineSha && deployTimes.get(baselineSha) || baselineAt) ? changedAt : null;
19168
19317
  }
19169
19318
  function computeRerun(input) {
19170
- const { specs, ledger, log, touchIndex, drift, locks, attestations, now } = input;
19319
+ const { specs, ledger, log, touchIndex, drift, locks, attestations, dismissals, now } = input;
19171
19320
  const range = buildRange(log, touchIndex);
19172
19321
  const deployTimes = deployedAt(log);
19173
19322
  const out = {};
@@ -19180,6 +19329,9 @@ function computeRerun(input) {
19180
19329
  let audit = auditState(drift, spec.key, range);
19181
19330
  let execution = executionState(coords, (sha) => freshness(sha, spec.key, range));
19182
19331
  const driftEntry = drift.specs[spec.key];
19332
+ const dismissal = dismissals.specs[spec.key];
19333
+ const dismissed = dismissal !== void 0 && driftEntry !== void 0 && dismissal.auditRunId === driftEntry.runId && dismissal.label === driftEntry.label && (audit.audit === "drifted" || audit.audit === "undecided");
19334
+ if (dismissed) audit = { audit: "clean" };
19183
19335
  const auditMoved = specMovedSince(spec.changedAt, driftEntry?.gitHead ?? null, driftEntry?.at ?? "", deployTimes);
19184
19336
  const runMoved = specMovedSince(spec.changedAt, coords.lastRun?.deployedSha ?? null, coords.lastRun?.at ?? "", deployTimes);
19185
19337
  if (auditMoved && audit.audit !== "due") audit = { audit: "due" };
@@ -19193,6 +19345,10 @@ function computeRerun(input) {
19193
19345
  ...auditMoved || runMoved ? { specChangedSince: auditMoved ?? runMoved } : {},
19194
19346
  ...audit,
19195
19347
  ...execution,
19348
+ ...dismissal ? {
19349
+ auditDismissed: dismissal,
19350
+ auditDismissalApplied: dismissed
19351
+ } : {},
19196
19352
  ...manualState?.kind === "covers" ? { manual: manualState.attest } : {},
19197
19353
  ...manualState?.kind === "lapsed" ? {
19198
19354
  manualLapsed: {
@@ -19356,14 +19512,15 @@ function createGetRerunHandler(storage) {
19356
19512
  return async (ctx) => {
19357
19513
  const project = requireSafeSegment(ctx.params.project, "project");
19358
19514
  const profile = requireProfileParam(ctx.url);
19359
- const [specs, ledger, log, touchIndex, drift, locks, attestations] = await Promise.all([
19515
+ const [specs, ledger, log, touchIndex, drift, locks, attestations, dismissals] = await Promise.all([
19360
19516
  requireSpecTargets(storage.perspectives, project, "which specs need a re-run"),
19361
19517
  storage.ledger.getMerged(project, profile),
19362
19518
  storage.deploys.getLog(project, profile),
19363
19519
  storage.deploys.getTouchIndex(project, profile),
19364
19520
  storage.driftLedger.getMerged(project),
19365
19521
  storage.locks.get(project, profile),
19366
- storage.attestations.get(project, profile)
19522
+ storage.attestations.get(project, profile),
19523
+ storage.auditDismissals.get(project)
19367
19524
  ]);
19368
19525
  const head = log.entries[log.entries.length - 1];
19369
19526
  sendJson(ctx.res, 200, {
@@ -19378,6 +19535,7 @@ function createGetRerunHandler(storage) {
19378
19535
  drift,
19379
19536
  locks,
19380
19537
  attestations,
19538
+ dismissals,
19381
19539
  now: /* @__PURE__ */ new Date()
19382
19540
  })
19383
19541
  });
@@ -20941,6 +21099,12 @@ const CSS = `
20941
21099
  .notebox .nstatus { font-size: 12px; color: var(--muted); }
20942
21100
  .notebox .nstatus.ok { color: var(--pass); }
20943
21101
  .notebox .nstatus.err { color: var(--fail); }
21102
+ /* The inline form an audit-dismissal or environment-attestation button
21103
+ expands into, in place of the two window.prompt() calls this replaces. */
21104
+ .override-form { margin-top: 10px; max-width: 480px; display: flex; flex-direction: column; gap: 10px; }
21105
+ .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; }
21106
+ .override-form .of-act { display: flex; align-items: center; gap: 8px; }
21107
+ .override-form .of-hint { font-size: 12px; color: var(--muted); max-width: 62ch; line-height: 1.5; }
20944
21108
 
20945
21109
  @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
21110
  @media (max-width: 700px) { .prompt-grid { grid-template-columns: 1fr; } .prompt-diff { grid-template-columns: 1fr; } }
@@ -21113,10 +21277,8 @@ const CLIENT_JS = `
21113
21277
  "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
21278
  "perspectives.rerun.deployHead": "deploy head",
21115
21279
  "perspectives.drift.graded": "confirmed",
21116
- "perspectives.manual.attestButton": "Mark as manually verified",
21117
21280
  "perspectives.manual.revokeButton": "Revoke manual verification",
21118
- "perspectives.manual.promptBy": "Who is verifying this?",
21119
- "perspectives.manual.promptNote": "Note (optional)",
21281
+ "perspectives.manual.envButton": "If the environment issue is resolved, use this",
21120
21282
  "perspectives.manual.confirmRevoke": "Revoke the manual verification for this case?",
21121
21283
  "perspectives.manual.error": "Could not save — retry",
21122
21284
  "perspectives.manual.verifiedBy": "{by} manually verified this ({at})",
@@ -21126,6 +21288,18 @@ const CLIENT_JS = `
21126
21288
  "perspectives.manual.lapsed.specEdited": "the manual verification lapsed when the spec was edited",
21127
21289
  "perspectives.manual.lapsed.newerRed": "the manual verification lapsed after a later run failed",
21128
21290
  "perspectives.manual.lapsed.unrecognized": "the manual verification lapsed for a reason this UI does not recognise",
21291
+ "perspectives.dismiss.offerButton": "If the test spec was fine, use this",
21292
+ "perspectives.dismiss.revokeButton": "Undo the dismissal",
21293
+ "perspectives.dismiss.confirmRevoke": "Undo the dismissal for this case?",
21294
+ "perspectives.dismiss.activeNote": "Audit finding “{headline}” was dismissed by {by} as a false positive ({at}) — “{note}”. The next run will settle it.",
21295
+ "perspectives.dismiss.priorNote": "This finding was previously dismissed by {by} ({at}) — “{note}”.",
21296
+ "perspectives.override.byLabel": "Verified by",
21297
+ "perspectives.override.reasonLabel": "Reason (required)",
21298
+ "perspectives.override.noteLabel": "What was resolved, and how you checked (required)",
21299
+ "perspectives.override.submit": "Record",
21300
+ "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.",
21129
21303
  "prompt.card.record": "Recording browser actions",
21130
21304
  "prompt.card.live": "Live run (AI-driven)",
21131
21305
  "prompt.card.playwright": "Playwright test generation",
@@ -21293,10 +21467,8 @@ const CLIENT_JS = `
21293
21467
  "perspectives.rerun.noDeployLogBanner": "プロファイル {profile} にデプロイの記録がないため、どのケースも判定できません。この環境のデプロイジョブに ccqa hub deploy record を組み込んでください。",
21294
21468
  "perspectives.rerun.deployHead": "最新デプロイ",
21295
21469
  "perspectives.drift.graded": "人が確認",
21296
- "perspectives.manual.attestButton": "手動で確認した",
21297
21470
  "perspectives.manual.revokeButton": "手動確認を取り消す",
21298
- "perspectives.manual.promptBy": "確認者名を入力してください",
21299
- "perspectives.manual.promptNote": "メモ(任意)",
21471
+ "perspectives.manual.envButton": "環境要因が解消した場合はこちら",
21300
21472
  "perspectives.manual.confirmRevoke": "このケースの手動確認を取り消しますか?",
21301
21473
  "perspectives.manual.error": "保存に失敗しました — 再試行してください",
21302
21474
  "perspectives.manual.verifiedBy": "{by}さんが手動確認({at})",
@@ -21306,6 +21478,18 @@ const CLIENT_JS = `
21306
21478
  "perspectives.manual.lapsed.specEdited": "手動確認はspecの編集により失効",
21307
21479
  "perspectives.manual.lapsed.newerRed": "手動確認は直後の実行失敗により失効",
21308
21480
  "perspectives.manual.lapsed.unrecognized": "このUIが認識できない理由により手動確認が失効",
21481
+ "perspectives.dismiss.offerButton": "テスト仕様に問題がなかった場合はこちら",
21482
+ "perspectives.dismiss.revokeButton": "棄却を取り消す",
21483
+ "perspectives.dismiss.confirmRevoke": "このケースの棄却を取り消しますか?",
21484
+ "perspectives.dismiss.activeNote": "監査指摘「{headline}」は{by}が誤検知として棄却({at})—「{note}」。次の実行が裁定します。",
21485
+ "perspectives.dismiss.priorNote": "前回この指摘は{by}が棄却しています({at})—「{note}」",
21486
+ "perspectives.override.byLabel": "確認した人",
21487
+ "perspectives.override.reasonLabel": "理由(必須)",
21488
+ "perspectives.override.noteLabel": "解消と確認の内容(必須)",
21489
+ "perspectives.override.submit": "記録する",
21490
+ "perspectives.override.cancel": "やめる",
21491
+ "perspectives.override.dismissHint": "監査の指摘を上書きして台帳を閉じます。判定は「要再実行」に移り、次の実行が正否を裁定します。",
21492
+ "perspectives.override.envHint": "失敗の記録は残したまま、判定は再実行を待たず「手動確認済み」になります。次のデプロイがこの spec に届くと失効します。",
21309
21493
  "prompt.card.record": "ブラウザ操作の記録",
21310
21494
  "prompt.card.live": "ライブ実行(AI操作)",
21311
21495
  "prompt.card.playwright": "Playwrightテスト生成",
@@ -23403,6 +23587,13 @@ const CLIENT_JS = `
23403
23587
  "/attestations?profile=" + encodeURIComponent(state.profile);
23404
23588
  }
23405
23589
 
23590
+ // A person's answer to an audit finding, not an environment: the finding is
23591
+ // about the repository, not a deployed profile, so this carries no
23592
+ // ?profile= (unlike attestationsPath above).
23593
+ function auditDismissalsPath() {
23594
+ return "/api/v1/projects/" + encodeURIComponent(state.project) + "/audit-dismissals";
23595
+ }
23596
+
23406
23597
  // Resolves { report } or { note } and never rejects: a hub that predates
23407
23598
  // the endpoint costs only the columns it feeds, not the whole tab. A 404
23408
23599
  // here can only mean "no such route" — the endpoint's own 404 is "the
@@ -24056,28 +24247,80 @@ const CLIENT_JS = `
24056
24247
  }
24057
24248
  // --- end pure: rerun detail labels ----------------------------------------
24058
24249
 
24250
+ // --- pure: audit dismissal reading ----------------------------------------
24251
+ // Self-contained (no DOM, no closures) for the same reason as the regions
24252
+ // above: read rr.auditDismissed against rr.audit, per the schema's own
24253
+ // comment on the field. "clean" means the dismissal is what is holding the
24254
+ // axis there; "drifted"/"undecided" means a later audit re-raised what it
24255
+ // answered, so the old dismissal no longer covers it.
24256
+ // The audit has something outstanding on this spec. Both values mean the
24257
+ // same thing to a reader deciding whether to answer it: the audit read the
24258
+ // code and did not clear the spec (ADR-0019).
24259
+ function auditOpen(rr) {
24260
+ return !!rr && (rr.audit === "drifted" || rr.audit === "undecided");
24261
+ }
24262
+ function auditDismissalActive(rr) {
24263
+ // The hub says whether the dismissal settled the axis. Inferring it from
24264
+ // "clean" would credit the person for a later audit clearing the spec on
24265
+ // its own, which reads identically here.
24266
+ return !!(rr && rr.auditDismissed && rr.auditDismissalApplied);
24267
+ }
24268
+ function auditDismissalReflagged(rr) {
24269
+ return !!(rr && rr.auditDismissed && auditOpen(rr));
24270
+ }
24271
+ // --- end pure: audit dismissal reading -------------------------------------
24272
+
24273
+ // The dismissal's own words, read against the current audit state: active,
24274
+ // it explains why the axis reads clean; re-flagged, it is a fact worth
24275
+ // keeping visible beside the finding that reopened it.
24276
+ function rerunDismissalLine(rr) {
24277
+ if (!rr || !rr.auditDismissed) return null;
24278
+ var d = rr.auditDismissed;
24279
+ if (auditDismissalActive(rr)) {
24280
+ return {
24281
+ muted: false,
24282
+ text: t("perspectives.dismiss.activeNote")
24283
+ .replace("{headline}", d.headline).replace("{by}", d.by).replace("{at}", relTime(d.at)).replace("{note}", d.note),
24284
+ };
24285
+ }
24286
+ if (auditDismissalReflagged(rr)) {
24287
+ return {
24288
+ muted: true,
24289
+ text: t("perspectives.dismiss.priorNote")
24290
+ .replace("{by}", d.by).replace("{at}", relTime(d.at)).replace("{note}", d.note),
24291
+ };
24292
+ }
24293
+ return null;
24294
+ }
24295
+
24059
24296
  // The evidence behind the verdict, as the value of whichever row
24060
24297
  // rerunEvidenceLabelKey chose. For needed/notNeeded that is what the deploy
24061
24298
  // log holds since this case last ran, named by rerunChangeLine.
24062
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.
24063
24303
  function rerunEvidenceValue(rr) {
24064
24304
  var wrap = el("div");
24065
24305
  if (!rerunHasEvidence(rr)) {
24066
24306
  wrap.appendChild(el("div", "d-prose", rerunWhyVerdict(rr)));
24067
- return wrap;
24068
- }
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));
24307
+ } else {
24308
+ // Both states require a non-empty deploy log, so a head-less report
24309
+ // contradicts itself; rerunChangeLine then names what is missing rather
24310
+ // than inventing a baseline.
24311
+ var line = rerunChangeLine(rr, perspState.rerun && perspState.rerun.deployHead);
24312
+ var text = t(line.key).replace("{sha}", shortSha(line.sha));
24313
+ if (line.at) text += " · " + relTime(line.at);
24314
+ wrap.appendChild(el("div", "d-prose", text));
24315
+ // A touch the index proved but cannot enumerate leaves no paths to
24316
+ // list; the line above still says a change landed, which is all that
24317
+ // is known.
24318
+ if (rr.verdict === "rerunNeeded" && rr.touchedBy && rr.touchedBy.length) {
24319
+ wrap.appendChild(pathCodes(rr.touchedBy));
24320
+ }
24080
24321
  }
24322
+ var dismissLine = rerunDismissalLine(rr);
24323
+ if (dismissLine) wrap.appendChild(el("div", "d-prose" + (dismissLine.muted ? " muted" : ""), dismissLine.text));
24081
24324
  return wrap;
24082
24325
  }
24083
24326
 
@@ -24096,9 +24339,7 @@ const CLIENT_JS = `
24096
24339
  return wrap;
24097
24340
  }
24098
24341
 
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()
24342
+ // Lets a person's own check stand in for the machine's verdict. reloadRerun()
24102
24343
  // is the same profile-scoped refresh a profile switch uses — it re-renders
24103
24344
  // the whole table, so an open detail panel closes along with it.
24104
24345
  function submitAttestation(method, body) {
@@ -24110,17 +24351,16 @@ const CLIENT_JS = `
24110
24351
  .catch(function (err) { window.alert(t("perspectives.manual.error") + ": " + err.message); });
24111
24352
  }
24112
24353
 
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;
24354
+ // Lets a person say an audit finding was wrong. Same reload contract as
24355
+ // submitAttestation above; a different endpoint (no ?profile=, ADR: a
24356
+ // finding is about the repository).
24357
+ function submitAuditDismissal(method, body) {
24358
+ apiFetch(auditDismissalsPath(), {
24359
+ method: method,
24360
+ headers: { "Content-Type": "application/json" },
24361
+ body: JSON.stringify(body),
24362
+ }).then(function () { reloadRerun(); })
24363
+ .catch(function (err) { window.alert(t("perspectives.manual.error") + ": " + err.message); });
24124
24364
  }
24125
24365
 
24126
24366
  function manualRevokeButton(feature, spec) {
@@ -24133,6 +24373,120 @@ const CLIENT_JS = `
24133
24373
  return btn;
24134
24374
  }
24135
24375
 
24376
+ function auditDismissalRevokeButton(feature, spec) {
24377
+ var btn = el("button", "btn ghost sm del", t("perspectives.dismiss.revokeButton"));
24378
+ btn.type = "button";
24379
+ btn.addEventListener("click", function () {
24380
+ if (!window.confirm(t("perspectives.dismiss.confirmRevoke"))) return;
24381
+ submitAuditDismissal("DELETE", { spec: perspSpecKey(feature, spec) });
24382
+ });
24383
+ return btn;
24384
+ }
24385
+
24386
+ // The inline form a "dismiss" or "environment" offer button expands into,
24387
+ // in place of the two window.prompt() calls this replaces. Both kinds ask
24388
+ // for the same two things — who, and why — and differ only in wording and
24389
+ // which endpoint the answer goes to.
24390
+ function buildOverrideForm(kind, feature, spec, onCancel) {
24391
+ var wrap = el("div", "override-form");
24392
+
24393
+ var byRow = el("div", "form-row");
24394
+ byRow.appendChild(el("label", null, t("perspectives.override.byLabel")));
24395
+ var byInput = el("input", "input");
24396
+ byInput.type = "text";
24397
+ byInput.value = loadAttestBy();
24398
+ byRow.appendChild(byInput);
24399
+ wrap.appendChild(byRow);
24400
+
24401
+ var noteRow = el("div", "form-row");
24402
+ noteRow.appendChild(el("label", null, t(kind === "dismiss" ? "perspectives.override.reasonLabel" : "perspectives.override.noteLabel")));
24403
+ var noteInput = el("textarea");
24404
+ noteRow.appendChild(noteInput);
24405
+ wrap.appendChild(noteRow);
24406
+
24407
+ var act = el("div", "of-act");
24408
+ var submitBtn = el("button", "btn sm primary", t("perspectives.override.submit"));
24409
+ submitBtn.type = "button";
24410
+ submitBtn.disabled = true;
24411
+ var cancelBtn = el("button", "btn ghost sm", t("perspectives.override.cancel"));
24412
+ cancelBtn.type = "button";
24413
+ act.appendChild(submitBtn);
24414
+ act.appendChild(cancelBtn);
24415
+ wrap.appendChild(act);
24416
+
24417
+ wrap.appendChild(el("div", "of-hint", t(kind === "dismiss" ? "perspectives.override.dismissHint" : "perspectives.override.envHint")));
24418
+
24419
+ function syncEnabled() {
24420
+ submitBtn.disabled = !(byInput.value.trim() && noteInput.value.trim());
24421
+ }
24422
+ byInput.addEventListener("input", syncEnabled);
24423
+ noteInput.addEventListener("input", syncEnabled);
24424
+ cancelBtn.addEventListener("click", onCancel);
24425
+
24426
+ submitBtn.addEventListener("click", function () {
24427
+ var by = byInput.value.trim();
24428
+ var note = noteInput.value.trim();
24429
+ if (!by || !note) return;
24430
+ storeAttestBy(by);
24431
+ if (kind === "dismiss") submitAuditDismissal("PUT", { spec: perspSpecKey(feature, spec), by: by, note: note });
24432
+ else submitAttestation("PUT", { spec: perspSpecKey(feature, spec), by: by, note: note });
24433
+ });
24434
+
24435
+ return wrap;
24436
+ }
24437
+
24438
+ // A button that expands into buildOverrideForm above in place, rather than
24439
+ // a modal — the action is rare enough that swapping the button for its own
24440
+ // form reads fine without one.
24441
+ function buildOverrideOffer(kind, feature, spec) {
24442
+ var box = el("div", "manual-attest");
24443
+ var openBtn = el("button", "btn sm primary", t(kind === "dismiss" ? "perspectives.dismiss.offerButton" : "perspectives.manual.envButton"));
24444
+ openBtn.type = "button";
24445
+ box.appendChild(openBtn);
24446
+ openBtn.addEventListener("click", function () {
24447
+ box.removeChild(openBtn);
24448
+ var form = buildOverrideForm(kind, feature, spec, function () {
24449
+ box.removeChild(form);
24450
+ box.appendChild(openBtn);
24451
+ });
24452
+ box.appendChild(form);
24453
+ });
24454
+ return box;
24455
+ }
24456
+
24457
+ // The audit-axis override slot: dismiss an open finding, or revoke a
24458
+ // dismissal that is currently the reason the axis reads clean. At most one
24459
+ // of the two ever shows — a finding the axis
24460
+ // itself has cleared, dismissed or not, offers nothing here.
24461
+ function auditOverrideBox(feature, spec, rr) {
24462
+ if (auditDismissalActive(rr)) {
24463
+ var box = el("div", "manual-attest");
24464
+ box.appendChild(auditDismissalRevokeButton(feature, spec));
24465
+ return box;
24466
+ }
24467
+ if (auditOpen(rr)) return buildOverrideOffer("dismiss", feature, spec);
24468
+ return null;
24469
+ }
24470
+
24471
+ // The execution-axis override slot: revoke a standing attestation, or offer
24472
+ // one for an environment-caused failure — but only when the audit axis has
24473
+ // no open finding of its own, which is auditOverrideBox's problem to answer,
24474
+ // not this one's.
24475
+ function executionOverrideBox(feature, spec, rr) {
24476
+ if (rr.manual) {
24477
+ var box = el("div", "manual-attest");
24478
+ if (rr.verdict !== "manuallyVerified") box.appendChild(el("div", "d-prose", manualAttestationText(rr.manual)));
24479
+ box.appendChild(manualRevokeButton(feature, spec));
24480
+ return box;
24481
+ }
24482
+ // Only once the audit has actually cleared the spec: while it is still
24483
+ // due, nobody knows yet whether the environment was the only thing wrong.
24484
+ if (rr.audit === "clean" && rr.execution === "failed" && rr.lastRed && rr.lastRed.label === "ENVIRONMENT") {
24485
+ return buildOverrideOffer("environment", feature, spec);
24486
+ }
24487
+ return null;
24488
+ }
24489
+
24136
24490
  // Detail row: a definition list of the case's fields plus the note editor.
24137
24491
  // Built with createElement/textContent throughout — every field here is
24138
24492
  // API-derived, so none of it may go through innerHTML.
@@ -24190,22 +24544,14 @@ const CLIENT_JS = `
24190
24544
  frag.appendChild(stepsBox);
24191
24545
  }
24192
24546
 
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);
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);
24209
24555
  }
24210
24556
 
24211
24557
  var notebox = el("div", "notebox");
@@ -25502,6 +25848,9 @@ function registerRoutes(router, config, queue) {
25502
25848
  router.get("/api/v1/projects/:project/attestations", createGetAttestationsHandler(storage));
25503
25849
  router.put("/api/v1/projects/:project/attestations", createPutAttestationHandler(storage));
25504
25850
  router.delete("/api/v1/projects/:project/attestations", createDeleteAttestationHandler(storage));
25851
+ router.get("/api/v1/projects/:project/audit-dismissals", createGetAuditDismissalsHandler(storage));
25852
+ router.put("/api/v1/projects/:project/audit-dismissals", createPutAuditDismissalHandler(storage));
25853
+ router.delete("/api/v1/projects/:project/audit-dismissals", createDeleteAuditDismissalHandler(storage));
25505
25854
  router.get("/api/v1/projects/:project/acks/:name", createGetAckHandler(storage));
25506
25855
  router.put("/api/v1/projects/:project/acks/:name", createPutAckHandler(storage));
25507
25856
  router.post("/api/v1/projects/:project/spend", createRecordSpendHandler(storage));
@@ -25794,6 +26143,9 @@ function specLocksPath(root, project, profile) {
25794
26143
  function attestationsPath(root, project, profile) {
25795
26144
  return join(root, "attestations", project, profile, "attestations.json");
25796
26145
  }
26146
+ function auditDismissalsPath(root, project) {
26147
+ return join(root, "audit-dismissals", `${project}.json`);
26148
+ }
25797
26149
  function ackPath(root, project, profile, name) {
25798
26150
  return join(root, "acks", project, profile, `${name}.json`);
25799
26151
  }
@@ -25851,6 +26203,22 @@ function createFileAttestationStore(root) {
25851
26203
  };
25852
26204
  }
25853
26205
  //#endregion
26206
+ //#region src/hub/core/storage/file/audit-dismissal-store.ts
26207
+ function toDismissals(doc) {
26208
+ const parsed = AuditDismissalsSchema.safeParse(doc);
26209
+ return parsed.success ? parsed.data : { specs: {} };
26210
+ }
26211
+ function createFileAuditDismissalStore(root) {
26212
+ return {
26213
+ async get(project) {
26214
+ return toDismissals(await readJson(auditDismissalsPath(root, project)));
26215
+ },
26216
+ async update(project, mutate) {
26217
+ return updateJson(auditDismissalsPath(root, project), (current) => mutate(toDismissals(current)));
26218
+ }
26219
+ };
26220
+ }
26221
+ //#endregion
25854
26222
  //#region src/hub/core/storage/file/artifact-store.ts
25855
26223
  /**
25856
26224
  * Defense-in-depth: `relPath` is expected to already be validated by the
@@ -26335,7 +26703,8 @@ function createFileHubStorage(dataDir) {
26335
26703
  locks: createFileLockStore(dataDir),
26336
26704
  acks: createFileAckStore(dataDir),
26337
26705
  spend: createFileSpendStore(dataDir),
26338
- attestations: createFileAttestationStore(dataDir)
26706
+ attestations: createFileAttestationStore(dataDir),
26707
+ auditDismissals: createFileAuditDismissalStore(dataDir)
26339
26708
  };
26340
26709
  }
26341
26710
  //#endregion
@@ -237,6 +237,39 @@ declare const AttestationsResponseSchema: z.ZodObject<{
237
237
  }, z.core.$strip>>;
238
238
  }, z.core.$strip>;
239
239
  type AttestationsResponse = z.infer<typeof AttestationsResponseSchema>;
240
+ declare const AuditDismissalResponseSchema: z.ZodObject<{
241
+ project: z.ZodString;
242
+ spec: z.ZodString;
243
+ dismissal: z.ZodObject<{
244
+ by: z.ZodString;
245
+ at: z.ZodString;
246
+ note: z.ZodString;
247
+ auditRunId: z.ZodString;
248
+ label: z.ZodEnum<{
249
+ TEST_DRIFT: "TEST_DRIFT";
250
+ SPEC_CHANGE: "SPEC_CHANGE";
251
+ UNKNOWN: "UNKNOWN";
252
+ }>;
253
+ headline: z.ZodString;
254
+ }, z.core.$strip>;
255
+ }, z.core.$strip>;
256
+ type AuditDismissalResponse = z.infer<typeof AuditDismissalResponseSchema>;
257
+ declare const AuditDismissalsResponseSchema: z.ZodObject<{
258
+ project: z.ZodString;
259
+ specs: z.ZodRecord<z.ZodString, z.ZodObject<{
260
+ by: z.ZodString;
261
+ at: z.ZodString;
262
+ note: z.ZodString;
263
+ auditRunId: z.ZodString;
264
+ label: z.ZodEnum<{
265
+ TEST_DRIFT: "TEST_DRIFT";
266
+ SPEC_CHANGE: "SPEC_CHANGE";
267
+ UNKNOWN: "UNKNOWN";
268
+ }>;
269
+ headline: z.ZodString;
270
+ }, z.core.$strip>>;
271
+ }, z.core.$strip>;
272
+ type AuditDismissalsResponse = z.infer<typeof AuditDismissalsResponseSchema>;
240
273
  /** Body of `GET /projects/:project/audit-needed?profile=`: one answer per spec. */
241
274
  declare const AuditNeedReportSchema: z.ZodObject<{
242
275
  project: z.ZodString;
@@ -303,6 +336,19 @@ declare const RerunReportSchema: z.ZodObject<{
303
336
  deployedShaNotInLog: "deployedShaNotInLog";
304
337
  gapInRange: "gapInRange";
305
338
  }>>;
339
+ auditDismissed: z.ZodOptional<z.ZodObject<{
340
+ by: z.ZodString;
341
+ at: z.ZodString;
342
+ note: z.ZodString;
343
+ auditRunId: z.ZodString;
344
+ label: z.ZodEnum<{
345
+ TEST_DRIFT: "TEST_DRIFT";
346
+ SPEC_CHANGE: "SPEC_CHANGE";
347
+ UNKNOWN: "UNKNOWN";
348
+ }>;
349
+ headline: z.ZodString;
350
+ }, z.core.$strip>>;
351
+ auditDismissalApplied: z.ZodOptional<z.ZodBoolean>;
306
352
  executionAssumedReached: z.ZodOptional<z.ZodEnum<{
307
353
  noSelectionInRange: "noSelectionInRange";
308
354
  selectionUnknown: "selectionUnknown";
@@ -1070,6 +1116,20 @@ interface HubClient {
1070
1116
  deleteAttestation(project: string, q: {
1071
1117
  profile: string;
1072
1118
  }, spec: string): Promise<void>;
1119
+ /** Every dismissed audit finding for the project, current and superseded alike. No profile — findings are about the repository. */
1120
+ getAuditDismissals(project: string): Promise<AuditDismissalsResponse>;
1121
+ /**
1122
+ * Record that a person judged `spec`'s current audit finding wrong. The hub
1123
+ * reads which finding that is from the ledger and pins the dismissal to it;
1124
+ * a spec with no open finding is rejected.
1125
+ */
1126
+ putAuditDismissal(project: string, body: {
1127
+ spec: string;
1128
+ by: string;
1129
+ note: string;
1130
+ }): Promise<AuditDismissalResponse>;
1131
+ /** Withdraw a dismissal, putting the audit's finding back in force. */
1132
+ deleteAuditDismissal(project: string, spec: string): Promise<void>;
1073
1133
  /**
1074
1134
  * Every spec's last `ccqa audit --report-to-hub` result, keyed by "feature/spec". No
1075
1135
  * profile — drift asks whether a spec still describes the code, not
@@ -199,6 +199,23 @@ function createHubClient(opts) {
199
199
  body: JSON.stringify({ spec })
200
200
  });
201
201
  },
202
+ getAuditDismissals(project) {
203
+ return json(auditDismissalsPath(project));
204
+ },
205
+ putAuditDismissal(project, body) {
206
+ return json(auditDismissalsPath(project), {
207
+ method: "PUT",
208
+ headers: { "Content-Type": "application/json" },
209
+ body: JSON.stringify(body)
210
+ });
211
+ },
212
+ async deleteAuditDismissal(project, spec) {
213
+ await request(auditDismissalsPath(project), {
214
+ method: "DELETE",
215
+ headers: { "Content-Type": "application/json" },
216
+ body: JSON.stringify({ spec })
217
+ });
218
+ },
202
219
  getAuditNeed(project, q) {
203
220
  return json(`/api/v1/projects/${encodeURIComponent(project)}/audit-needed?${queryString({ profile: q.profile })}`);
204
221
  },
@@ -325,6 +342,9 @@ function locksPath(project) {
325
342
  function attestationsPath(project) {
326
343
  return `/api/v1/projects/${encodeURIComponent(project)}/attestations`;
327
344
  }
345
+ function auditDismissalsPath(project) {
346
+ return `/api/v1/projects/${encodeURIComponent(project)}/audit-dismissals`;
347
+ }
328
348
  /** Perspectives are one document per project: `/api/v1/projects/<project>/perspectives`. */
329
349
  function perspectivesPath(project) {
330
350
  return `/api/v1/projects/${encodeURIComponent(project)}/perspectives`;
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.32.0",
3
+ "version": "1.33.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.32.0",
3
+ "version": "1.33.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {