ccqa 1.31.4 → 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);
@@ -17154,6 +17257,7 @@ async function buildSkeleton(tree) {
17154
17257
  specName: s.specName,
17155
17258
  title: meta.title,
17156
17259
  summary: "",
17260
+ ...meta.steps.length > 0 ? { steps: meta.steps } : {},
17157
17261
  status,
17158
17262
  ...lastEdit ? { changedAt: lastEdit } : {}
17159
17263
  };
@@ -17227,7 +17331,8 @@ function noteKey(featureName, specName) {
17227
17331
  function readSpecMeta(specName, specYaml) {
17228
17332
  if (specYaml === null) return {
17229
17333
  title: specName,
17230
- mode: DEFAULT_SPEC_MODE
17334
+ mode: DEFAULT_SPEC_MODE,
17335
+ steps: []
17231
17336
  };
17232
17337
  try {
17233
17338
  const parsed = parse(specYaml);
@@ -17235,16 +17340,39 @@ function readSpecMeta(specName, specYaml) {
17235
17340
  const modeResult = SpecModeSchema.safeParse(parsed.mode);
17236
17341
  return {
17237
17342
  title,
17238
- mode: modeResult.success ? modeResult.data : DEFAULT_SPEC_MODE
17343
+ mode: modeResult.success ? modeResult.data : DEFAULT_SPEC_MODE,
17344
+ steps: transcribeSteps(parsed.steps)
17239
17345
  };
17240
17346
  } catch {
17241
17347
  return {
17242
17348
  title: specName,
17243
- mode: DEFAULT_SPEC_MODE
17349
+ mode: DEFAULT_SPEC_MODE,
17350
+ steps: []
17244
17351
  };
17245
17352
  }
17246
17353
  }
17247
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
+ /**
17248
17376
  * Resolve a spec's generation target for coverage derivation, from its
17249
17377
  * already-read spec.yaml. Best-effort: an unparseable spec or a target that
17250
17378
  * can't be resolved (unknown id, agent-browser-only field misuse) falls back
@@ -18346,7 +18474,7 @@ function requireKey(config) {
18346
18474
  return config.encryptionKey;
18347
18475
  }
18348
18476
  /** Validate the `:project`/`:profile` route params into a store scope. */
18349
- function requireScope(ctx) {
18477
+ function requireScope$1(ctx) {
18350
18478
  return {
18351
18479
  project: requireSafeSegment(ctx.params.project, "project"),
18352
18480
  profile: requireSafeSegment(ctx.params.profile, "profile")
@@ -18356,7 +18484,7 @@ function requireScope(ctx) {
18356
18484
  function createPutSessionHandler(config) {
18357
18485
  return async (ctx) => {
18358
18486
  const key = requireKey(config);
18359
- const scope = requireScope(ctx);
18487
+ const scope = requireScope$1(ctx);
18360
18488
  const name = requireSafeSegment(ctx.params.name, "name");
18361
18489
  const body = await readBody(ctx.req, MAX_SECRET_BODY_BYTES);
18362
18490
  const blob = encodeEncryptedBlob(encrypt(new Uint8Array(body), key));
@@ -18368,7 +18496,7 @@ function createPutSessionHandler(config) {
18368
18496
  /** GET /api/v1/projects/:project/sessions/:profile — metadata only (names + timestamps). */
18369
18497
  function createListSessionsHandler(config) {
18370
18498
  return async (ctx) => {
18371
- const scope = requireScope(ctx);
18499
+ const scope = requireScope$1(ctx);
18372
18500
  const entries = await config.store.list(scope);
18373
18501
  sendJson(ctx.res, 200, { sessions: entries.map((e) => ({
18374
18502
  name: e.name,
@@ -18385,7 +18513,7 @@ function createListSessionsHandler(config) {
18385
18513
  function createGetSessionHandler(config) {
18386
18514
  return async (ctx) => {
18387
18515
  const key = requireKey(config);
18388
- const scope = requireScope(ctx);
18516
+ const scope = requireScope$1(ctx);
18389
18517
  const name = requireSafeSegment(ctx.params.name, "name");
18390
18518
  const stored = await config.store.get(scope, name);
18391
18519
  if (!stored) throw new HttpError(404, "not_found", `session "${name}" not found for ${scope.project}/${scope.profile}`);
@@ -18396,7 +18524,7 @@ function createGetSessionHandler(config) {
18396
18524
  /** DELETE /api/v1/projects/:project/sessions/:profile/:name */
18397
18525
  function createDeleteSessionHandler(config) {
18398
18526
  return async (ctx) => {
18399
- const scope = requireScope(ctx);
18527
+ const scope = requireScope$1(ctx);
18400
18528
  const name = requireSafeSegment(ctx.params.name, "name");
18401
18529
  await config.store.delete(scope, name);
18402
18530
  ctx.res.statusCode = 204;
@@ -18407,7 +18535,7 @@ function createDeleteSessionHandler(config) {
18407
18535
  function createPutVariableHandler(config) {
18408
18536
  return async (ctx) => {
18409
18537
  const key = requireKey(config);
18410
- const scope = requireScope(ctx);
18538
+ const scope = requireScope$1(ctx);
18411
18539
  const name = requireSafeSegment(ctx.params.name, "name");
18412
18540
  const body = await readBody(ctx.req, MAX_SECRET_BODY_BYTES);
18413
18541
  const parsed = PutVariableRequestSchema.safeParse(JSON.parse(body.toString("utf8") || "{}"));
@@ -18427,7 +18555,7 @@ function createPutVariableHandler(config) {
18427
18555
  */
18428
18556
  function createListVariablesHandler(config) {
18429
18557
  return async (ctx) => {
18430
- const scope = requireScope(ctx);
18558
+ const scope = requireScope$1(ctx);
18431
18559
  const includeValues = ctx.url.searchParams.get("include") === "values";
18432
18560
  const key = includeValues ? requireKey(config) : config.encryptionKey;
18433
18561
  const entries = await config.store.list(scope);
@@ -18462,7 +18590,7 @@ function createListVariablesHandler(config) {
18462
18590
  /** DELETE /api/v1/projects/:project/variables/:profile/:name */
18463
18591
  function createDeleteVariableHandler(config) {
18464
18592
  return async (ctx) => {
18465
- const scope = requireScope(ctx);
18593
+ const scope = requireScope$1(ctx);
18466
18594
  const name = requireSafeSegment(ctx.params.name, "name");
18467
18595
  await config.store.delete(scope, name);
18468
18596
  ctx.res.statusCode = 204;
@@ -18801,7 +18929,7 @@ function createGetAuditNeedHandler(storage) {
18801
18929
  //#endregion
18802
18930
  //#region src/hub/api/handlers/locks.ts
18803
18931
  /** A spec-key list and three short strings; nothing here should approach this. */
18804
- const MAX_BODY_BYTES$3 = 1024 * 1024;
18932
+ const MAX_BODY_BYTES$4 = 1024 * 1024;
18805
18933
  /**
18806
18934
  * POST /api/v1/projects/:project/locks?profile=
18807
18935
  *
@@ -18815,7 +18943,7 @@ function createAcquireLocksHandler(storage) {
18815
18943
  return async (ctx) => {
18816
18944
  const project = requireSafeSegment(ctx.params.project, "project");
18817
18945
  const profile = requireProfileParam(ctx.url);
18818
- 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");
18819
18947
  let result = {
18820
18948
  granted: [],
18821
18949
  denied: []
@@ -18844,7 +18972,7 @@ function createReleaseLocksHandler(storage) {
18844
18972
  return async (ctx) => {
18845
18973
  const project = requireSafeSegment(ctx.params.project, "project");
18846
18974
  const profile = requireProfileParam(ctx.url);
18847
- 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");
18848
18976
  await storage.locks.update(project, profile, (current) => releaseAll(current, body.holder));
18849
18977
  ctx.res.writeHead(204).end();
18850
18978
  };
@@ -18857,7 +18985,7 @@ function createReleaseLocksHandler(storage) {
18857
18985
  * 5000 keys of 256 `\uXXXX`-escaped characters — so a conforming client is
18858
18986
  * never answered 413 by a limit the documented bounds don't mention.
18859
18987
  */
18860
- const MAX_BODY_BYTES$2 = 8 * 1024 * 1024;
18988
+ const MAX_BODY_BYTES$3 = 8 * 1024 * 1024;
18861
18989
  function requireAckKey(ctx) {
18862
18990
  return {
18863
18991
  project: requireSafeSegment(ctx.params.project, "project"),
@@ -18884,7 +19012,7 @@ function createGetAckHandler(storage) {
18884
19012
  function createPutAckHandler(storage) {
18885
19013
  return async (ctx) => {
18886
19014
  const key = requireAckKey(ctx);
18887
- 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");
18888
19016
  const ack = await storage.acks.put(key.project, key.profile, key.name, body.keys);
18889
19017
  sendJson(ctx.res, 200, {
18890
19018
  ...key,
@@ -18893,6 +19021,80 @@ function createPutAckHandler(storage) {
18893
19021
  };
18894
19022
  }
18895
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
18896
19098
  //#region src/hub/api/handlers/spend.ts
18897
19099
  /** One entry is a handful of short fields; anything larger is a malformed client. */
18898
19100
  const MAX_BODY_BYTES$1 = 4 * 1024;
@@ -18965,7 +19167,7 @@ function specMovedSince(changedAt, baselineSha, baselineAt, deployTimes) {
18965
19167
  return changedAt > (baselineSha && deployTimes.get(baselineSha) || baselineAt) ? changedAt : null;
18966
19168
  }
18967
19169
  function computeRerun(input) {
18968
- const { specs, ledger, log, touchIndex, drift, locks, now } = input;
19170
+ const { specs, ledger, log, touchIndex, drift, locks, attestations, now } = input;
18969
19171
  const range = buildRange(log, touchIndex);
18970
19172
  const deployTimes = deployedAt(log);
18971
19173
  const out = {};
@@ -18983,11 +19185,23 @@ function computeRerun(input) {
18983
19185
  if (auditMoved && audit.audit !== "due") audit = { audit: "due" };
18984
19186
  if (runMoved && execution.execution === "passed") execution = { execution: "stale" };
18985
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";
18986
19191
  out[spec.key] = {
18987
- verdict: decide(audit.audit, execution.execution, held),
19192
+ verdict,
18988
19193
  ...auditMoved || runMoved ? { specChangedSince: auditMoved ?? runMoved } : {},
18989
19194
  ...audit,
18990
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
+ } : {},
18991
19205
  heldBy: held,
18992
19206
  ...coords
18993
19207
  };
@@ -18995,6 +19209,50 @@ function computeRerun(input) {
18995
19209
  return out;
18996
19210
  }
18997
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
+ /**
18998
19256
  * Axis 1, derived from the same freshness answer `--only-hub-audit-needed`
18999
19257
  * reads. The label only speaks once the audit is known to be current: a
19000
19258
  * verdict about an older commit says nothing about the one running now.
@@ -19098,13 +19356,14 @@ function createGetRerunHandler(storage) {
19098
19356
  return async (ctx) => {
19099
19357
  const project = requireSafeSegment(ctx.params.project, "project");
19100
19358
  const profile = requireProfileParam(ctx.url);
19101
- const [specs, ledger, log, touchIndex, drift, locks] = await Promise.all([
19359
+ const [specs, ledger, log, touchIndex, drift, locks, attestations] = await Promise.all([
19102
19360
  requireSpecTargets(storage.perspectives, project, "which specs need a re-run"),
19103
19361
  storage.ledger.getMerged(project, profile),
19104
19362
  storage.deploys.getLog(project, profile),
19105
19363
  storage.deploys.getTouchIndex(project, profile),
19106
19364
  storage.driftLedger.getMerged(project),
19107
- storage.locks.get(project, profile)
19365
+ storage.locks.get(project, profile),
19366
+ storage.attestations.get(project, profile)
19108
19367
  ]);
19109
19368
  const head = log.entries[log.entries.length - 1];
19110
19369
  sendJson(ctx.res, 200, {
@@ -19118,6 +19377,7 @@ function createGetRerunHandler(storage) {
19118
19377
  touchIndex,
19119
19378
  drift,
19120
19379
  locks,
19380
+ attestations,
19121
19381
  now: /* @__PURE__ */ new Date()
19122
19382
  })
19123
19383
  });
@@ -19842,6 +20102,7 @@ const HTML_BODY = `
19842
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>
19843
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>
19844
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>
19845
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>
19846
20107
  </div>
19847
20108
  <div class="spacer"></div>
@@ -20567,6 +20828,9 @@ const CSS = `
20567
20828
  .sg-audit-undecided { background: var(--info); }
20568
20829
  .sg-verified, .sg-audit-clean, .sg-exec-passed { background: var(--pass); }
20569
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); }
20570
20834
 
20571
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); }
20572
20836
  .search svg { width: 15px; height: 15px; flex: none; color: var(--muted-2); }
@@ -20605,6 +20869,11 @@ const CSS = `
20605
20869
  .badge.rr-unknown .d { background: var(--info); }
20606
20870
  .badge.rr-none { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
20607
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); }
20608
20877
  .cellsub { display: block; margin-top: 3px; max-width: 260px; color: var(--muted); font-size: 11.5px; line-height: 1.45; }
20609
20878
  .graded-mark { color: var(--fg-dim); font-weight: 600; }
20610
20879
  .cellsub a { color: var(--muted); text-decoration: none; border-bottom: 1px dotted var(--border-strong); }
@@ -20660,6 +20929,11 @@ const CSS = `
20660
20929
  .d-paths { display: flex; flex-wrap: wrap; gap: 6px; }
20661
20930
  .d-paths code { white-space: nowrap; }
20662
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; }
20663
20937
  .notebox { margin-top: 14px; max-width: 900px; }
20664
20938
  .notebox .nlabel { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
20665
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; }
@@ -20700,6 +20974,7 @@ const CLIENT_JS = `
20700
20974
  var THEME_KEY = "ccqa-hub-theme";
20701
20975
  var PROJECT_KEY = "ccqa-hub-project";
20702
20976
  var PROFILES_KEY = "ccqa-hub-profiles";
20977
+ var ATTEST_BY_KEY = "ccqa-attest-by";
20703
20978
 
20704
20979
  // ── i18n ──────────────────────────────────────────────────────────────
20705
20980
  // Chrome + labels only. Model output (headline/recommendation/reasoning) is
@@ -20788,6 +21063,8 @@ const CLIENT_JS = `
20788
21063
  "perspectives.ov.cases": "cases", "perspectives.ov.features": "features",
20789
21064
  "perspectives.d.preconditions": "Preconditions", "perspectives.d.startScreen": "Start screen",
20790
21065
  "perspectives.d.testCondition": "Condition", "perspectives.d.spec": "spec",
21066
+ "perspectives.d.steps": "Steps", "perspectives.d.stepInclude": "Include: {name}",
21067
+ "perspectives.d.stepExpected": "Expected:",
20791
21068
  "perspectives.note.label": "Note",
20792
21069
  "perspectives.note.placeholder": "Notes about this case…",
20793
21070
  "perspectives.note.saved": "Saved",
@@ -20800,6 +21077,7 @@ const CLIENT_JS = `
20800
21077
  "perspectives.rerun.state.needsRepair": "Needs repair",
20801
21078
  "perspectives.rerun.state.rerunNeeded": "Re-run needed",
20802
21079
  "perspectives.rerun.state.inProgress": "In progress",
21080
+ "perspectives.rerun.state.manuallyVerified": "Manually verified",
20803
21081
  "perspectives.rerun.state.verified": "Verified",
20804
21082
  "perspectives.rerun.vsDeploy": "judged against deploy",
20805
21083
  "perspectives.rerun.noDeployHead": "no deploy recorded for this profile",
@@ -20835,6 +21113,19 @@ const CLIENT_JS = `
20835
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.",
20836
21114
  "perspectives.rerun.deployHead": "deploy head",
20837
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",
20838
21129
  "prompt.card.record": "Recording browser actions",
20839
21130
  "prompt.card.live": "Live run (AI-driven)",
20840
21131
  "prompt.card.playwright": "Playwright test generation",
@@ -20952,6 +21243,8 @@ const CLIENT_JS = `
20952
21243
  "perspectives.ov.cases": "ケース", "perspectives.ov.features": "機能",
20953
21244
  "perspectives.d.preconditions": "前提条件", "perspectives.d.startScreen": "開始画面",
20954
21245
  "perspectives.d.testCondition": "実行条件", "perspectives.d.spec": "spec",
21246
+ "perspectives.d.steps": "手順", "perspectives.d.stepInclude": "ブロック: {name}",
21247
+ "perspectives.d.stepExpected": "期待結果:",
20955
21248
  "perspectives.note.label": "note",
20956
21249
  "perspectives.note.placeholder": "このケースについてのメモ…",
20957
21250
  "perspectives.note.saved": "保存しました",
@@ -20964,6 +21257,7 @@ const CLIENT_JS = `
20964
21257
  "perspectives.rerun.state.needsRepair": "修正待ち",
20965
21258
  "perspectives.rerun.state.rerunNeeded": "要再実行",
20966
21259
  "perspectives.rerun.state.inProgress": "進行中",
21260
+ "perspectives.rerun.state.manuallyVerified": "手動確認済み",
20967
21261
  "perspectives.rerun.state.verified": "検証済み",
20968
21262
  "perspectives.rerun.vsDeploy": "判定基準: デプロイ",
20969
21263
  "perspectives.rerun.noDeployHead": "このプロファイルにはデプロイの記録がありません",
@@ -20999,6 +21293,19 @@ const CLIENT_JS = `
20999
21293
  "perspectives.rerun.noDeployLogBanner": "プロファイル {profile} にデプロイの記録がないため、どのケースも判定できません。この環境のデプロイジョブに ccqa hub deploy record を組み込んでください。",
21000
21294
  "perspectives.rerun.deployHead": "最新デプロイ",
21001
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が認識できない理由により手動確認が失効",
21002
21309
  "prompt.card.record": "ブラウザ操作の記録",
21003
21310
  "prompt.card.live": "ライブ実行(AI操作)",
21004
21311
  "prompt.card.playwright": "Playwrightテスト生成",
@@ -21132,6 +21439,17 @@ const CLIENT_JS = `
21132
21439
  try { window.localStorage.removeItem(PROJECT_KEY); }
21133
21440
  catch (e) { console.warn("ccqa hub: could not clear stored project:", e); }
21134
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
+ }
21135
21453
  function loadProfileMap() {
21136
21454
  try {
21137
21455
  var raw = window.localStorage.getItem(PROFILES_KEY);
@@ -23080,6 +23398,11 @@ const CLIENT_JS = `
23080
23398
  "/rerun?profile=" + encodeURIComponent(state.profile);
23081
23399
  }
23082
23400
 
23401
+ function attestationsPath() {
23402
+ return "/api/v1/projects/" + encodeURIComponent(state.project) +
23403
+ "/attestations?profile=" + encodeURIComponent(state.profile);
23404
+ }
23405
+
23083
23406
  // Resolves { report } or { note } and never rejects: a hub that predates
23084
23407
  // the endpoint costs only the columns it feeds, not the whole tab. A 404
23085
23408
  // here can only mean "no such route" — the endpoint's own 404 is "the
@@ -23158,6 +23481,37 @@ const CLIENT_JS = `
23158
23481
  return text === prefix + reason ? t(prefix + "unrecognized") : text;
23159
23482
  }
23160
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
+
23161
23515
  // Every verdict that carries no evidence row explains itself here, in the
23162
23516
  // actionable phrasing the detail panel wants. decide() checks heldBy before
23163
23517
  // the audit axis, so a spec another job already holds must be explained by
@@ -23177,6 +23531,7 @@ const CLIENT_JS = `
23177
23531
  ? rerunReasonText("perspectives.rerun.fix.", rr.auditAssumedReached)
23178
23532
  : t("perspectives.rerun.inProgressHint");
23179
23533
  }
23534
+ if (rr.verdict === "manuallyVerified" && rr.manual) return manualAttestationText(rr.manual);
23180
23535
  return rerunReasonText("perspectives.rerun.fix.", rr.verdict);
23181
23536
  }
23182
23537
 
@@ -23195,16 +23550,23 @@ const CLIENT_JS = `
23195
23550
  // rather than claiming a deploy matched it.
23196
23551
  function rerunCellWhy(rr) {
23197
23552
  var head = perspState.rerun && perspState.rerun.deployHead;
23553
+ var why;
23198
23554
  if (rr.verdict === "rerunNeeded") {
23199
- if (rr.executionAssumedReached) return rerunReasonText("perspectives.rerun.why.", rr.executionAssumedReached);
23200
- if (!rr.touchedBy || !rr.touchedBy.length) return t("perspectives.rerun.touchedUnknown");
23201
- return t("perspectives.rerun.touchedCount").replace("{n}", String(rr.touchedBy.length));
23202
- }
23203
- if (rr.verdict === "verified") {
23204
- if (!head) return t("perspectives.rerun.noDeployHead");
23205
- 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);
23206
23564
  }
23207
- 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;
23208
23570
  }
23209
23571
 
23210
23572
 
@@ -23218,10 +23580,10 @@ const CLIENT_JS = `
23218
23580
  // the pipeline still owes, then what needs nothing. "needsRepair" leads
23219
23581
  // because it is the only verdict a run cannot clear — someone has to repair
23220
23582
  // the spec or the product.
23221
- var RERUN_ORDER = ["needsRepair", "rerunNeeded", "inProgress", "verified"];
23583
+ var RERUN_ORDER = ["needsRepair", "rerunNeeded", "inProgress", "manuallyVerified", "verified"];
23222
23584
  var RERUN_SEG_CLASS = {
23223
23585
  needsRepair: "sg-needsrepair", rerunNeeded: "sg-rerunneeded",
23224
- inProgress: "sg-inprogress", verified: "sg-verified"
23586
+ inProgress: "sg-inprogress", manuallyVerified: "sg-manual", verified: "sg-verified"
23225
23587
  };
23226
23588
 
23227
23589
  // The one rule the summary bar and the verdict filter chips both answer
@@ -23235,7 +23597,7 @@ const CLIENT_JS = `
23235
23597
 
23236
23598
  // One verdict per case, bucketed, via rerunVerdictOf above.
23237
23599
  function rerunComposition(verdicts) {
23238
- var counts = { needsRepair: 0, rerunNeeded: 0, inProgress: 0, verified: 0 };
23600
+ var counts = { needsRepair: 0, rerunNeeded: 0, inProgress: 0, manuallyVerified: 0, verified: 0 };
23239
23601
  verdicts.forEach(function (rr) { counts[rerunVerdictOf(rr)] += 1; });
23240
23602
  return counts;
23241
23603
  }
@@ -23403,7 +23765,7 @@ const CLIENT_JS = `
23403
23765
  // takes the attention colour and re-running — machine work — does not.
23404
23766
  var VERDICT_BADGE = {
23405
23767
  needsRepair: "rr-repair", rerunNeeded: "rr-needed",
23406
- inProgress: "rr-none", verified: "passed"
23768
+ inProgress: "rr-none", manuallyVerified: "rr-manual", verified: "passed"
23407
23769
  };
23408
23770
 
23409
23771
  function perspVerdictCell(rr) {
@@ -23734,6 +24096,43 @@ const CLIENT_JS = `
23734
24096
  return wrap;
23735
24097
  }
23736
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
+
23737
24136
  // Detail row: a definition list of the case's fields plus the note editor.
23738
24137
  // Built with createElement/textContent throughout — every field here is
23739
24138
  // API-derived, so none of it may go through innerHTML.
@@ -23771,6 +24170,44 @@ const CLIENT_JS = `
23771
24170
  }
23772
24171
  frag.appendChild(dl);
23773
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
+
23774
24211
  var notebox = el("div", "notebox");
23775
24212
  notebox.appendChild(el("div", "nlabel", t("perspectives.note.label")));
23776
24213
  var ta = el("textarea");
@@ -25062,6 +25499,9 @@ function registerRoutes(router, config, queue) {
25062
25499
  router.get("/api/v1/projects/:project/audit-needed", createGetAuditNeedHandler(storage));
25063
25500
  router.post("/api/v1/projects/:project/locks", createAcquireLocksHandler(storage));
25064
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));
25065
25505
  router.get("/api/v1/projects/:project/acks/:name", createGetAckHandler(storage));
25066
25506
  router.put("/api/v1/projects/:project/acks/:name", createPutAckHandler(storage));
25067
25507
  router.post("/api/v1/projects/:project/spend", createRecordSpendHandler(storage));
@@ -25351,6 +25791,9 @@ function deployTouchIndexPath(root, project, profile) {
25351
25791
  function specLocksPath(root, project, profile) {
25352
25792
  return join(root, "locks", project, profile, "locks.json");
25353
25793
  }
25794
+ function attestationsPath(root, project, profile) {
25795
+ return join(root, "attestations", project, profile, "attestations.json");
25796
+ }
25354
25797
  function ackPath(root, project, profile, name) {
25355
25798
  return join(root, "acks", project, profile, `${name}.json`);
25356
25799
  }
@@ -25392,6 +25835,22 @@ function createFileAckStore(root) {
25392
25835
  };
25393
25836
  }
25394
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
25395
25854
  //#region src/hub/core/storage/file/artifact-store.ts
25396
25855
  /**
25397
25856
  * Defense-in-depth: `relPath` is expected to already be validated by the
@@ -25875,7 +26334,8 @@ function createFileHubStorage(dataDir) {
25875
26334
  deploys: createFileDeployStore(dataDir),
25876
26335
  locks: createFileLockStore(dataDir),
25877
26336
  acks: createFileAckStore(dataDir),
25878
- spend: createFileSpendStore(dataDir)
26337
+ spend: createFileSpendStore(dataDir),
26338
+ attestations: createFileAttestationStore(dataDir)
25879
26339
  };
25880
26340
  }
25881
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.4",
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.4",
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": {