@patronage/software-factory 1.0.0-alpha.41 → 1.0.0-alpha.42

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/index.d.ts CHANGED
@@ -138,8 +138,8 @@ declare const boundaryManifestBaseSchema: z.ZodObject<{
138
138
  integrationBranch: z.ZodOptional<z.ZodString>;
139
139
  schemaVersion: z.ZodLiteral<1>;
140
140
  topology: z.ZodEnum<{
141
- flagged: "flagged";
142
141
  "each-to-main": "each-to-main";
142
+ flagged: "flagged";
143
143
  stacked: "stacked";
144
144
  "each-to-epic": "each-to-epic";
145
145
  }>;
@@ -167,8 +167,8 @@ declare const boundaryManifestSchema: z.ZodObject<{
167
167
  integrationBranch: z.ZodOptional<z.ZodString>;
168
168
  schemaVersion: z.ZodLiteral<1>;
169
169
  topology: z.ZodEnum<{
170
- flagged: "flagged";
171
170
  "each-to-main": "each-to-main";
171
+ flagged: "flagged";
172
172
  stacked: "stacked";
173
173
  "each-to-epic": "each-to-epic";
174
174
  }>;
@@ -228,7 +228,7 @@ interface GithubIssueComment {
228
228
  }
229
229
  declare const normalizeIssueComments: (value: GithubIssueCommentApi[] | GithubIssueCommentApi[][]) => GithubIssueComment[];
230
230
  declare namespace boundary_review_proof_d_exports {
231
- export { BOUNDARY_REVIEW_PROOF_KIND, BoundaryReviewProof, BoundaryReviewProofComment, boundaryReviewProofAuthorsSeen, boundaryReviewProofSchema, selectBoundaryReviewProof, undispositionedBlockingFindings };
231
+ export { BOUNDARY_REVIEW_PROOF_KIND, BoundaryReviewProof, BoundaryReviewProofComment, BoundaryReviewProofParseFailure, boundaryReviewProofAuthorsSeen, boundaryReviewProofParseFailures, boundaryReviewProofSchema, selectBoundaryReviewProof, undispositionedBlockingFindings };
232
232
  }
233
233
  declare const BOUNDARY_REVIEW_PROOF_KIND = "boundary-review-proof";
234
234
  declare const boundaryReviewProofBaseSchema: z.ZodObject<{
@@ -325,8 +325,14 @@ interface BoundaryReviewProofComment {
325
325
  commentId?: string | number;
326
326
  commentUrl?: string;
327
327
  }
328
+ interface BoundaryReviewProofParseFailure {
329
+ commentAuthor: string;
330
+ commentUrl?: string;
331
+ parseError: string;
332
+ }
328
333
  declare const selectBoundaryReviewProof: (comments: GithubIssueComment[], boundary: string, declaredBy: string) => BoundaryReviewProofComment | undefined;
329
334
  declare const boundaryReviewProofAuthorsSeen: (comments: GithubIssueComment[], boundary: string) => string[];
335
+ declare const boundaryReviewProofParseFailures: (comments: GithubIssueComment[], boundary: string) => BoundaryReviewProofParseFailure[];
330
336
  declare const undispositionedBlockingFindings: (proof: BoundaryReviewProof) => string[];
331
337
  //#endregion
332
338
  //#region src/boundary-membership-graph.d.ts
@@ -2017,8 +2023,8 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
2017
2023
  "ready-for-human": "ready-for-human";
2018
2024
  }>;
2019
2025
  stage: z.ZodEnum<{
2020
- interior: "interior";
2021
2026
  gate: "gate";
2027
+ interior: "interior";
2022
2028
  "interior-complete": "interior-complete";
2023
2029
  }>;
2024
2030
  }, z.core.$strip>>;
@@ -2034,9 +2040,9 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
2034
2040
  reviewedHeadSha: z.ZodOptional<z.ZodString>;
2035
2041
  reviewedPatchId: z.ZodOptional<z.ZodString>;
2036
2042
  status: z.ZodEnum<{
2037
- "not-required": "not-required";
2038
2043
  stale: "stale";
2039
2044
  blocked: "blocked";
2045
+ "not-required": "not-required";
2040
2046
  current: "current";
2041
2047
  missing: "missing";
2042
2048
  }>;
@@ -2046,9 +2052,9 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
2046
2052
  reviewedHeadSha: z.ZodOptional<z.ZodString>;
2047
2053
  reviewedPatchId: z.ZodOptional<z.ZodString>;
2048
2054
  status: z.ZodEnum<{
2049
- "not-required": "not-required";
2050
2055
  stale: "stale";
2051
2056
  blocked: "blocked";
2057
+ "not-required": "not-required";
2052
2058
  current: "current";
2053
2059
  missing: "missing";
2054
2060
  }>;
@@ -2500,6 +2506,12 @@ interface CompletedFactoryCheckSnapshot {
2500
2506
  readonly detailsUrl?: string;
2501
2507
  readonly headSha: string;
2502
2508
  readonly name: FactoryPrStatusCheckName;
2509
+ /**
2510
+ * Merge freeze only (#1501): the cancellation-only generations, newest
2511
+ * first, that the freeze selection read past to reach this snapshot's
2512
+ * generation (#1158). Absent when the newest generation stands.
2513
+ */
2514
+ readonly readPastRunIds?: readonly number[];
2503
2515
  }
2504
2516
  interface FailedPreviewSnapshot {
2505
2517
  readonly completedAt: string;
@@ -3119,9 +3131,9 @@ declare const loadEvidenceEnvelopes: (cwd: string) => LoadedEvidenceEnvelope[];
3119
3131
  //#region src/review-proof-applicability.d.ts
3120
3132
  declare const REVIEW_STATUS_VALUES: readonly ["not-required", "current", "stale", "missing", "blocked"];
3121
3133
  declare const reviewStatusSchema: z.ZodEnum<{
3122
- "not-required": "not-required";
3123
3134
  stale: "stale";
3124
3135
  blocked: "blocked";
3136
+ "not-required": "not-required";
3125
3137
  current: "current";
3126
3138
  missing: "missing";
3127
3139
  }>;
@@ -4080,8 +4092,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
4080
4092
  sessionId: z.ZodOptional<z.ZodString>;
4081
4093
  }, z.core.$strict>>;
4082
4094
  stage: z.ZodEnum<{
4083
- interior: "interior";
4084
4095
  gate: "gate";
4096
+ interior: "interior";
4085
4097
  }>;
4086
4098
  usage: z.ZodOptional<z.ZodObject<{
4087
4099
  estimatedCostUsd: z.ZodOptional<z.ZodNumber>;
@@ -4133,8 +4145,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
4133
4145
  sessionId: z.ZodOptional<z.ZodString>;
4134
4146
  }, z.core.$strict>>;
4135
4147
  stage: z.ZodEnum<{
4136
- interior: "interior";
4137
4148
  gate: "gate";
4149
+ interior: "interior";
4138
4150
  }>;
4139
4151
  usage: z.ZodOptional<z.ZodObject<{
4140
4152
  estimatedCostUsd: z.ZodOptional<z.ZodNumber>;
@@ -4177,8 +4189,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
4177
4189
  sessionId: z.ZodOptional<z.ZodString>;
4178
4190
  }, z.core.$strict>>;
4179
4191
  stage: z.ZodEnum<{
4180
- interior: "interior";
4181
4192
  gate: "gate";
4193
+ interior: "interior";
4182
4194
  }>;
4183
4195
  usage: z.ZodOptional<z.ZodObject<{
4184
4196
  estimatedCostUsd: z.ZodOptional<z.ZodNumber>;
@@ -4208,7 +4220,7 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
4208
4220
  staleRepeats: number;
4209
4221
  };
4210
4222
  observedAt: string;
4211
- stage: "interior" | "gate";
4223
+ stage: "gate" | "interior";
4212
4224
  issue?: number | undefined;
4213
4225
  pr?: number | undefined;
4214
4226
  threadId?: string | undefined;
@@ -4668,13 +4680,13 @@ declare const evaluateReadiness: (input: EvaluationInput) => {
4668
4680
  reviews: {
4669
4681
  correctness: {
4670
4682
  required: boolean;
4671
- status: "not-required" | "stale" | "blocked" | "current" | "missing";
4683
+ status: "stale" | "blocked" | "not-required" | "current" | "missing";
4672
4684
  reviewedHeadSha?: string | undefined;
4673
4685
  reviewedPatchId?: string | undefined;
4674
4686
  };
4675
4687
  security?: {
4676
4688
  required: boolean;
4677
- status: "not-required" | "stale" | "blocked" | "current" | "missing";
4689
+ status: "stale" | "blocked" | "not-required" | "current" | "missing";
4678
4690
  reviewedHeadSha?: string | undefined;
4679
4691
  reviewedPatchId?: string | undefined;
4680
4692
  } | undefined;
@@ -4737,7 +4749,7 @@ declare const evaluateReadiness: (input: EvaluationInput) => {
4737
4749
  interior: number;
4738
4750
  };
4739
4751
  nextAction: "run-interior-cycle" | "advance-to-gate" | "run-gate-cycle" | "accept-nonblocking-findings" | "escalate-to-triage" | "ready-for-human";
4740
- stage: "interior" | "gate" | "interior-complete";
4752
+ stage: "gate" | "interior" | "interior-complete";
4741
4753
  forcedTransition?: "gate-cap-exhausted" | "interior-cap-reached" | undefined;
4742
4754
  } | undefined;
4743
4755
  reviewRuns?: PrReviewResult[] | undefined;
@@ -4787,13 +4799,13 @@ declare const evaluateReadiness: (input: EvaluationInput) => {
4787
4799
  reviews: {
4788
4800
  correctness: {
4789
4801
  required: boolean;
4790
- status: "not-required" | "stale" | "blocked" | "current" | "missing";
4802
+ status: "stale" | "blocked" | "not-required" | "current" | "missing";
4791
4803
  reviewedHeadSha?: string | undefined;
4792
4804
  reviewedPatchId?: string | undefined;
4793
4805
  };
4794
4806
  security?: {
4795
4807
  required: boolean;
4796
- status: "not-required" | "stale" | "blocked" | "current" | "missing";
4808
+ status: "stale" | "blocked" | "not-required" | "current" | "missing";
4797
4809
  reviewedHeadSha?: string | undefined;
4798
4810
  reviewedPatchId?: string | undefined;
4799
4811
  } | undefined;
@@ -4856,7 +4868,7 @@ declare const evaluateReadiness: (input: EvaluationInput) => {
4856
4868
  interior: number;
4857
4869
  };
4858
4870
  nextAction: "run-interior-cycle" | "advance-to-gate" | "run-gate-cycle" | "accept-nonblocking-findings" | "escalate-to-triage" | "ready-for-human";
4859
- stage: "interior" | "gate" | "interior-complete";
4871
+ stage: "gate" | "interior" | "interior-complete";
4860
4872
  forcedTransition?: "gate-cap-exhausted" | "interior-cap-reached" | undefined;
4861
4873
  } | undefined;
4862
4874
  reviewRuns?: PrReviewResult[] | undefined;
package/dist/index.js CHANGED
@@ -22,7 +22,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
22
22
  import { pathToFileURL } from "node:url";
23
23
  //#region package.json
24
24
  var name = "@patronage/software-factory";
25
- var version = "1.0.0-alpha.41";
25
+ var version = "1.0.0-alpha.42";
26
26
  //#endregion
27
27
  //#region src/git-globals-parse.ts
28
28
  const VALUE_TAKING_GIT_OPTIONS = new Set([
@@ -2507,6 +2507,7 @@ const coveredSetDivergences = (computed, proof) => {
2507
2507
  var boundary_review_proof_exports = /* @__PURE__ */ __exportAll({
2508
2508
  BOUNDARY_REVIEW_PROOF_KIND: () => BOUNDARY_REVIEW_PROOF_KIND$1,
2509
2509
  boundaryReviewProofAuthorsSeen: () => boundaryReviewProofAuthorsSeen,
2510
+ boundaryReviewProofParseFailures: () => boundaryReviewProofParseFailures,
2510
2511
  boundaryReviewProofSchema: () => boundaryReviewProofSchema$1,
2511
2512
  selectBoundaryReviewProof: () => selectBoundaryReviewProof,
2512
2513
  undispositionedBlockingFindings: () => undispositionedBlockingFindings
@@ -2561,8 +2562,11 @@ const boundaryReviewProofSchema$1 = z.object({
2561
2562
  }).passthrough().optional()
2562
2563
  }).passthrough();
2563
2564
  const FENCED_JSON_PATTERN = /```json[ \t]*\r?\n(?<inner>[\s\S]*?)```/gu;
2564
- const collectValidBoundaryReviewProofs = (comments, boundary) => {
2565
- const entries = [];
2565
+ const scanBoundaryReviewProofs = (comments, boundary) => {
2566
+ const scan = {
2567
+ failed: [],
2568
+ valid: []
2569
+ };
2566
2570
  for (const comment of comments) {
2567
2571
  const commentAuthor = comment.author.login;
2568
2572
  if (!commentAuthor) continue;
@@ -2576,8 +2580,17 @@ const collectValidBoundaryReviewProofs = (comments, boundary) => {
2576
2580
  }
2577
2581
  if (typeof raw !== "object" || raw === null || raw.kind !== "boundary-review-proof") continue;
2578
2582
  const parsed = boundaryReviewProofSchema$1.safeParse(raw);
2579
- if (!parsed.success || parsed.data.boundary !== boundary) continue;
2580
- entries.push({
2583
+ if (!parsed.success) {
2584
+ const rawBoundary = raw.boundary;
2585
+ if (typeof rawBoundary !== "string" || rawBoundary === boundary) scan.failed.push({
2586
+ commentAuthor,
2587
+ ...comment.url === void 0 ? {} : { commentUrl: comment.url },
2588
+ parseError: formatZodIssues(parsed.error)
2589
+ });
2590
+ continue;
2591
+ }
2592
+ if (parsed.data.boundary !== boundary) continue;
2593
+ scan.valid.push({
2581
2594
  commentAuthor,
2582
2595
  ...comment.id === void 0 ? {} : { commentId: comment.id },
2583
2596
  ...comment.url === void 0 ? {} : { commentUrl: comment.url },
@@ -2585,12 +2598,12 @@ const collectValidBoundaryReviewProofs = (comments, boundary) => {
2585
2598
  });
2586
2599
  }
2587
2600
  }
2588
- return entries;
2601
+ return scan;
2589
2602
  };
2590
2603
  const selectBoundaryReviewProof = (comments, boundary, declaredBy) => {
2591
2604
  let selected;
2592
2605
  const declaredByLower = declaredBy.toLowerCase();
2593
- for (const entry of collectValidBoundaryReviewProofs(comments, boundary)) {
2606
+ for (const entry of scanBoundaryReviewProofs(comments, boundary).valid) {
2594
2607
  if (entry.commentAuthor.toLowerCase() !== declaredByLower) continue;
2595
2608
  selected = {
2596
2609
  ...entry.commentId === void 0 ? {} : { commentId: entry.commentId },
@@ -2602,9 +2615,10 @@ const selectBoundaryReviewProof = (comments, boundary, declaredBy) => {
2602
2615
  };
2603
2616
  const boundaryReviewProofAuthorsSeen = (comments, boundary) => {
2604
2617
  const authors = /* @__PURE__ */ new Set();
2605
- for (const entry of collectValidBoundaryReviewProofs(comments, boundary)) authors.add(entry.commentAuthor);
2618
+ for (const entry of scanBoundaryReviewProofs(comments, boundary).valid) authors.add(entry.commentAuthor);
2606
2619
  return [...authors];
2607
2620
  };
2621
+ const boundaryReviewProofParseFailures = (comments, boundary) => scanBoundaryReviewProofs(comments, boundary).failed;
2608
2622
  const undispositionedBlockingFindings = (proof) => proof.findings.filter((finding) => (finding.category !== "maintainability" || finding.blockingAfterCap === true) && finding.disposition === void 0).map((finding) => `boundary finding "${finding.title}" (category ${finding.category ?? "unknown"}) has no disposition; fix it or record waived / follow-up-filed`);
2609
2623
  //#endregion
2610
2624
  //#region src/boundary-topology.ts
@@ -3030,6 +3044,42 @@ const waivedDemandSchema = z.object({
3030
3044
  session: z.string().trim().min(1).optional(),
3031
3045
  unmetReasons: z.array(z.string().min(1)).min(1)
3032
3046
  });
3047
+ /**
3048
+ * The demands an operator waiver can reach (#354; moved out of `pr:ready` by
3049
+ * #1492 so `demand:waive` refuses by the same contract every reader folds by,
3050
+ * and can never record an authorization nothing applies).
3051
+ *
3052
+ * `pr:ready` folds the merge freeze, the profile's external required checks
3053
+ * and the boundary wave's review rung — the demands the resolver puts *in
3054
+ * force* for one candidate — plus the review-round limit (#1280), which exists
3055
+ * only to be waived and which `pr:review --assemble` folds too.
3056
+ * `boundary:check --final` folds the closeout pin `child-issue-open` (#901);
3057
+ * readiness never raises that demand, so naming it here widens nothing at
3058
+ * `pr:ready`.
3059
+ *
3060
+ * Everything else stays unwaivable. Structural refusals — candidate identity,
3061
+ * proof binding, an unreadable demand authority, wave membership — were never
3062
+ * waivable at merge time and are not waivable now; in `pr:ready` most of them
3063
+ * refuse by throwing before any blocker list exists. The evidence gates
3064
+ * readiness has always owned (verify, review modes, draft, human blockers) and
3065
+ * GitHub's own check and merge-state rollups are satisfied or not; a waiver is
3066
+ * not a second way to pass them.
3067
+ */
3068
+ const WAIVABLE_DEMAND_KEYS = [
3069
+ DEMAND_KEYS.mergeFreeze,
3070
+ DEMAND_KEYS.reviewRoundLimit,
3071
+ DEMAND_KEYS.childIssueOpen
3072
+ ];
3073
+ /** Every instance of these qualified families is waivable at `pr:ready`. */
3074
+ const WAIVABLE_QUALIFIED_DEMAND_FAMILIES = ["required-check", "review-rung"];
3075
+ const isWaivableDemand = (demand) => WAIVABLE_DEMAND_KEYS.includes(demand) || WAIVABLE_QUALIFIED_DEMAND_FAMILIES.some((family) => demand.startsWith(`${family}:`));
3076
+ /**
3077
+ * Why `demand:waive` refuses a well-formed key outside that contract. It names
3078
+ * the key and the waivable set, built from the same lists the predicate reads,
3079
+ * and says nothing about the demand itself: whether it is in force or met is
3080
+ * the reader's to decide, and a refused waiver changes neither.
3081
+ */
3082
+ const ineligibleWaiverRefusal = (demand) => `demand:waive refuses demand key "${demand}": no factory command applies a waiver of it, so a recorded waiver would authorize nothing. A waiver can name ${WAIVABLE_DEMAND_KEYS.join(", ")}, or a ${WAIVABLE_QUALIFIED_DEMAND_FAMILIES.map((family) => `${family}:<value>`).join(" or ")} key.`;
3033
3083
  /** The waivers that bind one candidate: same PR, same head. */
3034
3084
  const selectWaiversForCandidate = ({ headSha, pr, waivers }) => headSha === void 0 ? [] : waivers.filter((waiver) => waiver.candidate.pr === pr && waiver.candidate.headSha === headSha);
3035
3085
  /**
@@ -8835,7 +8885,7 @@ const readTransportedVerifyProof = (value, text) => {
8835
8885
  */
8836
8886
  const parsePrVerifyCheckPayload = (text) => {
8837
8887
  if (!text?.trim()) return;
8838
- const candidate = (text.match(/```(?:json)?\s*(?<body>[\s\S]*?)```/u)?.groups?.body ?? text).trim();
8888
+ const candidate = (text.match(/```(?:json)?\s*(?<body>[\s\S]*?)\n```/u)?.groups?.body ?? text).trim();
8839
8889
  try {
8840
8890
  const parsed = JSON.parse(candidate);
8841
8891
  if (parsed.schemaVersion !== PR_VERIFY_CHECK_PAYLOAD_SCHEMA_VERSION || parsed.kind !== "pr-verify-proof-binding" || typeof parsed.headSha !== "string" || parsed.headSha.length === 0 || !isResolvedPrVerifyMode(parsed.mode) || !isOutcome(parsed.outcome)) return;
@@ -9632,63 +9682,199 @@ const completeCheckRunPages = (pages) => {
9632
9682
  const runs = pages.flatMap((page) => page.check_runs);
9633
9683
  return pages.every((page) => page.total_count === runs.length) ? runs : void 0;
9634
9684
  };
9685
+ /** The only instant shape either pr-verify reader takes from a typed proof. */
9686
+ const CANONICAL_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/u;
9687
+ /** An instant in epoch milliseconds, rounded down to its whole second. */
9688
+ const wholeSecondMs = (ms) => Math.floor(ms / 1e3) * 1e3;
9689
+ /**
9690
+ * When the verification a transported typed proof records started: its
9691
+ * `endedAt` minus its `durationMs`, rounded down to the whole second. Nothing
9692
+ * when `endedAt` is not canonical UTC or `durationMs` is not a finite
9693
+ * non-negative number; the run is then ordered like one without a typed proof.
9694
+ */
9695
+ const proofStartMs = (proof) => {
9696
+ if (proof === void 0 || !CANONICAL_UTC.test(proof.endedAt)) return;
9697
+ const { durationMs } = proof;
9698
+ if (!(Number.isFinite(durationMs) && durationMs >= 0)) return;
9699
+ const startMs = Date.parse(proof.endedAt) - durationMs;
9700
+ return Number.isFinite(startMs) ? wholeSecondMs(startMs) : void 0;
9701
+ };
9702
+ /**
9703
+ * Whether a run is a pass: completed with conclusion `success`, and its bound
9704
+ * payload has outcome `passed`. The hosted gate's jq calls this `passing`.
9705
+ */
9706
+ const isPassingRun = (run, payload) => payload !== void 0 && run.status === "completed" && run.conclusion === "success" && payload.outcome === "passed";
9707
+ /**
9708
+ * When a passing run's verification started, by its transported typed proof
9709
+ * (`proofStartMs`). Nothing for any other run, which is ordered at its
9710
+ * `started_at` whatever proof it carries (#1506).
9711
+ */
9712
+ const passingProofStartMs = (run, payload) => isPassingRun(run, payload) ? proofStartMs(payload.verificationProof) : void 0;
9635
9713
  /** The same bound payload with the same status and conclusion. */
9636
9714
  const sameVerdict = (one, other) => one.payload !== void 0 && (one.run.status ?? "") === (other.run.status ?? "") && (one.run.conclusion ?? "") === (other.run.conclusion ?? "") && isDeepStrictEqual(one.payload, other.payload);
9637
9715
  /**
9638
- * The newest generation of the `pr:verify` proof at one head (#1409), or
9639
- * nothing when no single generation can be trusted. It is the rule #1408 gave
9640
- * the proof resume (`proof-resume.ts`, since reverted by #1411), applied to
9641
- * this reader:
9642
- *
9643
- * - A run whose bound payload transports a typed proof is ordered at the
9644
- * `endedAt` that proof records, never by when it reached GitHub. Runs that
9645
- * carry the same such payload with the same status and conclusion are one
9646
- * generation posted more than once (`pr:verify` posts, `pr:publish`
9647
- * re-posts), so an older pass re-posted after a newer failure does not
9648
- * outrank it, and the normal post plus re-post is not a tie.
9649
- * - Any other run is its own generation at its `started_at`: no bound payload,
9650
- * or a proof over the text cap that travels without its typed proof. The
9651
- * factory records a proof before it posts it, so that is the latest its
9652
- * proof could have been recorded, and it still vetoes every pass recorded
9653
- * before it was posted.
9654
- * - Such a run carries no generation identity, so it is never grouped. Two
9655
- * separate executions with the same command names and verdict post the same
9656
- * compact payload, and so does a re-post. When the newest generation is one
9657
- * of these and repeats another run's verdict (`sameVerdict`), it could be a
9658
- * re-post of an older verdict or a new one, and nothing is trusted. Proofs
9659
- * that size are rare, so refusing costs one fresh verification.
9660
- * - Two different generations at the newest instant refuse, by the shared
9661
- * sequencing owner's rule.
9662
- *
9663
- * Factory-ci's hosted proof-reuse gate applies the same rule in jq.
9716
+ * Every generation among the served runs, ordered and grouped by the rule
9717
+ * `newestVerifyProofGeneration` states, and every run's post with the
9718
+ * generation it belongs to.
9664
9719
  */
9665
- const newestVerifyProofGeneration = (runs, bound) => {
9720
+ const verifyProofGenerations = (runs, bound) => {
9666
9721
  const generations = [];
9722
+ const posts = [];
9667
9723
  for (const run of runs) {
9668
9724
  const parsed = parsePrVerifyCheckPayload(run.output?.text) ?? parsePrVerifyCheckPayload(run.output?.summary);
9669
9725
  const payload = run.head_sha === bound.sha && parsed?.headSha === bound.sha && parsed.repository === bound.repository ? parsed : void 0;
9670
- const recordedMs = generationOrderMs(payload?.verificationProof?.endedAt);
9671
- if (payload === void 0 || !Number.isFinite(recordedMs)) {
9672
- generations.push({
9673
- orderMs: generationOrderMs(run.started_at),
9726
+ const postedMs = wholeSecondMs(generationOrderMs(run.started_at));
9727
+ const startMs = passingProofStartMs(run, payload);
9728
+ if (payload === void 0 || startMs === void 0) {
9729
+ const generation = {
9730
+ orderMs: postedMs,
9674
9731
  ...payload === void 0 ? {} : { payload },
9675
9732
  recorded: false,
9676
9733
  run
9734
+ };
9735
+ generations.push(generation);
9736
+ posts.push({
9737
+ generation,
9738
+ postedMs
9677
9739
  });
9678
9740
  continue;
9679
9741
  }
9680
9742
  const generation = {
9681
- orderMs: recordedMs,
9743
+ orderMs: Math.min(startMs, postedMs),
9682
9744
  payload,
9683
9745
  recorded: true,
9684
9746
  run
9685
9747
  };
9686
- if (!generations.some((earlier) => earlier.recorded && sameVerdict(earlier, generation))) generations.push(generation);
9748
+ const earlier = generations.find((candidate) => candidate.recorded && sameVerdict(candidate, generation));
9749
+ if (earlier === void 0) generations.push(generation);
9750
+ else earlier.orderMs = Math.min(earlier.orderMs, generation.orderMs);
9751
+ posts.push({
9752
+ generation: earlier ?? generation,
9753
+ postedMs
9754
+ });
9687
9755
  }
9756
+ return {
9757
+ generations,
9758
+ posts
9759
+ };
9760
+ };
9761
+ /**
9762
+ * The newest generation of the `pr:verify` proof at one head, or nothing when
9763
+ * no single generation can be trusted. A passing generation is ordered by when
9764
+ * its verification started, never later than when its run reached GitHub and
9765
+ * never by when its verification ended (#1409, #1490). Every other run is
9766
+ * ordered by when it reached GitHub (#1506):
9767
+ *
9768
+ * - A passing run is completed with conclusion `success`, and its bound
9769
+ * payload has outcome `passed`. When that payload transports a typed proof
9770
+ * whose start is computable (`proofStartMs`: its `endedAt` minus its
9771
+ * `durationMs`), the run is ordered at that start, or at its own
9772
+ * `started_at` when that is earlier. Runs that carry the same such payload
9773
+ * with the same status and conclusion are one generation posted more than
9774
+ * once (`pr:verify` posts, `pr:publish` re-posts), ordered at the earliest of
9775
+ * its members' instants, whatever order GitHub serves them in. A re-post
9776
+ * does not move the proof's start, so an older pass re-posted after a newer
9777
+ * failure does not outrank it, and the normal post plus re-post is not a
9778
+ * tie.
9779
+ * - Any other run is its own generation at its `started_at`: one still
9780
+ * running, a failure or cancellation whether or not it carries a proof, one
9781
+ * with no bound payload (including one whose transported proof fails the
9782
+ * proof schema, which drops the whole payload), or a pass with no usable
9783
+ * typed proof (over the text cap, or with no computable start). A run is
9784
+ * created no earlier than its verification began, so `started_at` is an
9785
+ * upper bound on that start. Ranking there can only make such a run newer
9786
+ * than it is, so it vetoes more, never less.
9787
+ * - Neither `endedAt` nor a passing run's `started_at` is its start. Every
9788
+ * writer posts its `pr-verify` run only once verification has finished
9789
+ * (`pr:verify` on success or failure, `pr:publish` on re-post), so both say
9790
+ * when it ended, and an older pass ordered there would hide a run that
9791
+ * started after the pass began: still running, or failed (#1490). A pass's
9792
+ * `started_at` only caps its proof's start. A failure's `started_at` is when
9793
+ * it was posted, not when it started, and that is deliberate: a veto's
9794
+ * instant only has to be no earlier than its start.
9795
+ * - Only a pass is ordered by its proof because the hosted gate cannot run the
9796
+ * proof schema this reader runs (#1506). Ordered by its proof's claimed
9797
+ * start there, a failure carrying a malformed proof ranked under an older
9798
+ * pass, and the gate reused the pass where this reader refused. For an
9799
+ * honest proof, `started_at` is no earlier than `endedAt`, which is no
9800
+ * earlier than the start, so the cap changes nothing for a valid pass, and
9801
+ * it also caps a schema-valid proof from a skewed clock.
9802
+ * - A pass is reused only when its verification started strictly after every
9803
+ * other run at this head was posted (#1506). Its start is its instant
9804
+ * (`orderMs`): its proof's start, capped as above, or its `started_at` when
9805
+ * it is ordered there. Every other run's post is its `started_at`, rounded
9806
+ * down to the whole second. Only the pass's own generation is left out: its
9807
+ * re-post group, or, for a pass with no typed proof, that run alone. At most
9808
+ * one generation can satisfy this, and that one is the newest, so this
9809
+ * reader selects the newest generation and, when it is a pass
9810
+ * (`isPassingRun`), trusts nothing unless every other run was posted before
9811
+ * its instant (`postedSinceItStarted`). When the newest generation is not a
9812
+ * pass, the caller refuses reuse anyway, so it is returned as before.
9813
+ * - The check exists because a run may be ordered earlier than its post by
9814
+ * its proof. This reader validates proofs, so for it the check only refuses
9815
+ * a valid pass that started before another pass was posted. The hosted gate
9816
+ * cannot validate them: without the check, a successful run whose malformed
9817
+ * proof claimed an early start ranked under an older pass there, whether
9818
+ * that pass was ordered by its proof or at its `started_at`, and the gate
9819
+ * reused the older pass where this reader, which drops that payload and
9820
+ * ranks the run at its `started_at`, refused. Both readers apply the check
9821
+ * so they agree. With it, whether a pass may be reused never consults
9822
+ * another run's proof fields, only when each other run was posted, which
9823
+ * GitHub records. In the hosted gate those fields can only cause a refusal;
9824
+ * they never hide a veto. The pass's own clock is capped at its post, so it
9825
+ * can only count against that pass.
9826
+ * - Two changes on valid input follow, and both only add refusals, each
9827
+ * costing one fresh verification in a rare case. A failure carrying a proof
9828
+ * is ordered when it was posted instead of when it started, so an older
9829
+ * failure that finished after a newer pass began now refuses reuse. Two pass
9830
+ * generations whose verifications overlapped now refuse, as does an older
9831
+ * pass re-posted after a newer pass started. Passes that ran one after the
9832
+ * other, and a pass with its own re-posts, are reused as before.
9833
+ * - The hosted gate's trust boundary is a class. The gate does not
9834
+ * reimplement this reader's payload parser. On payloads the factory's
9835
+ * writer emits, both readers select the same pass or both refuse: the
9836
+ * differential suite, `pr-verify-reuse-differential.test.ts`, asserts it
9837
+ * over 9,653 generated run sets. On App-signed text the writer never emits,
9838
+ * the two can differ in either direction. The known instances where the
9839
+ * hosted gate reuses and this reader refuses are: (a) a reused run whose
9840
+ * typed proof fails `validatePrVerifyProof`; (b) a newest pass that repeats
9841
+ * another run's verdict only once this reader normalizes that run's
9842
+ * payload, say by dropping a key; (c) a payload in `output.summary` beside
9843
+ * a text payload this reader cannot read, which it reads from the summary
9844
+ * and the hosted gate from the text; (d) text the gate's strip-and-`fromjson`
9845
+ * reads and this reader's fence match or `JSON.parse` refuses, such as a
9846
+ * closing fence with no opening one, a doubled opening fence, a fence line
9847
+ * inside the JSON, a payload split across two fenced blocks, or a number
9848
+ * literal such as `NaN` where the gate's jq accepts it (jq 1.8 does).
9849
+ * Mirroring any of it would turn the gate's jq into a parser. Only the
9850
+ * factory App can post the run, and that identity bounds the whole class
9851
+ * (#1511).
9852
+ * - A run not ordered by a typed proof carries no generation identity, so it
9853
+ * is never grouped. Two separate executions with the same command names and
9854
+ * verdict post the same compact payload, and so does a re-post. When the
9855
+ * newest generation is one of these and repeats another run's verdict
9856
+ * (`sameVerdict`), it could be a re-post of an older verdict or a new one,
9857
+ * and nothing is trusted. Proofs that size are rare, so refusing costs one
9858
+ * fresh verification.
9859
+ * - Two different generations at the newest instant refuse, by the shared
9860
+ * sequencing owner's rule.
9861
+ *
9862
+ * Every instant is rounded down to its whole second (`wholeSecondMs`), as the
9863
+ * hosted gate's jq must: it compares epoch seconds. Rounding down is monotonic,
9864
+ * so it can merge two instants into a tie, which refuses, but never invert
9865
+ * them. It also fails closed: a proof's start never moves later, so it never
9866
+ * outranks an unbound run in the same second.
9867
+ *
9868
+ * Factory-ci's hosted proof-reuse gate applies the same rule in jq.
9869
+ */
9870
+ const newestVerifyProofGeneration = (runs, bound) => {
9871
+ const { generations, posts } = verifyProofGenerations(runs, bound);
9688
9872
  const selected = selectNewestGeneration(generations);
9689
9873
  if (selected.kind !== "newest") return;
9690
9874
  const newest = selected.generation;
9691
- return !newest.recorded && newest.payload !== void 0 && generations.some((generation) => generation !== newest && sameVerdict(generation, newest)) ? void 0 : newest;
9875
+ const repeatsAnotherRun = !newest.recorded && newest.payload !== void 0 && generations.some((generation) => generation !== newest && sameVerdict(generation, newest));
9876
+ const postedSinceItStarted = isPassingRun(newest.run, newest.payload) && posts.some((post) => post.generation !== newest && post.postedMs >= newest.orderMs);
9877
+ return repeatsAnotherRun || postedSinceItStarted ? void 0 : newest;
9692
9878
  };
9693
9879
  /**
9694
9880
  * Read the machine-readable `pr:verify` binding for one commit (#247).
@@ -11003,8 +11189,21 @@ var DemandWaiveRefusalError = class extends Error {
11003
11189
  this.refusals = refusals;
11004
11190
  }
11005
11191
  };
11006
- /** Refusals about the request itself: a malformed key, a missing rationale. */
11007
- const requestRefusals = ({ demand, parsedDemand, rationale }) => [...parsedDemand.success ? [] : [`demand:waive refuses demand key "${demand}": a demand key is a lowercase family, optionally qualified by its resolved value (e.g. review-rung:human).`], ...rationale.length === 0 ? ["demand:waive requires a non-empty --rationale."] : []];
11192
+ /**
11193
+ * Refusals about the named key: malformed, or well-formed but outside the one
11194
+ * eligibility contract the readers fold by (#1492). An ineligible key refuses
11195
+ * here, before anything is written, so the store never holds an apparent
11196
+ * operator authorization that no command applies.
11197
+ */
11198
+ const demandKeyRefusals = ({ demand, parsedDemand }) => {
11199
+ if (!parsedDemand.success) return [`demand:waive refuses demand key "${demand}": a demand key is a lowercase family, optionally qualified by its resolved value (e.g. review-rung:human).`];
11200
+ return isWaivableDemand(parsedDemand.data) ? [] : [ineligibleWaiverRefusal(parsedDemand.data)];
11201
+ };
11202
+ /** Refusals about the request itself: the key, a missing rationale. */
11203
+ const requestRefusals = ({ demand, parsedDemand, rationale }) => [...demandKeyRefusals({
11204
+ demand,
11205
+ parsedDemand
11206
+ }), ...rationale.length === 0 ? ["demand:waive requires a non-empty --rationale."] : []];
11008
11207
  /**
11009
11208
  * Refusals about the candidate the waiver would bind to. A waiver names one
11010
11209
  * pushed head. For ordinary demands that head must be the one verification
@@ -12320,10 +12519,14 @@ const defaultResolveRepo = (cwd) => runCapture("gh", [
12320
12519
  "--jq",
12321
12520
  ".nameWithOwner"
12322
12521
  ], cwd).stdout.trim();
12323
- const evaluateProofAgainstDemands = ({ authorsSeen, computed, declaredBy, demandedRung, manifestHash, selected }) => {
12522
+ const parseFailureReason = ({ commentAuthor, commentUrl, parseError }) => `found a boundary-review-proof block from ${commentAuthor}${commentUrl === void 0 ? "" : ` (${commentUrl})`} that failed to parse: ${parseError}; edit that comment so the block matches the boundary-review proof schema — an unparseable proof satisfies nothing`;
12523
+ const evaluateProofAgainstDemands = ({ boundary, comments, computed, declaredBy, demandedRung, manifestHash, selected }) => {
12324
12524
  if (!selected) {
12325
- if (authorsSeen.length === 0) return { blockingReasons: [`no boundary-review proof found on the epic issue; run the boundary review at the ${demandedRung} rung and post its fenced JSON proof comment — absence fails closed`] };
12326
- return { blockingReasons: [`boundary-review proof block(s) found on the epic issue, but none authored by declaredBy "${declaredBy}" (authors seen: ${authorsSeen.join(", ")}); run the boundary review at the ${demandedRung} rung from that account, or correct declaredBy if it names the wrong one`] };
12525
+ const authorsSeen = boundaryReviewProofAuthorsSeen(comments, boundary);
12526
+ const blockingReasons = boundaryReviewProofParseFailures(comments, boundary).map(parseFailureReason);
12527
+ if (authorsSeen.length > 0) blockingReasons.push(`boundary-review proof block(s) found on the epic issue, but none authored by declaredBy "${declaredBy}" (authors seen: ${authorsSeen.join(", ")}); run the boundary review at the ${demandedRung} rung from that account, or correct declaredBy if it names the wrong one`);
12528
+ if (blockingReasons.length === 0) blockingReasons.push(`no boundary-review proof found on the epic issue; run the boundary review at the ${demandedRung} rung and post its fenced JSON proof comment — absence fails closed`);
12529
+ return { blockingReasons };
12327
12530
  }
12328
12531
  const { proof } = selected;
12329
12532
  const blockingReasons = [];
@@ -12350,7 +12553,8 @@ function runBoundaryCheck(args, dependencies = {}) {
12350
12553
  const comments = github.fetchIssueComments(repo, args.epic);
12351
12554
  const selected = selectBoundaryReviewProof(comments, parsed.manifest.boundary, parsed.manifest.declaredBy);
12352
12555
  const { blockingReasons } = evaluateProofAgainstDemands({
12353
- authorsSeen: selected ? [] : boundaryReviewProofAuthorsSeen(comments, parsed.manifest.boundary),
12556
+ boundary: parsed.manifest.boundary,
12557
+ comments,
12354
12558
  computed,
12355
12559
  declaredBy: parsed.manifest.declaredBy,
12356
12560
  demandedRung,
@@ -17920,50 +18124,161 @@ const commitParentsSchema = z.object({
17920
18124
  parents: z.array(z.object({ sha: shaSchema$2 })),
17921
18125
  sha: shaSchema$2
17922
18126
  });
17923
- function selectListedMergeFreezeGeneration(listed, expectedHeadSha) {
17924
- const runs = z.object({ check_runs: z.array(z.unknown()) }).parse(listed).check_runs.map((run) => {
17925
- const listedRun = mergeFreezeCheckRunListItemSchema.parse(run);
17926
- if (!checkRunProducedByFactoryApp({
17927
- app: listedRun.app,
17928
- expectedAppId: FACTORY_PROOF_GATE_APP_ID
17929
- })) throw new Error(`${MERGE_FREEZE_CHECK_NAME} check run ${listedRun.id} was not written by the pinned Factory App ${FACTORY_PROOF_GATE_APP_ID}.`);
17930
- return listedRun;
17931
- });
17932
- if (runs.length === 0) return { kind: "missing" };
18127
+ /**
18128
+ * The verdict a completed generation carries. A malformed payload throws, and
18129
+ * a payload that disagrees with its own check run is unreadable.
18130
+ */
18131
+ function settleCompletedGeneration(run) {
18132
+ const completed = completedMergeFreezeCheckRunSchema.parse(run);
18133
+ const state = mergeFreezeStateSchema.parse(JSON.parse(completed.output.text));
18134
+ if (state.generationId !== completed.id || state.headSha !== completed.head_sha || state.outcome === "active" && completed.conclusion !== "failure" || state.outcome === "stale" && completed.conclusion !== "neutral" || !state.active && completed.conclusion !== "success") return {
18135
+ kind: "unreadable",
18136
+ reason: `Newest ${MERGE_FREEZE_CHECK_NAME} check run has inconsistent state.`
18137
+ };
18138
+ return {
18139
+ kind: "settled",
18140
+ state
18141
+ };
18142
+ }
18143
+ /**
18144
+ * Whether a generation's recorded verification was cancelled and nothing in it
18145
+ * failed (#1158): at least one `cancelled` job and no `failure`. It reads only
18146
+ * the recorded job facts and never infers why a run was cancelled. A legacy
18147
+ * schema-1 record without `verification` is never cancellation-only, because
18148
+ * the reader cannot tell why it froze.
18149
+ */
18150
+ function isCancellationOnly(state) {
18151
+ const results = Object.values(state.verification?.jobs ?? {});
18152
+ return results.includes("cancelled") && !results.includes("failure");
18153
+ }
18154
+ /**
18155
+ * The verdict behind a newest generation whose verification was only
18156
+ * cancelled (#1158).
18157
+ *
18158
+ * The freeze reads the newest completed generation at the exact tip whose
18159
+ * verification was not cancellation-only. A cancellation-only generation means
18160
+ * verification did not finish, so it says nothing about the code. A duplicate
18161
+ * push or a concurrency group can cancel a later Verify run at a tip an earlier
18162
+ * run already judged, and that later run must not override the earlier verdict.
18163
+ *
18164
+ * So the reader steps back, newest first by the same `started_at` rule, over
18165
+ * cancellation-only generations to the first older one that decides. A failed
18166
+ * or mixed failed-and-cancelled generation decides, and so does a legacy
18167
+ * record without job facts. If no older generation decides, `newest` stands,
18168
+ * which keeps the freeze active and fail-closed. That covers three cases:
18169
+ * every completed generation at the tip is cancellation-only; the next older
18170
+ * generation is still running, since an older green never outranks a newer
18171
+ * running one; and the next older generation is for another tip, is malformed,
18172
+ * or shares its `started_at` with another.
18173
+ *
18174
+ * `steppedPast` carries the cancellation-only runs walked so far, newest first,
18175
+ * starting with the newest run itself. The selection reports them only when an
18176
+ * older verdict decides; when `newest` stands, it read past nothing.
18177
+ */
18178
+ function verdictBehindCancellation(newest, olderRuns, expectedHeadSha, steppedPast) {
18179
+ const standing = {
18180
+ ...newest,
18181
+ readPast: []
18182
+ };
18183
+ const selected = selectNewestGeneration(olderRuns.map((run) => ({
18184
+ orderMs: generationOrderMs(run.started_at),
18185
+ run
18186
+ })));
18187
+ if (selected.kind !== "newest") return standing;
18188
+ const { run } = selected.generation;
18189
+ if (run.head_sha !== expectedHeadSha || run.status !== "completed") return standing;
18190
+ let older;
18191
+ try {
18192
+ older = settleCompletedGeneration(run);
18193
+ } catch {
18194
+ return standing;
18195
+ }
18196
+ if (older.kind !== "settled") return standing;
18197
+ if (!isCancellationOnly(older.state)) return {
18198
+ generation: older,
18199
+ readPast: steppedPast,
18200
+ run
18201
+ };
18202
+ return verdictBehindCancellation(newest, olderRuns.filter((candidate) => candidate !== run), expectedHeadSha, [...steppedPast, run]);
18203
+ }
18204
+ /**
18205
+ * The generation the freeze selects among the runs listed at one tip: the one
18206
+ * rule behind `readGeneration`, `read`, and the PR status HUD's freeze row
18207
+ * (#1501). It is pure. Its caller has already decided which runs to trust; the
18208
+ * reader refuses the whole read on a run the pinned App did not write.
18209
+ *
18210
+ * GitHub documents check-run IDs only as unique, not ordered. The factory
18211
+ * deliberately relies on GitHub-stamped `started_at`, and equal newest instants
18212
+ * stay ambiguous and fail closed. `filter=all` in the API reader is required
18213
+ * because `latest` can hide a live generation (#522, #524).
18214
+ *
18215
+ * A malformed newest payload throws, as `settleCompletedGeneration` does.
18216
+ */
18217
+ function selectMergeFreezeRun(runs, expectedHeadSha) {
18218
+ if (runs.length === 0) return {
18219
+ generation: { kind: "missing" },
18220
+ readPast: []
18221
+ };
17933
18222
  const selected = selectNewestGeneration(runs.map((run) => ({
17934
18223
  orderMs: generationOrderMs(run.started_at),
17935
18224
  run
17936
18225
  })));
17937
18226
  if (selected.kind === "ambiguous") return {
17938
- kind: "unreadable",
17939
- reason: `Newest ${MERGE_FREEZE_CHECK_NAME} generation is ambiguous: ${selected.tied.length} check runs share ${new Date(selected.orderMs).toISOString()}.`
18227
+ generation: {
18228
+ kind: "unreadable",
18229
+ reason: `Newest ${MERGE_FREEZE_CHECK_NAME} generation is ambiguous: ${selected.tied.length} check runs share ${new Date(selected.orderMs).toISOString()}.`
18230
+ },
18231
+ readPast: []
18232
+ };
18233
+ if (selected.kind === "none") return {
18234
+ generation: { kind: "missing" },
18235
+ readPast: []
17940
18236
  };
17941
- if (selected.kind === "none") return { kind: "missing" };
17942
18237
  const newest = selected.generation.run;
17943
18238
  if (newest.head_sha !== expectedHeadSha) return {
17944
- kind: "unreadable",
17945
- reason: `Newest ${MERGE_FREEZE_CHECK_NAME} check run targets ${newest.head_sha}, expected ${expectedHeadSha}.`
18239
+ generation: {
18240
+ kind: "unreadable",
18241
+ reason: `Newest ${MERGE_FREEZE_CHECK_NAME} check run targets ${newest.head_sha}, expected ${expectedHeadSha}.`
18242
+ },
18243
+ readPast: []
17946
18244
  };
17947
18245
  if (newest.status !== "completed") return {
17948
- kind: "settling",
17949
- phase: "running-generation",
17950
- reason: `Authoritative merge freeze generation ${newest.id} is in progress.`,
17951
- witness: {
17952
- headSha: newest.head_sha,
17953
- runId: newest.id
17954
- }
18246
+ generation: {
18247
+ kind: "settling",
18248
+ phase: "running-generation",
18249
+ reason: `Authoritative merge freeze generation ${newest.id} is in progress.`,
18250
+ witness: {
18251
+ headSha: newest.head_sha,
18252
+ runId: newest.id
18253
+ }
18254
+ },
18255
+ readPast: []
17955
18256
  };
17956
- const completed = completedMergeFreezeCheckRunSchema.parse(newest);
17957
- const state = mergeFreezeStateSchema.parse(JSON.parse(completed.output.text));
17958
- if (state.generationId !== completed.id || state.headSha !== completed.head_sha || state.outcome === "active" && completed.conclusion !== "failure" || state.outcome === "stale" && completed.conclusion !== "neutral" || !state.active && completed.conclusion !== "success") return {
17959
- kind: "unreadable",
17960
- reason: `Newest ${MERGE_FREEZE_CHECK_NAME} check run has inconsistent state.`
18257
+ const verdict = settleCompletedGeneration(newest);
18258
+ if (verdict.kind !== "settled") return {
18259
+ generation: verdict,
18260
+ readPast: []
17961
18261
  };
18262
+ if (isCancellationOnly(verdict.state)) return verdictBehindCancellation({
18263
+ generation: verdict,
18264
+ run: newest
18265
+ }, runs.filter((run) => run !== newest), expectedHeadSha, [newest]);
17962
18266
  return {
17963
- kind: "settled",
17964
- state
18267
+ generation: verdict,
18268
+ readPast: [],
18269
+ run: newest
17965
18270
  };
17966
18271
  }
18272
+ function selectListedMergeFreezeGeneration(listed, expectedHeadSha) {
18273
+ return selectMergeFreezeRun(z.object({ check_runs: z.array(z.unknown()) }).parse(listed).check_runs.map((run) => {
18274
+ const listedRun = mergeFreezeCheckRunListItemSchema.parse(run);
18275
+ if (!checkRunProducedByFactoryApp({
18276
+ app: listedRun.app,
18277
+ expectedAppId: FACTORY_PROOF_GATE_APP_ID
18278
+ })) throw new Error(`${MERGE_FREEZE_CHECK_NAME} check run ${listedRun.id} was not written by the pinned Factory App ${FACTORY_PROOF_GATE_APP_ID}.`);
18279
+ return listedRun;
18280
+ }), expectedHeadSha).generation;
18281
+ }
17967
18282
  function unconfiguredMergeFreeze(input, detail) {
17968
18283
  return {
17969
18284
  kind: "unreadable",
@@ -18835,26 +19150,9 @@ const reviewRoundLimitBlockers = (input) => {
18835
19150
  }];
18836
19151
  };
18837
19152
  /**
18838
- * The demands an operator waiver can reach (#354, moved here from
18839
- * `pr:merge-check` by #477). Exactly the set the merge-time site could waive:
18840
- * the merge freeze, the profile's external required checks, and the boundary
18841
- * wave's review rung — the demands the resolver puts *in force* for one
18842
- * candidate — plus the review-round limit (#1280), which exists only to be
18843
- * waived.
18844
- *
18845
- * Everything else stays unwaivable, and relocating the consumption site does
18846
- * not widen the operator's override. Structural refusals — candidate identity,
18847
- * proof binding, an unreadable demand authority, wave membership — were never
18848
- * waivable at merge time and are not waivable here; in `pr:ready` most of them
18849
- * refuse by throwing before any blocker list exists. The evidence gates
18850
- * readiness has always owned (verify, review modes, draft, human blockers) are
18851
- * satisfied or not; a waiver is not a second way to pass them.
18852
- */
18853
- const WAIVABLE_QUALIFIED_DEMAND_FAMILIES = ["required-check", "review-rung"];
18854
- const isWaivableDemand = (demand) => demand === DEMAND_KEYS.mergeFreeze || demand === DEMAND_KEYS.reviewRoundLimit || WAIVABLE_QUALIFIED_DEMAND_FAMILIES.some((family) => demand.startsWith(`${family}:`));
18855
- /**
18856
19153
  * Fold the operator's waivers through the blockers, one call per demand so
18857
- * `demand-waiver.ts` stays the single authority on what a waiver means. A
19154
+ * `demand-waiver.ts` stays the single authority on what a waiver means and
19155
+ * which demands it can reach — the contract `demand:waive` refuses by. A
18858
19156
  * waived demand keeps every refusal it made, verbatim, on the `WaivedDemand`
18859
19157
  * record — it is never rewritten as satisfied.
18860
19158
  */
@@ -19293,6 +19591,8 @@ const renderTable = (rows) => [
19293
19591
  "| --- | --- | --- | --- |",
19294
19592
  ...rows.map((row) => `| ${markdownCell(row.target)} | ${statusLabel(row.status)} | ${row.link} | ${row.timestamp} |`)
19295
19593
  ].join("\n");
19594
+ /** Name the cancellation-only runs a merge-freeze row read past (#1501). */
19595
+ const withReadPastNote = (target, runIds) => runIds && runIds.length > 0 ? `${target} (read past cancellation-only ${runIds.length === 1 ? "run" : "runs"} ${runIds.join(", ")})` : target;
19296
19596
  const checkRow = (target, snapshot, renderedAt) => {
19297
19597
  if (!snapshot) return {
19298
19598
  detail: "No completed Factory App snapshot is available at this head.",
@@ -19305,7 +19605,7 @@ const checkRow = (target, snapshot, renderedAt) => {
19305
19605
  detail: `Completed snapshot: ${snapshot.conclusion}.`,
19306
19606
  link: safeMarkdownLink("Details", snapshot.detailsUrl),
19307
19607
  status: snapshot.conclusion === "success" ? "Ready" : "Failed",
19308
- target,
19608
+ target: withReadPastNote(target, snapshot.readPastRunIds),
19309
19609
  timestamp: utcTimestamp(snapshot.completedAt)
19310
19610
  };
19311
19611
  };
@@ -19417,7 +19717,9 @@ const checkRunSchema = z.object({
19417
19717
  conclusion: z.string().nullable().optional(),
19418
19718
  details_url: z.string().nullable().optional(),
19419
19719
  head_sha: z.string().regex(/^[0-9a-f]{40}$/u),
19720
+ id: z.number().int().positive(),
19420
19721
  name: z.string(),
19722
+ output: z.object({ text: z.string().nullable().optional() }).nullable().optional(),
19421
19723
  started_at: z.iso.datetime().nullable(),
19422
19724
  status: z.enum([
19423
19725
  "completed",
@@ -19488,16 +19790,13 @@ const listFactoryCheckRuns = async (input, name, sha, dependencies) => {
19488
19790
  }
19489
19791
  throw new Error(`${name} pagination limit was exhausted before GitHub returned a final page.`);
19490
19792
  };
19491
- const completedSnapshot = (name, sha, runs) => {
19793
+ /** The Factory App's generations of one check at one SHA, each orderable. */
19794
+ const factoryGenerations = (name, sha, runs) => {
19492
19795
  const candidates = runs.filter((run) => String(run.app?.id ?? "") === FACTORY_PROOF_GATE_APP_ID && run.head_sha === sha && run.name === name);
19493
19796
  if (candidates.some((run) => !Number.isFinite(generationOrderMs(run.started_at)))) throw new Error(`${name} presentation snapshot has a generation with no usable started_at.`);
19494
- const selected = selectNewestGeneration(candidates.map((run) => ({
19495
- orderMs: generationOrderMs(run.started_at),
19496
- run
19497
- })));
19498
- if (selected.kind === "ambiguous") throw new Error(`Newest ${name} presentation snapshot is ambiguous: ${selected.tied.length} check runs share the newest timestamp.`);
19499
- if (selected.kind === "none") return;
19500
- const { run } = selected.generation;
19797
+ return candidates;
19798
+ };
19799
+ const runSnapshot = (name, run) => {
19501
19800
  if (run.status !== "completed" || !run.completed_at || !run.conclusion) return;
19502
19801
  return {
19503
19802
  completedAt: run.completed_at,
@@ -19507,6 +19806,45 @@ const completedSnapshot = (name, sha, runs) => {
19507
19806
  name
19508
19807
  };
19509
19808
  };
19809
+ const newestSnapshot = (name, generations) => {
19810
+ const selected = selectNewestGeneration(generations.map((run) => ({
19811
+ orderMs: generationOrderMs(run.started_at),
19812
+ run
19813
+ })));
19814
+ if (selected.kind === "ambiguous") throw new Error(`Newest ${name} presentation snapshot is ambiguous: ${selected.tied.length} check runs share the newest timestamp.`);
19815
+ if (selected.kind === "none") return;
19816
+ return runSnapshot(name, selected.generation.run);
19817
+ };
19818
+ const completedSnapshot = (name, sha, runs) => newestSnapshot(name, factoryGenerations(name, sha, runs));
19819
+ const MERGE_FREEZE_CHECK = "patronage-factory/merge-freeze";
19820
+ /**
19821
+ * The merge-freeze row shows the generation readiness reads (#1501), chosen by
19822
+ * the freeze's own selection rather than a second copy of it. That selection
19823
+ * reads past a cancellation-only generation to an older verdict at the same
19824
+ * tip (#1158). The newest completed run alone would show that cancellation as
19825
+ * a freeze readiness does not apply, so the row names the runs it read past.
19826
+ *
19827
+ * Otherwise the row is the newest completed snapshot, as for every other check:
19828
+ * running, missing and ambiguous generations present as before, and so does a
19829
+ * newest generation the selection cannot settle. Every case where the freeze
19830
+ * stays fail-closed keeps the newest generation, so the row stays red. The HUD
19831
+ * is advisory and never gates.
19832
+ */
19833
+ const mergeFreezeSnapshot = (sha, runs) => {
19834
+ const generations = factoryGenerations(MERGE_FREEZE_CHECK, sha, runs);
19835
+ const newest = newestSnapshot(MERGE_FREEZE_CHECK, generations);
19836
+ if (!newest) return newest;
19837
+ try {
19838
+ const { readPast, run } = selectMergeFreezeRun(generations, sha);
19839
+ const selected = run && readPast.length > 0 ? runSnapshot(MERGE_FREEZE_CHECK, run) : void 0;
19840
+ return selected ? {
19841
+ ...selected,
19842
+ readPastRunIds: readPast.map((past) => past.id)
19843
+ } : newest;
19844
+ } catch {
19845
+ return newest;
19846
+ }
19847
+ };
19510
19848
  const FAILED_PREVIEW_CONCLUSIONS = new Set([
19511
19849
  "action_required",
19512
19850
  "cancelled",
@@ -19555,7 +19893,8 @@ async function readFactoryPrStatusSources(input, dependencies = {}) {
19555
19893
  ];
19556
19894
  const listedChecks = await Promise.all(checkTargets.map(([name, sha]) => listFactoryCheckRuns(input, name, sha, dependencies)));
19557
19895
  const checks = Object.fromEntries(checkTargets.flatMap(([name, sha], index) => {
19558
- const snapshot = completedSnapshot(name, sha, listedChecks[index] ?? []);
19896
+ const runs = listedChecks[index] ?? [];
19897
+ const snapshot = name === MERGE_FREEZE_CHECK ? mergeFreezeSnapshot(sha, runs) : completedSnapshot(name, sha, runs);
19559
19898
  return snapshot ? [[name, snapshot]] : [];
19560
19899
  }));
19561
19900
  const previewTargets = input.previewTargets ?? [];
@@ -21418,11 +21757,20 @@ const assertEpicPullRequestOpen = ({ args, base }) => {
21418
21757
  * prompt for another patch, with no ladder cycle for that patch, is a review
21419
21758
  * that nobody recorded, and no ladder control can see it. Assembling again at
21420
21759
  * the same patch only rewrites that prompt.
21760
+ *
21761
+ * The prompt also counts as recorded when the checkout's own review proof
21762
+ * holds a cycle at exactly the patch and head the prompt names, whatever the
21763
+ * lineage (#1415). A squash merge of the previous pull request takes that head
21764
+ * off the next branch, so the ladder this candidate continues is empty, yet
21765
+ * the review of that prompt is on record. Only this question reads
21766
+ * `checkoutLadder`; the next review still continues `priorLadder`.
21421
21767
  */
21422
- const assertPriorPromptRecorded = ({ existing, patchId, priorLadder }) => {
21768
+ const assertPriorPromptRecorded = ({ checkoutLadder, existing, patchId, priorLadder }) => {
21423
21769
  if (existing.status !== "ok" || existing.artifact.patchId === patchId) return;
21424
21770
  const prompted = existing.artifact;
21425
- if ((priorLadder?.cycles ?? []).some((cycle) => cycle.identity?.patchId === prompted.patchId)) return;
21771
+ const recordedOnContinuedLadder = (priorLadder?.cycles ?? []).some((cycle) => cycle.identity?.patchId === prompted.patchId);
21772
+ const recordedAtPromptedHead = (checkoutLadder?.cycles ?? []).some((cycle) => cycle.identity?.patchId === prompted.patchId && cycle.identity.headSha === prompted.headSha);
21773
+ if (recordedOnContinuedLadder || recordedAtPromptedHead) return;
21426
21774
  throw new Error(`pr:review --assemble refuses: ${DEFAULT_ASSEMBLED_REVIEW_PROMPT_PATH} holds a prompt for patch ${prompted.patchId} at ${prompted.headSha}, and the review ladder has no cycle for that patch. The candidate is now patch ${patchId}. Record the review of that patch first: patronage-factory pr:review --historical --findings <findings file for patch ${prompted.patchId}>. If no reviewer read that prompt, delete ${DEFAULT_ASSEMBLED_REVIEW_PROMPT_PATH} and assemble again.`);
21427
21775
  };
21428
21776
  /**
@@ -21469,14 +21817,16 @@ const assertAssembleMayPrepareReview = ({ args, base, cwd, git, headSha, output,
21469
21817
  args,
21470
21818
  base
21471
21819
  });
21820
+ const prior = readOptionalProofFor(PR_REVIEW_PROOF_DESCRIPTOR, cwd, output);
21472
21821
  const priorLadder = priorLadderStateFor({
21473
21822
  base: base.ref,
21474
21823
  headSha,
21475
21824
  isAncestor: (ancestor, descendant) => git.isAncestor(cwd, ancestor, descendant),
21476
- prior: readOptionalProofFor(PR_REVIEW_PROOF_DESCRIPTOR, cwd, output)
21825
+ prior
21477
21826
  });
21478
21827
  const existing = readAssembledReviewPrompt(path.resolve(cwd, DEFAULT_ASSEMBLED_REVIEW_PROMPT_PATH));
21479
21828
  assertPriorPromptRecorded({
21829
+ checkoutLadder: prior?.ladder,
21480
21830
  existing,
21481
21831
  patchId,
21482
21832
  priorLadder
package/dist/schemas.js CHANGED
@@ -252,6 +252,7 @@ const waivedDemandSchema = z.object({
252
252
  session: z.string().trim().min(1).optional(),
253
253
  unmetReasons: z.array(z.string().min(1)).min(1)
254
254
  });
255
+ DEMAND_KEYS.mergeFreeze, DEMAND_KEYS.reviewRoundLimit, DEMAND_KEYS.childIssueOpen;
255
256
  //#endregion
256
257
  //#region src/diff-classification.ts
257
258
  const DIFF_CLASSIFICATIONS = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patronage/software-factory",
3
- "version": "1.0.0-alpha.41",
3
+ "version": "1.0.0-alpha.42",
4
4
  "description": "Shared Patronage software factory CLI and project-profile validation tools",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,7 +37,7 @@
37
37
  "picomatch": "^4.0.5",
38
38
  "yaml": "^2.9.0",
39
39
  "zod": "4.4.3",
40
- "@patronage/factory-ci": "1.0.0-alpha.41"
40
+ "@patronage/factory-ci": "1.0.0-alpha.42"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "24.13.3",