@patronage/software-factory 0.25.0 → 0.30.0-alpha.1

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.js CHANGED
@@ -18,7 +18,7 @@ import picomatch from "picomatch";
18
18
  import { promisify } from "node:util";
19
19
  import { parse } from "yaml";
20
20
  //#region package.json
21
- var version = "0.25.0";
21
+ var version = "0.30.0-alpha.1";
22
22
  //#endregion
23
23
  //#region src/review-rungs.ts
24
24
  const EVIDENCE_REVIEW_RUNGS$1 = [
@@ -45,6 +45,7 @@ const FACTORY_BOUNDARY_FENCE_LANG = "factory-boundary";
45
45
  const BOUNDARY_CLOSEOUT_DEFAULT_RUNG = "oracle";
46
46
  const rungMeetsMinimum = (rung, minimum) => RUNG_ORDER[rung] >= RUNG_ORDER[minimum];
47
47
  const rungSchema = z.enum(EVIDENCE_REVIEW_RUNGS$1);
48
+ const GITHUB_LOGIN_PATTERN = /^[A-Za-z\d](?:[A-Za-z\d]|-(?=[A-Za-z\d])){0,38}(?:\[[bB][oO][tT]\])?$/u;
48
49
  const waveSchema = z.object({
49
50
  autoMerge: z.boolean().optional(),
50
51
  issues: z.array(z.number().int().positive()).min(1),
@@ -54,7 +55,7 @@ const waveSchema = z.object({
54
55
  const boundaryManifestSchema = z.object({
55
56
  boundary: z.string().min(1),
56
57
  closeout: z.object({ review: rungSchema }).passthrough().optional(),
57
- declaredBy: z.string().min(1),
58
+ declaredBy: z.string().regex(GITHUB_LOGIN_PATTERN, { message: "must be a GitHub login (letters, digits, single hyphens, max 39 chars), optionally suffixed with \"[bot]\" for a GitHub App identity — not an email address or display name" }),
58
59
  prs: z.record(z.string().regex(/^[0-9]+$/u), z.string().min(1)).optional(),
59
60
  schemaVersion: z.literal(1),
60
61
  topology: z.enum([
@@ -132,16 +133,17 @@ const closeoutRungFor = (manifest) => manifest.closeout?.review ?? "oracle";
132
133
  //#region src/boundary-review-proof.ts
133
134
  var boundary_review_proof_exports = /* @__PURE__ */ __exportAll({
134
135
  BOUNDARY_REVIEW_PROOF_KIND: () => BOUNDARY_REVIEW_PROOF_KIND$1,
136
+ boundaryReviewProofAuthorsSeen: () => boundaryReviewProofAuthorsSeen,
135
137
  boundaryReviewProofSchema: () => boundaryReviewProofSchema$1,
136
138
  selectBoundaryReviewProof: () => selectBoundaryReviewProof,
137
139
  undispositionedBlockingFindings: () => undispositionedBlockingFindings
138
140
  });
139
141
  const BOUNDARY_REVIEW_PROOF_KIND$1 = "boundary-review-proof";
140
- const shaSchema$7 = z.string().regex(/^[0-9a-f]{7,40}$/u, { message: "must be a 7-40 char lowercase hex git SHA" });
142
+ const shaSchema$5 = z.string().regex(/^[0-9a-f]{7,40}$/u, { message: "must be a 7-40 char lowercase hex git SHA" });
141
143
  const coveredSetEntrySchema$1 = z.object({
142
- headSha: shaSchema$7.optional(),
144
+ headSha: shaSchema$5.optional(),
143
145
  issue: z.number().int().positive().optional(),
144
- mergedSha: shaSchema$7.optional(),
146
+ mergedSha: shaSchema$5.optional(),
145
147
  pr: z.number().int().positive(),
146
148
  state: z.enum(["merged", "open"]),
147
149
  wave: z.string().min(1).optional()
@@ -186,12 +188,11 @@ const boundaryReviewProofSchema$1 = z.object({
186
188
  }).passthrough().optional()
187
189
  }).passthrough();
188
190
  const FENCED_JSON_PATTERN = /```json[ \t]*\r?\n(?<inner>[\s\S]*?)```/gu;
189
- const selectBoundaryReviewProof = (comments, boundary, declaredBy) => {
190
- let selected;
191
- const declaredByLower = declaredBy.toLowerCase();
191
+ const collectValidBoundaryReviewProofs = (comments, boundary) => {
192
+ const entries = [];
192
193
  for (const comment of comments) {
193
194
  const commentAuthor = comment.author.login;
194
- if (!commentAuthor || commentAuthor.toLowerCase() !== declaredByLower) continue;
195
+ if (!commentAuthor) continue;
195
196
  for (const match of comment.body.matchAll(FENCED_JSON_PATTERN)) {
196
197
  const inner = match.groups?.inner ?? "";
197
198
  let raw;
@@ -203,15 +204,34 @@ const selectBoundaryReviewProof = (comments, boundary, declaredBy) => {
203
204
  if (typeof raw !== "object" || raw === null || raw.kind !== "boundary-review-proof") continue;
204
205
  const parsed = boundaryReviewProofSchema$1.safeParse(raw);
205
206
  if (!parsed.success || parsed.data.boundary !== boundary) continue;
206
- selected = {
207
+ entries.push({
208
+ commentAuthor,
207
209
  ...comment.id === void 0 ? {} : { commentId: comment.id },
208
210
  ...comment.url === void 0 ? {} : { commentUrl: comment.url },
209
211
  proof: parsed.data
210
- };
212
+ });
211
213
  }
212
214
  }
215
+ return entries;
216
+ };
217
+ const selectBoundaryReviewProof = (comments, boundary, declaredBy) => {
218
+ let selected;
219
+ const declaredByLower = declaredBy.toLowerCase();
220
+ for (const entry of collectValidBoundaryReviewProofs(comments, boundary)) {
221
+ if (entry.commentAuthor.toLowerCase() !== declaredByLower) continue;
222
+ selected = {
223
+ ...entry.commentId === void 0 ? {} : { commentId: entry.commentId },
224
+ ...entry.commentUrl === void 0 ? {} : { commentUrl: entry.commentUrl },
225
+ proof: entry.proof
226
+ };
227
+ }
213
228
  return selected;
214
229
  };
230
+ const boundaryReviewProofAuthorsSeen = (comments, boundary) => {
231
+ const authors = /* @__PURE__ */ new Set();
232
+ for (const entry of collectValidBoundaryReviewProofs(comments, boundary)) authors.add(entry.commentAuthor);
233
+ return [...authors];
234
+ };
215
235
  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`);
216
236
  //#endregion
217
237
  //#region src/comment-provenance.ts
@@ -692,7 +712,7 @@ function runInheritedTee(command, args, cwd, extraEnv = {}) {
692
712
  function currentHeadSha(cwd) {
693
713
  return runCapture("git", ["rev-parse", "HEAD"], cwd).stdout.trim();
694
714
  }
695
- function showFileAtRef$1(cwd, ref, absolutePath) {
715
+ function showFileAtRef(cwd, ref, absolutePath) {
696
716
  const rel = path.relative(cwd, absolutePath);
697
717
  const spec = rel.startsWith(".") ? rel : `./${rel}`;
698
718
  try {
@@ -979,38 +999,23 @@ const DIFF_CLASSIFICATIONS$1 = [
979
999
  //#endregion
980
1000
  //#region src/required-check-policy.ts
981
1001
  const EVIDENCE_CHECK_TYPES$1 = ["review", "verify"];
982
- const requiredCheckScopeSchema$1 = z.object({
983
- classifications: z.array(z.enum(DIFF_CLASSIFICATIONS$1)).min(1).optional(),
984
- labels: z.array(z.string().min(1)).min(1).optional()
985
- }).strict().superRefine((scope, context) => {
986
- if (scope.labels === void 0 && scope.classifications === void 0) context.addIssue({
987
- code: "custom",
988
- message: "requiredChecks scope must declare at least one condition (labels and/or classifications)."
989
- });
990
- }).meta({ anyOf: [{ required: ["classifications"] }, { required: ["labels"] }] });
1002
+ const requiredCheckScopeSchema$1 = z.object({ classifications: z.array(z.enum(DIFF_CLASSIFICATIONS$1)).min(1) }).strict();
991
1003
  const requiredCheckInScope = ({ context, scope }) => {
992
1004
  if (!scope) return {
993
1005
  inScope: true,
994
1006
  reason: "unconditional: no scope declared"
995
1007
  };
996
- const held = [];
997
- const failed = [];
998
- if (scope.labels !== void 0) if (context.labels === void 0) held.push(`label set unavailable; fail closed (scoping labels [${scope.labels.join(", ")}] treated as held)`);
999
- else {
1000
- const present = scope.labels.filter((label) => context.labels?.includes(label));
1001
- if (present.length > 0) held.push(`scoping label(s) present: ${present.join(", ")}`);
1002
- else failed.push(`none of the scoping labels are on the PR: ${scope.labels.join(", ")}`);
1003
- }
1004
- if (scope.classifications !== void 0) if (context.classification === void 0) held.push(`classification unavailable; fail closed (scoping classifications [${scope.classifications.join(", ")}] treated as in force)`);
1005
- else if (scope.classifications.includes(context.classification)) held.push(`classification "${context.classification}" is in scope`);
1006
- else failed.push(`classification "${context.classification}" is not one of: ${scope.classifications.join(", ")}`);
1007
- if (failed.length > 0) return {
1008
- inScope: false,
1009
- reason: `out of scope — ${failed.join("; ")}`
1008
+ if (context.classification === void 0) return {
1009
+ inScope: true,
1010
+ reason: `classification unavailable; fail closed (scoping classifications [${scope.classifications.join(", ")}] treated as in force)`
1010
1011
  };
1011
- return {
1012
+ if (scope.classifications.includes(context.classification)) return {
1012
1013
  inScope: true,
1013
- reason: `in scope — ${held.join("; ")}`
1014
+ reason: `in scope — classification "${context.classification}" is in scope`
1015
+ };
1016
+ return {
1017
+ inScope: false,
1018
+ reason: `out of scope — classification "${context.classification}" is not one of: ${scope.classifications.join(", ")}`
1014
1019
  };
1015
1020
  };
1016
1021
  //#endregion
@@ -3465,7 +3470,7 @@ const runCloseoutFsWorker = async (data, budgetMs) => {
3465
3470
  };
3466
3471
  const isIsolatedReadResult = (value) => value !== null && typeof value === "object" && value.status === "read";
3467
3472
  const deferProjectHqIngestIsolated = async (input) => {
3468
- const setupDeadline = performance.now() + 750;
3473
+ const setupDeadline = performance.now() + timeoutFor({ env: process.env });
3469
3474
  let profilePath;
3470
3475
  try {
3471
3476
  profilePath = input.profilePath ? path.resolve(input.cwd, input.profilePath) : defaultProfilePath(input.cwd);
@@ -3557,7 +3562,7 @@ const deferProjectHqIngestIsolated = async (input) => {
3557
3562
  root: layout.root,
3558
3563
  segments: layout.segments,
3559
3564
  type: "persist"
3560
- }, HQ_JOURNAL_FLUSH_BUDGET_MS);
3565
+ }, journalFlushBudgetFor({ env: process.env }));
3561
3566
  return persisted !== null && typeof persisted === "object" && persisted.status === "persisted" ? "deferred" : "held";
3562
3567
  };
3563
3568
  async function deferProjectHqIngest(input, dependencies = {}) {
@@ -3615,6 +3620,13 @@ function assertCleanWorktreeForProof({ changedFiles, cwd, dirtyMessage, statusPo
3615
3620
  //#region src/demand-keys.ts
3616
3621
  /** The demands that exist at most once per candidate. */
3617
3622
  const DEMAND_KEYS = {
3623
+ /**
3624
+ * The boundary wave that authorizes this candidate's merge schedule (#477).
3625
+ * In force exactly when GitHub already has auto-merge enabled: a live
3626
+ * schedule with no wave demand resolved is an unauthorized merge waiting to
3627
+ * happen.
3628
+ */
3629
+ boundaryWave: "boundary-wave",
3618
3630
  /** The PR is still a draft. */
3619
3631
  draft: "draft",
3620
3632
  /** The candidate is not the intended final human review point. */
@@ -3629,6 +3641,12 @@ const DEMAND_KEYS = {
3629
3641
  mergeFreeze: "merge-freeze",
3630
3642
  /** GitHub's mergeability / merge-state rollup. */
3631
3643
  mergeState: "merge-state",
3644
+ /**
3645
+ * A native GitHub approval, demanded when the boundary wave in force does
3646
+ * not authorize machine merge (#477). "Green = authorized to merge" is then
3647
+ * a property of the branded required check itself.
3648
+ */
3649
+ nativeApproval: "native-approval",
3632
3650
  /** The PR body's required rendered sections. */
3633
3651
  prBodySections: "pr-body-sections",
3634
3652
  /** A current, head-bound, passing typed `pr:verify` proof. */
@@ -3659,7 +3677,7 @@ const percentEncode = (character) => {
3659
3677
  * demand nobody waived, and make HQ count two causes as one.
3660
3678
  *
3661
3679
  * Deterministic in both directions of use: the key a `pr:ready` proof records
3662
- * is the key a `pr:merge-check` waiver matches.
3680
+ * is the key a `pr:ready` waiver matches.
3663
3681
  */
3664
3682
  const demandQualifier = (value) => value.replaceAll(/[^A-Za-z0-9._/-]/gu, percentEncode);
3665
3683
  /** The demand one profile-declared external required check makes. */
@@ -3743,7 +3761,7 @@ const demandKeySchema = z.string().refine((value) => {
3743
3761
  * reason codes must come from. A waiver store is durable operator evidence
3744
3762
  * written before today's vocabulary existed, so tightening its validation
3745
3763
  * would make an existing record parse as no waiver and let the next write drop
3746
- * it. A waiver naming a key nobody resolves is already inert — `pr:merge-check`
3764
+ * it. A waiver naming a key nobody resolves is already inert — `pr:ready`
3747
3765
  * reports it as an unapplied waiver — so nothing is admitted by accepting it.
3748
3766
  */
3749
3767
  const waiverDemandKeySchema = z.string().regex(/^[a-z][a-z0-9-]*(?::[A-Za-z0-9._%/-]+)?$/u, "A demand key is a lowercase family, optionally qualified by its resolved value (e.g. review-rung:human).");
@@ -3818,10 +3836,10 @@ const resolveAuthoringSessionIds = ({ override, recorded }) => {
3818
3836
  //#region src/demand-waiver.ts
3819
3837
  const DEMAND_WAIVER_SCHEMA_VERSION$1 = 1;
3820
3838
  const DEFAULT_DEMAND_WAIVER_PATH = ".factory-memory/demand-waivers.json";
3821
- const shaSchema$6 = z.string().regex(/^[0-9a-f]{40}$/u);
3839
+ const shaSchema$4 = z.string().regex(/^[0-9a-f]{40}$/u);
3822
3840
  const demandWaiverSchema = z.object({
3823
3841
  candidate: z.object({
3824
- headSha: shaSchema$6,
3842
+ headSha: shaSchema$4,
3825
3843
  pr: z.number().int().positive()
3826
3844
  }),
3827
3845
  demand: waiverDemandKeySchema,
@@ -4673,66 +4691,7 @@ const boundaryCheckProofSchema = z.object({
4673
4691
  }).optional(),
4674
4692
  status: z.enum(["ready", "blocked"])
4675
4693
  });
4676
- const mergeGuardIdentitySchema$1 = z.discriminatedUnion("kind", [
4677
- z.object({
4678
- headSha: z.string().regex(/^[0-9a-f]{40}$/u),
4679
- kind: z.literal("match")
4680
- }),
4681
- z.object({
4682
- kind: z.literal("diverged"),
4683
- liveHeadSha: z.string().regex(/^[0-9a-f]{40}$/u),
4684
- postProofCommits: z.array(z.object({
4685
- sha: z.string().min(1),
4686
- subject: z.string()
4687
- })).optional(),
4688
- proofHeadSha: z.string().regex(/^[0-9a-f]{40}$/u)
4689
- }),
4690
- z.object({
4691
- kind: z.literal("live-head-invalid"),
4692
- pr: z.number().int().positive(),
4693
- received: z.string()
4694
- }),
4695
- z.object({
4696
- errorDetail: z.string().optional(),
4697
- kind: z.literal("ready-proof-missing"),
4698
- readyProofPath: z.string().min(1)
4699
- }),
4700
- z.object({
4701
- kind: z.literal("ready-proof-pr-mismatch"),
4702
- proofPr: z.number().int().positive(),
4703
- requestedPr: z.number().int().positive()
4704
- }),
4705
- z.object({
4706
- blockingReasons: z.array(z.string()),
4707
- kind: z.literal("ready-proof-not-ready"),
4708
- status: z.string().min(1)
4709
- })
4710
- ]);
4711
- z.object({
4712
- blockingReasons: z.array(z.string()),
4713
- command: z.literal("patronage-factory pr:merge-check"),
4714
- followUp: followUpActionSchema.optional(),
4715
- identity: mergeGuardIdentitySchema$1,
4716
- liveHeadSha: z.string().regex(/^[0-9a-f]{40}$/u).optional(),
4717
- notices: z.array(z.string()).optional(),
4718
- pr: z.number().int().positive(),
4719
- schemaVersion: z.literal(1),
4720
- status: z.enum(["pass", "fail"]),
4721
- waivedDemands: z.array(waivedDemandSchema).optional(),
4722
- worktreeHeldBranch: z.object({
4723
- branch: z.string().min(1),
4724
- worktreePath: z.string().min(1)
4725
- }).optional()
4726
- });
4727
- const requiredCheckScopeSchema = z.object({
4728
- classifications: z.array(z.enum(DIFF_CLASSIFICATIONS)).min(1).optional(),
4729
- labels: z.array(z.string().min(1)).min(1).optional()
4730
- }).strict().superRefine((scope, context) => {
4731
- if (scope.labels === void 0 && scope.classifications === void 0) context.addIssue({
4732
- code: "custom",
4733
- message: "requiredChecks scope must declare at least one condition (labels and/or classifications)."
4734
- });
4735
- });
4694
+ const requiredCheckScopeSchema = z.object({ classifications: z.array(z.enum(DIFF_CLASSIFICATIONS)).min(1) }).strict();
4736
4695
  const readinessRepairSchema$1 = z.object({
4737
4696
  action: z.string().min(1),
4738
4697
  code: z.enum([
@@ -4908,19 +4867,34 @@ const managedReadinessLedgerSchema$1 = z.object({
4908
4867
  })
4909
4868
  });
4910
4869
  /**
4911
- * The pr:ready proof versions a reader still accepts. `pr:ready` emits v2 only
4912
- * (#391) — one current contract — but v1 events were spooled before the bump
4913
- * and HQ must ingest them without degrading, so the wire schema parses both.
4870
+ * The pr:ready proof versions a reader still accepts. `pr:ready` emits v3 only
4871
+ * (#477) — one current contract — but v1 and v2 events were spooled before the
4872
+ * bumps and HQ must ingest them without degrading, so the wire schema parses
4873
+ * all three.
4914
4874
  */
4915
- const SUPPORTED_PR_READY_SCHEMA_VERSIONS = [1, 2];
4875
+ const SUPPORTED_PR_READY_SCHEMA_VERSIONS = [
4876
+ 1,
4877
+ 2,
4878
+ 3
4879
+ ];
4916
4880
  const prReadySchemaVersionSchema = z.number().refine((value) => SUPPORTED_PR_READY_SCHEMA_VERSIONS.includes(value), { message: `schemaVersion must be one of: ${SUPPORTED_PR_READY_SCHEMA_VERSIONS.join(", ")}` });
4917
4881
  z.object({
4882
+ arming: z.object({
4883
+ detail: z.string().min(1).optional(),
4884
+ headSha: z.string().regex(/^[0-9a-f]{40}$/u),
4885
+ outcome: z.enum([
4886
+ "armed",
4887
+ "merged",
4888
+ "not-armed"
4889
+ ])
4890
+ }).optional(),
4918
4891
  blockedReasons: blockedReasonsSchema.optional(),
4919
4892
  blockingReasons: z.array(z.string()),
4920
4893
  command: z.literal("patronage-factory pr:ready"),
4921
4894
  followUp: followUpActionSchema.optional(),
4922
4895
  humanBlockingReasons: z.array(z.string()).default([]),
4923
4896
  ledger: managedReadinessLedgerSchema$1,
4897
+ notices: z.array(z.string().min(1)).optional(),
4924
4898
  profileBlobSha: z.string().regex(/^[0-9a-f]{40}$/u).optional(),
4925
4899
  profilePath: z.string().min(1).optional(),
4926
4900
  repairs: z.array(readinessRepairSchema$1).default([]),
@@ -4930,7 +4904,8 @@ z.object({
4930
4904
  "ready",
4931
4905
  "blocked",
4932
4906
  "slice-ready/not-final"
4933
- ])
4907
+ ]),
4908
+ waivedDemands: z.array(waivedDemandSchema).optional()
4934
4909
  }).superRefine((proof, context) => {
4935
4910
  if (proof.schemaVersion < 2) {
4936
4911
  if (proof.blockedReasons !== void 0) context.addIssue({
@@ -5093,8 +5068,11 @@ const defaultResolveRepo = (cwd) => runCapture("gh", [
5093
5068
  "--jq",
5094
5069
  ".nameWithOwner"
5095
5070
  ], cwd).stdout.trim();
5096
- const evaluateProofAgainstDemands = ({ computed, demandedRung, manifestHash, selected }) => {
5097
- if (!selected) 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`] };
5071
+ const evaluateProofAgainstDemands = ({ authorsSeen, computed, declaredBy, demandedRung, manifestHash, selected }) => {
5072
+ if (!selected) {
5073
+ 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`] };
5074
+ 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`] };
5075
+ }
5098
5076
  const { proof } = selected;
5099
5077
  const blockingReasons = [];
5100
5078
  if (proof.manifestHash.value !== manifestHash) blockingReasons.push(`boundary-review proof was minted against a different manifest (proof hash ${proof.manifestHash.value.slice(0, 12)}…, current ${manifestHash.slice(0, 12)}…); the manifest changed since review — re-run the boundary review`);
@@ -5116,9 +5094,12 @@ function runBoundaryCheck(args, dependencies = {}) {
5116
5094
  if (parsed.ok) {
5117
5095
  const demandedRung = closeoutRungFor(parsed.manifest);
5118
5096
  const computed = computeCoveredSet(parsed.manifest, repo, github);
5119
- const selected = selectBoundaryReviewProof(github.fetchIssueComments(repo, args.epic), parsed.manifest.boundary, parsed.manifest.declaredBy);
5097
+ const comments = github.fetchIssueComments(repo, args.epic);
5098
+ const selected = selectBoundaryReviewProof(comments, parsed.manifest.boundary, parsed.manifest.declaredBy);
5120
5099
  const { blockingReasons } = evaluateProofAgainstDemands({
5100
+ authorsSeen: selected ? [] : boundaryReviewProofAuthorsSeen(comments, parsed.manifest.boundary),
5121
5101
  computed,
5102
+ declaredBy: parsed.manifest.declaredBy,
5122
5103
  demandedRung,
5123
5104
  manifestHash: parsed.manifestHash,
5124
5105
  selected
@@ -7122,6 +7103,21 @@ const FACTORY_CHECK_NAMES = {
7122
7103
  "pr-verify": "patronage-factory/pr-verify"
7123
7104
  };
7124
7105
  const hqLaneRefUrl = (base, repo, number) => `${base}/${encodeURIComponent(repo)}/${number}`;
7106
+ /**
7107
+ * Fire-and-forget publication for a check run that is a *mirror* of a local
7108
+ * verdict — swallowing the failure cannot change what the gate decided.
7109
+ *
7110
+ * Not every branded check is a mirror any more (#477). `patronage-factory/
7111
+ * pr-ready` is a source-pinned required check in the branch ruleset: a
7112
+ * swallowed failure there leaves a candidate armed, unmergeable, and — as epic
7113
+ * #473 wave 2 measured — with no rollup row saying why. So no `pr-ready`
7114
+ * publication comes through here at all: `pr:ready` publishes once, completed,
7115
+ * through {@link ensureFactoryCheckRunPublished}, whose result the caller
7116
+ * reads. It used to publish an in-progress run here while hosted checks
7117
+ * settled, on the reasoning that a missing in-progress check cannot green
7118
+ * anything — true, and beside the point, because a *present* one cannot be
7119
+ * un-blocked and nothing ever completed it (#526).
7120
+ */
7125
7121
  function publishFactoryCheckSafely(publisher, input) {
7126
7122
  try {
7127
7123
  publisher?.(input);
@@ -7349,7 +7345,9 @@ const checkRunRequestBody = (input, detailsUrl) => {
7349
7345
  * means the credentials are wrong, and re-sending them cannot help.
7350
7346
  *
7351
7347
  * Throws the last error when every attempt fails; callers decide whether to
7352
- * fall back or to swallow.
7348
+ * fall back or to swallow. Returns the created check run, whose `id` is the
7349
+ * only handle on *which* run this call produced — what the read-back below
7350
+ * requires the served run to be.
7353
7351
  */
7354
7352
  async function postFactoryCheckRun({ config, deadlineMs, dependencies, detailsUrl, input, remainingAttempts }) {
7355
7353
  const { attempts, budgetMs, delayMs } = dependencies.retry ?? DEFAULT_CHECK_RUN_RETRY;
@@ -7360,7 +7358,7 @@ async function postFactoryCheckRun({ config, deadlineMs, dependencies, detailsUr
7360
7358
  const timeoutMs = dependencies.timeoutMs ?? 5e3;
7361
7359
  try {
7362
7360
  const token = await installationToken(input, config, request, (dependencies.now ?? Date.now)(), timeoutMs);
7363
- await githubJson(request, `https://api.github.com/repos/${input.owner}/${input.repo}/check-runs`, {
7361
+ return await githubJson(request, `https://api.github.com/repos/${input.owner}/${input.repo}/check-runs`, {
7364
7362
  body: JSON.stringify(checkRunRequestBody(input, detailsUrl)),
7365
7363
  headers: {
7366
7364
  Authorization: `Bearer ${token}`,
@@ -7371,7 +7369,7 @@ async function postFactoryCheckRun({ config, deadlineMs, dependencies, detailsUr
7371
7369
  } catch (error) {
7372
7370
  if (remaining <= 1 || !isRetryableCheckRunFailure(error) || deadline !== void 0 && clock() + delayMs >= deadline) throw error;
7373
7371
  await (dependencies.sleep ?? defaultSleep)(delayMs);
7374
- await postFactoryCheckRun({
7372
+ return await postFactoryCheckRun({
7375
7373
  config,
7376
7374
  deadlineMs: deadline,
7377
7375
  dependencies,
@@ -7408,6 +7406,177 @@ async function resolveDetailsUrlSafely(input, dependencies, onDiagnostic) {
7408
7406
  }
7409
7407
  }
7410
7408
  /**
7409
+ * Read back every run GitHub serves for this check name on this head SHA.
7410
+ *
7411
+ * `filter=all`, not `filter=latest` (#524). `latest` was chosen here on the
7412
+ * rationale that it is the view the required-check rollup reads; that rationale
7413
+ * is false, measured on PR #523's head `77cf425`. Two `patronage-factory/
7414
+ * pr-ready` runs existed there — `91323051988` (`in_progress`, stranded by
7415
+ * older code) and `91323113996` (`completed`/`success`). `latest` served the
7416
+ * completed one, while the pull request's rollup reported *both*
7417
+ * (`PENDING` and `SUCCESS`) and `mergeStateStatus` stayed `BLOCKED`. So `latest`
7418
+ * can report a publication confirmed while the gate still blocks on a different
7419
+ * run of the same name — false confidence in exactly the direction this
7420
+ * read-back exists to eliminate. The set the gate evaluates is every run of the
7421
+ * name, which is what this asks for.
7422
+ *
7423
+ * Three pins keep the answer usable as proof:
7424
+ *
7425
+ * - `app_id` — the required check is source-pinned to the factory App, so a
7426
+ * same-named run from another installed app (a GitHub Actions job named
7427
+ * `patronage-factory/pr-ready` materializes under integration 15368) is not
7428
+ * the pinned requirement. Left unpinned it could confirm a publication the
7429
+ * pinned check never got, or refuse every genuine one. This is the same
7430
+ * fail-closed rule `checkRunProducedByFactoryApp` applies to durable records
7431
+ * below. A run served without any `app` id is refused rather than filtered
7432
+ * out: dropping it fails closed for the newest-run clause but open for
7433
+ * terminality, and an unfinished run of unknown provenance still blocks the
7434
+ * gate.
7435
+ * - `per_page=100` with a required `total_count` that equals the page — a
7436
+ * truncated page holds neither the whole set nor a defensible newest run, and
7437
+ * `filter=all` returns strictly more runs than `latest` did, so this guard now
7438
+ * carries more weight. Truncation is unconfirmed, not paginated: a busy head
7439
+ * refuses rather than judging a publication against a page that silently
7440
+ * omits the run blocking it. An absent or malformed count cannot establish
7441
+ * completeness at all, so it refuses too.
7442
+ * - an unparsable or absent `started_at` — unorderable state is refused before
7443
+ * selection rather than sorting to an extreme and being chosen.
7444
+ *
7445
+ * Ordering among what survives goes through the sequencing owner rather than a
7446
+ * second rule here.
7447
+ */
7448
+ async function servedFactoryCheckRun(input, config, dependencies) {
7449
+ const request = dependencies.fetch ?? fetch;
7450
+ const timeoutMs = dependencies.timeoutMs ?? 5e3;
7451
+ const token = await installationToken(input, config, request, (dependencies.now ?? Date.now)(), timeoutMs);
7452
+ const query = new URLSearchParams({
7453
+ app_id: String(config.appId),
7454
+ check_name: FACTORY_CHECK_NAMES[input.gate],
7455
+ filter: "all",
7456
+ per_page: "100"
7457
+ });
7458
+ const served = await githubJson(request, `https://api.github.com/repos/${input.owner}/${input.repo}/commits/${input.sha}/check-runs?${query.toString()}`, {
7459
+ headers: { Authorization: `Bearer ${token}` },
7460
+ method: "GET"
7461
+ }, timeoutMs);
7462
+ const page = Array.isArray(served.check_runs) ? served.check_runs : [];
7463
+ const totalCount = served.total_count;
7464
+ if (!(typeof totalCount === "number" && Number.isSafeInteger(totalCount))) return {
7465
+ kind: "unavailable",
7466
+ reason: "the served page reports no usable total_count"
7467
+ };
7468
+ if (totalCount !== page.length) return {
7469
+ kind: "unavailable",
7470
+ reason: `${page.length} runs on the page against a total_count of ${totalCount}`
7471
+ };
7472
+ if (page.some((run) => (run.app?.id ?? null) === null)) return {
7473
+ kind: "unavailable",
7474
+ reason: "a served run has no app identity"
7475
+ };
7476
+ const runs = page.filter((run) => String(run.app?.id ?? "") === String(config.appId));
7477
+ const orderable = runs.flatMap((run) => {
7478
+ const orderMs = generationOrderMs(run.started_at);
7479
+ return Number.isFinite(orderMs) ? [{
7480
+ orderMs,
7481
+ run
7482
+ }] : [];
7483
+ });
7484
+ if (orderable.length !== runs.length) return {
7485
+ kind: "unavailable",
7486
+ reason: "a served run has no usable started_at"
7487
+ };
7488
+ const newest = selectNewestGeneration(orderable);
7489
+ if (newest.kind === "ambiguous") return {
7490
+ kind: "unavailable",
7491
+ reason: `${newest.tied.length} served runs share the newest started_at`
7492
+ };
7493
+ if (newest.kind === "none") return {
7494
+ kind: "unavailable",
7495
+ reason: "no run is served for this name"
7496
+ };
7497
+ return {
7498
+ kind: "newest",
7499
+ run: newest.generation.run,
7500
+ runs
7501
+ };
7502
+ }
7503
+ /** Runs GitHub has not finished. A non-terminal run blocks its required check. */
7504
+ const unfinishedRuns = (runs) => runs.filter((run) => run.status !== "completed");
7505
+ /**
7506
+ * Whether the served run *is* the publication that was just posted: the same
7507
+ * run, in the status that was asked for, and — for a completed one — with the
7508
+ * conclusion that was asked for.
7509
+ *
7510
+ * Requiring the id is what separates "GitHub serves a run that looks like
7511
+ * mine" from "GitHub serves mine". An older identical success from a previous
7512
+ * invocation satisfies every field comparison while this POST is still
7513
+ * invisible, and confirming on it is exactly the assumed-vs-live trust this
7514
+ * read-back exists to end.
7515
+ */
7516
+ const publishedRunIsServed = (input, createdId, served) => {
7517
+ if (createdId === void 0 || served.id !== createdId) return false;
7518
+ const status = input.status ?? "completed";
7519
+ if (served.status !== status) return false;
7520
+ return status !== "completed" || served.conclusion === input.conclusion;
7521
+ };
7522
+ /**
7523
+ * Whether the required check this publication targets is *satisfied* on the
7524
+ * head: the newest run of the name is this call's own, and no run of the name
7525
+ * survives unfinished.
7526
+ *
7527
+ * The second clause is what makes the answer the *gate's* answer rather than
7528
+ * one collapsed view of it (#524). A stranded `in_progress` run of a
7529
+ * required-check name blocks its pull request permanently and is not cleared by
7530
+ * publishing a newer completed run, so a confirmation that ignores it is a lie
7531
+ * in the one direction that matters. A publication that is itself non-terminal
7532
+ * therefore never confirms — correctly, since it cannot satisfy the check
7533
+ * either. No factory gate makes one for this name any more: `pr:ready`'s
7534
+ * in-progress writer, the last of them, is deleted (#526).
7535
+ */
7536
+ const servedRunConfirmsPublication = (input, createdId, selection) => publishedRunIsServed(input, createdId, selection.run) && unfinishedRuns(selection.runs).length === 0;
7537
+ /**
7538
+ * Which of the two refusals happened, reported in the order an operator can act
7539
+ * on: this call's own publication first, then any *other* run holding the check
7540
+ * open. Once the first clause holds, this call's run is terminal, so everything
7541
+ * the second clause names belongs to something else.
7542
+ */
7543
+ const unconfirmedDetail = (input, createdId, selection) => {
7544
+ const served = selection.run;
7545
+ if (!publishedRunIsServed(input, createdId, served)) return served.id === createdId ? `status ${served.status ?? "unknown"}${served.conclusion ? `, conclusion ${served.conclusion}` : ""}` : `run ${served.id ?? "unknown"} rather than the run ${createdId ?? "unknown"} this call created`;
7546
+ return `${unfinishedRuns(selection.runs).map((run) => `run ${run.id ?? "unknown"} (${run.status ?? "unknown"})`).join(", ")} unfinished for this name alongside it — a non-terminal run blocks the required check whatever this call published, and publishing a newer one does not clear it`;
7547
+ };
7548
+ /**
7549
+ * Confirm the publication against what GitHub serves, and say plainly which of
7550
+ * the two events failed when it cannot.
7551
+ *
7552
+ * Only the read is wrapped. A token, timeout, or 5xx failure here is not a
7553
+ * failure to publish — the POST already succeeded and the check is probably
7554
+ * present — so it must not be reported through the "unable to republish"
7555
+ * register, which drives `pr:ready`'s "this run's verdict never reached the
7556
+ * required check" notice.
7557
+ */
7558
+ async function confirmPublishedCheckRun({ config, createdId, dependencies, input, onDiagnostic }) {
7559
+ const checkName = FACTORY_CHECK_NAMES[input.gate];
7560
+ let selection;
7561
+ try {
7562
+ selection = await servedFactoryCheckRun(input, config, dependencies);
7563
+ } catch (error) {
7564
+ selection = {
7565
+ kind: "unavailable",
7566
+ reason: error instanceof Error ? error.message : String(error)
7567
+ };
7568
+ }
7569
+ if (selection.kind === "unavailable") {
7570
+ onDiagnostic?.(`${checkName}: the check run was accepted but could not be read back for ${input.sha.slice(0, 7)} (${selection.reason}), so the publication is not confirmed. The check itself may well be present.\n`);
7571
+ return false;
7572
+ }
7573
+ if (!servedRunConfirmsPublication(input, createdId, selection)) {
7574
+ onDiagnostic?.(`${checkName}: the check run was accepted but for ${input.sha.slice(0, 7)} GitHub serves ${unconfirmedDetail(input, createdId, selection)}, so the publication is not confirmed.\n`);
7575
+ return false;
7576
+ }
7577
+ return true;
7578
+ }
7579
+ /**
7411
7580
  * Publish a factory check run and *wait* for it, so a caller that has just made
7412
7581
  * the head SHA visible on GitHub (pushed the branch, created the PR) can make
7413
7582
  * the proof reliably present for that SHA before it returns (#247).
@@ -7416,6 +7585,28 @@ async function resolveDetailsUrlSafely(input, dependencies, onDiagnostic) {
7416
7585
  * human-readable mirror, not a proof surface, so a caller that needs an
7417
7586
  * App-verified check run must be told plainly whether it got one. Returns
7418
7587
  * `true` only when the App-owned check run landed.
7588
+ *
7589
+ * "Landed" means GitHub serves it, not that the POST was accepted (#520). On
7590
+ * PR #519 the POST was accepted, `pr:ready` reported ready and armed, and the
7591
+ * source-pinned required check read `in_progress` across three runs — so GitHub
7592
+ * never scheduled the merge and emitted no rollup row saying why. Arming
7593
+ * already refuses to infer its outcome from the invocation and reads the pull
7594
+ * request back (`arm-auto-merge.ts`); publication now does the same.
7595
+ *
7596
+ * "Landed" is judged against every run of the name from the pinned App, not
7597
+ * against the one GitHub collapses to (#524): the newest must be *this* run, in
7598
+ * the status and conclusion that were published, and no run of the name may
7599
+ * still be unfinished. A surviving `in_progress` run blocks the required check
7600
+ * on its own, so confirming past it would report success on a pull request
7601
+ * GitHub will never merge. One read, no polling: an unconfirmed publication
7602
+ * returns `false`, which `pr:ready` already turns into a notice and an
7603
+ * idempotent re-dispatch.
7604
+ *
7605
+ * Confirmation is a point-in-time read, deliberately: a later publication for
7606
+ * the same name changes the answer — a completed one by becoming the newest, an
7607
+ * unfinished one by holding the check open beside this verdict rather than
7608
+ * replacing it — and every remaining writer publishes once, as the last thing
7609
+ * its invocation does. No retry, no poll, no re-confirm.
7419
7610
  */
7420
7611
  async function ensureFactoryCheckRunPublished(input, dependencies = {}) {
7421
7612
  const { onDiagnostic } = dependencies;
@@ -7426,7 +7617,7 @@ async function ensureFactoryCheckRunPublished(input, dependencies = {}) {
7426
7617
  return false;
7427
7618
  }
7428
7619
  const detailsUrl = await resolveDetailsUrlSafely(input, dependencies, onDiagnostic);
7429
- await postFactoryCheckRun({
7620
+ const created = await postFactoryCheckRun({
7430
7621
  config,
7431
7622
  dependencies: {
7432
7623
  retry: DURABLE_CHECK_RUN_RETRY,
@@ -7435,7 +7626,13 @@ async function ensureFactoryCheckRunPublished(input, dependencies = {}) {
7435
7626
  detailsUrl,
7436
7627
  input
7437
7628
  });
7438
- return true;
7629
+ return await confirmPublishedCheckRun({
7630
+ config,
7631
+ createdId: typeof created.id === "number" ? created.id : void 0,
7632
+ dependencies,
7633
+ input,
7634
+ onDiagnostic
7635
+ });
7439
7636
  } catch (error) {
7440
7637
  const message = error instanceof Error ? error.message : String(error);
7441
7638
  onDiagnostic?.(`${FACTORY_CHECK_NAMES[input.gate]}: unable to republish check run: ${message}\n`);
@@ -10660,15 +10857,6 @@ function normalizeRepositoryIdentity(repository) {
10660
10857
  owner: repository.owner.toLowerCase()
10661
10858
  };
10662
10859
  }
10663
- function normalizeRepositorySlug(slug) {
10664
- const segments = slug.split("/");
10665
- const [owner, name] = segments;
10666
- if (!(owner && name) || segments.length !== 2) return;
10667
- return repositorySlug(normalizeRepositoryIdentity({
10668
- name,
10669
- owner
10670
- }));
10671
- }
10672
10860
  const GITHUB_HOSTNAME = "github.com";
10673
10861
  const SCP_GITHUB_REMOTE_PATTERN = /^(?:[^@\s]+@)?github\.com:(?<path>[^\s]+)$/iu;
10674
10862
  function githubRepositoryPath(remoteUrl) {
@@ -10739,6 +10927,37 @@ function resolveCandidateIdentity({ base, cwd, git }) {
10739
10927
  };
10740
10928
  }
10741
10929
  /**
10930
+ * Resolve only the applicability patch id for one diff base — no head or
10931
+ * merge-base read, and no validation beyond what the supplied `stablePatchId`
10932
+ * itself returns. Some callers (verify-once applicability, review-identity
10933
+ * comparison) only ever consumed the patch id half of the triple; before the
10934
+ * owner-routing fix for #379/#466 they invoked `stablePatchId` directly and
10935
+ * got its return value unchanged. Routing them through
10936
+ * `resolveCandidateIdentity` instead would tax every such call with two reads
10937
+ * it never used and two new ways to throw (`currentHeadSha`, optional
10938
+ * `mergeBaseSha`), changing the failure surface without changing the value —
10939
+ * and `resolveCandidateIdentity`'s own 40-character object-id check would
10940
+ * reject a legitimate 64-character patch id from a SHA-256 repository, which
10941
+ * the original direct call never rejected. This is a narrower view over the
10942
+ * same owned machine — not a second primitive and not a new `CONTEXT.md`
10943
+ * entry.
10944
+ *
10945
+ * Takes the primitive itself, not a `git` object to read it off: the original
10946
+ * expression this replaces (`dependencies.git?.stablePatchId ?? stablePatchId`)
10947
+ * nullish-coalesces on the *property* and calls the winner *unbound* — no
10948
+ * receiver, ever. An object-shaped parameter would reintroduce both a
10949
+ * newly-thrown `undefined(...)` call when a seam supplies the property as
10950
+ * `undefined` (rather than omitting the whole `git`) and a receiver
10951
+ * (`git.stablePatchId(...)`) the original expression never had. A
10952
+ * function-shaped parameter can only be called unbound, and the caller
10953
+ * chooses the fallback the same way the original expression did — see
10954
+ * `defaultStablePatchId` above for where that fallback comes from when the
10955
+ * caller may not import `git.ts` directly.
10956
+ */
10957
+ function resolveCandidatePatchId({ base, cwd, stablePatchId }) {
10958
+ return stablePatchId(cwd, base);
10959
+ }
10960
+ /**
10742
10961
  * A per-invocation resolver: one identity per diff base, computed once.
10743
10962
  */
10744
10963
  function createCandidateIdentityResolver({ cwd, git }) {
@@ -10772,16 +10991,16 @@ var external_evidence_exports = /* @__PURE__ */ __exportAll({
10772
10991
  requiredCheckScopeSchema: () => requiredCheckScopeSchema$1,
10773
10992
  resolveAuthoringSessionIds: () => resolveAuthoringSessionIds
10774
10993
  });
10775
- const shaSchema$5 = z.string().regex(/^[0-9a-f]{7,40}$/u, { message: "must be a 7-40 char lowercase hex git SHA" });
10994
+ const shaSchema$3 = z.string().regex(/^[0-9a-f]{7,40}$/u, { message: "must be a 7-40 char lowercase hex git SHA" });
10776
10995
  const evidenceEnvelopeSchema = z.object({
10777
10996
  check: z.string().min(1),
10778
10997
  checkType: z.enum(EVIDENCE_CHECK_TYPES$1),
10779
10998
  findingsPointer: z.string().min(1).optional(),
10780
- headSha: shaSchema$5,
10781
- mergeBaseSha: shaSchema$5,
10999
+ headSha: shaSchema$3,
11000
+ mergeBaseSha: shaSchema$3,
10782
11001
  model: z.string().min(1).optional(),
10783
11002
  outcome: z.enum(["pass", "fail"]),
10784
- patchId: shaSchema$5,
11003
+ patchId: shaSchema$3,
10785
11004
  policyVersion: z.string().min(1).optional(),
10786
11005
  producer: z.string().min(1),
10787
11006
  requestId: z.string().min(1).optional(),
@@ -11965,7 +12184,7 @@ function runPrVerify(args, dependencies = {}) {
11965
12184
  //#endregion
11966
12185
  //#region src/demand-waive.ts
11967
12186
  const DEMAND_WAIVER_SCHEMA_VERSION = 1;
11968
- const shaSchema$4 = z.string().regex(/^[0-9a-f]{40}$/u);
12187
+ const shaSchema$2 = z.string().regex(/^[0-9a-f]{40}$/u);
11969
12188
  const demandWaiverStorePath = (cwd, filePath) => path.resolve(cwd, filePath ?? ".factory-memory/demand-waivers.json");
11970
12189
  /** Every waiver recorded in this checkout; an absent or unreadable store is
11971
12190
  * no waivers, so a damaged file can only ever block, never permit. */
@@ -12015,7 +12234,7 @@ const requestRefusals = ({ demand, parsedDemand, rationale }) => [...parsedDeman
12015
12234
  * the authoring session read below describes a different candidate.
12016
12235
  */
12017
12236
  const candidateBindingRefusals = ({ headSha, pr, verifyProof }) => {
12018
- if (!headSha || !shaSchema$4.safeParse(headSha).success) return [`demand:waive could not resolve a pushed head SHA for PR #${pr}; a waiver binds to one candidate.`];
12237
+ if (!headSha || !shaSchema$2.safeParse(headSha).success) return [`demand:waive could not resolve a pushed head SHA for PR #${pr}; a waiver binds to one candidate.`];
12019
12238
  return verifyProof && verifyProof.headSha !== headSha ? [`The pr:verify proof in this checkout records head ${verifyProof.headSha}, not the pushed head ${headSha}; re-run pr:verify on the candidate before waiving one of its demands.`] : [];
12020
12239
  };
12021
12240
  /**
@@ -12213,12 +12432,52 @@ const resolveWaveDemand = ({ epic, fetchClosingPullRequests = defaultFetchClosin
12213
12432
  };
12214
12433
  /**
12215
12434
  * Whether recorded review evidence (and, for the human rung, live GitHub
12216
- * approvals) satisfies the demanded review rung. One implementation for both
12217
- * consumers: `pr:ready` evaluates it over the review proof it is folding, and
12218
- * `pr:merge-check` revalidates it over the ready proof's recorded review runs.
12435
+ * approvals) satisfies the demanded review rung. `pr:ready` the single
12436
+ * admission decision since #477/#479 — evaluates it over the review proof it
12437
+ * is folding.
12438
+ */
12439
+ /**
12440
+ * The submission states that carry a verdict. A `COMMENTED` submission does
12441
+ * not supersede an earlier approval — that is GitHub's own rule, and the
12442
+ * reason this cannot be a plain "latest submission wins".
12443
+ */
12444
+ const VERDICT_REVIEW_STATES = new Set([
12445
+ "APPROVED",
12446
+ "CHANGES_REQUESTED",
12447
+ "DISMISSED"
12448
+ ]);
12449
+ /**
12450
+ * Whether GitHub records a live human APPROVED review on this candidate.
12451
+ *
12452
+ * One predicate, two demands: the `human` review rung below, and the
12453
+ * native-approval demand a wave that does not authorize machine merge makes
12454
+ * at readiness (#477). A bot approval is not a human act.
12455
+ *
12456
+ * Latest-per-author, never "any submission ever" (#515 review): the fetch
12457
+ * returns the whole submission history, so a reviewer who approves and then
12458
+ * submits CHANGES_REQUESTED on the same head would otherwise keep satisfying
12459
+ * this predicate through their own standing rejection — the ruleset's
12460
+ * dismiss-stale-reviews-on-push never fires because the head has not moved.
12461
+ * This mirrors GitHub's `latestReviews` semantics.
12219
12462
  */
12463
+ const hasLiveHumanApproval = (reviews) => {
12464
+ const latestByAuthor = /* @__PURE__ */ new Map();
12465
+ for (const [index, review] of reviews.entries()) {
12466
+ const login = review.author?.login;
12467
+ if (!login || isBotLogin(login) || !VERDICT_REVIEW_STATES.has(review.state)) continue;
12468
+ const parsed = Date.parse(review.submittedAt ?? "");
12469
+ const at = Number.isNaN(parsed) ? void 0 : parsed;
12470
+ const previous = latestByAuthor.get(login);
12471
+ if (!previous || (at !== void 0 && previous.at !== void 0 ? at >= previous.at : index > previous.index)) latestByAuthor.set(login, {
12472
+ at,
12473
+ index,
12474
+ state: review.state
12475
+ });
12476
+ }
12477
+ return [...latestByAuthor.values()].some((latest) => latest.state === "APPROVED");
12478
+ };
12220
12479
  const demandedRungSatisfactionReasons = ({ demandedRung, liveHumanReviews, reviewRuns }) => {
12221
- if (demandedRung === "human") return liveHumanReviews.some((review) => review.state === "APPROVED" && Boolean(review.author?.login) && !isBotLogin(review.author?.login)) ? [] : ["Boundary wave requires the human rung, but GitHub has no live human APPROVED review."];
12480
+ if (demandedRung === "human") return hasLiveHumanApproval(liveHumanReviews) ? [] : ["Boundary wave requires the human rung, but GitHub has no live human APPROVED review."];
12222
12481
  return reviewRuns?.flatMap((review) => review.outcome === "passed" && review.rung !== void 0 ? [review.rung] : []).find((candidate) => rungMeetsMinimum(candidate, demandedRung)) ? [] : [`Boundary wave requires the ${demandedRung} rung, but the recorded review evidence has no passing review at that authority.`];
12223
12482
  };
12224
12483
  //#endregion
@@ -12690,7 +12949,7 @@ const selectReviewProof = ({ reviewProof }) => {
12690
12949
  if (isExplicitReviewFailure(reviewProof) || isUntypedReviewProof(reviewProof)) return {};
12691
12950
  return hasReviewProof(reviewProof) ? reviewProof : {};
12692
12951
  };
12693
- const shaSchema$3 = z.string().regex(/^[0-9a-f]{40}$/u);
12952
+ const shaSchema$1 = z.string().regex(/^[0-9a-f]{40}$/u);
12694
12953
  const patchIdSchema = z.string().regex(/^[0-9a-f]{40,64}$/u);
12695
12954
  const reviewCycleStateSchema = z.object({
12696
12955
  autoBlockingFindings: z.number().int().nonnegative(),
@@ -12785,7 +13044,7 @@ const readinessRepairSchema = z.object({
12785
13044
  command: z.string().min(1)
12786
13045
  });
12787
13046
  const managedReadinessLedgerSchema = z.object({
12788
- baseSha: shaSchema$3,
13047
+ baseSha: shaSchema$1,
12789
13048
  blockingReasons: z.array(z.string()),
12790
13049
  classification: z.enum([
12791
13050
  "docs/process-only",
@@ -12836,17 +13095,17 @@ const managedReadinessLedgerSchema = z.object({
12836
13095
  handledHumanComments: z.array(handledHumanCommentSchema).optional(),
12837
13096
  /**
12838
13097
  * The evaluated head SHA: the GitHub `pr.headRefOid` captured at pr:ready
12839
- * evaluation time, not a local git read. This is the identity anchor
12840
- * merge-time checks (pr:merge-check) compare against the live pushed HEAD
12841
- * with exact equality a pure comparison, never a re-derivation.
13098
+ * evaluation time, not a local git read. This is the identity anchor the
13099
+ * branded `patronage-factory/pr-ready` check binds to GitHub's required
13100
+ * checks compare a new head's checks against exactly this SHA (#477).
12842
13101
  */
12843
- headSha: shaSchema$3,
13102
+ headSha: shaSchema$1,
12844
13103
  /**
12845
13104
  * The merge base used to evaluate tree-scoped external evidence. Optional
12846
13105
  * only for backwards-compatible parsing of readiness ledgers written before
12847
13106
  * #732; a committed verify-type demand cannot pass the merge gate without it.
12848
13107
  */
12849
- mergeBaseSha: shaSchema$3.optional(),
13108
+ mergeBaseSha: shaSchema$1.optional(),
12850
13109
  patchId: patchIdSchema,
12851
13110
  postReadinessHumanComments: z.array(postReadinessCommentSchema).optional(),
12852
13111
  postReadinessHumanReviews: z.array(postReadinessCommentSchema).optional(),
@@ -12984,6 +13243,129 @@ const resolveReviewRequiredness = (input) => {
12984
13243
  };
12985
13244
  };
12986
13245
  //#endregion
13246
+ //#region src/pr-readiness/status-check-rollup.ts
13247
+ var status_check_rollup_exports = /* @__PURE__ */ __exportAll({
13248
+ HOSTED_VERIFY_CHECK_NAME: () => HOSTED_VERIFY_CHECK_NAME$1,
13249
+ hostedVerifyCheckState: () => hostedVerifyCheckState,
13250
+ isFactoryReadyCheck: () => isFactoryReadyCheck,
13251
+ isHostedVerifyCheck: () => isHostedVerifyCheck,
13252
+ statusCheckState: () => statusCheckState
13253
+ });
13254
+ const PASSED_STATES = new Set([
13255
+ "SUCCESS",
13256
+ "SKIPPED",
13257
+ "NEUTRAL"
13258
+ ]);
13259
+ const FAILED_STATES = new Set([
13260
+ "FAILURE",
13261
+ "ERROR",
13262
+ "CANCELLED",
13263
+ "TIMED_OUT",
13264
+ "ACTION_REQUIRED"
13265
+ ]);
13266
+ const PENDING_STATES = new Set([
13267
+ "PENDING",
13268
+ "QUEUED",
13269
+ "IN_PROGRESS",
13270
+ "EXPECTED"
13271
+ ]);
13272
+ const isFactoryReadyCheck = (check) => check.context?.startsWith("patronage-factory/") === true || !check.workflowName && check.name?.startsWith("patronage-factory/") === true;
13273
+ /**
13274
+ * The hosted verification gate's context name — the branch ruleset's other
13275
+ * source-pinned required check, alongside `patronage-factory/pr-ready`. One
13276
+ * name across the fleet because one generator emits the workflow that posts
13277
+ * it (ADR 0016, 2026-07-31 amendment).
13278
+ */
13279
+ const HOSTED_VERIFY_CHECK_NAME$1 = "verify";
13280
+ /**
13281
+ * Is this rollup entry the hosted `verify` gate, from the producer the
13282
+ * ruleset pins it to?
13283
+ *
13284
+ * `workflowName` is the producer signal the rollup actually carries: GitHub
13285
+ * Actions check runs name their workflow, App-posted check runs come back with
13286
+ * an empty one, and a user-token commit status arrives as a `context` with no
13287
+ * `name` at all. Epic #473 wave 2 measured that a same-named check from the
13288
+ * wrong writer is as inert as no check at all, so matching the name alone
13289
+ * would accept exactly the thing the pin rejects.
13290
+ */
13291
+ const isHostedVerifyCheck = (check) => check.name === "verify" && Boolean(check.workflowName);
13292
+ /**
13293
+ * The hosted `verify` gate's state on this head, read by presence (#477).
13294
+ *
13295
+ * This is the one rollup question the rollup can answer honestly. Wave 2
13296
+ * measured that GitHub omits an *unsatisfied pinned requirement* from
13297
+ * `statusCheckRollup` entirely — there is no "expected" or "missing" row — so
13298
+ * a green rollup is not evidence of mergeability. What the rollup does report
13299
+ * faithfully is the checks that ran. Asking whether this specific check ran,
13300
+ * and passed, on this head turns the rollup's silence from a false green into
13301
+ * a named refusal: `"none"` means the pinned requirement is unsatisfied and
13302
+ * nothing else in the rollup would have said so.
13303
+ */
13304
+ const hostedVerifyCheckState = (rollup) => statusCheckState(rollup, (check) => !isHostedVerifyCheck(check));
13305
+ const checkContext = (check) => {
13306
+ if (check.workflowName && check.name) return `${check.workflowName} / ${check.name}`;
13307
+ return check.context;
13308
+ };
13309
+ const checkTimestampMs = (check) => {
13310
+ const value = check.startedAt ?? check.completedAt;
13311
+ if (!value) return;
13312
+ const timestamp = Date.parse(value);
13313
+ return Number.isNaN(timestamp) ? void 0 : timestamp;
13314
+ };
13315
+ const effectiveChecks = (rollup) => {
13316
+ const checksByContext = /* @__PURE__ */ new Map();
13317
+ const unkeyed = [];
13318
+ for (const [index, check] of rollup.entries()) {
13319
+ const context = checkContext(check);
13320
+ if (!context) {
13321
+ unkeyed.push(check);
13322
+ continue;
13323
+ }
13324
+ const checks = checksByContext.get(context) ?? [];
13325
+ checks.push({
13326
+ check,
13327
+ index,
13328
+ timestampMs: checkTimestampMs(check)
13329
+ });
13330
+ checksByContext.set(context, checks);
13331
+ }
13332
+ const currentChecks = [...unkeyed];
13333
+ for (const checks of checksByContext.values()) {
13334
+ const [firstCheck] = checks;
13335
+ if (!firstCheck) continue;
13336
+ if (checks.length === 1) {
13337
+ currentChecks.push(firstCheck.check);
13338
+ continue;
13339
+ }
13340
+ if (checks.some((check) => check.timestampMs === void 0)) {
13341
+ currentChecks.push(...checks.map((check) => check.check));
13342
+ continue;
13343
+ }
13344
+ let latest = firstCheck;
13345
+ for (const candidate of checks.slice(1)) {
13346
+ if (latest.timestampMs === void 0 || candidate.timestampMs === void 0) continue;
13347
+ if (candidate.timestampMs > latest.timestampMs) {
13348
+ latest = candidate;
13349
+ continue;
13350
+ }
13351
+ if (candidate.timestampMs === latest.timestampMs && candidate.index > latest.index) latest = candidate;
13352
+ }
13353
+ currentChecks.push(latest.check);
13354
+ }
13355
+ return currentChecks;
13356
+ };
13357
+ const terminalState = (check) => check.conclusion ?? check.state ?? "";
13358
+ const progressState = (check) => check.status ?? check.state ?? "";
13359
+ const statusCheckState = (rollup, exclude) => {
13360
+ const included = rollup?.filter((check) => !exclude?.(check));
13361
+ if (!included || included.length === 0) return "none";
13362
+ const currentChecks = effectiveChecks(included);
13363
+ if (currentChecks.every((check) => PASSED_STATES.has(terminalState(check)))) return "passed";
13364
+ if (currentChecks.some((check) => FAILED_STATES.has(terminalState(check)))) return "failed";
13365
+ if (currentChecks.some((check) => PENDING_STATES.has(progressState(check)))) return "pending";
13366
+ return "unknown";
13367
+ };
13368
+ //#endregion
12987
13369
  //#region src/pr-readiness/readiness-evaluation.ts
12988
13370
  var readiness_evaluation_exports = /* @__PURE__ */ __exportAll({
12989
13371
  CORRECTNESS_UNTYPED_REVIEW_BLOCKER_REASON: () => CORRECTNESS_UNTYPED_REVIEW_BLOCKER_REASON,
@@ -13121,10 +13503,7 @@ const requiredChecksEvaluation = (input) => {
13121
13503
  candidate,
13122
13504
  envelopes: input.evidenceEnvelopes ?? [],
13123
13505
  requiredChecks,
13124
- scopeContext: {
13125
- classification: input.classification,
13126
- labels: input.prLabels
13127
- }
13506
+ scopeContext: { classification: input.classification }
13128
13507
  });
13129
13508
  const blockers = outcomes.flatMap((outcome) => outcome.reason ? [{
13130
13509
  demand: requiredCheckDemand(outcome.name),
@@ -13188,6 +13567,10 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
13188
13567
  demand: DEMAND_KEYS.mergeState,
13189
13568
  reason: `GitHub merge state is ${input.mergeStateStatus}/${input.mergeable}; expected CLEAN or review-only BLOCKED with MERGEABLE.`
13190
13569
  });
13570
+ if ((input.hostedVerifyCheck ?? "none") === "none") blockers.push({
13571
+ demand: DEMAND_KEYS.githubChecks,
13572
+ reason: `GitHub has no ${HOSTED_VERIFY_CHECK_NAME$1} check run from its workflow producer on this head; the branch ruleset pins it as a required check and the status rollup does not report the shortfall.`
13573
+ });
13191
13574
  if (input.requiredChecks === "none" && input.classification !== "docs/process-only") blockers.push({
13192
13575
  demand: DEMAND_KEYS.githubChecks,
13193
13576
  reason: "GitHub has no current checks for a non-docs PR."
@@ -13304,6 +13687,88 @@ const waveRungBlockers = (input) => {
13304
13687
  reason
13305
13688
  }));
13306
13689
  };
13690
+ const mergeFreezeBlockers = (input) => (input.mergeFreeze?.blockingReasons ?? []).map((reason) => ({
13691
+ demand: DEMAND_KEYS.mergeFreeze,
13692
+ reason
13693
+ }));
13694
+ /**
13695
+ * #477 (#515 review): a candidate GitHub is already scheduled to merge, with
13696
+ * no boundary wave in force to say it may be.
13697
+ *
13698
+ * Refusing to *arm* without a resolved wave was only half the fix. Arming
13699
+ * survives a push, and the durable head binding is the branded check — so a
13700
+ * later epicless run that greens the check re-satisfies the ruleset for a new
13701
+ * head and the still-enabled schedule fires, with no rung and no approval
13702
+ * evaluated, on a head whose approval the ruleset just dismissed. The branded
13703
+ * check cannot simply refuse to go green without `--epic`: it is required on
13704
+ * every pull request into the default branch, and most work carries no epic.
13705
+ * So the refusal is exactly as narrow as the danger — a live enablement is
13706
+ * present, and nothing authorized it.
13707
+ */
13708
+ const armedWithoutBoundaryBlockers = (input) => {
13709
+ if (!input.autoMergeEnabled || input.waveReviewDemand) return [];
13710
+ return [{
13711
+ demand: DEMAND_KEYS.boundaryWave,
13712
+ reason: "GitHub already has auto-merge enabled on this pull request, but no boundary wave is in force to authorize it; re-run pr:ready --epic <issue> so the wave's review rung and machine-merge authority are evaluated before the branded check goes green."
13713
+ }];
13714
+ };
13715
+ const nativeApprovalBlockers = (input) => {
13716
+ const demanded = input.waveReviewDemand;
13717
+ if (!demanded) return [];
13718
+ if (demanded.autoMerge && demanded.review !== "human" || hasLiveHumanApproval(input.reviews ?? [])) return [];
13719
+ return [{
13720
+ demand: DEMAND_KEYS.nativeApproval,
13721
+ reason: `Boundary wave ${demanded.wave} does not authorize machine merge; GitHub must record a human APPROVED review on this PR before the branded readiness check can go green.`
13722
+ }];
13723
+ };
13724
+ /**
13725
+ * The demands an operator waiver can reach (#354, moved here from
13726
+ * `pr:merge-check` by #477). Exactly the set the merge-time site could waive:
13727
+ * the merge freeze, the profile's external required checks, and the boundary
13728
+ * wave's review rung — the demands the resolver puts *in force* for one
13729
+ * candidate.
13730
+ *
13731
+ * Everything else stays unwaivable, and relocating the consumption site does
13732
+ * not widen the operator's override. Structural refusals — candidate identity,
13733
+ * proof binding, an unreadable demand authority, wave membership — were never
13734
+ * waivable at merge time and are not waivable here; in `pr:ready` most of them
13735
+ * refuse by throwing before any blocker list exists. The evidence gates
13736
+ * readiness has always owned (verify, review modes, draft, human blockers) are
13737
+ * satisfied or not; a waiver is not a second way to pass them.
13738
+ */
13739
+ const WAIVABLE_QUALIFIED_DEMAND_FAMILIES = ["required-check", "review-rung"];
13740
+ const isWaivableDemand = (demand) => demand === DEMAND_KEYS.mergeFreeze || WAIVABLE_QUALIFIED_DEMAND_FAMILIES.some((family) => demand.startsWith(`${family}:`));
13741
+ /**
13742
+ * Fold the operator's waivers through the blockers, one call per demand so
13743
+ * `demand-waiver.ts` stays the single authority on what a waiver means. A
13744
+ * waived demand keeps every refusal it made, verbatim, on the `WaivedDemand`
13745
+ * record — it is never rewritten as satisfied.
13746
+ */
13747
+ const foldWaivers = ({ blockers, waivers }) => {
13748
+ if (waivers.length === 0) return {
13749
+ blockers,
13750
+ waivedDemands: []
13751
+ };
13752
+ const reasonsByDemand = /* @__PURE__ */ new Map();
13753
+ for (const blocker of blockers) {
13754
+ if (!isWaivableDemand(blocker.demand)) continue;
13755
+ reasonsByDemand.set(blocker.demand, [...reasonsByDemand.get(blocker.demand) ?? [], blocker.reason]);
13756
+ }
13757
+ const waivedDemands = [];
13758
+ for (const [demand, reasons] of reasonsByDemand) {
13759
+ const { waived } = applyDemandWaiver({
13760
+ demand,
13761
+ reasons,
13762
+ waivers
13763
+ });
13764
+ if (waived) waivedDemands.push(waived);
13765
+ }
13766
+ const waivedKeys = new Set(waivedDemands.map((waived) => waived.demand));
13767
+ return {
13768
+ blockers: blockers.filter((blocker) => !waivedKeys.has(blocker.demand)),
13769
+ waivedDemands
13770
+ };
13771
+ };
13307
13772
  const previewDeployRequired = false;
13308
13773
  const evaluateReadiness = (input) => {
13309
13774
  const bodyMetadata = readPrBodyMetadata(input.body);
@@ -13326,26 +13791,40 @@ const evaluateReadiness = (input) => {
13326
13791
  });
13327
13792
  const { docsOnlyVerifiedHeadSha, docsOnlyVerifyDeltaAccepted, prVerifyStatus, trivialVerifiedHeadSha, trivialVerifyDeltaAccepted, verifiedHeadSha } = verifyGate;
13328
13793
  const requiredChecks = requiredChecksEvaluation(input);
13329
- const blockers = [
13330
- ...collectBlockers({
13331
- correctnessRequired,
13332
- correctnessStatus,
13333
- correctnessUntyped,
13334
- input,
13335
- prVerifyStatus,
13336
- sectionReady: bodyMetadata.requiredSectionsPresent,
13337
- securityRequired,
13338
- securityStatus,
13339
- securityUntyped,
13340
- trivialWaiverPresent: bodyMetadata.trivialWaiverAccepted,
13341
- verificationProof: verifyGate.verification.verificationProof
13342
- }),
13343
- ...requiredChecks.blockers,
13344
- ...gateCapMismatchBlockers.map((blocker) => ({
13345
- demand: DEMAND_KEYS.reviewLadder,
13346
- reason: blocker.reason
13347
- })),
13348
- ...waveRungBlockers(input)
13794
+ const { blockers, waivedDemands } = foldWaivers({
13795
+ blockers: [
13796
+ ...collectBlockers({
13797
+ correctnessRequired,
13798
+ correctnessStatus,
13799
+ correctnessUntyped,
13800
+ input,
13801
+ prVerifyStatus,
13802
+ sectionReady: bodyMetadata.requiredSectionsPresent,
13803
+ securityRequired,
13804
+ securityStatus,
13805
+ securityUntyped,
13806
+ trivialWaiverPresent: bodyMetadata.trivialWaiverAccepted,
13807
+ verificationProof: verifyGate.verification.verificationProof
13808
+ }),
13809
+ ...requiredChecks.blockers,
13810
+ ...gateCapMismatchBlockers.map((blocker) => ({
13811
+ demand: DEMAND_KEYS.reviewLadder,
13812
+ reason: blocker.reason
13813
+ })),
13814
+ ...waveRungBlockers(input),
13815
+ ...nativeApprovalBlockers(input),
13816
+ ...armedWithoutBoundaryBlockers(input),
13817
+ ...mergeFreezeBlockers(input)
13818
+ ],
13819
+ waivers: input.waivers ?? []
13820
+ });
13821
+ const notices = [
13822
+ ...input.mergeFreeze?.notices ?? [],
13823
+ ...waivedDemands.map(waivedDemandNotice),
13824
+ ...unusedWaiverNotices({
13825
+ applied: waivedDemands,
13826
+ waivers: input.waivers ?? []
13827
+ })
13349
13828
  ];
13350
13829
  const blockingReasons = blockers.map((blocker) => blocker.reason);
13351
13830
  const humanBlockingReasons = blockers.flatMap((blocker) => blocker.repair ? [] : [blocker.reason]);
@@ -13442,8 +13921,16 @@ const evaluateReadiness = (input) => {
13442
13921
  blockingReasons: finalBlockingReasons,
13443
13922
  humanBlockingReasons,
13444
13923
  ledger,
13924
+ /**
13925
+ * Facts a reader needs that are not refusals (#477): the settle-window
13926
+ * arming notice, and every waived demand rendered so it can never read as
13927
+ * a met one.
13928
+ */
13929
+ notices,
13445
13930
  repairs,
13446
- status: postReadiness.status(status)
13931
+ status: postReadiness.status(status),
13932
+ /** Demands that were in force, were NOT met, and the operator waived. */
13933
+ waivedDemands
13447
13934
  };
13448
13935
  };
13449
13936
  //#endregion
@@ -13599,10 +14086,7 @@ const requiredCheckChecks = ({ candidate, candidateError, cwd, profile, verifyPr
13599
14086
  },
13600
14087
  envelopes: loadEvidenceEnvelopes(cwd),
13601
14088
  requiredChecks,
13602
- scopeContext: {
13603
- classification: candidate.classification,
13604
- labels: void 0
13605
- }
14089
+ scopeContext: { classification: candidate.classification }
13606
14090
  });
13607
14091
  return outcomes.map((outcome) => ({
13608
14092
  message: requiredCheckMessage(outcome),
@@ -14478,17 +14962,203 @@ const FollowUpActionSchema = z.object({
14478
14962
  command: z.string()
14479
14963
  }).strict();
14480
14964
  //#endregion
14965
+ //#region src/pr-publish-transaction-error.ts
14966
+ /**
14967
+ * Publish's admission read is a bounded transaction (#348): undraft, await the
14968
+ * newest hosted run for this head, re-evaluate, and return the current
14969
+ * outcome. Neither abort path returns a verdict the command cannot currently
14970
+ * substantiate — an unsubstantiated verdict is this error, never a guess.
14971
+ */
14972
+ var PrPublishTransactionAbortedError = class extends Error {
14973
+ constructor(message) {
14974
+ super(`pr:publish aborted: ${message}`);
14975
+ this.name = "PrPublishTransactionAbortedError";
14976
+ }
14977
+ };
14978
+ //#endregion
14979
+ //#region src/pr-readiness/pr-body-patch.ts
14980
+ const markerSlug = (heading) => heading.toLowerCase().replaceAll(/[^a-z0-9]+/gu, "-");
14981
+ const markerFor = (heading, edge) => `<!-- patronage-factory:${markerSlug(heading)}:${edge} -->`;
14982
+ const renderManagedPrBodyBlock = (heading, content) => `${markerFor(heading, "start")}\n${content.trim()}\n${markerFor(heading, "end")}`;
14983
+ const isLegacyFactoryOnlyContent = (heading, content) => {
14984
+ const value = content.trim();
14985
+ if (!value) return true;
14986
+ if (heading === "Verification") return value === "Run `patronage-factory pr:verify` and attach the typed proof to `pr:ready --verify-proof`." || value.split("\n").filter(Boolean).every((line) => /^patronage-factory pr:verify(?: --(?:docs-only|trivial))? passed at head [0-9a-f]{40}$/u.test(line));
14987
+ if (heading === "Review proof") return value === "Have a clean session write the typed findings file, then run `patronage-factory pr:review --mode all --findings <path> --output .factory-memory/pr-review.json` and attach the proof to `pr:ready --review-proof`." || value.split("\n").filter(Boolean).every((line) => /^- (?:correctness|security): (?:passed with no actionable findings|[a-z-]+ with \d+ finding\(s\)) at patch-id [0-9a-f]{40}$/u.test(line));
14988
+ return heading === "How to review" && value === "Review the changed files and branded patronage-factory check runs.";
14989
+ };
14990
+ const patchExistingSection = (body, part) => {
14991
+ const bounds = findSectionBounds(body, part.heading);
14992
+ if (!bounds) return;
14993
+ const rawContent = body.slice(bounds.contentStart, bounds.contentEnd);
14994
+ const block = renderManagedPrBodyBlock(part.heading, part.content);
14995
+ const startMarker = markerFor(part.heading, "start");
14996
+ const endMarker = markerFor(part.heading, "end");
14997
+ const markerStart = rawContent.indexOf(startMarker);
14998
+ const markerEnd = rawContent.indexOf(endMarker);
14999
+ const startCount = rawContent.split(startMarker).length - 1;
15000
+ if (startCount !== rawContent.split(endMarker).length - 1 || startCount > 1 || startCount === 1 && markerEnd < markerStart) throw new Error(`Malformed patronage-factory managed markers in the ${part.heading} section.`);
15001
+ if (startCount === 1) {
15002
+ const absoluteStart = bounds.contentStart + markerStart;
15003
+ const absoluteEnd = bounds.contentStart + markerEnd + endMarker.length;
15004
+ return body.slice(0, absoluteStart) + block + body.slice(absoluteEnd);
15005
+ }
15006
+ if (isLegacyFactoryOnlyContent(part.heading, rawContent)) return `${body.slice(0, bounds.contentStart)}\n\n${block}\n\n${body.slice(bounds.contentEnd)}`;
15007
+ const separator = rawContent.endsWith("\n\n") ? "" : "\n\n";
15008
+ return `${body.slice(0, bounds.contentEnd)}${separator}${block}\n\n${body.slice(bounds.contentEnd)}`;
15009
+ };
15010
+ const patchPrBodySections = (body, parts) => {
15011
+ let result = body;
15012
+ const missing = [];
15013
+ for (const part of parts) {
15014
+ const patched = patchExistingSection(result, part);
15015
+ if (patched === void 0) missing.push(part);
15016
+ else result = patched;
15017
+ }
15018
+ for (const part of missing) {
15019
+ const separator = result.length === 0 || result.endsWith("\n\n") ? "" : "\n\n";
15020
+ result += `${separator}## ${part.heading}\n\n${renderManagedPrBodyBlock(part.heading, part.content)}\n`;
15021
+ }
15022
+ return result;
15023
+ };
15024
+ //#endregion
15025
+ //#region src/pr-readiness/pr-body-renderer.ts
15026
+ var pr_body_renderer_exports = /* @__PURE__ */ __exportAll({
15027
+ PR_BODY_MANAGED_SECTIONS: () => MANAGED_SECTION_NAMES,
15028
+ renderPrBodySectionParts: () => renderPrBodySectionParts,
15029
+ renderPrBodySections: () => renderPrBodySections
15030
+ });
15031
+ const commandFor = (proof) => {
15032
+ if (proof.mode === "docs-only") return "patronage-factory pr:verify --docs-only";
15033
+ if (proof.mode === "trivial") return "patronage-factory pr:verify --trivial";
15034
+ return "patronage-factory pr:verify";
15035
+ };
15036
+ const renderPrBodySectionParts = ({ reviewProof, verifyProof }) => {
15037
+ const verification = verifyProof ? [...verifyProof.mode === "docs-only" ? verifyProof.baselineFullProofs?.map((proof) => `patronage-factory pr:verify passed at head ${proof.headSha}`) ?? [] : [], `${commandFor(verifyProof)} passed at head ${verifyProof.headSha}`].join("\n") : "Run `patronage-factory pr:verify` and attach the typed proof to `pr:ready --verify-proof`.";
15038
+ const reviewRuns = reviewProof?.reviews.map((review) => {
15039
+ const patch = reviewProof.patchId;
15040
+ const result = review.outcome === "passed" ? "passed with no actionable findings" : `${review.outcome} with ${review.issuesFlagged} finding(s)`;
15041
+ return `- ${review.kind}: ${result} at patch-id ${patch}`;
15042
+ }).join("\n") ?? "Have a clean session write the typed findings file, then run `patronage-factory pr:review --mode all --findings <path> --output .factory-memory/pr-review.json` and attach the proof to `pr:ready --review-proof`.";
15043
+ return [
15044
+ {
15045
+ content: verification,
15046
+ heading: "Verification"
15047
+ },
15048
+ {
15049
+ content: reviewRuns,
15050
+ heading: "Review proof"
15051
+ },
15052
+ {
15053
+ content: "Review the changed files and branded patronage-factory check runs.",
15054
+ heading: "How to review"
15055
+ }
15056
+ ];
15057
+ };
15058
+ const renderPrBodySections = ({ reviewProof, verifyProof }) => `${renderPrBodySectionParts({
15059
+ reviewProof,
15060
+ verifyProof
15061
+ }).map((part) => `## ${part.heading}\n\n${renderManagedPrBodyBlock(part.heading, part.content)}`).join("\n\n")}\n`;
15062
+ //#endregion
15063
+ //#region src/arm-auto-merge.ts
15064
+ /**
15065
+ * The read-back, parsed rather than asserted. `state` is read loosely so a
15066
+ * merged pull request is still recognised as merged even when the enablement
15067
+ * record is junk; the enablement itself must be a real timestamped record,
15068
+ * because that record IS the claim that a merge is scheduled.
15069
+ */
15070
+ const readBackSchema = z.object({
15071
+ autoMergeRequest: z.unknown().optional(),
15072
+ state: z.string().optional()
15073
+ });
15074
+ const autoMergeEnablementSchema = z.object({ enabledAt: z.iso.datetime() });
15075
+ /**
15076
+ * The arming invocation, as a literal argv. Exported because it is also the
15077
+ * re-dispatch `pr:ready` records when the arming did not take effect: arming
15078
+ * is idempotent on an unchanged head (measured — re-arming leaves `enabledAt`
15079
+ * untouched), so running it again is safe.
15080
+ */
15081
+ const armAutoMergeArgv = ({ headSha, owner, pr, repo }) => [
15082
+ "gh",
15083
+ "pr",
15084
+ "merge",
15085
+ String(pr),
15086
+ "--repo",
15087
+ `${owner}/${repo}`,
15088
+ "--auto",
15089
+ "--squash",
15090
+ "--match-head-commit",
15091
+ headSha
15092
+ ];
15093
+ const defaultRun = (argv, cwd) => {
15094
+ const [command = "gh", ...args] = argv;
15095
+ runCapture(command, args, cwd);
15096
+ };
15097
+ const defaultReadState = ({ owner, pr, repo }) => runGhJson([
15098
+ "pr",
15099
+ "view",
15100
+ String(pr),
15101
+ "--repo",
15102
+ `${owner}/${repo}`,
15103
+ "--json",
15104
+ "autoMergeRequest,state"
15105
+ ]);
15106
+ /**
15107
+ * Arm native auto-merge for one candidate and report what GitHub actually did.
15108
+ *
15109
+ * `autoMergeRequest` survives the merge, so it is not on its own a
15110
+ * "still pending" signal; the discriminator pairs it with `state`.
15111
+ */
15112
+ function armAutoMerge(input, dependencies = {}) {
15113
+ const run = dependencies.run ?? defaultRun;
15114
+ const readState = dependencies.readState ?? defaultReadState;
15115
+ let invocationDetail;
15116
+ try {
15117
+ run(armAutoMergeArgv(input), input.cwd);
15118
+ } catch (error) {
15119
+ invocationDetail = error instanceof Error ? error.message : String(error);
15120
+ }
15121
+ let observed;
15122
+ try {
15123
+ observed = readBackSchema.parse(readState(input));
15124
+ } catch (error) {
15125
+ return {
15126
+ detail: `Could not read the pull request back after arming, so the handoff is unproven: ${error instanceof Error ? error.message : String(error)}`,
15127
+ headSha: input.headSha,
15128
+ outcome: "not-armed"
15129
+ };
15130
+ }
15131
+ if (observed.state === "MERGED") return {
15132
+ detail: "The candidate was already mergeable, so the arming call merged it synchronously.",
15133
+ headSha: input.headSha,
15134
+ outcome: "merged"
15135
+ };
15136
+ const enablement = autoMergeEnablementSchema.safeParse(observed.autoMergeRequest);
15137
+ if (observed.state === "OPEN" && enablement.success) return {
15138
+ headSha: input.headSha,
15139
+ outcome: "armed"
15140
+ };
15141
+ return {
15142
+ detail: invocationDetail ?? `The arming call reported success but the pull request read back as ${observed.state ?? "an unknown state"} with no valid auto-merge enablement.`,
15143
+ headSha: input.headSha,
15144
+ outcome: "not-armed"
15145
+ };
15146
+ }
15147
+ //#endregion
14481
15148
  //#region src/merge-freeze.ts
14482
15149
  const MERGE_FREEZE_CHECK_NAME = "patronage-factory/merge-freeze";
14483
15150
  const MERGE_FREEZE_APP_SLUG = "patronage-factory";
15151
+ const HOSTED_VERIFY_CHECK_NAME = "verify";
15152
+ const GITHUB_ACTIONS_APP_ID = 15368;
15153
+ const GITHUB_ACTIONS_APP_SLUG = "github-actions";
14484
15154
  const MERGE_FREEZE_SCHEMA_VERSION = 1;
14485
15155
  const CHECK_RUNS_PER_PAGE = 100;
14486
15156
  const CHECK_RUN_PAGE_LIMIT = 10;
14487
- const shaSchema$2 = z.string().regex(/^[0-9a-f]{40}$/u);
15157
+ const shaSchema = z.string().regex(/^[0-9a-f]{40}$/u);
14488
15158
  const activeMergeFreezeStateSchema = z.object({
14489
15159
  active: z.literal(true),
14490
15160
  generationId: z.number().int().positive(),
14491
- headSha: shaSchema$2,
15161
+ headSha: shaSchema,
14492
15162
  outcome: z.enum(["active", "stale"]),
14493
15163
  reason: z.string().min(1),
14494
15164
  recordedAt: z.iso.datetime(),
@@ -14498,7 +15168,7 @@ const inactiveMergeFreezeStateSchema = z.object({
14498
15168
  active: z.literal(false),
14499
15169
  clearRationale: z.string().min(1).optional(),
14500
15170
  generationId: z.number().int().positive(),
14501
- headSha: shaSchema$2,
15171
+ headSha: shaSchema,
14502
15172
  outcome: z.literal("inactive"),
14503
15173
  reason: z.string().min(1),
14504
15174
  recordedAt: z.iso.datetime(),
@@ -14522,7 +15192,7 @@ const mergeFreezeCheckRunListItemSchema = z.object({
14522
15192
  "neutral",
14523
15193
  "success"
14524
15194
  ]).nullable().optional(),
14525
- head_sha: shaSchema$2,
15195
+ head_sha: shaSchema,
14526
15196
  id: z.number().int().positive(),
14527
15197
  name: z.literal(MERGE_FREEZE_CHECK_NAME),
14528
15198
  output: z.object({ text: z.string().nullable().optional() }).optional(),
@@ -14542,395 +15212,200 @@ const completedMergeFreezeCheckRunSchema = mergeFreezeCheckRunListItemSchema.ext
14542
15212
  output: z.object({ text: z.string().min(1) }),
14543
15213
  status: z.literal("completed")
14544
15214
  });
14545
- function createGitHubCheckRunMergeFreezeStore(api) {
14546
- return { read(input) {
14547
- const runs = z.object({ check_runs: z.array(z.unknown()) }).parse(api.list({
14548
- ...input,
14549
- name: MERGE_FREEZE_CHECK_NAME
14550
- })).check_runs.map((run) => mergeFreezeCheckRunListItemSchema.parse(run));
14551
- if (runs.length === 0) throw new Error(`No ${MERGE_FREEZE_CHECK_NAME} check run exists for ${input.headSha}.`);
14552
- const selected = selectNewestGeneration(runs.map((run) => ({
14553
- orderMs: generationOrderMs(run.started_at),
14554
- run
14555
- })));
14556
- if (selected.kind === "ambiguous") throw new Error(`Newest ${MERGE_FREEZE_CHECK_NAME} generation is ambiguous: ${selected.tied.length} check runs share ${new Date(selected.orderMs).toISOString()}.`);
14557
- if (selected.kind === "none") throw new Error(`No ${MERGE_FREEZE_CHECK_NAME} check run exists for ${input.headSha}.`);
14558
- const newest = selected.generation.run;
14559
- if (newest.head_sha !== input.headSha) throw new Error(`Newest ${MERGE_FREEZE_CHECK_NAME} check run targets ${newest.head_sha}, expected ${input.headSha}.`);
14560
- if (newest.status !== "completed") return {
14561
- active: true,
14562
- generationId: newest.id,
15215
+ const hostedVerifyCheckRunSchema = z.object({
15216
+ app: z.object({
15217
+ id: z.literal(GITHUB_ACTIONS_APP_ID),
15218
+ slug: z.literal(GITHUB_ACTIONS_APP_SLUG)
15219
+ }),
15220
+ head_sha: shaSchema,
15221
+ id: z.number().int().positive(),
15222
+ name: z.literal(HOSTED_VERIFY_CHECK_NAME),
15223
+ started_at: z.iso.datetime().nullable(),
15224
+ status: z.enum([
15225
+ "completed",
15226
+ "in_progress",
15227
+ "queued"
15228
+ ])
15229
+ });
15230
+ const commitParentsSchema = z.object({
15231
+ parents: z.array(z.object({ sha: shaSchema })),
15232
+ sha: shaSchema
15233
+ });
15234
+ function selectListedMergeFreezeGeneration(listed, expectedHeadSha) {
15235
+ const runs = z.object({ check_runs: z.array(z.unknown()) }).parse(listed).check_runs.map((run) => mergeFreezeCheckRunListItemSchema.parse(run));
15236
+ if (runs.length === 0) return { kind: "missing" };
15237
+ const selected = selectNewestGeneration(runs.map((run) => ({
15238
+ orderMs: generationOrderMs(run.started_at),
15239
+ run
15240
+ })));
15241
+ if (selected.kind === "ambiguous") return {
15242
+ kind: "unreadable",
15243
+ reason: `Newest ${MERGE_FREEZE_CHECK_NAME} generation is ambiguous: ${selected.tied.length} check runs share ${new Date(selected.orderMs).toISOString()}.`
15244
+ };
15245
+ if (selected.kind === "none") return { kind: "missing" };
15246
+ const newest = selected.generation.run;
15247
+ if (newest.head_sha !== expectedHeadSha) return {
15248
+ kind: "unreadable",
15249
+ reason: `Newest ${MERGE_FREEZE_CHECK_NAME} check run targets ${newest.head_sha}, expected ${expectedHeadSha}.`
15250
+ };
15251
+ if (newest.status !== "completed") return {
15252
+ kind: "settling",
15253
+ reason: `Authoritative merge freeze generation ${newest.id} is in progress.`,
15254
+ running: {
14563
15255
  headSha: newest.head_sha,
14564
- outcome: "active",
14565
- reason: `Authoritative merge freeze generation ${newest.id} is in progress.`,
14566
- recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
14567
- schemaVersion: MERGE_FREEZE_SCHEMA_VERSION
14568
- };
14569
- const completed = completedMergeFreezeCheckRunSchema.parse(newest);
14570
- const state = mergeFreezeStateSchema.parse(JSON.parse(completed.output.text));
14571
- 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") throw new Error(`Newest ${MERGE_FREEZE_CHECK_NAME} check run has inconsistent state.`);
14572
- return state;
14573
- } };
14574
- }
14575
- function createGitHubCheckRunMergeFreezeApi(dependencies = {}) {
14576
- return { list({ cwd, headSha, name, repository }) {
14577
- const checkRuns = [];
14578
- for (let page = 1; page <= CHECK_RUN_PAGE_LIMIT; page += 1) {
14579
- const response = z.object({ check_runs: z.array(z.unknown()) }).parse((dependencies.readJson ?? runGhJsonAt)(["api", `/repos/${repository.owner}/${repository.name}/commits/${headSha}/check-runs?check_name=${encodeURIComponent(name)}&filter=all&per_page=${CHECK_RUNS_PER_PAGE}&page=${page}`], cwd));
14580
- checkRuns.push(...response.check_runs);
14581
- if (response.check_runs.length < CHECK_RUNS_PER_PAGE) break;
14582
- if (page === CHECK_RUN_PAGE_LIMIT) throw new Error(`${MERGE_FREEZE_CHECK_NAME} check-run pagination limit was exhausted before GitHub returned a final page.`);
15256
+ id: newest.id
14583
15257
  }
14584
- return { check_runs: checkRuns };
14585
- } };
15258
+ };
15259
+ const completed = completedMergeFreezeCheckRunSchema.parse(newest);
15260
+ const state = mergeFreezeStateSchema.parse(JSON.parse(completed.output.text));
15261
+ 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 {
15262
+ kind: "unreadable",
15263
+ reason: `Newest ${MERGE_FREEZE_CHECK_NAME} check run has inconsistent state.`
15264
+ };
15265
+ return {
15266
+ kind: "settled",
15267
+ state
15268
+ };
14586
15269
  }
14587
- const githubMergeFreezeStore = createGitHubCheckRunMergeFreezeStore(createGitHubCheckRunMergeFreezeApi());
14588
- function unavailableMergeFreeze(reason) {
15270
+ function unconfiguredMergeFreeze(input, detail) {
14589
15271
  return {
14590
- active: true,
14591
- reason,
14592
- recordedAt: (/* @__PURE__ */ new Date(0)).toISOString(),
14593
- schemaVersion: MERGE_FREEZE_SCHEMA_VERSION
15272
+ kind: "unreadable",
15273
+ reason: `The ${MERGE_FREEZE_CHECK_NAME} writer is unconfigured or its settle window cannot be confirmed for base ${input.headSha}: ${detail} Generate the main-push verify workflow, configure FACTORY_GITHUB_APP_PRIVATE_KEY, and rerun pr:ready after the first App-owned freeze generation appears.`
15274
+ };
15275
+ }
15276
+ function selectMissingMergeFreezeGenerationUnchecked(api, input) {
15277
+ if (!api.parent) return unconfiguredMergeFreeze(input, "the authority cannot read the base commit's parent.");
15278
+ const commit = commitParentsSchema.parse(api.parent(input));
15279
+ if (commit.sha !== input.headSha) return unconfiguredMergeFreeze(input, `the history query returned ${commit.sha} instead of the requested base tip.`);
15280
+ if (commit.parents.length !== 1) return unconfiguredMergeFreeze(input, `the base tip has ${commit.parents.length} parents rather than exactly one.`);
15281
+ const parentSha = commit.parents[0]?.sha;
15282
+ if (!parentSha) return unconfiguredMergeFreeze(input, "the base tip's immediate parent is unreadable.");
15283
+ const parentGeneration = selectListedMergeFreezeGeneration(api.list({
15284
+ ...input,
15285
+ headSha: parentSha,
15286
+ name: MERGE_FREEZE_CHECK_NAME
15287
+ }), parentSha);
15288
+ if (parentGeneration.kind !== "settled") return unconfiguredMergeFreeze(input, `the immediate parent ${parentSha} has no settled App-owned freeze generation (${parentGeneration.kind}).`);
15289
+ const verifyRuns = z.object({ check_runs: z.array(z.unknown()) }).parse(api.list({
15290
+ ...input,
15291
+ name: HOSTED_VERIFY_CHECK_NAME
15292
+ })).check_runs.map((run) => hostedVerifyCheckRunSchema.parse(run));
15293
+ const completedVerify = verifyRuns.find((run) => run.status === "completed");
15294
+ if (completedVerify) return unconfiguredMergeFreeze(input, `the current tip's source-pinned GitHub Actions ${HOSTED_VERIFY_CHECK_NAME} run ${completedVerify.id} already completed without producing a freeze generation.`);
15295
+ const orderedVerifyRuns = verifyRuns.flatMap((run) => run.started_at ? [{
15296
+ orderMs: generationOrderMs(run.started_at),
15297
+ run
15298
+ }] : []);
15299
+ if (verifyRuns.length > 1 && orderedVerifyRuns.length !== verifyRuns.length) return unconfiguredMergeFreeze(input, `multiple pending source-pinned ${HOSTED_VERIFY_CHECK_NAME} runs cannot be ordered because at least one has no start time.`);
15300
+ const selectedVerify = verifyRuns.length === 1 ? {
15301
+ generation: { run: verifyRuns[0] },
15302
+ kind: "selected"
15303
+ } : selectNewestGeneration(orderedVerifyRuns);
15304
+ if (selectedVerify.kind === "ambiguous") return unconfiguredMergeFreeze(input, `the newest source-pinned ${HOSTED_VERIFY_CHECK_NAME} generation is ambiguous.`);
15305
+ if (selectedVerify.kind === "none") return unconfiguredMergeFreeze(input, `the current tip has no source-pinned GitHub Actions ${HOSTED_VERIFY_CHECK_NAME} run.`);
15306
+ const verify = selectedVerify.generation.run;
15307
+ if (verify.head_sha !== input.headSha) return unconfiguredMergeFreeze(input, `the newest source-pinned ${HOSTED_VERIFY_CHECK_NAME} run targets ${verify.head_sha}.`);
15308
+ const refreshed = selectListedMergeFreezeGeneration(api.list({
15309
+ ...input,
15310
+ name: MERGE_FREEZE_CHECK_NAME
15311
+ }), input.headSha);
15312
+ if (refreshed.kind !== "missing") return refreshed;
15313
+ return {
15314
+ kind: "settling",
15315
+ reason: `No ${MERGE_FREEZE_CHECK_NAME} generation exists yet for ${input.headSha}; the immediate parent has a settled generation and source-pinned GitHub Actions ${HOSTED_VERIFY_CHECK_NAME} run ${verify.id} is ${verify.status}.`
14594
15316
  };
14595
15317
  }
14596
- function readSharedMergeFreeze(input, store) {
15318
+ function selectMissingMergeFreezeGeneration(api, input) {
14597
15319
  try {
14598
- return mergeFreezeStateSchema.parse(store.read(input));
15320
+ return selectMissingMergeFreezeGenerationUnchecked(api, input);
14599
15321
  } catch (error) {
14600
- return unavailableMergeFreeze(`Authoritative GitHub merge freeze check-run state is unavailable or invalid: ${error instanceof Error ? error.message : String(error)}`);
15322
+ return unconfiguredMergeFreeze(input, `the required GitHub history or check-run evidence is unreadable: ${error instanceof Error ? error.message : String(error)}.`);
14601
15323
  }
14602
15324
  }
14603
- function sharedMergeFreezeBlockingReasons(input, store) {
14604
- const freeze = readSharedMergeFreeze(input, store);
14605
- if (!freeze.active) return [];
14606
- return [`Merge freeze is active (${freeze.reason}). It clears automatically when the main-push verify workflow completes green after a fix-forward or revert; an operator may waive it for one candidate with patronage-factory demand:waive --pr <pr> --demand merge-freeze --rationale <why>.`];
15325
+ function selectMergeFreezeGeneration(api, input) {
15326
+ const selected = selectListedMergeFreezeGeneration(api.list({
15327
+ ...input,
15328
+ name: MERGE_FREEZE_CHECK_NAME
15329
+ }), input.headSha);
15330
+ if (selected.kind === "missing") return selectMissingMergeFreezeGeneration(api, input);
15331
+ return selected;
14607
15332
  }
14608
- //#endregion
14609
- //#region src/merge-preflight/worktree-held-branch.ts
14610
- var worktree_held_branch_exports = /* @__PURE__ */ __exportAll({
14611
- checkWorktreeHeldBranch: () => checkWorktreeHeldBranch,
14612
- findWorktreeHeldBranch: () => findWorktreeHeldBranch,
14613
- formatHeldBranchCloseoutSummary: () => formatHeldBranchCloseoutSummary,
14614
- listWorktreesPorcelain: () => listWorktreesPorcelain,
14615
- parseGitWorktreeList: () => parseGitWorktreeList,
14616
- resolveMergeOperationalNotices: () => resolveMergeOperationalNotices,
14617
- worktreeHeldBranchNotice: () => worktreeHeldBranchNotice
14618
- });
14619
- /**
14620
- * Parses `git worktree list --porcelain` output. Entries are separated by
14621
- * blank lines; each starts with `worktree <path>` and carries an optional
14622
- * `branch refs/heads/<name>` attribute (detached worktrees have none).
14623
- */
14624
- function parseGitWorktreeList(porcelain) {
14625
- const entries = [];
14626
- let current = {};
14627
- const flush = () => {
14628
- if (current.path !== void 0) entries.push({
14629
- path: current.path,
14630
- ...current.branch === void 0 ? {} : { branch: current.branch }
14631
- });
14632
- current = {};
14633
- };
14634
- for (const line of porcelain.split(/\r?\n/u)) {
14635
- if (line === "") {
14636
- flush();
14637
- continue;
14638
- }
14639
- if (line.startsWith("worktree ")) {
14640
- flush();
14641
- current.path = line.slice(9);
14642
- continue;
15333
+ function createGitHubCheckRunMergeFreezeStore(api) {
15334
+ const readGeneration = (input) => {
15335
+ try {
15336
+ return selectMergeFreezeGeneration(api, input);
15337
+ } catch (error) {
15338
+ return {
15339
+ kind: "unreadable",
15340
+ reason: error instanceof Error ? error.message : String(error)
15341
+ };
14643
15342
  }
14644
- if (line.startsWith("branch refs/heads/")) current.branch = line.slice(18);
14645
- }
14646
- flush();
14647
- return entries;
14648
- }
14649
- /**
14650
- * Returns the worktree holding `branch`, or undefined when no local worktree
14651
- * has it checked out (or the porcelain listing was unavailable).
14652
- */
14653
- function findWorktreeHeldBranch({ branch, worktreeListPorcelain }) {
14654
- if (worktreeListPorcelain === void 0) return;
14655
- const held = parseGitWorktreeList(worktreeListPorcelain).find((entry) => entry.branch === branch);
14656
- return held === void 0 ? void 0 : {
14657
- branch,
14658
- worktreePath: held.path
14659
15343
  };
14660
- }
14661
- function worktreeHeldBranchNotice(held) {
14662
- return `Branch '${held.branch}' is checked out in the local worktree at ${held.worktreePath}. The remote-only merge handoff leaves this worktree untouched and defers local branch cleanup to closeout.`;
14663
- }
14664
- /**
14665
- * Single entry point for non-blocking merge preflight notices, mirroring
14666
- * `resolveMergeGuardIdentity`/`mergeGuardBlockingReasons` in
14667
- * merge-identity.ts: adding a notice kind never requires coordinated edits
14668
- * in the command runner, and persisted notice strings cannot drift from the
14669
- * structured detection result.
14670
- */
14671
- function resolveMergeOperationalNotices({ headRefName, worktreeListPorcelain }) {
14672
- const heldBranch = headRefName === void 0 ? void 0 : findWorktreeHeldBranch({
14673
- branch: headRefName,
14674
- worktreeListPorcelain
14675
- });
14676
- return heldBranch === void 0 ? { notices: [] } : {
14677
- notices: [worktreeHeldBranchNotice(heldBranch)],
14678
- worktreeHeldBranch: heldBranch,
14679
- worktreeHeldBranches: [heldBranch]
15344
+ return {
15345
+ read(input) {
15346
+ const generation = readGeneration(input);
15347
+ if (generation.kind === "settled") return generation.state;
15348
+ if (generation.kind === "settling" && generation.running) return {
15349
+ active: true,
15350
+ generationId: generation.running.id,
15351
+ headSha: generation.running.headSha,
15352
+ outcome: "active",
15353
+ reason: generation.reason,
15354
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
15355
+ schemaVersion: MERGE_FREEZE_SCHEMA_VERSION
15356
+ };
15357
+ throw new Error(generation.reason);
15358
+ },
15359
+ readGeneration
14680
15360
  };
14681
15361
  }
14682
- /**
14683
- * Shared `git worktree list --porcelain` reader for merge preflight and
14684
- * closeout. Returns undefined when the directory is missing, not a git
14685
- * repository, or git is unavailable — detection is best-effort and callers
14686
- * report "skipped" rather than fabricating a result.
14687
- */
14688
- function listWorktreesPorcelain(cwd) {
14689
- try {
14690
- return execFileSync("git", [
14691
- "worktree",
14692
- "list",
14693
- "--porcelain"
14694
- ], {
14695
- cwd,
14696
- encoding: "utf-8",
14697
- stdio: [
14698
- "ignore",
14699
- "pipe",
14700
- "ignore"
14701
- ]
14702
- });
14703
- } catch {
14704
- return;
14705
- }
15362
+ function createGitHubCheckRunMergeFreezeApi(dependencies = {}) {
15363
+ return {
15364
+ list({ cwd, headSha, name, repository }) {
15365
+ const checkRuns = [];
15366
+ for (let page = 1; page <= CHECK_RUN_PAGE_LIMIT; page += 1) {
15367
+ const response = z.object({ check_runs: z.array(z.unknown()) }).parse((dependencies.readJson ?? runGhJsonAt)(["api", `/repos/${repository.owner}/${repository.name}/commits/${headSha}/check-runs?check_name=${encodeURIComponent(name)}&filter=all&per_page=${CHECK_RUNS_PER_PAGE}&page=${page}`], cwd));
15368
+ checkRuns.push(...response.check_runs);
15369
+ if (response.check_runs.length < CHECK_RUNS_PER_PAGE) break;
15370
+ if (page === CHECK_RUN_PAGE_LIMIT) throw new Error(`${MERGE_FREEZE_CHECK_NAME} check-run pagination limit was exhausted before GitHub returned a final page.`);
15371
+ }
15372
+ return { check_runs: checkRuns };
15373
+ },
15374
+ parent({ cwd, headSha, repository }) {
15375
+ return (dependencies.readJson ?? runGhJsonAt)(["api", `/repos/${repository.owner}/${repository.name}/commits/${headSha}`], cwd);
15376
+ }
15377
+ };
14706
15378
  }
15379
+ const githubMergeFreezeStore = createGitHubCheckRunMergeFreezeStore(createGitHubCheckRunMergeFreezeApi());
15380
+ /** The one refusal sentence, naming both exits, wherever the freeze refuses. */
15381
+ const activeMergeFreezeReason = (reason) => `Merge freeze is active (${reason}). It clears automatically when the main-push verify workflow completes green after a fix-forward or revert; an operator may waive it for one candidate with patronage-factory demand:waive --pr <pr> --demand merge-freeze --rationale <why>.`;
14707
15382
  /**
14708
- * Closeout-side counterpart to the merge-preflight notice: the merge step
14709
- * defers local branch cleanup to closeout, so closeout planning checks
14710
- * whether the worker branch is still held by a worktree (#284).
15383
+ * The freeze read at arming time (#477, ADR 0016 as amended 2026-07-31).
15384
+ *
15385
+ * An **active** generation refuses arming and names both exits. **Unreadable**
15386
+ * authority refuses too — the identity and ordering predicates are unchanged,
15387
+ * so ambiguous, malformed, or foreign-App state still fails closed. A
15388
+ * **settling** generation arms with a notice only when the writer is observable:
15389
+ * either its generation is running, or the prior tip has a settled generation
15390
+ * while this tip's source-pinned hosted verify is running (#521). Epic #473
15391
+ * decision 5 deliberately trades the settle-window wait against the measured
15392
+ * rarity of a red main. A writerless or unprovable repository fails closed.
14711
15393
  */
14712
- function checkWorktreeHeldBranch({ branch, worktreeListPorcelain }) {
14713
- if (branch === void 0 || worktreeListPorcelain === void 0) return { status: "skipped" };
14714
- const held = findWorktreeHeldBranch({
14715
- branch,
14716
- worktreeListPorcelain
14717
- });
14718
- return held === void 0 ? { status: "clean" } : {
14719
- held,
14720
- status: "held"
15394
+ function armingMergeFreezeOutcome(input, authority) {
15395
+ const generation = authority.readGeneration(input);
15396
+ if (generation.kind === "settling") return {
15397
+ blockingReasons: [],
15398
+ notices: [`Merge freeze generation for base ${input.headSha} is still settling (${generation.reason}); arming proceeds (ADR 0016, 2026-07-31 amendment). The main-push verify for that tip still refuses every later arming if it goes red.`]
14721
15399
  };
14722
- }
14723
- /** One-line closeout summary, owned beside the detection logic. */
14724
- function formatHeldBranchCloseoutSummary(check) {
14725
- if (check.status === "held" && check.held) return `Held branch: '${check.held.branch}' is still checked out in the worktree at ${check.held.worktreePath}. Finish the deferred merge cleanup: remove the worktree, then delete the local branch.`;
14726
- if (check.status === "clean") return "Held branch: none";
14727
- return "Held branch: not checked (no branch recorded or worktrees unlistable)";
14728
- }
14729
- //#endregion
14730
- //#region src/pr-readiness/merge-identity.ts
14731
- var merge_identity_exports = /* @__PURE__ */ __exportAll({
14732
- evaluateMergeIdentity: () => evaluateMergeIdentity,
14733
- mergeGuardBlockingReasons: () => mergeGuardBlockingReasons,
14734
- mergeGuardIdentitySchema: () => mergeGuardIdentitySchema,
14735
- resolveMergeGuardIdentity: () => resolveMergeGuardIdentity
14736
- });
14737
- const evaluateMergeIdentity = ({ liveHeadSha, postProofCommits, proofHeadSha }) => {
14738
- if (proofHeadSha === liveHeadSha) return {
14739
- headSha: liveHeadSha,
14740
- kind: "match"
15400
+ if (generation.kind === "unreadable") return {
15401
+ blockingReasons: [activeMergeFreezeReason(`Authoritative GitHub merge freeze check-run state is unavailable or invalid: ${generation.reason}`)],
15402
+ notices: []
14741
15403
  };
14742
15404
  return {
14743
- kind: "diverged",
14744
- liveHeadSha,
14745
- ...postProofCommits ? { postProofCommits } : {},
14746
- proofHeadSha
14747
- };
14748
- };
14749
- const mergeGuardIdentityFor = ({ commitsBetween, liveHeadSha, readyProof, readyProofError, readyProofPath, requestedPr }) => {
14750
- if (!readyProof) return {
14751
- kind: "ready-proof-missing",
14752
- ...readyProofError === void 0 ? {} : { errorDetail: readyProofError instanceof Error ? readyProofError.message : String(readyProofError) },
14753
- readyProofPath
14754
- };
14755
- if (readyProof.ledger.pr !== requestedPr) return {
14756
- kind: "ready-proof-pr-mismatch",
14757
- proofPr: readyProof.ledger.pr,
14758
- requestedPr
14759
- };
14760
- if (readyProof.status !== "ready") return {
14761
- blockingReasons: readyProof.blockingReasons,
14762
- kind: "ready-proof-not-ready",
14763
- status: readyProof.status
14764
- };
14765
- const proofHeadSha = readyProof.ledger.headSha;
14766
- return evaluateMergeIdentity({
14767
- liveHeadSha,
14768
- ...proofHeadSha === liveHeadSha ? {} : { postProofCommits: commitsBetween?.(proofHeadSha, liveHeadSha) },
14769
- proofHeadSha
14770
- });
14771
- };
14772
- /**
14773
- * Single entry point that owns construction of every MergeGuardIdentity
14774
- * kind, so adding or changing a kind never requires coordinated edits in the
14775
- * command runner.
14776
- */
14777
- const resolveMergeGuardIdentity = ({ commitsBetween, liveHead, pr, readyProof, readyProofError, readyProofPath }) => {
14778
- if ("invalidResponse" in liveHead) return {
14779
- kind: "live-head-invalid",
14780
- pr,
14781
- received: liveHead.invalidResponse
14782
- };
14783
- return mergeGuardIdentityFor({
14784
- commitsBetween,
14785
- liveHeadSha: liveHead.headSha,
14786
- readyProof,
14787
- readyProofError,
14788
- readyProofPath,
14789
- requestedPr: pr
14790
- });
14791
- };
14792
- const shaSchema$1 = z.string().regex(/^[0-9a-f]{40}$/u);
14793
- const postProofCommitSchema = z.object({
14794
- sha: z.string().min(1),
14795
- subject: z.string()
14796
- });
14797
- const mergeGuardIdentitySchema = z.discriminatedUnion("kind", [
14798
- z.object({
14799
- headSha: shaSchema$1,
14800
- kind: z.literal("match")
14801
- }),
14802
- z.object({
14803
- kind: z.literal("diverged"),
14804
- liveHeadSha: shaSchema$1,
14805
- postProofCommits: z.array(postProofCommitSchema).optional(),
14806
- proofHeadSha: shaSchema$1
14807
- }),
14808
- z.object({
14809
- kind: z.literal("live-head-invalid"),
14810
- pr: z.number().int().positive(),
14811
- received: z.string()
14812
- }),
14813
- z.object({
14814
- errorDetail: z.string().optional(),
14815
- kind: z.literal("ready-proof-missing"),
14816
- readyProofPath: z.string().min(1)
14817
- }),
14818
- z.object({
14819
- kind: z.literal("ready-proof-pr-mismatch"),
14820
- proofPr: z.number().int().positive(),
14821
- requestedPr: z.number().int().positive()
14822
- }),
14823
- z.object({
14824
- blockingReasons: z.array(z.string()),
14825
- kind: z.literal("ready-proof-not-ready"),
14826
- status: z.string().min(1)
14827
- })
14828
- ]);
14829
- const RERUN_INSTRUCTION = "Re-run patronage-factory pr:verify and pr:ready on the current pushed head, then re-check before merging.";
14830
- const postProofCommitLines = (postProofCommits) => {
14831
- if (!postProofCommits) return "Unable to enumerate the post-proof commits locally; run `git fetch` and inspect the range before proceeding.";
14832
- if (postProofCommits.length === 0) return "No commits found between the proofed head and the pushed head, so branch history may have been rewritten (for example by a force push).";
14833
- return `Commits pushed after the proof: ${postProofCommits.map((commit) => `${commit.sha} (${commit.subject})`).join(", ")}.`;
14834
- };
14835
- const mergeGuardBlockingReasons = (identity) => {
14836
- switch (identity.kind) {
14837
- case "match": return [];
14838
- case "diverged": return [`Proof ledger headSha ${identity.proofHeadSha} does not match the pushed PR head ${identity.liveHeadSha}. ${postProofCommitLines(identity.postProofCommits)} ${RERUN_INSTRUCTION}`];
14839
- case "live-head-invalid": return [`GitHub did not return a valid 40-hex headRefOid for PR #${identity.pr} (got ${identity.received}). Refusing to compare proof identity; re-fetch the PR head and retry.`];
14840
- case "ready-proof-missing": return [`Typed pr:ready proof is missing or invalid at ${identity.readyProofPath}${identity.errorDetail ? ` (${identity.errorDetail})` : ""}; run patronage-factory pr:ready before merging.`];
14841
- case "ready-proof-pr-mismatch": return [`Typed pr:ready proof was evaluated for PR #${identity.proofPr}, not PR #${identity.requestedPr}; run patronage-factory pr:ready for this PR before merging.`];
14842
- case "ready-proof-not-ready": return [`Typed pr:ready proof status is ${identity.status}, not ready: ${identity.blockingReasons.join(" ") || "no blocking reasons recorded."}`];
14843
- default: return identity;
14844
- }
14845
- };
14846
- //#endregion
14847
- //#region src/pr-readiness/status-check-rollup.ts
14848
- var status_check_rollup_exports = /* @__PURE__ */ __exportAll({
14849
- isFactoryReadyCheck: () => isFactoryReadyCheck,
14850
- statusCheckState: () => statusCheckState
14851
- });
14852
- const PASSED_STATES = new Set([
14853
- "SUCCESS",
14854
- "SKIPPED",
14855
- "NEUTRAL"
14856
- ]);
14857
- const FAILED_STATES = new Set([
14858
- "FAILURE",
14859
- "ERROR",
14860
- "CANCELLED",
14861
- "TIMED_OUT",
14862
- "ACTION_REQUIRED"
14863
- ]);
14864
- const PENDING_STATES = new Set([
14865
- "PENDING",
14866
- "QUEUED",
14867
- "IN_PROGRESS",
14868
- "EXPECTED"
14869
- ]);
14870
- const isFactoryReadyCheck = (check) => check.context?.startsWith("patronage-factory/") === true || !check.workflowName && check.name?.startsWith("patronage-factory/") === true;
14871
- const checkContext = (check) => {
14872
- if (check.workflowName && check.name) return `${check.workflowName} / ${check.name}`;
14873
- return check.context;
14874
- };
14875
- const checkTimestampMs = (check) => {
14876
- const value = check.startedAt ?? check.completedAt;
14877
- if (!value) return;
14878
- const timestamp = Date.parse(value);
14879
- return Number.isNaN(timestamp) ? void 0 : timestamp;
14880
- };
14881
- const effectiveChecks = (rollup) => {
14882
- const checksByContext = /* @__PURE__ */ new Map();
14883
- const unkeyed = [];
14884
- for (const [index, check] of rollup.entries()) {
14885
- const context = checkContext(check);
14886
- if (!context) {
14887
- unkeyed.push(check);
14888
- continue;
14889
- }
14890
- const checks = checksByContext.get(context) ?? [];
14891
- checks.push({
14892
- check,
14893
- index,
14894
- timestampMs: checkTimestampMs(check)
14895
- });
14896
- checksByContext.set(context, checks);
14897
- }
14898
- const currentChecks = [...unkeyed];
14899
- for (const checks of checksByContext.values()) {
14900
- const [firstCheck] = checks;
14901
- if (!firstCheck) continue;
14902
- if (checks.length === 1) {
14903
- currentChecks.push(firstCheck.check);
14904
- continue;
14905
- }
14906
- if (checks.some((check) => check.timestampMs === void 0)) {
14907
- currentChecks.push(...checks.map((check) => check.check));
14908
- continue;
14909
- }
14910
- let latest = firstCheck;
14911
- for (const candidate of checks.slice(1)) {
14912
- if (latest.timestampMs === void 0 || candidate.timestampMs === void 0) continue;
14913
- if (candidate.timestampMs > latest.timestampMs) {
14914
- latest = candidate;
14915
- continue;
14916
- }
14917
- if (candidate.timestampMs === latest.timestampMs && candidate.index > latest.index) latest = candidate;
14918
- }
14919
- currentChecks.push(latest.check);
14920
- }
14921
- return currentChecks;
14922
- };
14923
- const terminalState = (check) => check.conclusion ?? check.state ?? "";
14924
- const progressState = (check) => check.status ?? check.state ?? "";
14925
- const statusCheckState = (rollup, exclude) => {
14926
- const included = rollup?.filter((check) => !exclude?.(check));
14927
- if (!included || included.length === 0) return "none";
14928
- const currentChecks = effectiveChecks(included);
14929
- if (currentChecks.every((check) => PASSED_STATES.has(terminalState(check)))) return "passed";
14930
- if (currentChecks.some((check) => FAILED_STATES.has(terminalState(check)))) return "failed";
14931
- if (currentChecks.some((check) => PENDING_STATES.has(progressState(check)))) return "pending";
14932
- return "unknown";
14933
- };
15405
+ blockingReasons: generation.state.active ? [activeMergeFreezeReason(generation.state.reason)] : [],
15406
+ notices: []
15407
+ };
15408
+ }
14934
15409
  //#endregion
14935
15410
  //#region src/pr-ready.ts
14936
15411
  const CHECK_CONCLUSION_BY_READINESS_STATUS = {
@@ -14938,7 +15413,6 @@ const CHECK_CONCLUSION_BY_READINESS_STATUS = {
14938
15413
  ready: "success",
14939
15414
  "slice-ready/not-final": "failure"
14940
15415
  };
14941
- const DEFAULT_PR_READY_PROOF_PATH = ".factory-memory/pr-ready.json";
14942
15416
  function defaultPrReadyGit() {
14943
15417
  return {
14944
15418
  canResolveCommit,
@@ -14968,7 +15442,7 @@ function defaultPrReadyGit() {
14968
15442
  ], cwd).stdout.trim();
14969
15443
  } catch {}
14970
15444
  },
14971
- showFileAtRef: showFileAtRef$1,
15445
+ showFileAtRef,
14972
15446
  stablePatchId
14973
15447
  };
14974
15448
  }
@@ -15016,22 +15490,33 @@ function defaultPrReadyGithub() {
15016
15490
  };
15017
15491
  }
15018
15492
  const prReadyProofSchema = z.object({
15493
+ arming: z.object({
15494
+ detail: z.string().min(1).optional(),
15495
+ headSha: z.string().regex(/^[0-9a-f]{40}$/u),
15496
+ outcome: z.enum([
15497
+ "armed",
15498
+ "merged",
15499
+ "not-armed"
15500
+ ])
15501
+ }).optional(),
15019
15502
  blockedReasons: blockedReasonsSchema.optional(),
15020
15503
  blockingReasons: z.array(z.string()),
15021
15504
  command: z.literal("patronage-factory pr:ready"),
15022
15505
  followUp: FollowUpActionSchema.optional(),
15023
15506
  humanBlockingReasons: z.array(z.string()).default([]),
15024
15507
  ledger: managedReadinessLedgerSchema,
15508
+ notices: z.array(z.string().min(1)).optional(),
15025
15509
  profileBlobSha: z.string().regex(/^[0-9a-f]{40}$/u).optional(),
15026
15510
  profilePath: z.string().min(1).optional(),
15027
15511
  repairs: z.array(readinessRepairSchema).default([]),
15028
15512
  repository: z.string().regex(/^[^/\s]+\/[^/\s]+$/u).optional(),
15029
- schemaVersion: z.literal(2),
15513
+ schemaVersion: z.literal(3),
15030
15514
  status: z.enum([
15031
15515
  "ready",
15032
15516
  "blocked",
15033
15517
  "slice-ready/not-final"
15034
- ])
15518
+ ]),
15519
+ waivedDemands: z.array(waivedDemandSchema).optional()
15035
15520
  }).superRefine((proof, context) => {
15036
15521
  for (const issue of blockedReasonIssues({
15037
15522
  ...proof,
@@ -15044,18 +15529,12 @@ function validatePrReadyProof(value) {
15044
15529
  function readPrReadyProof(filePath) {
15045
15530
  return readProofFromPath(validatePrReadyProof, filePath);
15046
15531
  }
15047
- const PR_READY_PROOF_DESCRIPTOR = {
15048
- defaultPath: DEFAULT_PR_READY_PROOF_PATH,
15049
- label: "pr:ready proof",
15050
- parse: validatePrReadyProof,
15051
- schemaVersion: 2
15052
- };
15053
15532
  const PR_VIEW_JSON_FIELDS = [
15533
+ "autoMergeRequest",
15054
15534
  "baseRefName",
15055
15535
  "body",
15056
15536
  "headRefOid",
15057
15537
  "isDraft",
15058
- "labels",
15059
15538
  "mergeStateStatus",
15060
15539
  "mergeable",
15061
15540
  "number",
@@ -15267,7 +15746,7 @@ function policyProfileAtCommittedHead({ cwd, git, headSha, profilePath }) {
15267
15746
  profile: committedProfile
15268
15747
  };
15269
15748
  }
15270
- function checkoutRelativeProfilePath$1(cwd, profilePath) {
15749
+ function checkoutRelativeProfilePath(cwd, profilePath) {
15271
15750
  const relativePath = path.relative(cwd, profilePath);
15272
15751
  if (relativePath.length === 0 || relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) throw new Error("The readiness profile must be inside the trusted checkout.");
15273
15752
  return relativePath.split(path.sep).join("/");
@@ -15286,6 +15765,12 @@ function docsOnlyDeltaSinceReviewProof({ classification, cwd, git, profile, prof
15286
15765
  targetHeadSha
15287
15766
  });
15288
15767
  }
15768
+ /** How each arming outcome reads to a human, including the one that failed. */
15769
+ const armingNotice = (arming) => {
15770
+ if (arming.outcome === "armed") return `Native auto-merge is armed for ${arming.headSha}; GitHub merges this candidate once both source-pinned required checks are green on that head.`;
15771
+ if (arming.outcome === "merged") return `This candidate merged during the arming call: ${arming.detail ?? "GitHub found it already mergeable."}`;
15772
+ return `Native auto-merge was NOT armed for ${arming.headSha}: ${arming.detail ?? "unknown reason"} Nothing will merge this candidate until the recorded follow-up runs.`;
15773
+ };
15289
15774
  async function runPrReady(args, dependencies = {}) {
15290
15775
  const cwd = path.resolve(args.cwd ?? process.cwd());
15291
15776
  const profilePath = readinessProfilePath(cwd, args.profilePath);
@@ -15301,19 +15786,6 @@ async function runPrReady(args, dependencies = {}) {
15301
15786
  }),
15302
15787
  sleep: dependencies.sleep
15303
15788
  });
15304
- publishFactoryCheckSafely(dependencies.publishCheckRun, {
15305
- cwd,
15306
- gate: "pr-ready",
15307
- owner: repository.owner,
15308
- pr: args.pr,
15309
- proof: {
15310
- schemaVersion: 2,
15311
- status: "evaluating"
15312
- },
15313
- repo: repository.name,
15314
- sha: pr.headRefOid,
15315
- status: "in_progress"
15316
- });
15317
15789
  let finalCheckPublished = false;
15318
15790
  try {
15319
15791
  const authoritativeBaseSha = authoritativePrBase({
@@ -15336,7 +15808,7 @@ async function runPrReady(args, dependencies = {}) {
15336
15808
  });
15337
15809
  const policyProfile = committedPolicy.profile;
15338
15810
  if (repositorySlug(policyProfile.repository) !== checkoutRepositorySlug) throw new Error(`Committed readiness profile repository ${repositorySlug(policyProfile.repository)} does not match trusted checkout repository ${checkoutRepositorySlug}.`);
15339
- const committedProfilePath = checkoutRelativeProfilePath$1(cwd, profilePath);
15811
+ const committedProfilePath = checkoutRelativeProfilePath(cwd, profilePath);
15340
15812
  const verifyProofPath = path.resolve(cwd, args.verifyProof ?? ".factory-memory/pr-verify.json");
15341
15813
  const reviewProofPath = path.resolve(cwd, args.reviewProof ?? ".factory-memory/pr-review.json");
15342
15814
  const verifyProofRead = tryReadProof(readPrVerifyProof, verifyProofPath);
@@ -15404,9 +15876,21 @@ async function runPrReady(args, dependencies = {}) {
15404
15876
  sha: pr.headRefOid
15405
15877
  });
15406
15878
  const externalCheckState = statusCheckState(pr.statusCheckRollup, isFactoryReadyCheck);
15879
+ const hostedVerifyCheck = hostedVerifyCheckState(pr.statusCheckRollup);
15407
15880
  const effectiveMergeStateStatus = pr.mergeStateStatus === "UNSTABLE" && (externalCheckState === "passed" || externalCheckState === "pending" || externalCheckState === "none") ? "CLEAN" : pr.mergeStateStatus;
15881
+ const mergeFreeze = armingMergeFreezeOutcome({
15882
+ cwd,
15883
+ headSha: pr.baseRefOid,
15884
+ repository
15885
+ }, dependencies.mergeFreeze ?? githubMergeFreezeStore);
15886
+ const candidateWaivers = selectWaiversForCandidate({
15887
+ headSha: pr.headRefOid,
15888
+ pr: args.pr,
15889
+ waivers: readDemandWaivers(cwd)
15890
+ });
15408
15891
  const evaluation = evaluateReadiness({
15409
15892
  ...externalEvidence,
15893
+ autoMergeEnabled: Boolean(pr.autoMergeRequest),
15410
15894
  baseSha: pr.baseRefOid,
15411
15895
  body: args.bodyForEvaluation ?? pr.body,
15412
15896
  ...handledCommentInputs,
@@ -15417,14 +15901,15 @@ async function runPrReady(args, dependencies = {}) {
15417
15901
  docsOnlyVerifyBaselineHeadShas: verification.fullVerifiedHeadShas,
15418
15902
  draft: pr.isDraft,
15419
15903
  headSha: pr.headRefOid,
15904
+ hostedVerifyCheck,
15420
15905
  ladderPolicy,
15421
15906
  localHeadSha,
15422
15907
  mergeBaseSha: candidateIdentityForBase(authoritativeBaseSha).mergeBaseSha,
15908
+ mergeFreeze,
15423
15909
  mergeStateStatus: effectiveMergeStateStatus,
15424
15910
  mergeable: pr.mergeable,
15425
15911
  patchId,
15426
15912
  pr: args.pr,
15427
- prLabels: pr.labels?.map((label) => label.name),
15428
15913
  prTitle: pr.title,
15429
15914
  requiredChecks: externalCheckState,
15430
15915
  reviewModes: demands.reviewModes,
@@ -15432,7 +15917,9 @@ async function runPrReady(args, dependencies = {}) {
15432
15917
  reviews: pr.reviews,
15433
15918
  unresolvedReviewThreads: pr.unresolvedReviewThreads,
15434
15919
  verificationProof: verification.verificationProof,
15920
+ waivers: candidateWaivers,
15435
15921
  ...waveDemand?.demand ? { waveReviewDemand: {
15922
+ autoMerge: waveDemand.demand.autoMerge,
15436
15923
  review: waveDemand.demand.review,
15437
15924
  wave: waveDemand.demand.wave
15438
15925
  } } : {}
@@ -15443,16 +15930,26 @@ async function runPrReady(args, dependencies = {}) {
15443
15930
  });
15444
15931
  const undraftRepair = evaluation.repairs.find((repair) => repair.code === "undraft-pr");
15445
15932
  const pendingExternalChecksOnly = externalCheckState === "pending" && evaluation.blockingReasons.length === 1;
15446
- let followUp;
15447
- if (evaluation.status === "ready") followUp = followUpFromArgv([
15933
+ const boundary = waveDemand?.demand;
15934
+ const arming = evaluation.status === "ready" && boundary ? (dependencies.armAutoMerge ?? armAutoMerge)({
15935
+ cwd,
15936
+ headSha: pr.headRefOid,
15937
+ owner: repository.owner,
15938
+ pr: args.pr,
15939
+ repo: repository.name
15940
+ }) : void 0;
15941
+ const readyRedispatch = () => followUpFromArgv([
15448
15942
  "patronage-factory",
15449
- "pr:merge-check",
15943
+ "pr:ready",
15450
15944
  "--pr",
15451
15945
  String(args.pr),
15452
15946
  "--profile",
15453
15947
  committedProfilePath,
15454
- ...args.output ? ["--ready-proof", args.output] : []
15948
+ ...args.epic === void 0 ? [] : ["--epic", String(args.epic)],
15949
+ ...args.output ? ["--output", args.output] : []
15455
15950
  ]);
15951
+ let followUp;
15952
+ if (evaluation.status === "ready") followUp = arming?.outcome === "not-armed" ? readyRedispatch() : void 0;
15456
15953
  else if (undraftRepair && evaluation.humanBlockingReasons.length === 0) followUp = followUpFromArgv([
15457
15954
  "gh",
15458
15955
  "pr",
@@ -15460,32 +15957,29 @@ async function runPrReady(args, dependencies = {}) {
15460
15957
  String(args.pr)
15461
15958
  ]);
15462
15959
  else if (!pendingExternalChecksOnly) followUp = prVerifyFollowUp(authoringSession);
15960
+ const notices = [
15961
+ ...evaluation.notices,
15962
+ ...arming ? [armingNotice(arming)] : [],
15963
+ ...evaluation.status === "ready" && !boundary ? ["No boundary wave authorized this candidate, so nothing was armed: pr:ready admits it but schedules no merge. Re-run with --epic <issue> to resolve the wave demand and arm."] : []
15964
+ ];
15463
15965
  const proof = {
15966
+ ...arming ? { arming } : {},
15464
15967
  ...evaluation.blockedReasons.length > 0 ? { blockedReasons: evaluation.blockedReasons } : {},
15465
15968
  blockingReasons: evaluation.blockingReasons,
15466
15969
  command: "patronage-factory pr:ready",
15467
15970
  ...followUp ? { followUp } : {},
15468
15971
  humanBlockingReasons: evaluation.humanBlockingReasons,
15469
15972
  ledger: evaluation.ledger,
15973
+ ...notices.length > 0 ? { notices } : {},
15470
15974
  profileBlobSha: committedPolicy.blobSha,
15471
15975
  profilePath: committedProfilePath,
15472
15976
  repairs: evaluation.repairs,
15473
15977
  repository: checkoutRepositorySlug,
15474
- schemaVersion: 2,
15475
- status: evaluation.status
15978
+ schemaVersion: 3,
15979
+ status: evaluation.status,
15980
+ ...evaluation.waivedDemands.length > 0 ? { waivedDemands: evaluation.waivedDemands } : {}
15476
15981
  };
15477
- writeProof(proof, output);
15478
- enqueueHqIngest({
15479
- cwd,
15480
- kind: "pr-ready-proof",
15481
- payload: proof,
15482
- payloadPath: output,
15483
- profile: policyProfile,
15484
- subject: { prNumber: args.pr }
15485
- }, dependencies.hq);
15486
- reportProof(proof, output, Boolean(args.json), args.report ?? true);
15487
- publishFactoryCheckSafely(dependencies.publishCheckRun, {
15488
- ...pendingExternalChecksOnly ? { status: "in_progress" } : { conclusion: CHECK_CONCLUSION_BY_READINESS_STATUS[proof.status] },
15982
+ const finalCheck = {
15489
15983
  cwd,
15490
15984
  gate: "pr-ready",
15491
15985
  hqLaneBaseUrl: hqLaneRefBaseUrlFromProfile(policyProfile),
@@ -15494,14 +15988,34 @@ async function runPrReady(args, dependencies = {}) {
15494
15988
  proof,
15495
15989
  repo: repository.name,
15496
15990
  sha: pr.headRefOid
15991
+ };
15992
+ const publishedFinalCheck = await (dependencies.publishFinalCheckRun ?? ensureFactoryCheckRunPublished)({
15993
+ ...finalCheck,
15994
+ conclusion: CHECK_CONCLUSION_BY_READINESS_STATUS[proof.status]
15497
15995
  });
15498
15996
  finalCheckPublished = true;
15997
+ if (!publishedFinalCheck) {
15998
+ notices.push(`The ${FACTORY_CHECK_NAMES["pr-ready"]} check run was NOT confirmed for ${pr.headRefOid}, so GitHub is not serving this run's verdict as the settled state of the required check. Either the publication never landed there, or another run of that name is still unfinished on this head (#524) — an unfinished run blocks the merge whatever this run published, and publishing a newer one does not clear it. GitHub reports no rollup reason for either. Re-run pr:ready once the factory GitHub App can post; if it stays unconfirmed, look for a non-terminal ${FACTORY_CHECK_NAMES["pr-ready"]} run on ${pr.headRefOid}.`);
15999
+ proof.notices = [...notices];
16000
+ proof.followUp ??= readyRedispatch();
16001
+ }
16002
+ for (const notice of notices) console.warn(`pr:ready notice: ${notice}`);
16003
+ writeProof(proof, output);
16004
+ enqueueHqIngest({
16005
+ cwd,
16006
+ kind: "pr-ready-proof",
16007
+ payload: proof,
16008
+ payloadPath: output,
16009
+ profile: policyProfile,
16010
+ subject: { prNumber: args.pr }
16011
+ }, dependencies.hq);
16012
+ reportProof(proof, output, Boolean(args.json), args.report ?? true);
15499
16013
  if (proof.status !== "ready" && (args.throwWhenBlocked ?? true)) throw new Error(`pr:ready blocked: ${proof.blockingReasons.join(" ")}`);
15500
16014
  return proof;
15501
16015
  } catch (error) {
15502
16016
  if (!finalCheckPublished) {
15503
16017
  const message = error instanceof Error ? error.message : String(error);
15504
- publishFactoryCheckSafely(dependencies.publishCheckRun, {
16018
+ if (!await (dependencies.publishFinalCheckRun ?? ensureFactoryCheckRunPublished)({
15505
16019
  conclusion: "failure",
15506
16020
  cwd,
15507
16021
  gate: "pr-ready",
@@ -15509,506 +16023,17 @@ async function runPrReady(args, dependencies = {}) {
15509
16023
  pr: args.pr,
15510
16024
  proof: {
15511
16025
  blockingReasons: [message],
15512
- schemaVersion: 2,
16026
+ schemaVersion: 3,
15513
16027
  status: "blocked"
15514
16028
  },
15515
16029
  repo: repository.name,
15516
16030
  sha: pr.headRefOid
15517
- });
16031
+ })) console.warn(`pr:ready could not publish the failing ${FACTORY_CHECK_NAMES["pr-ready"]} check run for ${pr.headRefOid}; the required check is absent rather than red.`);
15518
16032
  }
15519
16033
  throw error;
15520
16034
  }
15521
16035
  }
15522
16036
  //#endregion
15523
- //#region src/pr-merge-check.ts
15524
- const PR_MERGE_CHECK_SCHEMA_VERSION = 1;
15525
- const shaSchema = z.string().regex(/^[0-9a-f]{40}$/u);
15526
- const prMergeCheckProofSchema = z.object({
15527
- blockingReasons: z.array(z.string()),
15528
- boundary: z.object({
15529
- autoMerge: z.boolean(),
15530
- epic: z.number().int().positive(),
15531
- review: z.enum([
15532
- "independent-model",
15533
- "oracle",
15534
- "human"
15535
- ]),
15536
- wave: z.string().min(1)
15537
- }).optional(),
15538
- command: z.literal("patronage-factory pr:merge-check"),
15539
- followUp: FollowUpActionSchema.optional(),
15540
- identity: mergeGuardIdentitySchema,
15541
- liveHeadSha: shaSchema.optional(),
15542
- notices: z.array(z.string()).optional(),
15543
- pr: z.number().int().positive(),
15544
- schemaVersion: z.literal(PR_MERGE_CHECK_SCHEMA_VERSION),
15545
- status: z.enum(["pass", "fail"]),
15546
- waivedDemands: z.array(waivedDemandSchema).optional(),
15547
- worktreeHeldBranch: z.object({
15548
- branch: z.string().min(1),
15549
- worktreePath: z.string().min(1)
15550
- }).optional(),
15551
- worktreeHeldBranches: z.array(z.object({
15552
- branch: z.string().min(1),
15553
- worktreePath: z.string().min(1)
15554
- })).optional()
15555
- });
15556
- function validatePrMergeCheckProof(value) {
15557
- return prMergeCheckProofSchema.parse(value);
15558
- }
15559
- function readPrMergeCheckProof(filePath) {
15560
- return readProofFromPath(validatePrMergeCheckProof, filePath);
15561
- }
15562
- function commitsBetween(cwd, fromSha, toSha) {
15563
- try {
15564
- return runCapture("git", [
15565
- "log",
15566
- "--format=%H%x09%s",
15567
- `${fromSha}..${toSha}`
15568
- ], cwd).stdout.split("\n").filter(Boolean).map((line) => {
15569
- const [sha = "", ...subject] = line.split(" ");
15570
- return {
15571
- sha,
15572
- subject: subject.join(" ")
15573
- };
15574
- });
15575
- } catch {
15576
- return;
15577
- }
15578
- }
15579
- function fetchPullRequestHead({ cwd, owner, pr, repo }) {
15580
- const head = JSON.parse(runCapture("gh", [
15581
- "pr",
15582
- "view",
15583
- String(pr),
15584
- "--repo",
15585
- `${owner}/${repo}`,
15586
- "--json",
15587
- "baseRefName,headRefName,headRefOid,headRepository,headRepositoryOwner,labels,mergedAt"
15588
- ], cwd).stdout);
15589
- const baseRefOid = runCapture("gh", [
15590
- "api",
15591
- `repos/${owner}/${repo}/pulls/${pr}`,
15592
- "--jq",
15593
- ".base.sha"
15594
- ], cwd).stdout.trim();
15595
- let remoteHeadRefExists;
15596
- const headOwner = head.headRepositoryOwner?.login;
15597
- const headRepo = head.headRepository?.name;
15598
- if (head.headRefName && headOwner && headRepo) try {
15599
- remoteHeadRefExists = JSON.parse(runCapture("gh", ["api", `repos/${headOwner}/${headRepo}/git/matching-refs/heads/${head.headRefName}`], cwd).stdout).some((entry) => entry.ref === `refs/heads/${head.headRefName}`);
15600
- } catch {}
15601
- return {
15602
- ...head,
15603
- baseRefOid,
15604
- remoteHeadRefExists
15605
- };
15606
- }
15607
- const pullRequestHeadOidSchema = z.object({ headRefOid: shaSchema });
15608
- const pullRequestHeadRefNameSchema = z.object({ headRefName: z.string().min(1) });
15609
- const pullRequestLabelsSchema = z.object({ labels: z.array(z.object({ name: z.string().min(1) })) });
15610
- const pullRequestBaseOidSchema = z.object({ baseRefOid: shaSchema });
15611
- function showFileAtRef(cwd, ref, absolutePath) {
15612
- const relativePath = path.relative(cwd, absolutePath);
15613
- const refPath = relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
15614
- try {
15615
- return runCapture("git", ["show", `${ref}:${refPath}`], cwd).stdout;
15616
- } catch {
15617
- return;
15618
- }
15619
- }
15620
- function objectIdAtRef(cwd, ref, absolutePath) {
15621
- const relativePath = path.relative(cwd, absolutePath);
15622
- try {
15623
- return runCapture("git", ["rev-parse", `${ref}:${relativePath}`], cwd).stdout.trim();
15624
- } catch {
15625
- return;
15626
- }
15627
- }
15628
- function profileAtRef({ cwd, git, profilePath, ref }) {
15629
- if (!ref) return;
15630
- const content = git.showFileAtRef(cwd, ref, profilePath);
15631
- return content ? tryParseProfileContent(content) : void 0;
15632
- }
15633
- function committedPolicyState({ cwd, git, liveHeadResponse, profilePath }) {
15634
- const parsedHead = pullRequestHeadOidSchema.safeParse(liveHeadResponse);
15635
- const parsedBase = pullRequestBaseOidSchema.safeParse(liveHeadResponse);
15636
- const parsedLabels = pullRequestLabelsSchema.safeParse(liveHeadResponse);
15637
- return {
15638
- committedProfile: profileAtRef({
15639
- cwd,
15640
- git,
15641
- profilePath,
15642
- ref: parsedHead.success ? parsedHead.data.headRefOid : void 0
15643
- }),
15644
- committedProfileBlobSha: parsedHead.success ? git.objectIdAtRef(cwd, parsedHead.data.headRefOid, profilePath) : void 0,
15645
- currentMergeBaseSha: parsedBase.success ? parsedBase.data.baseRefOid : void 0,
15646
- liveLabels: parsedLabels.success ? parsedLabels.data.labels.map((label) => label.name) : void 0
15647
- };
15648
- }
15649
- function checkoutRelativeProfilePath(cwd, profilePath) {
15650
- const relativePath = path.relative(cwd, profilePath);
15651
- if (relativePath.length === 0 || relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) throw new Error("The merge-check profile must be inside the trusted checkout.");
15652
- return relativePath.split(path.sep).join("/");
15653
- }
15654
- function proofBindingReasons({ committedProfile, committedProfileBlobSha, profilePath, readyProof, repository }) {
15655
- if (!readyProof) return [];
15656
- const checkoutSlug = repositorySlug(repository);
15657
- const reasons = [];
15658
- if (!readyProof.repository || normalizeRepositorySlug(readyProof.repository) !== checkoutSlug) reasons.push(`Readiness proof repository ${readyProof.repository ?? "missing"} does not match trusted checkout repository ${checkoutSlug}; re-run pr:ready in this checkout.`);
15659
- if (readyProof.profilePath !== profilePath) reasons.push(`Readiness proof profile path ${readyProof.profilePath ?? "missing"} does not match merge-check profile path ${profilePath}; re-run pr:ready with the same committed profile.`);
15660
- if (!readyProof.profileBlobSha || !committedProfileBlobSha || readyProof.profileBlobSha !== committedProfileBlobSha) reasons.push(`Readiness proof profile blob ${readyProof.profileBlobSha ?? "missing"} does not match committed profile blob ${committedProfileBlobSha ?? "unresolvable"}; re-run pr:ready from the committed PR head.`);
15661
- if (committedProfile && repositorySlug(committedProfile.repository) !== checkoutSlug) reasons.push(`Committed profile repository ${repositorySlug(committedProfile.repository)} does not match trusted checkout repository ${checkoutSlug}; merge is blocked.`);
15662
- return reasons;
15663
- }
15664
- function requiredCheckDemandGroups({ committedProfile, liveLabels, readyProof }) {
15665
- const ledger = readyProof?.ledger;
15666
- if (!committedProfile) return [{ reasons: ["Could not read the project profile from the committed PR candidate; merge-time requiredChecks policy must come from committed refs. Fetch the PR head, then re-run pr:merge-check."] }];
15667
- const recordedChecks = ledger?.externalChecks ?? [];
15668
- return (committedProfile.requiredChecks ?? []).map((requiredCheck) => ({
15669
- demand: requiredCheckDemand(requiredCheck.name),
15670
- reasons: requiredCheckDriftReasons({
15671
- classification: ledger?.classification,
15672
- liveLabels,
15673
- recordedChecks,
15674
- requiredCheck
15675
- })
15676
- }));
15677
- }
15678
- function requiredCheckDriftReasons({ classification, liveLabels, recordedChecks, requiredCheck }) {
15679
- const scopeDecision = requiredCheckInScope({
15680
- context: {
15681
- classification,
15682
- labels: liveLabels
15683
- },
15684
- scope: requiredCheck.scope
15685
- });
15686
- const recorded = recordedChecks.find((candidate) => candidate.name === requiredCheck.name && candidate.checkType === requiredCheck.checkType);
15687
- if (!recorded) return [`Committed required check "${requiredCheck.name}" is missing from the ready proof; re-run pr:ready from the committed PR head.`];
15688
- if (JSON.stringify(recorded.scope) !== JSON.stringify(requiredCheck.scope)) return [`Committed required check "${requiredCheck.name}" does not match the scope recorded by pr:ready; re-run pr:ready from the committed PR head.`];
15689
- if (!scopeDecision.inScope) return [];
15690
- if (requiredCheck.scope?.labels !== void 0 && liveLabels === void 0) return [`Could not read the live PR labels to re-check conditional required check "${requiredCheck.name}"; fail closed — re-run pr:ready.`];
15691
- if (recorded.status !== "satisfied") return [`Required check "${requiredCheck.name}" is in scope for the merge candidate but the ready proof recorded it "${recorded.status}"; re-run pr:ready.`];
15692
- return [];
15693
- }
15694
- function mergeCheckFollowUp(status, pr, identity, profilePath, repository, matchedHeadSha, mergeFreezeActive) {
15695
- if (status === "pass") {
15696
- if (!matchedHeadSha) throw new Error("Passing merge-check is missing its validated head SHA.");
15697
- return followUpFromArgv([
15698
- "gh",
15699
- "api",
15700
- "--method",
15701
- "PUT",
15702
- `repos/${repositorySlug(repository)}/pulls/${pr}/merge`,
15703
- "-f",
15704
- `sha=${matchedHeadSha}`,
15705
- "-f",
15706
- "merge_method=squash"
15707
- ]);
15708
- }
15709
- if (mergeFreezeActive) return;
15710
- if (identity.kind === "diverged") return followUpFromArgv([
15711
- ...prVerifyFollowUp().argv,
15712
- "--profile",
15713
- profilePath
15714
- ]);
15715
- return followUpFromArgv([
15716
- "patronage-factory",
15717
- "pr:ready",
15718
- "--pr",
15719
- String(pr),
15720
- "--profile",
15721
- profilePath
15722
- ]);
15723
- }
15724
- function validatedHeadSha(liveHead) {
15725
- return "headSha" in liveHead ? liveHead.headSha : void 0;
15726
- }
15727
- function runPrMergeCheck(args, dependencies = {}) {
15728
- const cwd = path.resolve(args.cwd ?? process.cwd());
15729
- const profilePath = args.profilePath ? path.resolve(cwd, args.profilePath) : defaultProfilePath(cwd);
15730
- const git = {
15731
- checkoutRepository,
15732
- commitsBetween,
15733
- objectIdAtRef,
15734
- showFileAtRef,
15735
- worktreeListPorcelain: listWorktreesPorcelain,
15736
- ...dependencies.git
15737
- };
15738
- const readyProofPath = path.resolve(cwd, args.readyProof ?? ".factory-memory/pr-ready.json");
15739
- const readyProofRead = tryReadProof((p) => readProofFor(PR_READY_PROOF_DESCRIPTOR, cwd, p), readyProofPath);
15740
- const repository = git.checkoutRepository(cwd);
15741
- const relativeProfilePath = checkoutRelativeProfilePath(cwd, profilePath);
15742
- const github = dependencies.github ?? { fetchPullRequestHead };
15743
- const fetchIssueBody = github.fetchIssueBody ?? defaultFetchIssueBody;
15744
- const fetchClosingPullRequests = github.fetchClosingPullRequests ?? defaultFetchClosingPullRequests;
15745
- const fetchReviews = github.fetchPullRequestReviews ?? ((input) => fetchPullRequestReviews(`${input.owner}/${input.repo}`, input.pr));
15746
- const liveHeadResponse = github.fetchPullRequestHead({
15747
- cwd,
15748
- owner: repository.owner,
15749
- pr: args.pr,
15750
- repo: repository.name
15751
- });
15752
- const parsedLiveHead = pullRequestHeadOidSchema.safeParse(liveHeadResponse);
15753
- const parsedBase = pullRequestBaseOidSchema.safeParse(liveHeadResponse);
15754
- const liveHead = parsedLiveHead.success ? { headSha: parsedLiveHead.data.headRefOid } : { invalidResponse: JSON.stringify(liveHeadResponse) ?? "undefined" };
15755
- const liveHeadSha = validatedHeadSha(liveHead);
15756
- const parsedHeadRefName = pullRequestHeadRefNameSchema.safeParse(liveHeadResponse);
15757
- const identity = resolveMergeGuardIdentity({
15758
- commitsBetween: (fromSha, toSha) => git.commitsBetween(cwd, fromSha, toSha),
15759
- liveHead,
15760
- pr: args.pr,
15761
- readyProof: readyProofRead.proof,
15762
- readyProofError: readyProofRead.error,
15763
- readyProofPath
15764
- });
15765
- const policyState = committedPolicyState({
15766
- cwd,
15767
- git,
15768
- liveHeadResponse,
15769
- profilePath
15770
- });
15771
- const freezeReasons = parsedBase.success ? sharedMergeFreezeBlockingReasons({
15772
- cwd,
15773
- headSha: parsedBase.data.baseRefOid,
15774
- repository
15775
- }, dependencies.mergeFreeze ?? githubMergeFreezeStore) : ["Authoritative GitHub merge freeze check-run state is unavailable or invalid: GitHub did not return a valid current base tip."];
15776
- let boundary;
15777
- const waveMembershipReasons = [];
15778
- const rungDemandGroups = [];
15779
- if (args.epic !== void 0) {
15780
- const membership = resolveWaveDemand({
15781
- epic: args.epic,
15782
- fetchClosingPullRequests,
15783
- fetchIssueBody,
15784
- owner: repository.owner,
15785
- pr: args.pr,
15786
- repo: repository.name
15787
- });
15788
- waveMembershipReasons.push(...membership.reasons);
15789
- if (membership.demand) {
15790
- boundary = {
15791
- ...membership.demand,
15792
- epic: args.epic
15793
- };
15794
- rungDemandGroups.push({
15795
- demand: reviewRungDemand(boundary.review),
15796
- reasons: demandedRungSatisfactionReasons({
15797
- demandedRung: boundary.review,
15798
- liveHumanReviews: boundary.review === "human" ? fetchReviews({
15799
- owner: repository.owner,
15800
- pr: args.pr,
15801
- repo: repository.name
15802
- }) : [],
15803
- reviewRuns: readyProofRead.proof?.ledger.reviewRuns
15804
- })
15805
- });
15806
- }
15807
- }
15808
- const candidateWaivers = selectWaiversForCandidate({
15809
- headSha: liveHeadSha,
15810
- pr: args.pr,
15811
- waivers: readDemandWaivers(cwd)
15812
- });
15813
- const demandOutcomes = [
15814
- { reasons: mergeGuardBlockingReasons(identity) },
15815
- { reasons: proofBindingReasons({
15816
- ...policyState,
15817
- profilePath: relativeProfilePath,
15818
- readyProof: readyProofRead.proof,
15819
- repository
15820
- }) },
15821
- {
15822
- demand: DEMAND_KEYS.mergeFreeze,
15823
- reasons: freezeReasons
15824
- },
15825
- ...requiredCheckDemandGroups({
15826
- ...policyState,
15827
- readyProof: readyProofRead.proof
15828
- }),
15829
- { reasons: waveMembershipReasons },
15830
- ...rungDemandGroups
15831
- ].map((group) => group.demand === void 0 ? { blockingReasons: group.reasons } : applyDemandWaiver({
15832
- demand: group.demand,
15833
- reasons: group.reasons,
15834
- waivers: candidateWaivers
15835
- }));
15836
- const blockingReasons = demandOutcomes.flatMap((outcome) => outcome.blockingReasons);
15837
- const waivedDemands = demandOutcomes.flatMap((outcome) => outcome.waived ? [outcome.waived] : []);
15838
- const worktreeListPorcelain = git.worktreeListPorcelain(cwd);
15839
- const headTopology = resolveMergeOperationalNotices({
15840
- headRefName: parsedHeadRefName.success ? parsedHeadRefName.data.headRefName : void 0,
15841
- worktreeListPorcelain
15842
- });
15843
- const defaultTopology = resolveMergeOperationalNotices({
15844
- headRefName: liveHeadResponse.baseRefName ?? policyState.committedProfile?.repository.defaultBranch,
15845
- worktreeListPorcelain
15846
- });
15847
- const { worktreeHeldBranch } = headTopology;
15848
- const worktreeHeldBranches = [...defaultTopology.worktreeHeldBranches ?? [], ...headTopology.worktreeHeldBranches ?? []].filter((held, index, all) => all.findIndex((candidate) => candidate.branch === held.branch) === index);
15849
- const notices = [
15850
- ...worktreeHeldBranches.map(worktreeHeldBranchNotice),
15851
- ...waivedDemands.map(waivedDemandNotice),
15852
- ...unusedWaiverNotices({
15853
- applied: waivedDemands,
15854
- waivers: candidateWaivers
15855
- })
15856
- ];
15857
- let cleanupNotice = "Merge handoff uses GitHub's remote API; remote branch state is unverified and cleanup is deferred to closeout.";
15858
- if (liveHeadResponse.mergedAt) cleanupNotice = `PR was already merged at ${liveHeadResponse.mergedAt}; no merge command is required. Local branch cleanup remains deferred to closeout.`;
15859
- else if (liveHeadResponse.remoteHeadRefExists === false) cleanupNotice = "The remote PR branch is already deleted. The remote-only merge handoff leaves only local worktree and branch cleanup for closeout.";
15860
- else if (liveHeadResponse.remoteHeadRefExists) cleanupNotice = "Merge handoff uses GitHub's remote API; remote and local branch deletion is deferred to closeout.";
15861
- notices.push(cleanupNotice);
15862
- if (args.epic === void 0) notices.push("No epic was supplied; merge-check permits a human handoff but emits no automated merge action.");
15863
- else if (boundary && !boundary.autoMerge) notices.push(`Boundary wave ${boundary.wave} does not authorize auto-merge; merge-check permits a human handoff but emits no automated merge action.`);
15864
- const status = blockingReasons.length === 0 ? "pass" : "fail";
15865
- const followUp = liveHeadResponse.mergedAt || status === "pass" && !boundary?.autoMerge ? void 0 : mergeCheckFollowUp(status, args.pr, identity, relativeProfilePath, repository, liveHeadSha, parsedBase.success && freezeReasons.length > 0);
15866
- const proof = {
15867
- blockingReasons,
15868
- ...boundary ? { boundary: {
15869
- autoMerge: boundary.autoMerge,
15870
- epic: boundary.epic,
15871
- review: boundary.review,
15872
- wave: boundary.wave
15873
- } } : {},
15874
- command: "patronage-factory pr:merge-check",
15875
- ...followUp ? { followUp } : {},
15876
- identity,
15877
- ...liveHeadSha ? { liveHeadSha } : {},
15878
- ...notices.length > 0 ? { notices } : {},
15879
- pr: args.pr,
15880
- schemaVersion: PR_MERGE_CHECK_SCHEMA_VERSION,
15881
- status,
15882
- ...waivedDemands.length > 0 ? { waivedDemands } : {},
15883
- ...worktreeHeldBranch ? { worktreeHeldBranch } : {},
15884
- ...worktreeHeldBranches ? { worktreeHeldBranches } : {}
15885
- };
15886
- const output = path.resolve(cwd, args.output ?? ".factory-memory/pr-merge-check.json");
15887
- writeProofJson(proof, output);
15888
- console.log(`pr:merge-check proof written to ${output}`);
15889
- for (const notice of notices) console.warn(`pr:merge-check notice: ${notice}`);
15890
- if (args.json) console.log(JSON.stringify(proof, null, 2));
15891
- if (proof.status !== "pass") throw new Error(`pr:merge-check blocked: ${blockingReasons.join(" ")}`);
15892
- return proof;
15893
- }
15894
- //#endregion
15895
- //#region src/commands/pr-merge-check.ts
15896
- function createPrMergeCheckCommand(output, action = runPrMergeCheck) {
15897
- return new Command("pr:merge-check").description("Revalidate the ready candidate and emit a remote-only, worktree-safe merge handoff").requiredOption("--pr <number>", "pull request number", positiveInteger("--pr")).option("--cwd <path>", "working directory to evaluate", ".").option("--epic <number>", "epic issue containing the factory-boundary manifest", positiveInteger("--epic")).option("--json", "print the merge-check proof as JSON").option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--ready-proof <path>", "pr:ready proof JSON path").action(withGateTiming({
15898
- gate: "pr:merge-check",
15899
- resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
15900
- stderr: output.stderr
15901
- }, (options) => {
15902
- action({
15903
- cwd: resolveCwdOption(options.cwd),
15904
- epic: options.epic,
15905
- json: Boolean(options.json),
15906
- output: options.output,
15907
- pr: options.pr,
15908
- profilePath: options.profile,
15909
- readyProof: options.readyProof
15910
- });
15911
- }));
15912
- }
15913
- //#endregion
15914
- //#region src/pr-publish-transaction-error.ts
15915
- /**
15916
- * Publish's admission read is a bounded transaction (#348): undraft, await the
15917
- * newest hosted run for this head, re-evaluate, and return the current
15918
- * outcome. Neither abort path returns a verdict the command cannot currently
15919
- * substantiate — an unsubstantiated verdict is this error, never a guess.
15920
- */
15921
- var PrPublishTransactionAbortedError = class extends Error {
15922
- constructor(message) {
15923
- super(`pr:publish aborted: ${message}`);
15924
- this.name = "PrPublishTransactionAbortedError";
15925
- }
15926
- };
15927
- //#endregion
15928
- //#region src/pr-readiness/pr-body-patch.ts
15929
- const markerSlug = (heading) => heading.toLowerCase().replaceAll(/[^a-z0-9]+/gu, "-");
15930
- const markerFor = (heading, edge) => `<!-- patronage-factory:${markerSlug(heading)}:${edge} -->`;
15931
- const renderManagedPrBodyBlock = (heading, content) => `${markerFor(heading, "start")}\n${content.trim()}\n${markerFor(heading, "end")}`;
15932
- const isLegacyFactoryOnlyContent = (heading, content) => {
15933
- const value = content.trim();
15934
- if (!value) return true;
15935
- if (heading === "Verification") return value === "Run `patronage-factory pr:verify` and attach the typed proof to `pr:ready --verify-proof`." || value.split("\n").filter(Boolean).every((line) => /^patronage-factory pr:verify(?: --(?:docs-only|trivial))? passed at head [0-9a-f]{40}$/u.test(line));
15936
- if (heading === "Review proof") return value === "Have a clean session write the typed findings file, then run `patronage-factory pr:review --mode all --findings <path> --output .factory-memory/pr-review.json` and attach the proof to `pr:ready --review-proof`." || value.split("\n").filter(Boolean).every((line) => /^- (?:correctness|security): (?:passed with no actionable findings|[a-z-]+ with \d+ finding\(s\)) at patch-id [0-9a-f]{40}$/u.test(line));
15937
- return heading === "How to review" && value === "Review the changed files and branded patronage-factory check runs.";
15938
- };
15939
- const patchExistingSection = (body, part) => {
15940
- const bounds = findSectionBounds(body, part.heading);
15941
- if (!bounds) return;
15942
- const rawContent = body.slice(bounds.contentStart, bounds.contentEnd);
15943
- const block = renderManagedPrBodyBlock(part.heading, part.content);
15944
- const startMarker = markerFor(part.heading, "start");
15945
- const endMarker = markerFor(part.heading, "end");
15946
- const markerStart = rawContent.indexOf(startMarker);
15947
- const markerEnd = rawContent.indexOf(endMarker);
15948
- const startCount = rawContent.split(startMarker).length - 1;
15949
- if (startCount !== rawContent.split(endMarker).length - 1 || startCount > 1 || startCount === 1 && markerEnd < markerStart) throw new Error(`Malformed patronage-factory managed markers in the ${part.heading} section.`);
15950
- if (startCount === 1) {
15951
- const absoluteStart = bounds.contentStart + markerStart;
15952
- const absoluteEnd = bounds.contentStart + markerEnd + endMarker.length;
15953
- return body.slice(0, absoluteStart) + block + body.slice(absoluteEnd);
15954
- }
15955
- if (isLegacyFactoryOnlyContent(part.heading, rawContent)) return `${body.slice(0, bounds.contentStart)}\n\n${block}\n\n${body.slice(bounds.contentEnd)}`;
15956
- const separator = rawContent.endsWith("\n\n") ? "" : "\n\n";
15957
- return `${body.slice(0, bounds.contentEnd)}${separator}${block}\n\n${body.slice(bounds.contentEnd)}`;
15958
- };
15959
- const patchPrBodySections = (body, parts) => {
15960
- let result = body;
15961
- const missing = [];
15962
- for (const part of parts) {
15963
- const patched = patchExistingSection(result, part);
15964
- if (patched === void 0) missing.push(part);
15965
- else result = patched;
15966
- }
15967
- for (const part of missing) {
15968
- const separator = result.length === 0 || result.endsWith("\n\n") ? "" : "\n\n";
15969
- result += `${separator}## ${part.heading}\n\n${renderManagedPrBodyBlock(part.heading, part.content)}\n`;
15970
- }
15971
- return result;
15972
- };
15973
- //#endregion
15974
- //#region src/pr-readiness/pr-body-renderer.ts
15975
- var pr_body_renderer_exports = /* @__PURE__ */ __exportAll({
15976
- PR_BODY_MANAGED_SECTIONS: () => MANAGED_SECTION_NAMES,
15977
- renderPrBodySectionParts: () => renderPrBodySectionParts,
15978
- renderPrBodySections: () => renderPrBodySections
15979
- });
15980
- const commandFor = (proof) => {
15981
- if (proof.mode === "docs-only") return "patronage-factory pr:verify --docs-only";
15982
- if (proof.mode === "trivial") return "patronage-factory pr:verify --trivial";
15983
- return "patronage-factory pr:verify";
15984
- };
15985
- const renderPrBodySectionParts = ({ reviewProof, verifyProof }) => {
15986
- const verification = verifyProof ? [...verifyProof.mode === "docs-only" ? verifyProof.baselineFullProofs?.map((proof) => `patronage-factory pr:verify passed at head ${proof.headSha}`) ?? [] : [], `${commandFor(verifyProof)} passed at head ${verifyProof.headSha}`].join("\n") : "Run `patronage-factory pr:verify` and attach the typed proof to `pr:ready --verify-proof`.";
15987
- const reviewRuns = reviewProof?.reviews.map((review) => {
15988
- const patch = reviewProof.patchId;
15989
- const result = review.outcome === "passed" ? "passed with no actionable findings" : `${review.outcome} with ${review.issuesFlagged} finding(s)`;
15990
- return `- ${review.kind}: ${result} at patch-id ${patch}`;
15991
- }).join("\n") ?? "Have a clean session write the typed findings file, then run `patronage-factory pr:review --mode all --findings <path> --output .factory-memory/pr-review.json` and attach the proof to `pr:ready --review-proof`.";
15992
- return [
15993
- {
15994
- content: verification,
15995
- heading: "Verification"
15996
- },
15997
- {
15998
- content: reviewRuns,
15999
- heading: "Review proof"
16000
- },
16001
- {
16002
- content: "Review the changed files and branded patronage-factory check runs.",
16003
- heading: "How to review"
16004
- }
16005
- ];
16006
- };
16007
- const renderPrBodySections = ({ reviewProof, verifyProof }) => `${renderPrBodySectionParts({
16008
- reviewProof,
16009
- verifyProof
16010
- }).map((part) => `## ${part.heading}\n\n${renderManagedPrBodyBlock(part.heading, part.content)}`).join("\n\n")}\n`;
16011
- //#endregion
16012
16037
  //#region src/pr-review-gate-trace.ts
16013
16038
  const eventTime = () => (/* @__PURE__ */ new Date()).toISOString();
16014
16039
  const buildReviewGateNotRequiredTraceEvent = ({ createdAt = eventTime(), identity, proof }) => ReviewGateTraceEventSchema.parse({
@@ -16429,6 +16454,7 @@ const summarizeHumanHandoff = (proof) => {
16429
16454
  return {
16430
16455
  humanActions: proof.status === "ready" ? [] : [...humanActions].toSorted((left, right) => left.localeCompare(right)),
16431
16456
  routeOwnedRepairs,
16457
+ scheduled: proof.status === "ready" && (proof.arming?.outcome === "armed" || proof.arming?.outcome === "merged") && proof.followUp === void 0,
16432
16458
  status: proof.status
16433
16459
  };
16434
16460
  };
@@ -16505,8 +16531,9 @@ const followUpSummaryLine = (outcome) => {
16505
16531
  return "Follow-up: not requested.";
16506
16532
  };
16507
16533
  const verificationReuseSummaryLine = (reuse) => reuse.reused ? `Verification reused the existing pr:verify proof: ${reuse.reason}.` : `Verification ran pr:verify: ${reuse.reason}.`;
16534
+ const readySummaryLine = (handoff) => handoff.scheduled ? "pr:publish ready — the merge is on GitHub's schedule; no human actions owed." : "pr:publish admitted, not handed off — the candidate passed but nothing is scheduled to merge it. See the readiness proof's notices and follow-up.";
16508
16535
  const renderPublishSummary = (result) => {
16509
- const lines = result.handoff.status === "ready" ? ["pr:publish ready — no human actions owed before merge."] : [`pr:publish blocked (${result.handoff.status}). Human actions owed:`, ...result.handoff.humanActions.length === 0 ? ["- Route-owned repairs remain; see readiness proof repairs."] : result.handoff.humanActions.map((action) => `- ${action}`)];
16536
+ const lines = result.handoff.status === "ready" ? [readySummaryLine(result.handoff)] : [`pr:publish blocked (${result.handoff.status}). Human actions owed:`, ...result.handoff.humanActions.length === 0 ? ["- Route-owned repairs remain; see readiness proof repairs."] : result.handoff.humanActions.map((action) => `- ${action}`)];
16510
16537
  if (result.verificationReuse) lines.push(verificationReuseSummaryLine(result.verificationReuse));
16511
16538
  lines.push(followUpSummaryLine(result.followUp));
16512
16539
  const binding = result.verifyProofBinding ? verifyProofBindingSummaryLine(result.verifyProofBinding) : void 0;
@@ -16528,7 +16555,14 @@ async function ensureVerifyProof({ args, cwd, dependencies, headSha, verifyProof
16528
16555
  let declineReason = "no pr:verify proof was available to reuse";
16529
16556
  if (typeof headSha === "string") {
16530
16557
  const applicability = resolveVerifyProofApplicability({
16531
- currentVerifyIdentityForBase: () => ({ patchId: (dependencies.git?.stablePatchId ?? stablePatchId)(cwd, args.base) }),
16558
+ currentVerifyIdentityForBase: () => {
16559
+ const selectedStablePatchId = dependencies.git?.stablePatchId ?? stablePatchId;
16560
+ return { patchId: resolveCandidatePatchId({
16561
+ base: args.base,
16562
+ cwd,
16563
+ stablePatchId: selectedStablePatchId
16564
+ }) };
16565
+ },
16532
16566
  explicitProof: false,
16533
16567
  files: (dependencies.git?.changedFiles ?? changedFiles)(cwd, args.base),
16534
16568
  headSha,
@@ -16622,8 +16656,9 @@ async function ensureReviewProof({ args, cwd, dependencies, currentReviewIdentit
16622
16656
  *
16623
16657
  * Still best effort by construction: the sink never throws and never
16624
16658
  * substitutes the commit-status mirror, so a failed proof mirror cannot fail
16625
- * the gate. The sink is injected by the CLI exactly like `publishCheckRun`, so
16626
- * an absent sink means no republish and unit fixtures never reach GitHub.
16659
+ * the gate. The sink is injected by the CLI it is the only check-run sink
16660
+ * publish injects now (#526) — so an absent sink means no republish and unit
16661
+ * fixtures never reach GitHub.
16627
16662
  */
16628
16663
  async function republishVerifyProofCheckRun({ cwd, dependencies, pr, profile, verified }) {
16629
16664
  const { proof, verifiedHeadSha } = verified;
@@ -16696,6 +16731,19 @@ const planPublishFollowUp = ({ binding, followUp, proofHeadSha, verifiedHeadSha
16696
16731
  * pre-action verdict it holds no longer describes the candidate, full stop.
16697
16732
  */
16698
16733
  const isUndraftFollowUp = (action) => action.argv[0] === "gh" && action.argv[1] === "pr" && action.argv[2] === "ready";
16734
+ /**
16735
+ * Whether the settled follow-up was readiness re-dispatching itself (#477):
16736
+ * the arming did not take effect, or the branded check's publication was not
16737
+ * confirmed. Self-invalidating for the same reason the undraft repair is —
16738
+ * publish just performed the action, so the pre-action proof no longer
16739
+ * describes what is scheduled.
16740
+ */
16741
+ const isReadyRedispatch = (action) => action.argv[0] === "patronage-factory" && action.argv[1] === "pr:ready";
16742
+ /**
16743
+ * The follow-ups publish must not report a pre-action verdict for, because
16744
+ * publish itself performed the action they name.
16745
+ */
16746
+ const isSelfInvalidatingFollowUp = (action) => isUndraftFollowUp(action) || isReadyRedispatch(action);
16699
16747
  const PUBLISH_HOSTED_RUN_POLL_MS = 15e3;
16700
16748
  /**
16701
16749
  * Re-evaluate readiness for the same candidate (#348). Used both to capture
@@ -16763,10 +16811,17 @@ async function settleFollowUp({ action, cwd, factoryCliInvocation, onFailure, pl
16763
16811
  status: "succeeded"
16764
16812
  };
16765
16813
  }
16766
- const currentReviewIdentityFor = ({ args, cwd, dependencies, headSha, verifyProof }) => ({
16767
- headSha: headSha ?? verifyProof.headSha,
16768
- patchId: (dependencies.git?.stablePatchId ?? stablePatchId)(cwd, args.base)
16769
- });
16814
+ const currentReviewIdentityFor = ({ args, cwd, dependencies, headSha, verifyProof }) => {
16815
+ const selectedStablePatchId = dependencies.git?.stablePatchId ?? stablePatchId;
16816
+ return {
16817
+ headSha: headSha ?? verifyProof.headSha,
16818
+ patchId: resolveCandidatePatchId({
16819
+ base: args.base,
16820
+ cwd,
16821
+ stablePatchId: selectedStablePatchId
16822
+ })
16823
+ };
16824
+ };
16770
16825
  async function runPrPublish(args, dependencies = {}) {
16771
16826
  const cwd = path.resolve(args.cwd ?? process.cwd());
16772
16827
  const factoryCliInvocation = prPublishCliInvocation(dependencies.factoryCliInvocation);
@@ -16887,7 +16942,7 @@ async function runPrPublish(args, dependencies = {}) {
16887
16942
  }),
16888
16943
  runFollowUp: dependencies.runFollowUp
16889
16944
  });
16890
- if (followUp.status === "succeeded" && isUndraftFollowUp(initialProof.followUp)) currentProof = await reevaluateReadiness(readyEvalArgs, publishDependencies);
16945
+ if (followUp.status === "succeeded" && isSelfInvalidatingFollowUp(initialProof.followUp)) currentProof = await reevaluateReadiness(readyEvalArgs, publishDependencies);
16891
16946
  }
16892
16947
  const proof = await awaitCurrentReadyVerdict({
16893
16948
  args: readyEvalArgs,
@@ -16977,7 +17032,7 @@ function createPrReadyCommand(output, action) {
16977
17032
  reviewProof: options.reviewProof,
16978
17033
  verifyProof: options.verifyProof
16979
17034
  };
16980
- await (action ? action(args) : runPrReady(args, { publishCheckRun: createFactoryCheckPublisher(output) }));
17035
+ await (action ? action(args) : runPrReady(args));
16981
17036
  }));
16982
17037
  }
16983
17038
  //#endregion
@@ -17112,8 +17167,8 @@ function checkWorktreeScratchFiles(worktreePath, listRootEntries = listWorktreeR
17112
17167
  }
17113
17168
  /**
17114
17169
  * Human-readable one-line summary of a scratch-file check, owned beside the
17115
- * detection logic (mirroring worktreeHeldBranchNotice) so planner output,
17116
- * docs, and future surfaces cannot drift apart.
17170
+ * detection logic so planner output, docs, and future surfaces cannot drift
17171
+ * apart.
17117
17172
  */
17118
17173
  function formatScratchFileCheckSummary(check) {
17119
17174
  if (check.status === "flagged") return `Scratch files: flagged — ${check.files.join(", ")} in ${check.worktreePath ?? "worktree"}. PR-body scratch files belong in /tmp; remove these before archiving the worktree.`;
@@ -17306,11 +17361,7 @@ function createProgram(options = {}) {
17306
17361
  };
17307
17362
  const prPublishAction = options.actions?.prPublish ?? ((args) => runPrPublish(args, {
17308
17363
  ensureVerifyCheckRun: ensureFactoryCheckRunPublished,
17309
- factoryCliInvocation,
17310
- publishCheckRun: createFactoryCheckPublisher({
17311
- stderr: output.stderr,
17312
- stdout: output.stderr
17313
- })
17364
+ factoryCliInvocation
17314
17365
  }));
17315
17366
  const program = new Command();
17316
17367
  program.name("patronage-factory").description("Shared Patronage software factory CLI").version(version);
@@ -17326,7 +17377,6 @@ function createProgram(options = {}) {
17326
17377
  program.addCommand(createPrReviewCommand(output, options.actions?.prReview));
17327
17378
  program.addCommand(createPrReadyCommand(output, options.actions?.prReady));
17328
17379
  program.addCommand(createPrPublishCommand(output, prPublishAction));
17329
- program.addCommand(createPrMergeCheckCommand(output, options.actions?.prMergeCheck));
17330
17380
  program.addCommand(createDemandWaiveCommand(output, options.actions?.demandWaive));
17331
17381
  return program;
17332
17382
  }
@@ -17350,4 +17400,4 @@ if (isDirectCliExecution()) try {
17350
17400
  process.exit(1);
17351
17401
  }
17352
17402
  //#endregion
17353
- export { DEFAULT_DEMAND_WAIVER_PATH, DEFAULT_FACTORY_REPOSITORY, DemandWaiveRefusalError, EPIC_STRUCTURE_NODE_STATUSES, EPIC_STRUCTURE_SCHEMA_VERSION, EpicStructureValidationError, FactoryCliInvocationSchema, FollowUpActionSchema, MERGE_FREEZE_APP_SLUG, MERGE_FREEZE_CHECK_NAME, PrPublishFollowUpError, RETRO_ENVELOPE_SCHEMA_VERSION, RETRO_ENVELOPE_VALIDATORS, REVIEW_FOCUS_SECTION, SUPPORTED_RETRO_ENVELOPE_SCHEMA_VERSIONS, WorkerCheckoutGuardError, appendReviewLadderStageTraceEvent, appendWithTraceSinks, applyDemandWaiver, assembleReviewPrompt, assertWorkerCheckoutAllowed, authorizeDemandWaiver, blockingLadderFindings, boundary_manifest_exports as boundaryManifest, boundary_review_proof_exports as boundaryReviewProof, buildEpicStructureEvent, buildEpicStructurePayload, buildEvidenceEnvelope, buildRetroEnvelope, buildReviewGateNotRequiredTraceEvent, buildReviewLadderStageTraceEvent, comment_provenance_exports as commentProvenance, createLocalJsonlTraceSink, createProgram, doctorProjectProfile, epicStructureEventId, evaluateReviewLadder, evidenceEnvelopeFilename, findingKey, followUpFromArgv, inferFixedInThreadDispositions, isProductionHqUrl, isRetroEnvelopeWireComplete, loadProjectProfile, normalizeIssueComments, openLadderFindings, parseRetroEnvelope, planPublishFollowUp, worktree_held_branch_exports as prMergePreflight, pr_body_metadata_exports as prReadinessBodyMetadata, readiness_evaluation_exports as prReadinessEvaluation, external_evidence_exports as prReadinessExternalEvidence, merge_identity_exports as prReadinessMergeIdentity, post_readiness_comments_exports as prReadinessPostComments, pr_body_renderer_exports as prReadinessPrBodyRenderer, proof_identity_exports as prReadinessProofIdentity, review_proof_exports as prReadinessReviewProof, status_check_rollup_exports as prReadinessStatusChecks, verification_proof_exports as prReadinessVerificationProof, publishEpicStructure, readDemandWaivers, readPrMergeCheckProof, readPrReadyProof, readPrReviewProof, resolveFactoryRepository, resolveFindingBlocking, resolveReviewFindingCategory, resolveReviewFindingSeverity, resolveReviewLadderPolicy, resolveTraceWriteSinks, retroEnvelopeSchemaVersionOf, retroEnvelopeV1Schema, retroEpicReference, reviewCycleStateFor, reviewFocusFromIssueBody, reviewPromptSectionSchema, reviewPromptSectionsSchema, run, runBoundaryCheck, runDemandWaive, runEvidenceEmit, runPrMergeCheck, runPrPublish, runPrReady, runPrReview, runPrVerify, scanFactoryTraceDiagnostics, scanFactoryTraceEvents, selectWaiversForCandidate, staleRepeatLadderFindings, toFactoryTraceEnvelope, tryAppendReviewGateNotRequiredTraceEvent, tryAppendReviewLadderStageTraceEvent, validateBoundaryCheckProof, validateDagDocument, validateDemandWaiverStore, validateFactoryTraceEvent, validateMergeFreezeState, validatePrMergeCheckProof, validatePrReadyProof, validatePrReviewProof, validatePrVerifyProof, waivedDemandNotice, waivedDemandSchema, worktree_scratch_files_exports as worktreeScratchFiles };
17403
+ export { DEFAULT_DEMAND_WAIVER_PATH, DEFAULT_FACTORY_REPOSITORY, DemandWaiveRefusalError, EPIC_STRUCTURE_NODE_STATUSES, EPIC_STRUCTURE_SCHEMA_VERSION, EpicStructureValidationError, FactoryCliInvocationSchema, FollowUpActionSchema, MERGE_FREEZE_APP_SLUG, MERGE_FREEZE_CHECK_NAME, PrPublishFollowUpError, RETRO_ENVELOPE_SCHEMA_VERSION, RETRO_ENVELOPE_VALIDATORS, REVIEW_FOCUS_SECTION, SUPPORTED_RETRO_ENVELOPE_SCHEMA_VERSIONS, WorkerCheckoutGuardError, appendReviewLadderStageTraceEvent, appendWithTraceSinks, applyDemandWaiver, assembleReviewPrompt, assertWorkerCheckoutAllowed, authorizeDemandWaiver, blockingLadderFindings, boundary_manifest_exports as boundaryManifest, boundary_review_proof_exports as boundaryReviewProof, buildEpicStructureEvent, buildEpicStructurePayload, buildEvidenceEnvelope, buildRetroEnvelope, buildReviewGateNotRequiredTraceEvent, buildReviewLadderStageTraceEvent, comment_provenance_exports as commentProvenance, createLocalJsonlTraceSink, createProgram, doctorProjectProfile, epicStructureEventId, evaluateReviewLadder, evidenceEnvelopeFilename, findingKey, followUpFromArgv, inferFixedInThreadDispositions, isProductionHqUrl, isRetroEnvelopeWireComplete, loadProjectProfile, normalizeIssueComments, openLadderFindings, parseRetroEnvelope, planPublishFollowUp, pr_body_metadata_exports as prReadinessBodyMetadata, readiness_evaluation_exports as prReadinessEvaluation, external_evidence_exports as prReadinessExternalEvidence, post_readiness_comments_exports as prReadinessPostComments, pr_body_renderer_exports as prReadinessPrBodyRenderer, proof_identity_exports as prReadinessProofIdentity, review_proof_exports as prReadinessReviewProof, status_check_rollup_exports as prReadinessStatusChecks, verification_proof_exports as prReadinessVerificationProof, publishEpicStructure, readDemandWaivers, readPrReadyProof, readPrReviewProof, resolveFactoryRepository, resolveFindingBlocking, resolveReviewFindingCategory, resolveReviewFindingSeverity, resolveReviewLadderPolicy, resolveTraceWriteSinks, retroEnvelopeSchemaVersionOf, retroEnvelopeV1Schema, retroEpicReference, reviewCycleStateFor, reviewFocusFromIssueBody, reviewPromptSectionSchema, reviewPromptSectionsSchema, run, runBoundaryCheck, runDemandWaive, runEvidenceEmit, runPrPublish, runPrReady, runPrReview, runPrVerify, scanFactoryTraceDiagnostics, scanFactoryTraceEvents, selectWaiversForCandidate, staleRepeatLadderFindings, toFactoryTraceEnvelope, tryAppendReviewGateNotRequiredTraceEvent, tryAppendReviewLadderStageTraceEvent, validateBoundaryCheckProof, validateDagDocument, validateDemandWaiverStore, validateFactoryTraceEvent, validateMergeFreezeState, validatePrReadyProof, validatePrReviewProof, validatePrVerifyProof, waivedDemandNotice, waivedDemandSchema, worktree_scratch_files_exports as worktreeScratchFiles };