ccqa 1.31.3 → 1.32.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
@@ -4775,6 +4775,18 @@ const PerspectiveStatusSchema = z.object({
4775
4775
  target: z.string().min(1).optional()
4776
4776
  }).strip();
4777
4777
  /**
4778
+ * One step of a spec, transcribed verbatim for display. Exactly one shape per
4779
+ * step: an include step carries the block name it invokes (its params stay in
4780
+ * the spec — they are wiring, not procedure), an action step carries the
4781
+ * instruction and what it expects. Never authored or reworded here: like
4782
+ * `title`, this is a mechanical copy the next `ccqa perspectives` rewrites.
4783
+ */
4784
+ const PerspectiveStepSchema = z.object({
4785
+ include: z.string().min(1).optional(),
4786
+ instruction: z.string().optional(),
4787
+ expected: z.string().optional()
4788
+ }).strip();
4789
+ /**
4778
4790
  * One test case in the inventory.
4779
4791
  *
4780
4792
  * - `title` is transcribed verbatim from the spec.yaml.
@@ -4786,11 +4798,12 @@ const PerspectiveStatusSchema = z.object({
4786
4798
  * steps (the opening screen, the state the test assumes, and the setup
4787
4799
  * prerequisites such as which role logs in). Optional: a spec may not
4788
4800
  * express all of them.
4801
+ * - `steps` is the procedure itself, transcribed verbatim (never authored —
4802
+ * see PerspectiveStepSchema) so the hub UI can show a case in full without
4803
+ * a repo checkout. The spec.yaml stays the single source of truth: this
4804
+ * copy is rewritten wholesale every time the inventory is regenerated, the
4805
+ * same way `title` is.
4789
4806
  * - `note` is a human-only field. Regenerating perspectives preserves it.
4790
- *
4791
- * The detailed test procedure and expected results are deliberately NOT
4792
- * duplicated here — the spec.yaml steps are the single source of truth for
4793
- * those. The Markdown view links back to the spec instead of restating them.
4794
4807
  */
4795
4808
  const PerspectiveSpecSchema = z.object({
4796
4809
  specName: z.string().min(1),
@@ -4799,6 +4812,7 @@ const PerspectiveSpecSchema = z.object({
4799
4812
  startScreen: z.string().optional(),
4800
4813
  testCondition: z.string().optional(),
4801
4814
  preconditions: z.array(z.string().min(1)).optional(),
4815
+ steps: z.array(PerspectiveStepSchema).optional(),
4802
4816
  status: PerspectiveStatusSchema,
4803
4817
  changedAt: z.string().optional(),
4804
4818
  note: z.string().optional()
@@ -7396,7 +7410,8 @@ const SpecVerdictSchema = z.enum([
7396
7410
  "inProgress",
7397
7411
  "needsRepair",
7398
7412
  "rerunNeeded",
7399
- "verified"
7413
+ "verified",
7414
+ "manuallyVerified"
7400
7415
  ]);
7401
7416
  /**
7402
7417
  * Which hole in the deploy log made the hub assume a deploy reached this spec
@@ -7452,6 +7467,57 @@ z.object({
7452
7467
  /** Body of `DELETE /projects/:project/locks?profile=`. */
7453
7468
  const ReleaseLocksRequestSchema = z.object({ holder: z.string().min(1) });
7454
7469
  /**
7470
+ * A person's word that they checked a spec's behaviour by hand against the
7471
+ * deployed environment. It overrides the verdict, never the ledgers: the
7472
+ * drift entry that parked the spec stays open, so the repair loop keeps its
7473
+ * reason to fix the test, while the verdict stops asking a person for what a
7474
+ * person already did.
7475
+ *
7476
+ * Anchored to the deploy head at the moment it was recorded, so it lapses on
7477
+ * its own — a deploy reaching the spec, or the spec being edited, ends its
7478
+ * coverage the same way those end a run's (ADR-0010). One per spec: a new
7479
+ * attestation replaces the previous one.
7480
+ */
7481
+ const AttestationSchema = z.object({
7482
+ by: z.string().min(1),
7483
+ at: z.string(),
7484
+ note: z.string().optional(),
7485
+ deployedSha: z.string().nullable()
7486
+ });
7487
+ /** The per-(project, profile) attestation document: "feature/spec" → the standing attestation. */
7488
+ const AttestationsSchema = z.object({ specs: z.record(z.string(), AttestationSchema).default({}) });
7489
+ /**
7490
+ * Why an attestation stopped covering the spec. One reason is named even when
7491
+ * several hold, in the order the checks run (coverage, then the spec's own
7492
+ * edits, then a later red run) — enough for a reader to see what to verify
7493
+ * before attesting again.
7494
+ */
7495
+ const AttestationLapseSchema = z.enum([
7496
+ "deployReached",
7497
+ "cannotPlace",
7498
+ "specEdited",
7499
+ "newerRed"
7500
+ ]);
7501
+ /** Body of `PUT /projects/:project/attestations?profile=`. */
7502
+ const PutAttestationRequestSchema = z.object({
7503
+ spec: z.string().min(1).max(512),
7504
+ by: z.string().min(1).max(256),
7505
+ note: z.string().max(4e3).optional()
7506
+ });
7507
+ /** Body of `DELETE /projects/:project/attestations?profile=`. */
7508
+ const DeleteAttestationRequestSchema = z.object({ spec: z.string().min(1).max(512) });
7509
+ z.object({
7510
+ project: z.string(),
7511
+ profile: z.string(),
7512
+ spec: z.string(),
7513
+ attestation: AttestationSchema
7514
+ });
7515
+ z.object({
7516
+ project: z.string(),
7517
+ profile: z.string(),
7518
+ specs: z.record(z.string(), AttestationSchema)
7519
+ });
7520
+ /**
7455
7521
  * One spec's verdict, the two axes it was derived from, and the three ledger
7456
7522
  * coordinates the view shows alongside them. The coordinates are always
7457
7523
  * present (null when the spec has no such entry); the optional fields appear
@@ -7469,6 +7535,10 @@ const SpecRerunSchema = z.object({
7469
7535
  auditAssumedReached: RerunUnknownReasonSchema.optional(),
7470
7536
  executionAssumedReached: RerunUnknownReasonSchema.optional(),
7471
7537
  specChangedSince: z.string().optional(),
7538
+ manual: AttestationSchema.optional(),
7539
+ manualLapsed: AttestationSchema.extend({ because: AttestationLapseSchema }).optional(),
7540
+ manualLapsedByDeploy: DeployRefSchema.nullable().optional(),
7541
+ manualLapsedReason: RerunUnknownReasonSchema.optional(),
7472
7542
  heldBy: SpecLockSchema.nullable(),
7473
7543
  lastRun: SpecLedgerEntrySchema.nullable(),
7474
7544
  lastGreen: SpecLedgerEntrySchema.nullable(),
@@ -7711,7 +7781,7 @@ async function fetchRerunReport(hubCtx, profile) {
7711
7781
  throw new RunUsageError(`--only-hub-rerun-needed: could not ask the hub which specs need a re-run: ${errMessage(err)}`);
7712
7782
  }
7713
7783
  const parsed = RerunReportSchema.safeParse(report);
7714
- if (!parsed.success) throw new RunUsageError(`${FLAG$1}: this hub's re-run answer is not in a shape this ccqa understands — it is likely older than this CLI. Upgrade the hub, or select with --only-affected-by <ref> instead.`);
7784
+ if (!parsed.success) throw new RunUsageError(`${FLAG$1}: this hub's re-run answer is not in a shape this ccqa understands — the hub and this CLI are on different versions, and either side being newer can cause it (a newer hub may answer with verdicts this CLI does not know). Align the two, or select with --only-affected-by <ref> instead.`);
7715
7785
  report = parsed.data;
7716
7786
  if (report.deployHead === null) throw new RunUsageError(`--only-hub-rerun-needed: no deploy has been recorded for profile "${profile}" of project "${hubCtx.project}", so nothing can be compared against. Wire \`ccqa hub deploy record\` into the deploy job, or select with --only-affected-by <ref> instead.`);
7717
7787
  return {
@@ -7723,7 +7793,8 @@ const SUMMARY_ORDER$1 = rankedOrder({
7723
7793
  needsRepair: 0,
7724
7794
  rerunNeeded: 1,
7725
7795
  inProgress: 2,
7726
- verified: 3
7796
+ manuallyVerified: 3,
7797
+ verified: 4
7727
7798
  });
7728
7799
  /**
7729
7800
  * Narrow `specs` to the ones the hub says are worth running.
@@ -7733,7 +7804,9 @@ const SUMMARY_ORDER$1 = rankedOrder({
7733
7804
  * cannot vouch for, are both as uncovered as one a deploy demonstrably
7734
7805
  * invalidated (ADR-0014). `needsRepair`, `inProgress` and `verified` are never
7735
7806
  * selected: running them repairs nothing, races something already in flight,
7736
- * or repeats work that is still current.
7807
+ * or repeats work that is still current. `manuallyVerified` is never selected
7808
+ * either — the test is still the broken one the attestation stands in for,
7809
+ * and running it would only relabel a person's answer with a machine failure.
7737
7810
  */
7738
7811
  function selectSpecsNeedingRerun(specs, report) {
7739
7812
  const counts = /* @__PURE__ */ new Map();
@@ -9518,7 +9591,37 @@ const pushCommand = new Command("push").description("Upload the report directory
9518
9591
  meta("specs", `${run.specs.passed}/${run.specs.total} passed`);
9519
9592
  info(`${resolveBaseUrl(opts)}/#/runs/${run.id}`);
9520
9593
  }));
9521
- 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);
9594
+ 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
+ const project = resolveProject(opts);
9596
+ 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
+ }
9605
+ if (opts.revoke) {
9606
+ await hub.deleteAttestation(project, { profile: opts.profile }, specId);
9607
+ header("hub attest", `${specId} revoked`);
9608
+ return;
9609
+ }
9610
+ if (!opts.by) {
9611
+ error("--by <name> is required: an attestation is a person's word, and it needs the person");
9612
+ process.exit(2);
9613
+ }
9614
+ const res = await hub.putAttestation(project, { profile: opts.profile }, {
9615
+ spec: specId,
9616
+ by: opts.by,
9617
+ ...opts.note !== void 0 ? { note: opts.note } : {}
9618
+ });
9619
+ header("hub attest", specId);
9620
+ meta("by", res.attestation.by);
9621
+ meta("anchored to deploy", res.attestation.deployedSha ?? "(no deploy log)");
9622
+ info("the verdict answers manuallyVerified until a deploy reaches this spec or the spec is edited");
9623
+ }));
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);
9522
9625
  /** Loose check that a fetched session is agent-browser storage-state, mirroring loadStorageState. */
9523
9626
  function isStorageStateShape(state) {
9524
9627
  return typeof state === "object" && state !== null && Array.isArray(state.cookies) && Array.isArray(state.origins);
@@ -15903,6 +16006,21 @@ For a \`mode: live\` spec there is no generated surface, so always \`spec\`.
15903
16006
 
15904
16007
  ${surfaceAxisAside("`TEST_DRIFT`")}
15905
16008
 
16009
+ ## What the \`replay-unstable\` comments are
16010
+
16011
+ Generated code may carry \`// [warn] replay-unstable: ...\` comments. These are
16012
+ observations from the one validation replay run right after recording — a
16013
+ selector that did not appear within its timeout *in that run*, on that day's
16014
+ data and load. They are diagnostic breadcrumbs, not part of the test, and a
16015
+ slow environment produces them on selectors that are perfectly correct.
16016
+
16017
+ Judge the selector the comment sits on like any other: find its string in the
16018
+ source. If it is there, the comment alone is **not** drift evidence — do not
16019
+ cite a \`replay-unstable\` comment as your evidence for TEST_DRIFT. If the
16020
+ string is genuinely absent from the source, the finding stands on that
16021
+ absence, with the source as the citation, whether or not a comment happens to
16022
+ sit nearby.
16023
+
15906
16024
  ## Earning each answer
15907
16025
 
15908
16026
  - **No drift is a claim, not a default.** Make it after picking the concrete strings from *every* surface you were given — the spec's \`expected\` and the generated code's selectors alike — and finding each of them in the source. Clearing the test case because one surface checked out is the most common way to miss a real finding. If you never looked, the honest answer is UNKNOWN.
@@ -17139,6 +17257,7 @@ async function buildSkeleton(tree) {
17139
17257
  specName: s.specName,
17140
17258
  title: meta.title,
17141
17259
  summary: "",
17260
+ ...meta.steps.length > 0 ? { steps: meta.steps } : {},
17142
17261
  status,
17143
17262
  ...lastEdit ? { changedAt: lastEdit } : {}
17144
17263
  };
@@ -17212,7 +17331,8 @@ function noteKey(featureName, specName) {
17212
17331
  function readSpecMeta(specName, specYaml) {
17213
17332
  if (specYaml === null) return {
17214
17333
  title: specName,
17215
- mode: DEFAULT_SPEC_MODE
17334
+ mode: DEFAULT_SPEC_MODE,
17335
+ steps: []
17216
17336
  };
17217
17337
  try {
17218
17338
  const parsed = parse(specYaml);
@@ -17220,16 +17340,39 @@ function readSpecMeta(specName, specYaml) {
17220
17340
  const modeResult = SpecModeSchema.safeParse(parsed.mode);
17221
17341
  return {
17222
17342
  title,
17223
- mode: modeResult.success ? modeResult.data : DEFAULT_SPEC_MODE
17343
+ mode: modeResult.success ? modeResult.data : DEFAULT_SPEC_MODE,
17344
+ steps: transcribeSteps(parsed.steps)
17224
17345
  };
17225
17346
  } catch {
17226
17347
  return {
17227
17348
  title: specName,
17228
- mode: DEFAULT_SPEC_MODE
17349
+ mode: DEFAULT_SPEC_MODE,
17350
+ steps: []
17229
17351
  };
17230
17352
  }
17231
17353
  }
17232
17354
  /**
17355
+ * The spec's procedure, copied verbatim for the inventory: an include step
17356
+ * keeps only the block name (its params are wiring, not procedure), an
17357
+ * action step keeps its instruction/expected text. Anything malformed is
17358
+ * skipped — the inventory never fails over one bad step, matching how the
17359
+ * rest of this sweep treats a broken spec.
17360
+ */
17361
+ function transcribeSteps(raw) {
17362
+ if (!Array.isArray(raw)) return [];
17363
+ const steps = [];
17364
+ for (const step of raw) {
17365
+ if (typeof step !== "object" || step === null) continue;
17366
+ const s = step;
17367
+ if (typeof s.include === "string" && s.include.length > 0) steps.push({ include: s.include });
17368
+ else if (typeof s.instruction === "string" && s.instruction.length > 0) steps.push({
17369
+ instruction: s.instruction,
17370
+ ...typeof s.expected === "string" && s.expected.length > 0 ? { expected: s.expected } : {}
17371
+ });
17372
+ }
17373
+ return steps;
17374
+ }
17375
+ /**
17233
17376
  * Resolve a spec's generation target for coverage derivation, from its
17234
17377
  * already-read spec.yaml. Best-effort: an unparseable spec or a target that
17235
17378
  * can't be resolved (unknown id, agent-browser-only field misuse) falls back
@@ -18331,7 +18474,7 @@ function requireKey(config) {
18331
18474
  return config.encryptionKey;
18332
18475
  }
18333
18476
  /** Validate the `:project`/`:profile` route params into a store scope. */
18334
- function requireScope(ctx) {
18477
+ function requireScope$1(ctx) {
18335
18478
  return {
18336
18479
  project: requireSafeSegment(ctx.params.project, "project"),
18337
18480
  profile: requireSafeSegment(ctx.params.profile, "profile")
@@ -18341,7 +18484,7 @@ function requireScope(ctx) {
18341
18484
  function createPutSessionHandler(config) {
18342
18485
  return async (ctx) => {
18343
18486
  const key = requireKey(config);
18344
- const scope = requireScope(ctx);
18487
+ const scope = requireScope$1(ctx);
18345
18488
  const name = requireSafeSegment(ctx.params.name, "name");
18346
18489
  const body = await readBody(ctx.req, MAX_SECRET_BODY_BYTES);
18347
18490
  const blob = encodeEncryptedBlob(encrypt(new Uint8Array(body), key));
@@ -18353,7 +18496,7 @@ function createPutSessionHandler(config) {
18353
18496
  /** GET /api/v1/projects/:project/sessions/:profile — metadata only (names + timestamps). */
18354
18497
  function createListSessionsHandler(config) {
18355
18498
  return async (ctx) => {
18356
- const scope = requireScope(ctx);
18499
+ const scope = requireScope$1(ctx);
18357
18500
  const entries = await config.store.list(scope);
18358
18501
  sendJson(ctx.res, 200, { sessions: entries.map((e) => ({
18359
18502
  name: e.name,
@@ -18370,7 +18513,7 @@ function createListSessionsHandler(config) {
18370
18513
  function createGetSessionHandler(config) {
18371
18514
  return async (ctx) => {
18372
18515
  const key = requireKey(config);
18373
- const scope = requireScope(ctx);
18516
+ const scope = requireScope$1(ctx);
18374
18517
  const name = requireSafeSegment(ctx.params.name, "name");
18375
18518
  const stored = await config.store.get(scope, name);
18376
18519
  if (!stored) throw new HttpError(404, "not_found", `session "${name}" not found for ${scope.project}/${scope.profile}`);
@@ -18381,7 +18524,7 @@ function createGetSessionHandler(config) {
18381
18524
  /** DELETE /api/v1/projects/:project/sessions/:profile/:name */
18382
18525
  function createDeleteSessionHandler(config) {
18383
18526
  return async (ctx) => {
18384
- const scope = requireScope(ctx);
18527
+ const scope = requireScope$1(ctx);
18385
18528
  const name = requireSafeSegment(ctx.params.name, "name");
18386
18529
  await config.store.delete(scope, name);
18387
18530
  ctx.res.statusCode = 204;
@@ -18392,7 +18535,7 @@ function createDeleteSessionHandler(config) {
18392
18535
  function createPutVariableHandler(config) {
18393
18536
  return async (ctx) => {
18394
18537
  const key = requireKey(config);
18395
- const scope = requireScope(ctx);
18538
+ const scope = requireScope$1(ctx);
18396
18539
  const name = requireSafeSegment(ctx.params.name, "name");
18397
18540
  const body = await readBody(ctx.req, MAX_SECRET_BODY_BYTES);
18398
18541
  const parsed = PutVariableRequestSchema.safeParse(JSON.parse(body.toString("utf8") || "{}"));
@@ -18412,7 +18555,7 @@ function createPutVariableHandler(config) {
18412
18555
  */
18413
18556
  function createListVariablesHandler(config) {
18414
18557
  return async (ctx) => {
18415
- const scope = requireScope(ctx);
18558
+ const scope = requireScope$1(ctx);
18416
18559
  const includeValues = ctx.url.searchParams.get("include") === "values";
18417
18560
  const key = includeValues ? requireKey(config) : config.encryptionKey;
18418
18561
  const entries = await config.store.list(scope);
@@ -18447,7 +18590,7 @@ function createListVariablesHandler(config) {
18447
18590
  /** DELETE /api/v1/projects/:project/variables/:profile/:name */
18448
18591
  function createDeleteVariableHandler(config) {
18449
18592
  return async (ctx) => {
18450
- const scope = requireScope(ctx);
18593
+ const scope = requireScope$1(ctx);
18451
18594
  const name = requireSafeSegment(ctx.params.name, "name");
18452
18595
  await config.store.delete(scope, name);
18453
18596
  ctx.res.statusCode = 204;
@@ -18786,7 +18929,7 @@ function createGetAuditNeedHandler(storage) {
18786
18929
  //#endregion
18787
18930
  //#region src/hub/api/handlers/locks.ts
18788
18931
  /** A spec-key list and three short strings; nothing here should approach this. */
18789
- const MAX_BODY_BYTES$3 = 1024 * 1024;
18932
+ const MAX_BODY_BYTES$4 = 1024 * 1024;
18790
18933
  /**
18791
18934
  * POST /api/v1/projects/:project/locks?profile=
18792
18935
  *
@@ -18800,7 +18943,7 @@ function createAcquireLocksHandler(storage) {
18800
18943
  return async (ctx) => {
18801
18944
  const project = requireSafeSegment(ctx.params.project, "project");
18802
18945
  const profile = requireProfileParam(ctx.url);
18803
- const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$3, AcquireLocksRequestSchema, "lock request");
18946
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$4, AcquireLocksRequestSchema, "lock request");
18804
18947
  let result = {
18805
18948
  granted: [],
18806
18949
  denied: []
@@ -18829,7 +18972,7 @@ function createReleaseLocksHandler(storage) {
18829
18972
  return async (ctx) => {
18830
18973
  const project = requireSafeSegment(ctx.params.project, "project");
18831
18974
  const profile = requireProfileParam(ctx.url);
18832
- const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$3, ReleaseLocksRequestSchema, "release request");
18975
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$4, ReleaseLocksRequestSchema, "release request");
18833
18976
  await storage.locks.update(project, profile, (current) => releaseAll(current, body.holder));
18834
18977
  ctx.res.writeHead(204).end();
18835
18978
  };
@@ -18842,7 +18985,7 @@ function createReleaseLocksHandler(storage) {
18842
18985
  * 5000 keys of 256 `\uXXXX`-escaped characters — so a conforming client is
18843
18986
  * never answered 413 by a limit the documented bounds don't mention.
18844
18987
  */
18845
- const MAX_BODY_BYTES$2 = 8 * 1024 * 1024;
18988
+ const MAX_BODY_BYTES$3 = 8 * 1024 * 1024;
18846
18989
  function requireAckKey(ctx) {
18847
18990
  return {
18848
18991
  project: requireSafeSegment(ctx.params.project, "project"),
@@ -18869,7 +19012,7 @@ function createGetAckHandler(storage) {
18869
19012
  function createPutAckHandler(storage) {
18870
19013
  return async (ctx) => {
18871
19014
  const key = requireAckKey(ctx);
18872
- const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$2, PutAckRequestSchema, "ack body");
19015
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$3, PutAckRequestSchema, "ack body");
18873
19016
  const ack = await storage.acks.put(key.project, key.profile, key.name, body.keys);
18874
19017
  sendJson(ctx.res, 200, {
18875
19018
  ...key,
@@ -18878,6 +19021,80 @@ function createPutAckHandler(storage) {
18878
19021
  };
18879
19022
  }
18880
19023
  //#endregion
19024
+ //#region src/hub/api/handlers/attestations.ts
19025
+ /** Far above the largest body `PutAttestationRequestSchema`'s bounds admit. */
19026
+ const MAX_BODY_BYTES$2 = 64 * 1024;
19027
+ function requireScope(ctx) {
19028
+ return {
19029
+ project: requireSafeSegment(ctx.params.project, "project"),
19030
+ profile: requireProfileParam(ctx.url)
19031
+ };
19032
+ }
19033
+ /**
19034
+ * GET /api/v1/projects/:project/attestations?profile= — the raw document,
19035
+ * standing and lapsed alike. Whether one still covers its spec is `/rerun`'s
19036
+ * answer; this exists so a lapsed attestation can still be found and revoked.
19037
+ */
19038
+ function createGetAttestationsHandler(storage) {
19039
+ return async (ctx) => {
19040
+ const scope = requireScope(ctx);
19041
+ const doc = await storage.attestations.get(scope.project, scope.profile);
19042
+ sendJson(ctx.res, 200, {
19043
+ ...scope,
19044
+ specs: doc.specs
19045
+ });
19046
+ };
19047
+ }
19048
+ /**
19049
+ * PUT /api/v1/projects/:project/attestations?profile= — record that a person
19050
+ * checked a spec by hand. The hub stamps the time and the profile's deploy
19051
+ * head: the anchor must be what the hub knows was deployed at this moment,
19052
+ * not what the caller believes. Replaces any previous attestation for the
19053
+ * spec.
19054
+ */
19055
+ function createPutAttestationHandler(storage) {
19056
+ return async (ctx) => {
19057
+ const scope = requireScope(ctx);
19058
+ const [body, head, targets] = await Promise.all([
19059
+ readJsonBody(ctx.req, MAX_BODY_BYTES$2, PutAttestationRequestSchema, "attestation body"),
19060
+ storage.deploys.head(scope.project, scope.profile),
19061
+ requireSpecTargets(storage.perspectives, scope.project, "what can be attested")
19062
+ ]);
19063
+ if (!targets.some((target) => target.key === body.spec)) throw new HttpError(400, "unknown_spec", `spec "${body.spec}" is not in project "${scope.project}"'s perspectives document — attest with the canonical feature/spec key, or push \`ccqa perspectives\` first`);
19064
+ const attestation = {
19065
+ by: body.by,
19066
+ at: (/* @__PURE__ */ new Date()).toISOString(),
19067
+ ...body.note !== void 0 ? { note: body.note } : {},
19068
+ deployedSha: head?.sha ?? null
19069
+ };
19070
+ await storage.attestations.update(scope.project, scope.profile, (current) => ({ specs: {
19071
+ ...current.specs,
19072
+ [body.spec]: attestation
19073
+ } }));
19074
+ sendJson(ctx.res, 200, {
19075
+ ...scope,
19076
+ spec: body.spec,
19077
+ attestation
19078
+ });
19079
+ };
19080
+ }
19081
+ /**
19082
+ * DELETE /api/v1/projects/:project/attestations?profile= — revoke a spec's
19083
+ * attestation. Deleting one that does not exist is 200 like deleting one that
19084
+ * does: the caller asked for its absence, and it is absent.
19085
+ */
19086
+ function createDeleteAttestationHandler(storage) {
19087
+ return async (ctx) => {
19088
+ const scope = requireScope(ctx);
19089
+ const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$2, DeleteAttestationRequestSchema, "attestation body");
19090
+ await storage.attestations.update(scope.project, scope.profile, (current) => {
19091
+ const { [body.spec]: _, ...rest } = current.specs;
19092
+ return { specs: rest };
19093
+ });
19094
+ sendJson(ctx.res, 200, { removed: body.spec });
19095
+ };
19096
+ }
19097
+ //#endregion
18881
19098
  //#region src/hub/api/handlers/spend.ts
18882
19099
  /** One entry is a handful of short fields; anything larger is a malformed client. */
18883
19100
  const MAX_BODY_BYTES$1 = 4 * 1024;
@@ -18950,7 +19167,7 @@ function specMovedSince(changedAt, baselineSha, baselineAt, deployTimes) {
18950
19167
  return changedAt > (baselineSha && deployTimes.get(baselineSha) || baselineAt) ? changedAt : null;
18951
19168
  }
18952
19169
  function computeRerun(input) {
18953
- const { specs, ledger, log, touchIndex, drift, locks, now } = input;
19170
+ const { specs, ledger, log, touchIndex, drift, locks, attestations, now } = input;
18954
19171
  const range = buildRange(log, touchIndex);
18955
19172
  const deployTimes = deployedAt(log);
18956
19173
  const out = {};
@@ -18968,11 +19185,23 @@ function computeRerun(input) {
18968
19185
  if (auditMoved && audit.audit !== "due") audit = { audit: "due" };
18969
19186
  if (runMoved && execution.execution === "passed") execution = { execution: "stale" };
18970
19187
  const held = heldBy(locks, spec.key, now);
19188
+ let verdict = decide(audit.audit, execution.execution, held);
19189
+ const manualState = readAttestation(attestations.specs[spec.key], spec, coords.lastRed, range, log, deployTimes);
19190
+ if (manualState?.kind === "covers" && !held && verdict !== "verified") verdict = "manuallyVerified";
18971
19191
  out[spec.key] = {
18972
- verdict: decide(audit.audit, execution.execution, held),
19192
+ verdict,
18973
19193
  ...auditMoved || runMoved ? { specChangedSince: auditMoved ?? runMoved } : {},
18974
19194
  ...audit,
18975
19195
  ...execution,
19196
+ ...manualState?.kind === "covers" ? { manual: manualState.attest } : {},
19197
+ ...manualState?.kind === "lapsed" ? {
19198
+ manualLapsed: {
19199
+ ...manualState.attest,
19200
+ because: manualState.because
19201
+ },
19202
+ ...manualState.because === "deployReached" ? { manualLapsedByDeploy: manualState.byDeploy } : {},
19203
+ ...manualState.reason ? { manualLapsedReason: manualState.reason } : {}
19204
+ } : {},
18976
19205
  heldBy: held,
18977
19206
  ...coords
18978
19207
  };
@@ -18980,6 +19209,50 @@ function computeRerun(input) {
18980
19209
  return out;
18981
19210
  }
18982
19211
  /**
19212
+ * Does the attestation still speak for what is deployed? Checked in the order
19213
+ * the lapse enum documents: deploy coverage first (a sha the log cannot place
19214
+ * reads as reached, ADR-0014, with the hole kept as an annotation), then the
19215
+ * spec's own edits — compared against when the person looked, because they
19216
+ * read the spec as it stood that moment, which `specMovedSince` covers via a
19217
+ * null baseline sha — then a red run recorded after them, which is newer
19218
+ * information than their word. The null-sha case is the profile that had no
19219
+ * deploy log when they checked: their word covers exactly as long as that
19220
+ * stays true.
19221
+ */
19222
+ function readAttestation(attest, spec, lastRed, range, log, deployTimes) {
19223
+ if (!attest) return null;
19224
+ const coverage = attest.deployedSha === null ? log.entries.length === 0 ? { kind: "current" } : {
19225
+ kind: "unanswerable",
19226
+ reason: "unknownDeployedSha"
19227
+ } : freshness(attest.deployedSha, spec.key, range);
19228
+ if (coverage.kind === "touched") return {
19229
+ kind: "lapsed",
19230
+ attest,
19231
+ because: "deployReached",
19232
+ byDeploy: coverage.touchedByDeploy
19233
+ };
19234
+ if (coverage.kind === "unanswerable") return {
19235
+ kind: "lapsed",
19236
+ attest,
19237
+ because: "cannotPlace",
19238
+ reason: coverage.reason
19239
+ };
19240
+ if (specMovedSince(spec.changedAt, null, attest.at, deployTimes)) return {
19241
+ kind: "lapsed",
19242
+ attest,
19243
+ because: "specEdited"
19244
+ };
19245
+ if (lastRed !== null && lastRed.at > attest.at) return {
19246
+ kind: "lapsed",
19247
+ attest,
19248
+ because: "newerRed"
19249
+ };
19250
+ return {
19251
+ kind: "covers",
19252
+ attest
19253
+ };
19254
+ }
19255
+ /**
18983
19256
  * Axis 1, derived from the same freshness answer `--only-hub-audit-needed`
18984
19257
  * reads. The label only speaks once the audit is known to be current: a
18985
19258
  * verdict about an older commit says nothing about the one running now.
@@ -19083,13 +19356,14 @@ function createGetRerunHandler(storage) {
19083
19356
  return async (ctx) => {
19084
19357
  const project = requireSafeSegment(ctx.params.project, "project");
19085
19358
  const profile = requireProfileParam(ctx.url);
19086
- const [specs, ledger, log, touchIndex, drift, locks] = await Promise.all([
19359
+ const [specs, ledger, log, touchIndex, drift, locks, attestations] = await Promise.all([
19087
19360
  requireSpecTargets(storage.perspectives, project, "which specs need a re-run"),
19088
19361
  storage.ledger.getMerged(project, profile),
19089
19362
  storage.deploys.getLog(project, profile),
19090
19363
  storage.deploys.getTouchIndex(project, profile),
19091
19364
  storage.driftLedger.getMerged(project),
19092
- storage.locks.get(project, profile)
19365
+ storage.locks.get(project, profile),
19366
+ storage.attestations.get(project, profile)
19093
19367
  ]);
19094
19368
  const head = log.entries[log.entries.length - 1];
19095
19369
  sendJson(ctx.res, 200, {
@@ -19103,6 +19377,7 @@ function createGetRerunHandler(storage) {
19103
19377
  touchIndex,
19104
19378
  drift,
19105
19379
  locks,
19380
+ attestations,
19106
19381
  now: /* @__PURE__ */ new Date()
19107
19382
  })
19108
19383
  });
@@ -19827,6 +20102,7 @@ const HTML_BODY = `
19827
20102
  <button class="fchip" id="persp-chip-needsrepair" data-f="needsRepair" aria-pressed="false" type="button"><span data-i18n="perspectives.rerun.state.needsRepair">Needs repair</span><span class="fcount"></span></button>
19828
20103
  <button class="fchip" id="persp-chip-rerunneeded" data-f="rerunNeeded" aria-pressed="false" type="button"><span data-i18n="perspectives.rerun.state.rerunNeeded">Re-run needed</span><span class="fcount"></span></button>
19829
20104
  <button class="fchip" id="persp-chip-inprogress" data-f="inProgress" aria-pressed="false" type="button"><span data-i18n="perspectives.rerun.state.inProgress">In progress</span><span class="fcount"></span></button>
20105
+ <button class="fchip" id="persp-chip-manuallyverified" data-f="manuallyVerified" aria-pressed="false" type="button"><span data-i18n="perspectives.rerun.state.manuallyVerified">Manually verified</span><span class="fcount"></span></button>
19830
20106
  <button class="fchip" id="persp-chip-verified" data-f="verified" aria-pressed="false" type="button"><span data-i18n="perspectives.rerun.state.verified">Verified</span><span class="fcount"></span></button>
19831
20107
  </div>
19832
20108
  <div class="spacer"></div>
@@ -20552,6 +20828,9 @@ const CSS = `
20552
20828
  .sg-audit-undecided { background: var(--info); }
20553
20829
  .sg-verified, .sg-audit-clean, .sg-exec-passed { background: var(--pass); }
20554
20830
  .sg-inprogress, .sg-audit-due, .sg-exec-never { background: var(--muted-2); }
20831
+ /* A person's word, not the machine's — kept off the pass/fail palette both
20832
+ axes share so it never reads as either one. */
20833
+ .sg-manual { background: var(--violet); }
20555
20834
 
20556
20835
  .search { flex: 1; min-width: 200px; max-width: 340px; display: flex; align-items: center; gap: 7px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 0 10px; height: 32px; background: var(--surface); }
20557
20836
  .search svg { width: 15px; height: 15px; flex: none; color: var(--muted-2); }
@@ -20590,6 +20869,11 @@ const CSS = `
20590
20869
  .badge.rr-unknown .d { background: var(--info); }
20591
20870
  .badge.rr-none { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
20592
20871
  .badge.rr-none .d { background: var(--muted); }
20872
+ /* A person's attestation, not the audit/run pipeline's own verdict — kept
20873
+ off that palette (rr-repair/rr-needed/rr-none/passed) the same way
20874
+ sg-manual is kept off the summary bar's. */
20875
+ .badge.rr-manual { background: var(--violet-bg); color: var(--violet); border-color: var(--violet-border); }
20876
+ .badge.rr-manual .d { background: var(--violet); }
20593
20877
  .cellsub { display: block; margin-top: 3px; max-width: 260px; color: var(--muted); font-size: 11.5px; line-height: 1.45; }
20594
20878
  .graded-mark { color: var(--fg-dim); font-weight: 600; }
20595
20879
  .cellsub a { color: var(--muted); text-decoration: none; border-bottom: 1px dotted var(--border-strong); }
@@ -20645,6 +20929,11 @@ const CSS = `
20645
20929
  .d-paths { display: flex; flex-wrap: wrap; gap: 6px; }
20646
20930
  .d-paths code { white-space: nowrap; }
20647
20931
  .d-prose + .d-paths { margin-top: 6px; }
20932
+ .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
+ .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
+ .d-steps .step-expected { display: block; margin-top: 2px; font-size: 12.5px; }
20648
20937
  .notebox { margin-top: 14px; max-width: 900px; }
20649
20938
  .notebox .nlabel { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
20650
20939
  .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; }
@@ -20685,6 +20974,7 @@ const CLIENT_JS = `
20685
20974
  var THEME_KEY = "ccqa-hub-theme";
20686
20975
  var PROJECT_KEY = "ccqa-hub-project";
20687
20976
  var PROFILES_KEY = "ccqa-hub-profiles";
20977
+ var ATTEST_BY_KEY = "ccqa-attest-by";
20688
20978
 
20689
20979
  // ── i18n ──────────────────────────────────────────────────────────────
20690
20980
  // Chrome + labels only. Model output (headline/recommendation/reasoning) is
@@ -20773,6 +21063,8 @@ const CLIENT_JS = `
20773
21063
  "perspectives.ov.cases": "cases", "perspectives.ov.features": "features",
20774
21064
  "perspectives.d.preconditions": "Preconditions", "perspectives.d.startScreen": "Start screen",
20775
21065
  "perspectives.d.testCondition": "Condition", "perspectives.d.spec": "spec",
21066
+ "perspectives.d.steps": "Steps", "perspectives.d.stepInclude": "Include: {name}",
21067
+ "perspectives.d.stepExpected": "Expected:",
20776
21068
  "perspectives.note.label": "Note",
20777
21069
  "perspectives.note.placeholder": "Notes about this case…",
20778
21070
  "perspectives.note.saved": "Saved",
@@ -20785,6 +21077,7 @@ const CLIENT_JS = `
20785
21077
  "perspectives.rerun.state.needsRepair": "Needs repair",
20786
21078
  "perspectives.rerun.state.rerunNeeded": "Re-run needed",
20787
21079
  "perspectives.rerun.state.inProgress": "In progress",
21080
+ "perspectives.rerun.state.manuallyVerified": "Manually verified",
20788
21081
  "perspectives.rerun.state.verified": "Verified",
20789
21082
  "perspectives.rerun.vsDeploy": "judged against deploy",
20790
21083
  "perspectives.rerun.noDeployHead": "no deploy recorded for this profile",
@@ -20820,6 +21113,19 @@ const CLIENT_JS = `
20820
21113
  "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.",
20821
21114
  "perspectives.rerun.deployHead": "deploy head",
20822
21115
  "perspectives.drift.graded": "confirmed",
21116
+ "perspectives.manual.attestButton": "Mark as manually verified",
21117
+ "perspectives.manual.revokeButton": "Revoke manual verification",
21118
+ "perspectives.manual.promptBy": "Who is verifying this?",
21119
+ "perspectives.manual.promptNote": "Note (optional)",
21120
+ "perspectives.manual.confirmRevoke": "Revoke the manual verification for this case?",
21121
+ "perspectives.manual.error": "Could not save — retry",
21122
+ "perspectives.manual.verifiedBy": "{by} manually verified this ({at})",
21123
+ "perspectives.manual.lapsed.deployReached": "the manual verification lapsed when a deploy reached this case",
21124
+ "perspectives.manual.lapsed.deployReachedNamed": "the manual verification lapsed when deploy {sha} reached this case ({at})",
21125
+ "perspectives.manual.lapsed.cannotPlace": "the manual verification lapsed — its baseline deploy could not be placed in the log",
21126
+ "perspectives.manual.lapsed.specEdited": "the manual verification lapsed when the spec was edited",
21127
+ "perspectives.manual.lapsed.newerRed": "the manual verification lapsed after a later run failed",
21128
+ "perspectives.manual.lapsed.unrecognized": "the manual verification lapsed for a reason this UI does not recognise",
20823
21129
  "prompt.card.record": "Recording browser actions",
20824
21130
  "prompt.card.live": "Live run (AI-driven)",
20825
21131
  "prompt.card.playwright": "Playwright test generation",
@@ -20937,6 +21243,8 @@ const CLIENT_JS = `
20937
21243
  "perspectives.ov.cases": "ケース", "perspectives.ov.features": "機能",
20938
21244
  "perspectives.d.preconditions": "前提条件", "perspectives.d.startScreen": "開始画面",
20939
21245
  "perspectives.d.testCondition": "実行条件", "perspectives.d.spec": "spec",
21246
+ "perspectives.d.steps": "手順", "perspectives.d.stepInclude": "ブロック: {name}",
21247
+ "perspectives.d.stepExpected": "期待結果:",
20940
21248
  "perspectives.note.label": "note",
20941
21249
  "perspectives.note.placeholder": "このケースについてのメモ…",
20942
21250
  "perspectives.note.saved": "保存しました",
@@ -20949,6 +21257,7 @@ const CLIENT_JS = `
20949
21257
  "perspectives.rerun.state.needsRepair": "修正待ち",
20950
21258
  "perspectives.rerun.state.rerunNeeded": "要再実行",
20951
21259
  "perspectives.rerun.state.inProgress": "進行中",
21260
+ "perspectives.rerun.state.manuallyVerified": "手動確認済み",
20952
21261
  "perspectives.rerun.state.verified": "検証済み",
20953
21262
  "perspectives.rerun.vsDeploy": "判定基準: デプロイ",
20954
21263
  "perspectives.rerun.noDeployHead": "このプロファイルにはデプロイの記録がありません",
@@ -20984,6 +21293,19 @@ const CLIENT_JS = `
20984
21293
  "perspectives.rerun.noDeployLogBanner": "プロファイル {profile} にデプロイの記録がないため、どのケースも判定できません。この環境のデプロイジョブに ccqa hub deploy record を組み込んでください。",
20985
21294
  "perspectives.rerun.deployHead": "最新デプロイ",
20986
21295
  "perspectives.drift.graded": "人が確認",
21296
+ "perspectives.manual.attestButton": "手動で確認した",
21297
+ "perspectives.manual.revokeButton": "手動確認を取り消す",
21298
+ "perspectives.manual.promptBy": "確認者名を入力してください",
21299
+ "perspectives.manual.promptNote": "メモ(任意)",
21300
+ "perspectives.manual.confirmRevoke": "このケースの手動確認を取り消しますか?",
21301
+ "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が認識できない理由により手動確認が失効",
20987
21309
  "prompt.card.record": "ブラウザ操作の記録",
20988
21310
  "prompt.card.live": "ライブ実行(AI操作)",
20989
21311
  "prompt.card.playwright": "Playwrightテスト生成",
@@ -21117,6 +21439,17 @@ const CLIENT_JS = `
21117
21439
  try { window.localStorage.removeItem(PROJECT_KEY); }
21118
21440
  catch (e) { console.warn("ccqa hub: could not clear stored project:", e); }
21119
21441
  }
21442
+
21443
+ // Remembered across attestations so the prompt's default is whoever last
21444
+ // used this browser, not a blank field every time.
21445
+ function loadAttestBy() {
21446
+ try { return window.localStorage.getItem(ATTEST_BY_KEY) || ""; }
21447
+ catch (e) { return ""; }
21448
+ }
21449
+ function storeAttestBy(by) {
21450
+ try { window.localStorage.setItem(ATTEST_BY_KEY, by); }
21451
+ catch (e) { /* non-fatal: next prompt just starts blank again */ }
21452
+ }
21120
21453
  function loadProfileMap() {
21121
21454
  try {
21122
21455
  var raw = window.localStorage.getItem(PROFILES_KEY);
@@ -23065,6 +23398,11 @@ const CLIENT_JS = `
23065
23398
  "/rerun?profile=" + encodeURIComponent(state.profile);
23066
23399
  }
23067
23400
 
23401
+ function attestationsPath() {
23402
+ return "/api/v1/projects/" + encodeURIComponent(state.project) +
23403
+ "/attestations?profile=" + encodeURIComponent(state.profile);
23404
+ }
23405
+
23068
23406
  // Resolves { report } or { note } and never rejects: a hub that predates
23069
23407
  // the endpoint costs only the columns it feeds, not the whole tab. A 404
23070
23408
  // here can only mean "no such route" — the endpoint's own 404 is "the
@@ -23143,6 +23481,37 @@ const CLIENT_JS = `
23143
23481
  return text === prefix + reason ? t(prefix + "unrecognized") : text;
23144
23482
  }
23145
23483
 
23484
+ // Who attested and when, plus their note if they left one — the whole
23485
+ // reason a "manuallyVerified" verdict has no evidence row of its own.
23486
+ function manualAttestationText(manual) {
23487
+ var text = t("perspectives.manual.verifiedBy").replace("{by}", manual.by).replace("{at}", relTime(manual.at));
23488
+ return manual.note ? text + " — " + manual.note : text;
23489
+ }
23490
+
23491
+ // An attestation that no longer covers what is deployed, named alongside
23492
+ // whatever the current verdict's own reason is (rerunCellWhy appends this)
23493
+ // rather than replacing it — the person deciding whether to attest again
23494
+ // needs both: what the axes say now, and that a person's word used to
23495
+ // stand in for it. deployReached is the only reason with a named cause
23496
+ // (manualLapsedByDeploy) to add when the log could confirm one.
23497
+ function rerunManualLapseText(rr) {
23498
+ var lapsed = rr && rr.manualLapsed;
23499
+ if (!lapsed) return null;
23500
+ if (lapsed.because === "deployReached") {
23501
+ var deploy = rr.manualLapsedByDeploy;
23502
+ return deploy
23503
+ ? t("perspectives.manual.lapsed.deployReachedNamed").replace("{sha}", shortSha(deploy.sha)).replace("{at}", relTime(deploy.at))
23504
+ : t("perspectives.manual.lapsed.deployReached");
23505
+ }
23506
+ if (lapsed.because === "cannotPlace" && rr.manualLapsedReason) {
23507
+ // The same per-hole vocabulary the axes' assumed-reached annotations
23508
+ // use — an empty deploy log and an unjudged range call for different
23509
+ // next actions, so the shrug names which hole it is.
23510
+ return t("perspectives.manual.lapsed.cannotPlace") + " (" + rerunReasonText("perspectives.rerun.why.", rr.manualLapsedReason) + ")";
23511
+ }
23512
+ return rerunReasonText("perspectives.manual.lapsed.", lapsed.because);
23513
+ }
23514
+
23146
23515
  // Every verdict that carries no evidence row explains itself here, in the
23147
23516
  // actionable phrasing the detail panel wants. decide() checks heldBy before
23148
23517
  // the audit axis, so a spec another job already holds must be explained by
@@ -23162,6 +23531,7 @@ const CLIENT_JS = `
23162
23531
  ? rerunReasonText("perspectives.rerun.fix.", rr.auditAssumedReached)
23163
23532
  : t("perspectives.rerun.inProgressHint");
23164
23533
  }
23534
+ if (rr.verdict === "manuallyVerified" && rr.manual) return manualAttestationText(rr.manual);
23165
23535
  return rerunReasonText("perspectives.rerun.fix.", rr.verdict);
23166
23536
  }
23167
23537
 
@@ -23180,16 +23550,23 @@ const CLIENT_JS = `
23180
23550
  // rather than claiming a deploy matched it.
23181
23551
  function rerunCellWhy(rr) {
23182
23552
  var head = perspState.rerun && perspState.rerun.deployHead;
23553
+ var why;
23183
23554
  if (rr.verdict === "rerunNeeded") {
23184
- if (rr.executionAssumedReached) return rerunReasonText("perspectives.rerun.why.", rr.executionAssumedReached);
23185
- if (!rr.touchedBy || !rr.touchedBy.length) return t("perspectives.rerun.touchedUnknown");
23186
- return t("perspectives.rerun.touchedCount").replace("{n}", String(rr.touchedBy.length));
23187
- }
23188
- if (rr.verdict === "verified") {
23189
- if (!head) return t("perspectives.rerun.noDeployHead");
23190
- return t("perspectives.rerun.vsDeploy") + " " + shortSha(head.sha) + " · " + relTime(head.at);
23555
+ if (rr.executionAssumedReached) why = rerunReasonText("perspectives.rerun.why.", rr.executionAssumedReached);
23556
+ else if (!rr.touchedBy || !rr.touchedBy.length) why = t("perspectives.rerun.touchedUnknown");
23557
+ else why = t("perspectives.rerun.touchedCount").replace("{n}", String(rr.touchedBy.length));
23558
+ } else if (rr.verdict === "verified") {
23559
+ why = !head
23560
+ ? t("perspectives.rerun.noDeployHead")
23561
+ : t("perspectives.rerun.vsDeploy") + " " + shortSha(head.sha) + " · " + relTime(head.at);
23562
+ } else {
23563
+ why = rerunWhyVerdict(rr);
23191
23564
  }
23192
- return rerunWhyVerdict(rr);
23565
+ // A lapsed attestation is worth saying no matter which verdict the axes
23566
+ // landed on afterwards — it is not that verdict's reason, so it is
23567
+ // appended rather than replacing it.
23568
+ var lapse = rerunManualLapseText(rr);
23569
+ return lapse ? why + " · " + lapse : why;
23193
23570
  }
23194
23571
 
23195
23572
 
@@ -23203,10 +23580,10 @@ const CLIENT_JS = `
23203
23580
  // the pipeline still owes, then what needs nothing. "needsRepair" leads
23204
23581
  // because it is the only verdict a run cannot clear — someone has to repair
23205
23582
  // the spec or the product.
23206
- var RERUN_ORDER = ["needsRepair", "rerunNeeded", "inProgress", "verified"];
23583
+ var RERUN_ORDER = ["needsRepair", "rerunNeeded", "inProgress", "manuallyVerified", "verified"];
23207
23584
  var RERUN_SEG_CLASS = {
23208
23585
  needsRepair: "sg-needsrepair", rerunNeeded: "sg-rerunneeded",
23209
- inProgress: "sg-inprogress", verified: "sg-verified"
23586
+ inProgress: "sg-inprogress", manuallyVerified: "sg-manual", verified: "sg-verified"
23210
23587
  };
23211
23588
 
23212
23589
  // The one rule the summary bar and the verdict filter chips both answer
@@ -23220,7 +23597,7 @@ const CLIENT_JS = `
23220
23597
 
23221
23598
  // One verdict per case, bucketed, via rerunVerdictOf above.
23222
23599
  function rerunComposition(verdicts) {
23223
- var counts = { needsRepair: 0, rerunNeeded: 0, inProgress: 0, verified: 0 };
23600
+ var counts = { needsRepair: 0, rerunNeeded: 0, inProgress: 0, manuallyVerified: 0, verified: 0 };
23224
23601
  verdicts.forEach(function (rr) { counts[rerunVerdictOf(rr)] += 1; });
23225
23602
  return counts;
23226
23603
  }
@@ -23388,7 +23765,7 @@ const CLIENT_JS = `
23388
23765
  // takes the attention colour and re-running — machine work — does not.
23389
23766
  var VERDICT_BADGE = {
23390
23767
  needsRepair: "rr-repair", rerunNeeded: "rr-needed",
23391
- inProgress: "rr-none", verified: "passed"
23768
+ inProgress: "rr-none", manuallyVerified: "rr-manual", verified: "passed"
23392
23769
  };
23393
23770
 
23394
23771
  function perspVerdictCell(rr) {
@@ -23719,6 +24096,43 @@ const CLIENT_JS = `
23719
24096
  return wrap;
23720
24097
  }
23721
24098
 
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.
24104
+ function submitAttestation(method, body) {
24105
+ apiFetch(attestationsPath(), {
24106
+ method: method,
24107
+ headers: { "Content-Type": "application/json" },
24108
+ body: JSON.stringify(body),
24109
+ }).then(function () { reloadRerun(); })
24110
+ .catch(function (err) { window.alert(t("perspectives.manual.error") + ": " + err.message); });
24111
+ }
24112
+
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;
24124
+ }
24125
+
24126
+ function manualRevokeButton(feature, spec) {
24127
+ var btn = el("button", "btn ghost sm del", t("perspectives.manual.revokeButton"));
24128
+ btn.type = "button";
24129
+ btn.addEventListener("click", function () {
24130
+ if (!window.confirm(t("perspectives.manual.confirmRevoke"))) return;
24131
+ submitAttestation("DELETE", { spec: perspSpecKey(feature, spec) });
24132
+ });
24133
+ return btn;
24134
+ }
24135
+
23722
24136
  // Detail row: a definition list of the case's fields plus the note editor.
23723
24137
  // Built with createElement/textContent throughout — every field here is
23724
24138
  // API-derived, so none of it may go through innerHTML.
@@ -23756,6 +24170,44 @@ const CLIENT_JS = `
23756
24170
  }
23757
24171
  frag.appendChild(dl);
23758
24172
 
24173
+ if (spec.steps && spec.steps.length) {
24174
+ var stepsBox = el("div", "steps-box");
24175
+ stepsBox.appendChild(el("div", "slabel", t("perspectives.d.steps")));
24176
+ var stepsList = el("ol", "d-steps");
24177
+ spec.steps.forEach(function (step) {
24178
+ var li = el("li");
24179
+ if (step.include) {
24180
+ li.textContent = t("perspectives.d.stepInclude").replace("{name}", step.include);
24181
+ } else {
24182
+ li.appendChild(document.createTextNode(step.instruction || ""));
24183
+ if (step.expected) {
24184
+ li.appendChild(el("div", "muted step-expected", t("perspectives.d.stepExpected") + " " + step.expected));
24185
+ }
24186
+ }
24187
+ stepsList.appendChild(li);
24188
+ });
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);
24209
+ }
24210
+
23759
24211
  var notebox = el("div", "notebox");
23760
24212
  notebox.appendChild(el("div", "nlabel", t("perspectives.note.label")));
23761
24213
  var ta = el("textarea");
@@ -25047,6 +25499,9 @@ function registerRoutes(router, config, queue) {
25047
25499
  router.get("/api/v1/projects/:project/audit-needed", createGetAuditNeedHandler(storage));
25048
25500
  router.post("/api/v1/projects/:project/locks", createAcquireLocksHandler(storage));
25049
25501
  router.delete("/api/v1/projects/:project/locks", createReleaseLocksHandler(storage));
25502
+ router.get("/api/v1/projects/:project/attestations", createGetAttestationsHandler(storage));
25503
+ router.put("/api/v1/projects/:project/attestations", createPutAttestationHandler(storage));
25504
+ router.delete("/api/v1/projects/:project/attestations", createDeleteAttestationHandler(storage));
25050
25505
  router.get("/api/v1/projects/:project/acks/:name", createGetAckHandler(storage));
25051
25506
  router.put("/api/v1/projects/:project/acks/:name", createPutAckHandler(storage));
25052
25507
  router.post("/api/v1/projects/:project/spend", createRecordSpendHandler(storage));
@@ -25336,6 +25791,9 @@ function deployTouchIndexPath(root, project, profile) {
25336
25791
  function specLocksPath(root, project, profile) {
25337
25792
  return join(root, "locks", project, profile, "locks.json");
25338
25793
  }
25794
+ function attestationsPath(root, project, profile) {
25795
+ return join(root, "attestations", project, profile, "attestations.json");
25796
+ }
25339
25797
  function ackPath(root, project, profile, name) {
25340
25798
  return join(root, "acks", project, profile, `${name}.json`);
25341
25799
  }
@@ -25377,6 +25835,22 @@ function createFileAckStore(root) {
25377
25835
  };
25378
25836
  }
25379
25837
  //#endregion
25838
+ //#region src/hub/core/storage/file/attestation-store.ts
25839
+ function toAttestations(doc) {
25840
+ const parsed = AttestationsSchema.safeParse(doc);
25841
+ return parsed.success ? parsed.data : { specs: {} };
25842
+ }
25843
+ function createFileAttestationStore(root) {
25844
+ return {
25845
+ async get(project, profile) {
25846
+ return toAttestations(await readJson(attestationsPath(root, project, profile)));
25847
+ },
25848
+ async update(project, profile, mutate) {
25849
+ return updateJson(attestationsPath(root, project, profile), (current) => mutate(toAttestations(current)));
25850
+ }
25851
+ };
25852
+ }
25853
+ //#endregion
25380
25854
  //#region src/hub/core/storage/file/artifact-store.ts
25381
25855
  /**
25382
25856
  * Defense-in-depth: `relPath` is expected to already be validated by the
@@ -25860,7 +26334,8 @@ function createFileHubStorage(dataDir) {
25860
26334
  deploys: createFileDeployStore(dataDir),
25861
26335
  locks: createFileLockStore(dataDir),
25862
26336
  acks: createFileAckStore(dataDir),
25863
- spend: createFileSpendStore(dataDir)
26337
+ spend: createFileSpendStore(dataDir),
26338
+ attestations: createFileAttestationStore(dataDir)
25864
26339
  };
25865
26340
  }
25866
26341
  //#endregion
@@ -213,14 +213,38 @@ declare const AcquireLocksResponseSchema: z.ZodObject<{
213
213
  denied: z.ZodArray<z.ZodString>;
214
214
  }, z.core.$strip>;
215
215
  type AcquireLocksResponse = z.infer<typeof AcquireLocksResponseSchema>;
216
+ /** Answer of PUT (the attestation as stamped) and GET (the whole document). */
217
+ declare const AttestationResponseSchema: z.ZodObject<{
218
+ project: z.ZodString;
219
+ profile: z.ZodString;
220
+ spec: z.ZodString;
221
+ attestation: z.ZodObject<{
222
+ by: z.ZodString;
223
+ at: z.ZodString;
224
+ note: z.ZodOptional<z.ZodString>;
225
+ deployedSha: z.ZodNullable<z.ZodString>;
226
+ }, z.core.$strip>;
227
+ }, z.core.$strip>;
228
+ type AttestationResponse = z.infer<typeof AttestationResponseSchema>;
229
+ declare const AttestationsResponseSchema: z.ZodObject<{
230
+ project: z.ZodString;
231
+ profile: z.ZodString;
232
+ specs: z.ZodRecord<z.ZodString, z.ZodObject<{
233
+ by: z.ZodString;
234
+ at: z.ZodString;
235
+ note: z.ZodOptional<z.ZodString>;
236
+ deployedSha: z.ZodNullable<z.ZodString>;
237
+ }, z.core.$strip>>;
238
+ }, z.core.$strip>;
239
+ type AttestationsResponse = z.infer<typeof AttestationsResponseSchema>;
216
240
  /** Body of `GET /projects/:project/audit-needed?profile=`: one answer per spec. */
217
241
  declare const AuditNeedReportSchema: z.ZodObject<{
218
242
  project: z.ZodString;
219
243
  profile: z.ZodString;
220
244
  specs: z.ZodRecord<z.ZodString, z.ZodObject<{
221
245
  because: z.ZodEnum<{
222
- neverAudited: "neverAudited";
223
246
  deployReached: "deployReached";
247
+ neverAudited: "neverAudited";
224
248
  cannotTell: "cannotTell";
225
249
  held: "held";
226
250
  current: "current";
@@ -252,6 +276,7 @@ declare const RerunReportSchema: z.ZodObject<{
252
276
  needsRepair: "needsRepair";
253
277
  rerunNeeded: "rerunNeeded";
254
278
  verified: "verified";
279
+ manuallyVerified: "manuallyVerified";
255
280
  }>;
256
281
  audit: z.ZodEnum<{
257
282
  due: "due";
@@ -288,6 +313,38 @@ declare const RerunReportSchema: z.ZodObject<{
288
313
  gapInRange: "gapInRange";
289
314
  }>>;
290
315
  specChangedSince: z.ZodOptional<z.ZodString>;
316
+ manual: z.ZodOptional<z.ZodObject<{
317
+ by: z.ZodString;
318
+ at: z.ZodString;
319
+ note: z.ZodOptional<z.ZodString>;
320
+ deployedSha: z.ZodNullable<z.ZodString>;
321
+ }, z.core.$strip>>;
322
+ manualLapsed: z.ZodOptional<z.ZodObject<{
323
+ by: z.ZodString;
324
+ at: z.ZodString;
325
+ note: z.ZodOptional<z.ZodString>;
326
+ deployedSha: z.ZodNullable<z.ZodString>;
327
+ because: z.ZodEnum<{
328
+ deployReached: "deployReached";
329
+ cannotPlace: "cannotPlace";
330
+ specEdited: "specEdited";
331
+ newerRed: "newerRed";
332
+ }>;
333
+ }, z.core.$strip>>;
334
+ manualLapsedByDeploy: z.ZodOptional<z.ZodNullable<z.ZodObject<{
335
+ index: z.ZodNumber;
336
+ sha: z.ZodString;
337
+ at: z.ZodString;
338
+ }, z.core.$strip>>>;
339
+ manualLapsedReason: z.ZodOptional<z.ZodEnum<{
340
+ noSelectionInRange: "noSelectionInRange";
341
+ selectionUnknown: "selectionUnknown";
342
+ noDeployLog: "noDeployLog";
343
+ unknownDeployedSha: "unknownDeployedSha";
344
+ ambiguousDeployedSha: "ambiguousDeployedSha";
345
+ deployedShaNotInLog: "deployedShaNotInLog";
346
+ gapInRange: "gapInRange";
347
+ }>>;
291
348
  heldBy: z.ZodNullable<z.ZodObject<{
292
349
  kind: z.ZodEnum<{
293
350
  run: "run";
@@ -997,6 +1054,22 @@ interface HubClient {
997
1054
  releaseLocks(project: string, q: {
998
1055
  profile: string;
999
1056
  }, holder: string): Promise<void>;
1057
+ /** Every attestation for the profile, standing and lapsed alike. */
1058
+ getAttestations(project: string, q: {
1059
+ profile: string;
1060
+ }): Promise<AttestationsResponse>;
1061
+ /** Record that a person checked `spec` by hand. The hub stamps the time and deploy head. */
1062
+ putAttestation(project: string, q: {
1063
+ profile: string;
1064
+ }, body: {
1065
+ spec: string;
1066
+ by: string;
1067
+ note?: string;
1068
+ }): Promise<AttestationResponse>;
1069
+ /** Revoke a spec's attestation. Revoking one that does not exist succeeds. */
1070
+ deleteAttestation(project: string, q: {
1071
+ profile: string;
1072
+ }, spec: string): Promise<void>;
1000
1073
  /**
1001
1074
  * Every spec's last `ccqa audit --report-to-hub` result, keyed by "feature/spec". No
1002
1075
  * profile — drift asks whether a spec still describes the code, not
@@ -182,6 +182,23 @@ function createHubClient(opts) {
182
182
  body: JSON.stringify({ holder })
183
183
  });
184
184
  },
185
+ getAttestations(project, q) {
186
+ return json(`${attestationsPath(project)}?${queryString({ profile: q.profile })}`);
187
+ },
188
+ putAttestation(project, q, body) {
189
+ return json(`${attestationsPath(project)}?${queryString({ profile: q.profile })}`, {
190
+ method: "PUT",
191
+ headers: { "Content-Type": "application/json" },
192
+ body: JSON.stringify(body)
193
+ });
194
+ },
195
+ async deleteAttestation(project, q, spec) {
196
+ await request(`${attestationsPath(project)}?${queryString({ profile: q.profile })}`, {
197
+ method: "DELETE",
198
+ headers: { "Content-Type": "application/json" },
199
+ body: JSON.stringify({ spec })
200
+ });
201
+ },
185
202
  getAuditNeed(project, q) {
186
203
  return json(`/api/v1/projects/${encodeURIComponent(project)}/audit-needed?${queryString({ profile: q.profile })}`);
187
204
  },
@@ -305,6 +322,9 @@ function spendPath(project) {
305
322
  function locksPath(project) {
306
323
  return `/api/v1/projects/${encodeURIComponent(project)}/locks`;
307
324
  }
325
+ function attestationsPath(project) {
326
+ return `/api/v1/projects/${encodeURIComponent(project)}/attestations`;
327
+ }
308
328
  /** Perspectives are one document per project: `/api/v1/projects/<project>/perspectives`. */
309
329
  function perspectivesPath(project) {
310
330
  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.31.3",
3
+ "version": "1.32.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.31.3",
3
+ "version": "1.32.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {