@patronage/software-factory 1.0.0-alpha.20 → 1.0.0-alpha.22

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/README.md CHANGED
@@ -66,7 +66,7 @@ Generated production workflows may call `psf production:impact --before <sha> --
66
66
 
67
67
  Generated pull-request workflows may call `psf candidate:impact --base <sha> --head <sha> --github-output <path>` to route Candidate verification surfaces. The command evaluates the same profile impact declarations and pnpm dependency graph from the exact base/head identity, diffed from their merge base; it is the candidate-named sibling of `production:impact` and replaces repository-owned path filters, never adds a second matcher. The `head` commit must be the checked-out `HEAD`, and both commits must be reachable (`fetch-depth: 0`). Every doubt path — unreadable identity, malformed or unsupported graph data, an unowned profile change, a working-tree profile that differs from the head commit, or classifier refusal — fails closed and leaves every declared target demanded. `production:impact` keeps its merge-push `before`/`after` contract unchanged.
68
68
 
69
- For factory workflow, install the four canonical skills from the repository:
69
+ For factory workflow, install the four canonical skills as the `patronage-software-factory` host plugin (Claude Code, Codex, or Cursor; see [`skills/README.md`](../skills/README.md)). Cloud agents that cannot load host plugins fall back to:
70
70
 
71
71
  ```bash
72
72
  npx skills add https://github.com/patronage/software-factory -g --all
package/dist/index.d.ts CHANGED
@@ -229,6 +229,8 @@ interface BoundaryPullRequestRef {
229
229
  headRefOid?: string;
230
230
  headRefName?: string;
231
231
  mergeCommitOid?: string;
232
+ /** GitHub's merge timestamp; the merge-ordering fact topology reads (#821). */
233
+ mergedAt?: string;
232
234
  number: number;
233
235
  state: "OPEN" | "CLOSED" | "MERGED";
234
236
  }
@@ -1283,7 +1285,7 @@ interface PrReviewProof {
1283
1285
  cleanedPaths: string[];
1284
1286
  ladder?: PrReviewLadderState;
1285
1287
  reviewRequirement?: {
1286
- reason: "no-applicable-mode";
1288
+ reason: "no-applicable-mode" | "faithful-merge";
1287
1289
  status: "not-required";
1288
1290
  };
1289
1291
  reviews: PrReviewResult[];
@@ -1421,9 +1423,9 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
1421
1423
  reviewedHeadSha: z.ZodOptional<z.ZodString>;
1422
1424
  reviewedPatchId: z.ZodOptional<z.ZodString>;
1423
1425
  status: z.ZodEnum<{
1426
+ blocked: "blocked";
1424
1427
  "not-required": "not-required";
1425
1428
  stale: "stale";
1426
- blocked: "blocked";
1427
1429
  current: "current";
1428
1430
  missing: "missing";
1429
1431
  }>;
@@ -1433,9 +1435,9 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
1433
1435
  reviewedHeadSha: z.ZodOptional<z.ZodString>;
1434
1436
  reviewedPatchId: z.ZodOptional<z.ZodString>;
1435
1437
  status: z.ZodEnum<{
1438
+ blocked: "blocked";
1436
1439
  "not-required": "not-required";
1437
1440
  stale: "stale";
1438
- blocked: "blocked";
1439
1441
  current: "current";
1440
1442
  missing: "missing";
1441
1443
  }>;
@@ -1545,6 +1547,23 @@ interface UpsertFactoryPrStatusCommentResult {
1545
1547
  }
1546
1548
  declare function upsertFactoryPrStatusComment(input: UpsertFactoryPrStatusCommentInput, dependencies?: UpsertFactoryPrStatusCommentDependencies): Promise<UpsertFactoryPrStatusCommentResult>;
1547
1549
  //#endregion
1550
+ //#region src/catch-up-recognition-record.d.ts
1551
+ interface CatchUpRecognition {
1552
+ /** The ref the candidate targets, as the recognizing command resolved it. */
1553
+ baseRef: string;
1554
+ /** The live target tip at recognition time; the head's first parent. */
1555
+ baseTipSha: string;
1556
+ /** The head's second parent: the admitted base-side content merged in. */
1557
+ mergedParentSha: string;
1558
+ /**
1559
+ * The tree `git merge-tree --write-tree` produced for the two parents. It
1560
+ * equals the head's own tree; that equality is the faithfulness proof.
1561
+ */
1562
+ mergedTreeSha: string;
1563
+ /** The ref that proves the second parent is admitted content. */
1564
+ upstreamRef: string;
1565
+ }
1566
+ //#endregion
1548
1567
  //#region src/pr-verify-proof-contract.d.ts
1549
1568
  declare const SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS: readonly [1, 2, 3, 4];
1550
1569
  type PrVerifySchemaVersion = (typeof SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS)[number];
@@ -1611,6 +1630,7 @@ interface PrVerifyProof {
1611
1630
  projectKey: string;
1612
1631
  repository: string;
1613
1632
  }[];
1633
+ catchUpRecognition?: CatchUpRecognition;
1614
1634
  changedFiles: string[];
1615
1635
  classification: DiffClassification;
1616
1636
  classificationReasons: string[];
@@ -1635,7 +1655,7 @@ interface PrVerifyProof {
1635
1655
  type PrVerifyBaselineFullProof = NonNullable<PrVerifyProof["baselineFullProofs"]>[number];
1636
1656
  declare function validatePrVerifyProof(value: unknown): PrVerifyProof;
1637
1657
  declare namespace verification_proof_d_exports {
1638
- export { PrVerifyBaselineFullProof, PrVerifyCommandResult, PrVerifyExecutedCommand, PrVerifyNotDemandedRecord, PrVerifyNotRequiredCommand, PrVerifyProof, PrVerifySchemaVersion, ResolvedPrVerifyMode, SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS, VerificationHeadShas, VerificationProofState, VerifiedHeadShas, prVerifyFullHeadShasForDiff, prVerifyProofHeadShas, readPrVerifyProof, resolveVerifyProofApplicability, trustedImpactStamp, validatePrVerifyProof, verificationProofApplicabilityFor, verificationProofBlockingReason, verificationProofForReadiness, verificationProofStateFor, verifyProofIdentityMatches, verifyProofPassed };
1658
+ export { PrVerifyBaselineFullProof, PrVerifyCommandResult, PrVerifyExecutedCommand, PrVerifyNotDemandedRecord, PrVerifyNotRequiredCommand, PrVerifyProof, PrVerifySchemaVersion, ResolvedPrVerifyMode, SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS, VerificationHeadShas, VerificationProofState, VerifiedHeadShas, prVerifyFullHeadShasForDiff, prVerifyProofHeadShas, readPrVerifyProof, resolveVerifyProofApplicability, trustedCatchUpRecognition, trustedImpactStamp, validatePrVerifyProof, verificationProofApplicabilityFor, verificationProofBlockingReason, verificationProofForReadiness, verificationProofStateFor, verifyProofIdentityMatches, verifyProofPassed };
1639
1659
  }
1640
1660
  interface VerifiedHeadShas {
1641
1661
  docsOnlyVerifiedHeadShas: string[];
@@ -1680,6 +1700,24 @@ declare const trustedImpactStamp: ({
1680
1700
  };
1681
1701
  proof: PrVerifyProof | undefined;
1682
1702
  }) => ImpactStamp | undefined;
1703
+ /**
1704
+ * #833: the recorded catch-up recognition, consumed ONLY when it is
1705
+ * identity-bound to the current candidate under exactly the rules
1706
+ * `trustedImpactStamp` applies — the proof passed, is v4+, and its recorded
1707
+ * headSha AND patchId both name this candidate. The recognition withdraws
1708
+ * review and preview demands, so it earns the same strict double binding and
1709
+ * the same fail-closed answer (`undefined`) on every other state.
1710
+ */
1711
+ declare const trustedCatchUpRecognition: ({
1712
+ candidate,
1713
+ proof
1714
+ }: {
1715
+ candidate: {
1716
+ headSha: string;
1717
+ patchId: string;
1718
+ };
1719
+ proof: PrVerifyProof | undefined;
1720
+ }) => CatchUpRecognition | undefined;
1683
1721
  declare const prVerifyFullHeadShasForDiff: ({
1684
1722
  docsOnlyDeltaAcceptedSince,
1685
1723
  files,
@@ -2194,6 +2232,7 @@ declare const requiredCheckScopeSchema: z.ZodObject<{
2194
2232
  }, z.core.$strict>;
2195
2233
  type RequiredCheckScope = z.infer<typeof requiredCheckScopeSchema>;
2196
2234
  interface RequiredCheckScopeContext {
2235
+ catchUpRecognition?: CatchUpRecognition;
2197
2236
  classification: DiffClassification | undefined;
2198
2237
  impactStamp?: ImpactStamp;
2199
2238
  }
@@ -2376,9 +2415,9 @@ declare const loadEvidenceEnvelopes: (cwd: string) => LoadedEvidenceEnvelope[];
2376
2415
  //#region src/review-proof-applicability.d.ts
2377
2416
  declare const REVIEW_STATUS_VALUES: readonly ["not-required", "current", "stale", "missing", "blocked"];
2378
2417
  declare const reviewStatusSchema: z.ZodEnum<{
2418
+ blocked: "blocked";
2379
2419
  "not-required": "not-required";
2380
2420
  stale: "stale";
2381
- blocked: "blocked";
2382
2421
  current: "current";
2383
2422
  missing: "missing";
2384
2423
  }>;
@@ -2527,8 +2566,8 @@ declare const boundaryCheckProofSchema: z.ZodObject<{
2527
2566
  value: z.ZodString;
2528
2567
  }, z.core.$strip>>;
2529
2568
  status: z.ZodEnum<{
2530
- ready: "ready";
2531
2569
  blocked: "blocked";
2570
+ ready: "ready";
2532
2571
  }>;
2533
2572
  }, z.core.$strip>;
2534
2573
  type BoundaryCheckProofRecord = z.infer<typeof boundaryCheckProofSchema>;
@@ -2543,6 +2582,14 @@ interface BoundaryCheckGithub {
2543
2582
  interface BoundaryCheckArgs {
2544
2583
  cwd?: string;
2545
2584
  epic: number;
2585
+ /**
2586
+ * Closeout-final mode (#853): a manifest-declared issue whose closing-PR
2587
+ * set is empty is a blocking reason, not a notice. Off by default so the
2588
+ * interior boundary review (run before the terminal release PR exists,
2589
+ * e.g. epic #842's own review of #844) keeps working; the epic closeout
2590
+ * step turns this on as the last check before factory:closeout.
2591
+ */
2592
+ final?: boolean;
2546
2593
  json?: boolean;
2547
2594
  output?: string;
2548
2595
  repo?: string;
@@ -2626,6 +2673,19 @@ type HqFlushResult = (HqFlushOrphans & {
2626
2673
  status: "flushed";
2627
2674
  });
2628
2675
  //#endregion
2676
+ //#region src/catch-up-recognition.d.ts
2677
+ type CatchUpRecognitionResult = {
2678
+ recognition: CatchUpRecognition;
2679
+ recognized: true;
2680
+ } | {
2681
+ reasons: string[];
2682
+ recognized: false;
2683
+ };
2684
+ type CatchUpRecognizer = (input: {
2685
+ base: string;
2686
+ cwd: string;
2687
+ }) => CatchUpRecognitionResult;
2688
+ //#endregion
2629
2689
  //#region src/pr-review.d.ts
2630
2690
  interface PrReviewArgs extends LoadProjectProfileInput {
2631
2691
  base: string;
@@ -2641,6 +2701,7 @@ interface PrReviewGitDependencies {
2641
2701
  changedFiles: (cwd: string, base: string) => string[];
2642
2702
  currentHeadSha: (cwd: string) => string;
2643
2703
  isAncestor: (cwd: string, ancestor: string, descendant: string) => boolean;
2704
+ recognizeCatchUp?: CatchUpRecognizer;
2644
2705
  stablePatchId: (cwd: string, base: string) => string;
2645
2706
  statusPorcelain: (cwd: string) => string;
2646
2707
  }
@@ -2680,6 +2741,7 @@ interface PrVerifyGitDependencies {
2680
2741
  currentHeadSha: (cwd: string) => string;
2681
2742
  emptyTreeHash: (cwd: string) => string;
2682
2743
  mergeBaseSha: (cwd: string, base: string) => string;
2744
+ recognizeCatchUp?: CatchUpRecognizer;
2683
2745
  runVerificationCommand: (command: string, cwd: string, env: Record<string, string>) => VerificationCommandOutcome;
2684
2746
  showFileAtRef?: (cwd: string, ref: string, absolutePath: string) => string | undefined;
2685
2747
  statusPorcelain: (cwd: string) => string;
@@ -3034,6 +3096,7 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
3034
3096
  patchId: z.ZodString;
3035
3097
  reason: z.ZodEnum<{
3036
3098
  "no-applicable-mode": "no-applicable-mode";
3099
+ "faithful-merge": "faithful-merge";
3037
3100
  }>;
3038
3101
  reviewCycle: z.ZodNumber;
3039
3102
  }, z.core.$strict>, readonly [z.ZodObject<{
@@ -3056,6 +3119,7 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
3056
3119
  patchId: z.ZodString;
3057
3120
  reason: z.ZodEnum<{
3058
3121
  "no-applicable-mode": "no-applicable-mode";
3122
+ "faithful-merge": "faithful-merge";
3059
3123
  }>;
3060
3124
  reviewCycle: z.ZodNumber;
3061
3125
  }, z.core.$strict>], {
@@ -3069,6 +3133,7 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
3069
3133
  patchId: z.ZodString;
3070
3134
  reason: z.ZodEnum<{
3071
3135
  "no-applicable-mode": "no-applicable-mode";
3136
+ "faithful-merge": "faithful-merge";
3072
3137
  }>;
3073
3138
  reviewCycle: z.ZodNumber;
3074
3139
  }, z.core.$strict>;
@@ -3086,7 +3151,7 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
3086
3151
  observedAt: string;
3087
3152
  outcome: "not-required";
3088
3153
  patchId: string;
3089
- reason: "no-applicable-mode";
3154
+ reason: "no-applicable-mode" | "faithful-merge";
3090
3155
  reviewCycle: number;
3091
3156
  issue?: number | undefined;
3092
3157
  pr?: number | undefined;
@@ -4090,13 +4155,13 @@ declare const evaluateReadiness: (input: EvaluationInput) => {
4090
4155
  reviews: {
4091
4156
  correctness: {
4092
4157
  required: boolean;
4093
- status: "not-required" | "stale" | "blocked" | "current" | "missing";
4158
+ status: "blocked" | "not-required" | "stale" | "current" | "missing";
4094
4159
  reviewedHeadSha?: string | undefined;
4095
4160
  reviewedPatchId?: string | undefined;
4096
4161
  };
4097
4162
  security?: {
4098
4163
  required: boolean;
4099
- status: "not-required" | "stale" | "blocked" | "current" | "missing";
4164
+ status: "blocked" | "not-required" | "stale" | "current" | "missing";
4100
4165
  reviewedHeadSha?: string | undefined;
4101
4166
  reviewedPatchId?: string | undefined;
4102
4167
  } | undefined;
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ import { parse } from "yaml";
17
17
  import { FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_STEP_NAME, GitHubApiError, mintInstallationToken, previewProofInventory, productionImpactTargetOutput, readVitestProfileDocument } from "@patronage/factory-ci";
18
18
  import { promisify } from "node:util";
19
19
  //#region package.json
20
- var version = "1.0.0-alpha.20";
20
+ var version = "1.0.0-alpha.22";
21
21
  //#endregion
22
22
  //#region src/review-rungs.ts
23
23
  const EVIDENCE_REVIEW_RUNGS$1 = [
@@ -159,23 +159,48 @@ const DEFAULT_TERMINAL_BASE = "main";
159
159
  * Derive the membership graph from the manifest's declared issues and their
160
160
  * live closing links. A PR observed under several issues keeps one
161
161
  * membership per issue; nothing is collapsed here and nothing is refused.
162
+ *
163
+ * A PR that closed without merging contributes NO closing link (#821): it
164
+ * carried nothing into the boundary, so it is not a member and cannot fail
165
+ * one. The dropped link is recorded on the issue. Closeout blocks by default
166
+ * only on that flavor: an issue whose carriers all closed without merging. A
167
+ * declared issue that never had any closing link is reported as a notice,
168
+ * not a blocking reason — except under boundary:check --final, which blocks
169
+ * on that flavor too (#853).
162
170
  */
163
171
  const buildBoundaryMembershipGraph = (manifest, fetchClosingPullRequests) => {
164
172
  const memberships = [];
165
173
  const unlinkedIssues = [];
174
+ const closedWithoutMergeLinks = [];
166
175
  for (const wave of manifest.waves) for (const issue of wave.issues) {
167
176
  const refs = fetchClosingPullRequests(issue);
168
- if (refs.length === 0) unlinkedIssues.push({
177
+ const live = [];
178
+ const closedWithoutMerge = [];
179
+ for (const ref of refs) {
180
+ if (ref.state === "CLOSED") {
181
+ closedWithoutMerge.push(ref.number);
182
+ closedWithoutMergeLinks.push({
183
+ issue,
184
+ pr: ref.number,
185
+ wave: wave.name
186
+ });
187
+ continue;
188
+ }
189
+ live.push(ref);
190
+ }
191
+ if (live.length === 0) unlinkedIssues.push({
192
+ ...closedWithoutMerge.length > 0 ? { closedWithoutMerge } : {},
169
193
  issue,
170
194
  wave: wave.name
171
195
  });
172
- for (const ref of refs) memberships.push({
196
+ for (const ref of live) memberships.push({
173
197
  issue,
174
198
  ref,
175
199
  wave
176
200
  });
177
201
  }
178
202
  return {
203
+ closedWithoutMergeLinks,
179
204
  memberships,
180
205
  unlinkedIssues
181
206
  };
@@ -208,7 +233,8 @@ const membershipWaveDemand = (graph, pr) => {
208
233
  * validation; a repeated PR number is not evidence the memberships agree.
209
234
  */
210
235
  const coveredSetProjection = (graph) => {
211
- const notices = graph.unlinkedIssues.map(({ issue, wave }) => `issue #${issue} (wave ${wave}) has no linked closing PR yet`);
236
+ const notices = graph.unlinkedIssues.map(({ closedWithoutMerge, issue, wave }) => closedWithoutMerge === void 0 ? `issue #${issue} (wave ${wave}) has no linked closing PR yet` : `issue #${issue} (wave ${wave}) has no live closing PR; its only closing link(s) ${closedWithoutMerge.map((pr) => `PR #${pr}`).join(", ")} closed without merging`);
237
+ for (const pr of new Set(graph.closedWithoutMergeLinks.map((l) => l.pr))) notices.push(`PR #${pr} is closed without merge; it contributes nothing to the covered set`);
212
238
  const entries = [];
213
239
  const pullRequests = [];
214
240
  for (const { issue, ref, wave } of graph.memberships) {
@@ -235,7 +261,6 @@ const coveredSetProjection = (graph) => {
235
261
  else notices.push(`PR #${ref.number} is open but GitHub reports no head SHA; excluded from the covered set`);
236
262
  continue;
237
263
  }
238
- notices.push(`PR #${ref.number} is closed without merge; it contributes nothing to the covered set`);
239
264
  }
240
265
  return {
241
266
  entries: entries.toSorted((a, b) => a.pr - b.pr || a.issue - b.issue),
@@ -392,6 +417,14 @@ const eachToEpicBranch = (manifest) => manifest.topology === "each-to-epic" ? ma
392
417
  * Validate only the current member during admission. This deliberately does
393
418
  * not inspect sibling membership or require the terminal PR: an epic branch
394
419
  * is assembled one admitted interior candidate at a time.
420
+ *
421
+ * A declared member that targets the terminal base is admitted from any head
422
+ * (#821). At admission the candidate has not merged, so merge ordering — the
423
+ * fact that separates the terminal from a post-terminal release member —
424
+ * does not exist yet. Closeout owns that judgment, where the whole graph is
425
+ * visible: it still requires exactly one terminal from the integration
426
+ * branch, and it still refuses a main-targeting member that did not merge
427
+ * after that terminal.
395
428
  */
396
429
  const pullRequestTopologyReasons = ({ manifest, pullRequest, terminalBase = DEFAULT_TERMINAL_BASE }) => {
397
430
  const integrationBranch = eachToEpicBranch(manifest);
@@ -399,29 +432,67 @@ const pullRequestTopologyReasons = ({ manifest, pullRequest, terminalBase = DEFA
399
432
  const { baseRefName, headRefName, number } = pullRequest;
400
433
  if (!headRefName) return [`PR #${number} has no live head branch name; each-to-epic admission fails closed until GitHub reports the member's headRefName.`];
401
434
  if (baseRefName === integrationBranch && headRefName !== integrationBranch) return [];
402
- if (baseRefName === terminalBase && headRefName === integrationBranch) return [];
403
- return [`PR #${number} contradicts each-to-epic topology: live head/base ${headRefName ?? "(unknown)"} -> ${baseRefName ?? "(unknown)"}; expected an interior member targeting ${integrationBranch}, or the terminal ${integrationBranch} -> ${terminalBase} PR.`];
435
+ if (baseRefName === terminalBase) return [];
436
+ return [`PR #${number} contradicts each-to-epic topology: live head/base ${headRefName ?? "(unknown)"} -> ${baseRefName ?? "(unknown)"}; expected an interior member targeting ${integrationBranch}, or a member targeting ${terminalBase} (the terminal, or a post-terminal release member).`];
437
+ };
438
+ const mergedAtMs = (pullRequest) => {
439
+ if (pullRequest.mergedAt === void 0) return;
440
+ const parsed = Date.parse(pullRequest.mergedAt);
441
+ return Number.isNaN(parsed) ? void 0 : parsed;
404
442
  };
405
443
  /**
406
- * Validate the complete declared member set at boundary closeout. Callers
407
- * supply only issue-linked PRs and unlinked manifest-fallback PRs; branch
408
- * names classify those authoritative members' roles but never discover
409
- * membership.
444
+ * Judge a declared member that targets the terminal base from a branch other
445
+ * than the integration branch. It is admitted as a post-terminal release
446
+ * member (#821) exactly when it merged strictly after the one terminal PR:
447
+ * an already-merged integration branch cannot receive later work, so a
448
+ * ratified release slice has nowhere else to target. Every fact this needs is
449
+ * membership plus merge ordering; nothing is declared in the manifest.
410
450
  */
411
- const boundaryTopologyReasons = ({ manifest, pullRequests, terminalBase = DEFAULT_TERMINAL_BASE }) => {
412
- const integrationBranch = eachToEpicBranch(manifest);
413
- if (!integrationBranch) return [];
414
- const reasons = [];
451
+ const postTerminalReleaseReasons = ({ candidate, integrationBranch, terminal, terminalBase }) => {
452
+ const label = `PR #${candidate.number} targets ${terminalBase} from ${candidate.headRefName ?? "(unknown)"}`;
453
+ if (!terminal) return [`${label}; each-to-epic accepts that only as a post-terminal release member, and no terminal ${integrationBranch} -> ${terminalBase} PR was found.`];
454
+ if (candidate.state !== "MERGED") return [`${label} and is not merged (state ${candidate.state ?? "unknown"}); a post-terminal release member must be merged after terminal PR #${terminal.number}.`];
455
+ const candidateMergedAt = mergedAtMs(candidate);
456
+ const terminalMergedAt = mergedAtMs(terminal);
457
+ if (candidateMergedAt === void 0 || terminalMergedAt === void 0) return [`${label}; merge ordering against terminal PR #${terminal.number} cannot be established because GitHub reports no usable merge timestamp for ${candidateMergedAt === void 0 ? `PR #${candidate.number}` : `PR #${terminal.number}`}.`];
458
+ if (candidateMergedAt <= terminalMergedAt) return [`${label} but merged at ${candidate.mergedAt}, not after terminal PR #${terminal.number} (merged ${terminal.mergedAt}); each-to-epic admits a ${terminalBase}-targeting member from another head only as a post-terminal release member.`];
459
+ return [];
460
+ };
461
+ const contradicts = (previous, observation) => previous.baseRefName !== observation.baseRefName || previous.headRefName !== observation.headRefName || previous.state !== observation.state || previous.mergedAt !== observation.mergedAt;
462
+ /**
463
+ * Collapse the repeated observations of one PR. A PR declared under several
464
+ * issues is observed once per issue; identical observations collapse, and any
465
+ * disagreement about that PR's live facts fails closed instead of letting
466
+ * manifest order pick a winner.
467
+ */
468
+ const collapseObservations = (pullRequests) => {
415
469
  const byNumber = /* @__PURE__ */ new Map();
470
+ const reasons = [];
416
471
  for (const pullRequest of pullRequests) {
417
472
  const previous = byNumber.get(pullRequest.number);
418
- if (previous && (previous.baseRefName !== pullRequest.baseRefName || previous.headRefName !== pullRequest.headRefName)) {
419
- reasons.push(`PR #${pullRequest.number} has contradictory live branch data across its declared memberships.`);
473
+ if (previous && contradicts(previous, pullRequest)) {
474
+ reasons.push(`PR #${pullRequest.number} has contradictory live data across its declared memberships.`);
420
475
  continue;
421
476
  }
422
477
  byNumber.set(pullRequest.number, pullRequest);
423
478
  }
479
+ return {
480
+ byNumber,
481
+ reasons
482
+ };
483
+ };
484
+ /**
485
+ * Validate the complete declared member set at boundary closeout. Callers
486
+ * supply only issue-linked PRs and unlinked manifest-fallback PRs; branch
487
+ * names classify those authoritative members' roles but never discover
488
+ * membership.
489
+ */
490
+ const boundaryTopologyReasons = ({ manifest, pullRequests, terminalBase = DEFAULT_TERMINAL_BASE }) => {
491
+ const integrationBranch = eachToEpicBranch(manifest);
492
+ if (!integrationBranch) return [];
493
+ const { byNumber, reasons } = collapseObservations(pullRequests);
424
494
  const terminalPullRequests = [];
495
+ const releaseCandidates = [];
425
496
  for (const pullRequest of byNumber.values()) {
426
497
  if (!pullRequest.headRefName) {
427
498
  reasons.push(`PR #${pullRequest.number} has no live head branch name; each-to-epic closeout fails closed until GitHub reports headRefName.`);
@@ -437,16 +508,26 @@ const boundaryTopologyReasons = ({ manifest, pullRequests, terminalBase = DEFAUL
437
508
  continue;
438
509
  }
439
510
  if (pullRequest.baseRefName === terminalBase) {
440
- if (pullRequest.headRefName !== integrationBranch) {
441
- reasons.push(`PR #${pullRequest.number} targets ${terminalBase} from ${pullRequest.headRefName ?? "(unknown)"}; the each-to-epic terminal must originate from declared integrationBranch ${integrationBranch}.`);
442
- continue;
443
- }
444
- terminalPullRequests.push(pullRequest.number);
511
+ if (pullRequest.headRefName === integrationBranch) terminalPullRequests.push(pullRequest);
512
+ else releaseCandidates.push(pullRequest);
445
513
  continue;
446
514
  }
447
515
  reasons.push(`PR #${pullRequest.number} targets ${pullRequest.baseRefName ?? "(unknown)"}; every each-to-epic interior member must target declared integrationBranch ${integrationBranch}.`);
448
516
  }
449
517
  if (terminalPullRequests.length !== 1) reasons.push(`each-to-epic requires exactly one terminal member PR from ${integrationBranch} to ${terminalBase}; found ${terminalPullRequests.length}.`);
518
+ const [terminal] = terminalPullRequests;
519
+ const admittedReleaseMembers = [];
520
+ for (const candidate of releaseCandidates) {
521
+ const candidateReasons = postTerminalReleaseReasons({
522
+ candidate,
523
+ integrationBranch,
524
+ terminal: terminalPullRequests.length === 1 ? terminal : void 0,
525
+ terminalBase
526
+ });
527
+ if (candidateReasons.length === 0) admittedReleaseMembers.push(candidate);
528
+ reasons.push(...candidateReasons);
529
+ }
530
+ if (admittedReleaseMembers.length > 1) reasons.push(`each-to-epic admits at most one post-terminal release member targeting ${terminalBase}; found ${admittedReleaseMembers.length}: ${admittedReleaseMembers.map((member) => `PR #${member.number}`).join(", ")}.`);
450
531
  return reasons;
451
532
  };
452
533
  //#endregion
@@ -1017,6 +1098,72 @@ function isAncestor(cwd, ancestor, descendant) {
1017
1098
  if (result.status === 1) return false;
1018
1099
  return false;
1019
1100
  }
1101
+ /** The head commit's parent shas, in order. Empty on a root commit. */
1102
+ function commitParents(cwd, ref) {
1103
+ return runCapture("git", [
1104
+ "rev-list",
1105
+ "--parents",
1106
+ "-n",
1107
+ "1",
1108
+ ref
1109
+ ], cwd).stdout.trim().split(/\s+/u).slice(1);
1110
+ }
1111
+ /** The tree object a commit-ish points at. */
1112
+ function treeShaOf(cwd, ref) {
1113
+ return runCapture("git", ["rev-parse", `${ref}^{tree}`], cwd).stdout.trim();
1114
+ }
1115
+ /**
1116
+ * Run the real merge machinery without touching the worktree
1117
+ * (`git merge-tree --write-tree`). Exit 0 is a clean merge, exit 1 is a
1118
+ * conflicted one; both write a tree. Any other outcome (old git, bad refs)
1119
+ * returns `undefined` so the caller fails closed.
1120
+ */
1121
+ function mergeTreeWriteTree(cwd, firstParent, secondParent) {
1122
+ const result = spawnSync("git", [
1123
+ "merge-tree",
1124
+ "--write-tree",
1125
+ "--no-messages",
1126
+ firstParent,
1127
+ secondParent
1128
+ ], {
1129
+ cwd,
1130
+ encoding: "utf-8",
1131
+ env: gitEnv(),
1132
+ stdio: [
1133
+ "ignore",
1134
+ "pipe",
1135
+ "pipe"
1136
+ ]
1137
+ });
1138
+ if (result.error || result.status !== 0 && result.status !== 1) return;
1139
+ const [treeSha] = result.stdout.trim().split("\n");
1140
+ if (!treeSha || !/^[0-9a-f]{40,64}$/u.test(treeSha)) return;
1141
+ return {
1142
+ conflicted: result.status === 1,
1143
+ treeSha
1144
+ };
1145
+ }
1146
+ /**
1147
+ * The remote-tracking ref of the repository's default branch (e.g.
1148
+ * `refs/remotes/origin/main`), read from the local `origin/HEAD` symref.
1149
+ * `undefined` when the symref is unset; `git remote set-head origin --auto`
1150
+ * records it.
1151
+ */
1152
+ function remoteDefaultBranchRef(cwd) {
1153
+ const result = spawnSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], {
1154
+ cwd,
1155
+ encoding: "utf-8",
1156
+ env: gitEnv(),
1157
+ stdio: [
1158
+ "ignore",
1159
+ "pipe",
1160
+ "pipe"
1161
+ ]
1162
+ });
1163
+ if (result.error || result.status !== 0) return;
1164
+ const ref = result.stdout.trim();
1165
+ return ref.length > 0 ? ref : void 0;
1166
+ }
1020
1167
  //#endregion
1021
1168
  //#region src/github-issue-comments.ts
1022
1169
  const isCommentPage = (value) => Array.isArray(value[0]);
@@ -3778,6 +3925,16 @@ const authorizeDemandWaiver = ({ authenticatedLogin, session }) => {
3778
3925
  } };
3779
3926
  };
3780
3927
  //#endregion
3928
+ //#region src/catch-up-recognition-record.ts
3929
+ const objectShaSchema = z.string().regex(/^[0-9a-f]{40}$/u);
3930
+ const catchUpRecognitionSchema = z.object({
3931
+ baseRef: z.string().min(1),
3932
+ baseTipSha: objectShaSchema,
3933
+ mergedParentSha: objectShaSchema,
3934
+ mergedTreeSha: objectShaSchema,
3935
+ upstreamRef: z.string().min(1)
3936
+ }).strict();
3937
+ //#endregion
3781
3938
  //#region src/impact-stamp.ts
3782
3939
  const LOCKFILE_PATH = "pnpm-lock.yaml";
3783
3940
  const SUPPORTED_LOCKFILE_VERSION = "9.0";
@@ -4491,6 +4648,11 @@ const assertPrVerifyProofRules = (proof, context) => {
4491
4648
  message: "notRequiredCommands is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
4492
4649
  path: ["notRequiredCommands"]
4493
4650
  });
4651
+ if (proof.schemaVersion < 4 && proof.catchUpRecognition !== void 0) context.addIssue({
4652
+ code: "custom",
4653
+ message: "catchUpRecognition is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
4654
+ path: ["catchUpRecognition"]
4655
+ });
4494
4656
  if (proof.schemaVersion < 4 && proof.notDemandedRecords !== void 0) context.addIssue({
4495
4657
  code: "custom",
4496
4658
  message: "notDemandedRecords is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
@@ -4549,6 +4711,7 @@ const prVerifyProofSchema = z.object({
4549
4711
  projectKey: z.string().min(1),
4550
4712
  repository: z.string().min(1)
4551
4713
  })).optional(),
4714
+ catchUpRecognition: catchUpRecognitionSchema.optional(),
4552
4715
  changedFiles: z.array(z.string()),
4553
4716
  classification: z.enum([
4554
4717
  "docs/process-only",
@@ -4863,7 +5026,7 @@ z.object({
4863
5026
  patchId: z.string().regex(/^[0-9a-f]{40,64}$/u),
4864
5027
  reviewCycle: z.number().int().positive().optional(),
4865
5028
  reviewRequirement: z.object({
4866
- reason: z.enum(["no-applicable-mode"]),
5029
+ reason: z.enum(["no-applicable-mode", "faithful-merge"]),
4867
5030
  status: z.literal("not-required")
4868
5031
  }).strict().optional(),
4869
5032
  reviews: z.array(prReviewResultSchema$1),
@@ -5183,7 +5346,7 @@ const CLOSING_PRS_QUERY = `query($owner: String!, $repo: String!, $issue: Int!)
5183
5346
  repository(owner: $owner, name: $repo) {
5184
5347
  issue(number: $issue) {
5185
5348
  closedByPullRequestsReferences(first: 50, includeClosedPrs: true) {
5186
- nodes { number state baseRefName headRefName headRefOid mergeCommit { oid } }
5349
+ nodes { number state baseRefName headRefName headRefOid mergedAt mergeCommit { oid } }
5187
5350
  }
5188
5351
  }
5189
5352
  }
@@ -5215,6 +5378,7 @@ const defaultBoundaryCheckGithub = () => ({
5215
5378
  ...node.headRefOid ? { headRefOid: node.headRefOid } : {},
5216
5379
  ...node.headRefName ? { headRefName: node.headRefName } : {},
5217
5380
  ...node.mergeCommit?.oid ? { mergeCommitOid: node.mergeCommit.oid } : {},
5381
+ ...node.mergedAt ? { mergedAt: node.mergedAt } : {},
5218
5382
  number: node.number,
5219
5383
  state: node.state
5220
5384
  }));
@@ -5263,7 +5427,8 @@ function runBoundaryCheck(args, dependencies = {}) {
5263
5427
  let record;
5264
5428
  if (parsed.ok) {
5265
5429
  const demandedRung = closeoutRungFor(parsed.manifest);
5266
- const computed = coveredSetProjection(buildBoundaryMembershipGraph(parsed.manifest, (issue) => github.fetchClosingPullRequests(repo, issue)));
5430
+ const graph = buildBoundaryMembershipGraph(parsed.manifest, (issue) => github.fetchClosingPullRequests(repo, issue));
5431
+ const computed = coveredSetProjection(graph);
5267
5432
  const comments = github.fetchIssueComments(repo, args.epic);
5268
5433
  const selected = selectBoundaryReviewProof(comments, parsed.manifest.boundary, parsed.manifest.declaredBy);
5269
5434
  const { blockingReasons } = evaluateProofAgainstDemands({
@@ -5274,10 +5439,13 @@ function runBoundaryCheck(args, dependencies = {}) {
5274
5439
  manifestHash: parsed.manifestHash,
5275
5440
  selected
5276
5441
  });
5442
+ const uncoveredIssueReasons = graph.unlinkedIssues.flatMap((entry) => entry.closedWithoutMerge === void 0 ? [] : [`issue #${entry.issue} (wave ${entry.wave}) is uncovered: its only closing link(s) ${entry.closedWithoutMerge.map((pr) => `PR #${pr}`).join(", ")} closed without merging`]);
5443
+ const finalUncoveredIssueReasons = args.final ? graph.unlinkedIssues.flatMap((entry) => entry.closedWithoutMerge === void 0 ? [`issue #${entry.issue} (wave ${entry.wave}) has no linked closing PR; --final requires every declared issue to be covered before closeout`] : []) : [];
5277
5444
  blockingReasons.unshift(...boundaryTopologyReasons({
5278
5445
  manifest: parsed.manifest,
5279
5446
  pullRequests: computed.pullRequests
5280
- }));
5447
+ }), ...uncoveredIssueReasons, ...finalUncoveredIssueReasons);
5448
+ const specHashNotices = selected?.proof.specHash !== void 0 && selected.proof.specHash.value !== specHash ? [`boundary-review proof spec sha256 ${selected.proof.specHash.value} does not match current epic spec sha256 ${specHash}; use the specHash from the blocked boundary:check record instead of hashing shell-rendered output`] : [];
5281
5449
  record = {
5282
5450
  blockingReasons,
5283
5451
  boundary: parsed.manifest.boundary,
@@ -5289,7 +5457,7 @@ function runBoundaryCheck(args, dependencies = {}) {
5289
5457
  algo: "sha256",
5290
5458
  value: parsed.manifestHash
5291
5459
  },
5292
- notices: computed.notices,
5460
+ notices: [...computed.notices, ...specHashNotices],
5293
5461
  repo,
5294
5462
  ...selected ? { reviewProof: {
5295
5463
  ...selected.commentId === void 0 ? {} : { commentId: selected.commentId },
@@ -6038,7 +6206,7 @@ const renderReport = (record) => {
6038
6206
  return `${lines.join("\n")}\n`;
6039
6207
  };
6040
6208
  function createBoundaryCheckCommand(output, action) {
6041
- return markCwdOptionDefault(new Command("boundary:check").description("Boundary readiness gate (T8): require a membership-fresh boundary-review proof at the declared closeout rung. Blocks closeout/enablement, never merge.").requiredOption("--epic <number>", "issue number carrying the factory-boundary manifest, which may be the work issue itself (#789)", positiveInteger$1("--epic")).option("--repo <owner/name>", "GitHub repository (default: resolved from --cwd)").option("--cwd <path>", "repository working directory", collectCwdOption).option("--output <path>", "write an additional proof copy to this path").option("--profile <path>", "path to the project profile JSON file").option("--json", "print the full proof record as JSON").action(withGateTiming({
6209
+ return markCwdOptionDefault(new Command("boundary:check").description("Boundary readiness gate (T8): require a membership-fresh boundary-review proof at the declared closeout rung. Blocks closeout/enablement, never merge.").requiredOption("--epic <number>", "issue number carrying the factory-boundary manifest, which may be the work issue itself (#789)", positiveInteger$1("--epic")).option("--repo <owner/name>", "GitHub repository (default: resolved from --cwd)").option("--cwd <path>", "repository working directory", collectCwdOption).option("--output <path>", "write an additional proof copy to this path").option("--profile <path>", "path to the project profile JSON file").option("--json", "print the full proof record as JSON").option("--final", "closeout-final mode (#853): a declared issue with no closing PR blocks instead of only noticing. Run as the last check before factory:closeout.").action(withGateTiming({
6042
6210
  gate: "boundary:check",
6043
6211
  resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
6044
6212
  stderr: output.stderr
@@ -6048,7 +6216,8 @@ function createBoundaryCheckCommand(output, action) {
6048
6216
  epic: options.epic,
6049
6217
  ...options.repo ? { repo: options.repo } : {},
6050
6218
  ...options.output ? { output: options.output } : {},
6051
- ...options.json ? { json: true } : {}
6219
+ ...options.json ? { json: true } : {},
6220
+ ...options.final ? { final: true } : {}
6052
6221
  };
6053
6222
  const record = action ? action(args) : runBoundaryCheck(args);
6054
6223
  output.stdout.write(options.json ? `${JSON.stringify(record, null, 2)}\n` : renderReport(record));
@@ -7279,7 +7448,7 @@ const ReviewGateTracePayloadShape = z.object({
7279
7448
  observedAt: isoTimestamp,
7280
7449
  outcome: z.literal("not-required"),
7281
7450
  patchId: z.string().regex(/^[0-9a-f]{40,64}$/u),
7282
- reason: z.enum(["no-applicable-mode"]),
7451
+ reason: z.enum(["no-applicable-mode", "faithful-merge"]),
7283
7452
  reviewCycle: positiveInteger
7284
7453
  }).strict();
7285
7454
  const ReviewGateTracePayloadSchema = ReviewGateTracePayloadShape;
@@ -8638,6 +8807,46 @@ function createCandidateIdentityResolver({ cwd, git }) {
8638
8807
  };
8639
8808
  }
8640
8809
  //#endregion
8810
+ //#region src/catch-up-recognition.ts
8811
+ const refused = (reason) => ({
8812
+ reasons: [reason],
8813
+ recognized: false
8814
+ });
8815
+ const recognizeCatchUpCandidate = ({ base, cwd }) => {
8816
+ try {
8817
+ if (statusPorcelain(cwd) !== "") return refused("catch-up recognition refused: the worktree is dirty, so the committed head is not the candidate content.");
8818
+ const parents = commitParents(cwd, "HEAD");
8819
+ if (parents.length !== 2) return refused(`catch-up recognition refused: HEAD has ${parents.length} parent(s); a catch-up head is a merge commit with exactly two.`);
8820
+ const [firstParent, secondParent] = parents;
8821
+ const baseTipSha = runCapture("git", [
8822
+ "rev-parse",
8823
+ "--verify",
8824
+ `${base}^{commit}`
8825
+ ], cwd).stdout.trim();
8826
+ if (firstParent !== baseTipSha) return refused(`catch-up recognition refused: the head's first parent ${firstParent} is not the live target tip ${baseTipSha} (${base}); re-merge onto the current tip and re-run.`);
8827
+ const upstreamRef = remoteDefaultBranchRef(cwd);
8828
+ if (upstreamRef === void 0) return refused("catch-up recognition refused: origin/HEAD is unset, so the second parent's provenance cannot be proved; run `git remote set-head origin --auto` and re-run.");
8829
+ if (!isAncestor(cwd, secondParent, upstreamRef)) return refused(`catch-up recognition refused: the head's second parent ${secondParent} is not an ancestor of ${upstreamRef}, so its content is not provably admitted.`);
8830
+ const mergeTree = mergeTreeWriteTree(cwd, firstParent, secondParent);
8831
+ if (mergeTree === void 0) return refused("catch-up recognition refused: `git merge-tree --write-tree` did not produce a tree (git older than 2.38, or unreadable parents).");
8832
+ if (mergeTree.conflicted) return refused("catch-up recognition refused: merging the two parents conflicts, so the head carries manual resolutions; a conflicted catch-up is a full candidate.");
8833
+ const headTreeSha = treeShaOf(cwd, "HEAD");
8834
+ if (headTreeSha !== mergeTree.treeSha) return refused(`catch-up recognition refused: the head tree ${headTreeSha} differs from the faithful merge tree ${mergeTree.treeSha}, so the merge carries content beyond its two parents.`);
8835
+ return {
8836
+ recognition: {
8837
+ baseRef: base,
8838
+ baseTipSha,
8839
+ mergedParentSha: secondParent,
8840
+ mergedTreeSha: mergeTree.treeSha,
8841
+ upstreamRef
8842
+ },
8843
+ recognized: true
8844
+ };
8845
+ } catch (error) {
8846
+ return refused(`catch-up recognition refused: ${error instanceof Error ? error.message : String(error)}`);
8847
+ }
8848
+ };
8849
+ //#endregion
8641
8850
  //#region src/evidence-freshness.ts
8642
8851
  const patchIdMatches = (recorded, candidate) => Boolean(recorded.patchId) && recorded.patchId === candidate.patchId;
8643
8852
  const headShaMatches = (recorded, candidate) => Boolean(recorded.headSha && candidate.headSha && sameHeadSha(candidate.headSha, recorded.headSha));
@@ -8927,6 +9136,14 @@ const evaluateRequiredChecks = ({ authoringSessionIds, candidate, envelopes, req
8927
9136
  scopeReason: release.reason,
8928
9137
  status: "out-of-scope"
8929
9138
  };
9139
+ if (scopeContext.catchUpRecognition && !boundFailureObserved) return {
9140
+ checkType: check.checkType,
9141
+ inScope: false,
9142
+ name: check.name,
9143
+ ...check.scope ? { scope: check.scope } : {},
9144
+ scopeReason: `catch-up recognition released: the head is a faithful merge of ${scopeContext.catchUpRecognition.baseTipSha} and admitted base-side content (#833)`,
9145
+ status: "out-of-scope"
9146
+ };
8930
9147
  return {
8931
9148
  ...evaluateInScopeCheck({
8932
9149
  authoringSessionIds,
@@ -9904,6 +10121,7 @@ var verification_proof_exports = /* @__PURE__ */ __exportAll({
9904
10121
  prVerifyProofHeadShas: () => prVerifyProofHeadShas,
9905
10122
  readPrVerifyProof: () => readPrVerifyProof,
9906
10123
  resolveVerifyProofApplicability: () => resolveVerifyProofApplicability,
10124
+ trustedCatchUpRecognition: () => trustedCatchUpRecognition,
9907
10125
  trustedImpactStamp: () => trustedImpactStamp,
9908
10126
  validatePrVerifyProof: () => validatePrVerifyProof,
9909
10127
  verificationProofApplicabilityFor: () => verificationProofApplicabilityFor,
@@ -9973,6 +10191,24 @@ const trustedImpactStamp = ({ candidate, proof }) => {
9973
10191
  });
9974
10192
  return headBound && patchBound ? proof.impactStamp : void 0;
9975
10193
  };
10194
+ /**
10195
+ * #833: the recorded catch-up recognition, consumed ONLY when it is
10196
+ * identity-bound to the current candidate under exactly the rules
10197
+ * `trustedImpactStamp` applies — the proof passed, is v4+, and its recorded
10198
+ * headSha AND patchId both name this candidate. The recognition withdraws
10199
+ * review and preview demands, so it earns the same strict double binding and
10200
+ * the same fail-closed answer (`undefined`) on every other state.
10201
+ */
10202
+ const trustedCatchUpRecognition = ({ candidate, proof }) => {
10203
+ if (!proof || !verifyProofPassed(proof) || proof.schemaVersion < 4) return;
10204
+ const headBound = sameHeadSha(candidate.headSha, proof.headSha);
10205
+ const patchBound = evidenceFresh({
10206
+ candidate: { patchId: candidate.patchId },
10207
+ recorded: { patchId: proof.patchId },
10208
+ rule: "patch-id"
10209
+ });
10210
+ return headBound && patchBound ? proof.catchUpRecognition : void 0;
10211
+ };
9976
10212
  const prVerifyFullHeadShasForDiff = ({ docsOnlyDeltaAcceptedSince, files, proof }) => {
9977
10213
  if (!verifyProofPassed(proof)) return [];
9978
10214
  if (proof.mode === "full") return sameFileList(proof.changedFiles, files) ? [proof.headSha] : [];
@@ -10254,6 +10490,7 @@ function defaultPrVerifyGit() {
10254
10490
  currentHeadSha,
10255
10491
  emptyTreeHash,
10256
10492
  mergeBaseSha,
10493
+ recognizeCatchUp: recognizeCatchUpCandidate,
10257
10494
  runVerificationCommand(command, cwd, env) {
10258
10495
  const result = runInheritedTee("bash", ["-lc", command], cwd, env);
10259
10496
  const counts = parseVitestCounts(result.stdout);
@@ -10293,11 +10530,12 @@ function baselineFullProofsForDocsOnly({ previousProof, profilePath, projectKey,
10293
10530
  seedFilter: matchesProfile
10294
10531
  });
10295
10532
  }
10296
- function buildPrVerifyProof({ authoringSession, base, baselineFullProofs, classification, commands, endedAt, executedCommands, files, headSha, impactStamp, mode, mergeBaseSha: proofMergeBaseSha, notRequiredCommands, outcome, patchId, profilePath, projectKey, repository, startedAt }) {
10533
+ function buildPrVerifyProof({ authoringSession, base, baselineFullProofs, catchUpRecognition, classification, commands, endedAt, executedCommands, files, headSha, impactStamp, mode, mergeBaseSha: proofMergeBaseSha, notRequiredCommands, outcome, patchId, profilePath, projectKey, repository, startedAt }) {
10297
10534
  return {
10298
10535
  authoringSession,
10299
10536
  base,
10300
10537
  ...baselineFullProofs ? { baselineFullProofs } : {},
10538
+ ...catchUpRecognition ? { catchUpRecognition } : {},
10301
10539
  changedFiles: files,
10302
10540
  classification: classification.classification,
10303
10541
  classificationReasons: classification.reasons,
@@ -10380,6 +10618,11 @@ function runPrVerify(args, dependencies = {}) {
10380
10618
  cwd,
10381
10619
  git
10382
10620
  });
10621
+ const catchUpRecognitionResult = (git.recognizeCatchUp ?? recognizeCatchUpCandidate)({
10622
+ base: args.base,
10623
+ cwd
10624
+ });
10625
+ const catchUpRecognition = catchUpRecognitionResult.recognized ? catchUpRecognitionResult.recognition : void 0;
10383
10626
  const readLockfileAtRef = git.showFileAtRef ?? showFileAtRef;
10384
10627
  let impactStamp;
10385
10628
  try {
@@ -10425,6 +10668,7 @@ function runPrVerify(args, dependencies = {}) {
10425
10668
  console.log(`classification: ${classification.classification}`);
10426
10669
  for (const line of context.runSummaryLines()) console.log(line);
10427
10670
  for (const line of impactStampSummaryLines(impactStamp)) console.log(line);
10671
+ if (catchUpRecognition) console.log(`catch-up recognition (#833): faithful merge of ${catchUpRecognition.baseTipSha} (${catchUpRecognition.baseRef}) and ${catchUpRecognition.mergedParentSha} (admitted via ${catchUpRecognition.upstreamRef}); verification mode and battery scoping are unchanged.`);
10428
10672
  const vetoedTargets = boundFailingCheckNames({
10429
10673
  candidate: {
10430
10674
  headSha,
@@ -10461,6 +10705,7 @@ function runPrVerify(args, dependencies = {}) {
10461
10705
  const proofContext = {
10462
10706
  authoringSession,
10463
10707
  base: args.base,
10708
+ ...catchUpRecognition ? { catchUpRecognition } : {},
10464
10709
  classification,
10465
10710
  commands,
10466
10711
  files,
@@ -10856,7 +11101,7 @@ const closingPullRequestsQuery = `query($owner: String!, $repo: String!, $issue:
10856
11101
  repository(owner: $owner, name: $repo) {
10857
11102
  issue(number: $issue) {
10858
11103
  closedByPullRequestsReferences(first: 50, includeClosedPrs: true) {
10859
- nodes { number }
11104
+ nodes { number state }
10860
11105
  }
10861
11106
  }
10862
11107
  }
@@ -11818,7 +12063,7 @@ const prReviewProofSchema = z.object({
11818
12063
  patchId: z.string().regex(/^[0-9a-f]{40,64}$/u),
11819
12064
  reviewCycle: z.number().int().positive().optional(),
11820
12065
  reviewRequirement: z.object({
11821
- reason: z.enum(["no-applicable-mode"]),
12066
+ reason: z.enum(["no-applicable-mode", "faithful-merge"]),
11822
12067
  status: z.literal("not-required")
11823
12068
  }).strict().optional(),
11824
12069
  reviews: z.array(prReviewResultSchema),
@@ -14006,6 +14251,21 @@ const addSecurityBlockingReasons = ({ blockers, securityRequired, securityStatus
14006
14251
  reason: "Security review proof ran and explicitly failed (open blocking findings or errored run); resolve or disposition the findings and re-run pr:review."
14007
14252
  });
14008
14253
  };
14254
+ /**
14255
+ * #833: the trusted catch-up recognition for this evaluation's candidate —
14256
+ * `trustedCatchUpRecognition` over the same passed verify proof the impact
14257
+ * stamp is read from, fail closed to `undefined` on every other state.
14258
+ */
14259
+ const recognitionForCandidate = (input) => {
14260
+ const { verificationProof } = input;
14261
+ return trustedCatchUpRecognition({
14262
+ candidate: {
14263
+ headSha: input.headSha,
14264
+ patchId: input.patchId
14265
+ },
14266
+ proof: verificationProof && "proof" in verificationProof ? verificationProof.proof : void 0
14267
+ });
14268
+ };
14009
14269
  const requiredChecksEvaluation = (input) => {
14010
14270
  const requiredChecks = input.externalRequiredChecks ?? [];
14011
14271
  if (requiredChecks.length === 0) return {
@@ -14018,6 +14278,7 @@ const requiredChecksEvaluation = (input) => {
14018
14278
  patchId: input.patchId
14019
14279
  };
14020
14280
  const { verificationProof } = input;
14281
+ const catchUpRecognition = recognitionForCandidate(input);
14021
14282
  const impactStamp = trustedImpactStamp({
14022
14283
  candidate: {
14023
14284
  headSha: input.headSha,
@@ -14031,6 +14292,7 @@ const requiredChecksEvaluation = (input) => {
14031
14292
  envelopes: input.evidenceEnvelopes ?? [],
14032
14293
  requiredChecks,
14033
14294
  scopeContext: {
14295
+ ...catchUpRecognition ? { catchUpRecognition } : {},
14034
14296
  classification: input.classification,
14035
14297
  ...impactStamp ? { impactStamp } : {}
14036
14298
  }
@@ -14243,7 +14505,8 @@ const foldWaivers = ({ blockers, waivers }) => {
14243
14505
  };
14244
14506
  };
14245
14507
  const evaluateReadiness = (input) => {
14246
- const reviewModes = input.reviewModes ?? ["correctness"];
14508
+ const catchUpRecognition = recognitionForCandidate(input);
14509
+ const reviewModes = catchUpRecognition ? [] : input.reviewModes ?? ["correctness"];
14247
14510
  const epoch = reviewEpochModeFor(input.reviewProof ?? {}, input.ladderPolicy);
14248
14511
  const verifyGate = verifyGateFor({ input });
14249
14512
  const { correctnessProof, correctnessRequired, correctnessStatus, correctnessUntyped, gateCapMismatchBlockers, reviewCycleState, reviewTerminalState, reviewLadder, securityProof, securityRequired, securityStatus, securityUntyped } = resolveReviewRequiredness({
@@ -14281,6 +14544,7 @@ const evaluateReadiness = (input) => {
14281
14544
  waivers: input.waivers ?? []
14282
14545
  });
14283
14546
  const notices = [
14547
+ ...catchUpRecognition ? [`Catch-up recognition (#833): the head is a faithful merge of target tip ${catchUpRecognition.baseTipSha} and admitted base-side content (${catchUpRecognition.upstreamRef}); review modes and impact-demanded envelopes are not demanded for this candidate.`] : [],
14284
14548
  ...input.mergeFreeze?.notices ?? [],
14285
14549
  ...waivedDemands.map(waivedDemandNotice),
14286
14550
  ...unusedWaiverNotices({
@@ -15774,6 +16038,7 @@ const defaultPrReviewGit = () => ({
15774
16038
  changedFiles,
15775
16039
  currentHeadSha,
15776
16040
  isAncestor,
16041
+ recognizeCatchUp: recognizeCatchUpCandidate,
15777
16042
  stablePatchId: runStablePatchId,
15778
16043
  statusPorcelain
15779
16044
  });
@@ -15908,11 +16173,7 @@ async function runPrReview(args, dependencies = {}) {
15908
16173
  const startedAt = /* @__PURE__ */ new Date();
15909
16174
  const findingsFile = args.findings ? parseReviewFindingsFile(args.findings, cwd) : void 0;
15910
16175
  const kinds = enabledKinds(profile, args.mode, files, findingsFile?.reviews.map((review) => review.kind) ?? []);
15911
- if (kinds.length === 0) {
15912
- const reviewRequirement = {
15913
- reason: "no-applicable-mode",
15914
- status: "not-required"
15915
- };
16176
+ const mintNotRequiredProof = (reviewRequirement) => {
15916
16177
  const proof = emptyProof({
15917
16178
  base: args.base,
15918
16179
  cycle: args.cycle,
@@ -15940,8 +16201,21 @@ async function runPrReview(args, dependencies = {}) {
15940
16201
  repoRoot: cwd
15941
16202
  });
15942
16203
  return proof;
16204
+ };
16205
+ if (kinds.length === 0) return mintNotRequiredProof({
16206
+ reason: "no-applicable-mode",
16207
+ status: "not-required"
16208
+ });
16209
+ if (!findingsFile) {
16210
+ if ((git.recognizeCatchUp ?? recognizeCatchUpCandidate)({
16211
+ base: args.base,
16212
+ cwd
16213
+ }).recognized) return mintNotRequiredProof({
16214
+ reason: "faithful-merge",
16215
+ status: "not-required"
16216
+ });
16217
+ throw new Error("--findings <path> is required when review kinds are enabled.");
15943
16218
  }
15944
- if (!findingsFile) throw new Error("--findings <path> is required when review kinds are enabled.");
15945
16219
  if (!evidenceFresh({
15946
16220
  candidate: {
15947
16221
  headSha,
package/dist/schemas.d.ts CHANGED
@@ -268,7 +268,7 @@ interface PrReviewProof {
268
268
  cleanedPaths: string[];
269
269
  ladder?: PrReviewLadderState;
270
270
  reviewRequirement?: {
271
- reason: "no-applicable-mode";
271
+ reason: "no-applicable-mode" | "faithful-merge";
272
272
  status: "not-required";
273
273
  };
274
274
  reviews: PrReviewResult[];
@@ -443,6 +443,23 @@ type ReadinessRepair = z.infer<typeof readinessRepairSchema>;
443
443
  type ReadinessStatus = "ready" | "blocked";
444
444
  type ManagedReadinessLedger = z.infer<typeof managedReadinessLedgerSchema>;
445
445
  //#endregion
446
+ //#region src/catch-up-recognition-record.d.ts
447
+ interface CatchUpRecognition {
448
+ /** The ref the candidate targets, as the recognizing command resolved it. */
449
+ baseRef: string;
450
+ /** The live target tip at recognition time; the head's first parent. */
451
+ baseTipSha: string;
452
+ /** The head's second parent: the admitted base-side content merged in. */
453
+ mergedParentSha: string;
454
+ /**
455
+ * The tree `git merge-tree --write-tree` produced for the two parents. It
456
+ * equals the head's own tree; that equality is the faithfulness proof.
457
+ */
458
+ mergedTreeSha: string;
459
+ /** The ref that proves the second parent is admitted content. */
460
+ upstreamRef: string;
461
+ }
462
+ //#endregion
446
463
  //#region src/pr-verify-proof-contract.d.ts
447
464
  declare const SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS: readonly [1, 2, 3, 4];
448
465
  type PrVerifySchemaVersion = (typeof SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS)[number];
@@ -509,6 +526,7 @@ interface PrVerifyProof {
509
526
  projectKey: string;
510
527
  repository: string;
511
528
  }[];
529
+ catchUpRecognition?: CatchUpRecognition;
512
530
  changedFiles: string[];
513
531
  classification: DiffClassification;
514
532
  classificationReasons: string[];
@@ -541,6 +559,7 @@ declare const prVerifyProofSchema: z.ZodObject<{
541
559
  projectKey: z.ZodString;
542
560
  repository: z.ZodString;
543
561
  }, z.core.$strip>>>;
562
+ catchUpRecognition: z.ZodOptional<z.ZodType<CatchUpRecognition, unknown, z.core.$ZodTypeInternals<CatchUpRecognition, unknown>>>;
544
563
  changedFiles: z.ZodArray<z.ZodString>;
545
564
  classification: z.ZodEnum<{
546
565
  "docs/process-only": "docs/process-only";
@@ -827,6 +846,7 @@ declare const prReviewProofSchema: z.ZodObject<{
827
846
  reviewRequirement: z.ZodOptional<z.ZodObject<{
828
847
  reason: z.ZodEnum<{
829
848
  "no-applicable-mode": "no-applicable-mode";
849
+ "faithful-merge": "faithful-merge";
830
850
  }>;
831
851
  status: z.ZodLiteral<"not-required">;
832
852
  }, z.core.$strict>>;
package/dist/schemas.js CHANGED
@@ -228,6 +228,16 @@ const waivedDemandSchema = z.object({
228
228
  session: z.string().trim().min(1).optional(),
229
229
  unmetReasons: z.array(z.string().min(1)).min(1)
230
230
  });
231
+ //#endregion
232
+ //#region src/catch-up-recognition-record.ts
233
+ const objectShaSchema = z.string().regex(/^[0-9a-f]{40}$/u);
234
+ const catchUpRecognitionSchema = z.object({
235
+ baseRef: z.string().min(1),
236
+ baseTipSha: objectShaSchema,
237
+ mergedParentSha: objectShaSchema,
238
+ mergedTreeSha: objectShaSchema,
239
+ upstreamRef: z.string().min(1)
240
+ }).strict();
231
241
  const impactStampTargetSchema = z.object({
232
242
  basis: z.string().min(1),
233
243
  impact: z.enum(["affected", "not-affected"]),
@@ -515,6 +525,11 @@ const assertPrVerifyProofRules = (proof, context) => {
515
525
  message: "notRequiredCommands is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
516
526
  path: ["notRequiredCommands"]
517
527
  });
528
+ if (proof.schemaVersion < 4 && proof.catchUpRecognition !== void 0) context.addIssue({
529
+ code: "custom",
530
+ message: "catchUpRecognition is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
531
+ path: ["catchUpRecognition"]
532
+ });
518
533
  if (proof.schemaVersion < 4 && proof.notDemandedRecords !== void 0) context.addIssue({
519
534
  code: "custom",
520
535
  message: "notDemandedRecords is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
@@ -573,6 +588,7 @@ const prVerifyProofSchema = z.object({
573
588
  projectKey: z.string().min(1),
574
589
  repository: z.string().min(1)
575
590
  })).optional(),
591
+ catchUpRecognition: catchUpRecognitionSchema.optional(),
576
592
  changedFiles: z.array(z.string()),
577
593
  classification: z.enum([
578
594
  "docs/process-only",
@@ -888,7 +904,7 @@ const prReviewProofSchema = z.object({
888
904
  patchId: z.string().regex(/^[0-9a-f]{40,64}$/u),
889
905
  reviewCycle: z.number().int().positive().optional(),
890
906
  reviewRequirement: z.object({
891
- reason: z.enum(["no-applicable-mode"]),
907
+ reason: z.enum(["no-applicable-mode", "faithful-merge"]),
892
908
  status: z.literal("not-required")
893
909
  }).strict().optional(),
894
910
  reviews: z.array(prReviewResultSchema),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patronage/software-factory",
3
- "version": "1.0.0-alpha.20",
3
+ "version": "1.0.0-alpha.22",
4
4
  "description": "Shared Patronage software factory CLI and project-profile validation tools",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,7 +37,7 @@
37
37
  "picomatch": "^4.0.5",
38
38
  "yaml": "^2.9.0",
39
39
  "zod": "4.4.3",
40
- "@patronage/factory-ci": "1.0.0-alpha.20"
40
+ "@patronage/factory-ci": "1.0.0-alpha.22"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "24.13.3",
@@ -46,14 +46,13 @@
46
46
  "dependency-cruiser": "18.1.0",
47
47
  "knip": "6.27.0",
48
48
  "oxfmt": "0.59.0",
49
- "oxlint": "1.74.0",
49
+ "oxlint": "1.76.0",
50
50
  "oxlint-tsgolint": "7.0.2001",
51
51
  "tsdown": "0.21.10",
52
52
  "tsx": "4.23.1",
53
53
  "typescript": "5.9.3",
54
- "ultracite": "7.9.4",
55
- "vitest": "4.1.10",
56
- "vitest-evals": "0.14.0"
54
+ "ultracite": "7.10.5",
55
+ "vitest": "4.1.10"
57
56
  },
58
57
  "engines": {
59
58
  "node": "^24.0.0"
@@ -70,10 +69,6 @@
70
69
  "precoverage": "bash ../scripts/ensure-worktree-bootstrap.sh",
71
70
  "coverage": "vitest run --coverage",
72
71
  "docs:reference": "tsx scripts/update-command-reference.ts",
73
- "eval:review": "tsx evals/review-prompts/track-b/reviewRunner.ts",
74
- "eval:review:report": "tsx evals/review-prompts/track-b/reviewReport.ts",
75
- "eval:review:rescore": "tsx evals/review-prompts/track-b/rescore.ts",
76
- "eval:review:run": "vitest run --config evals/review-prompts/vitest.eval.config.ts",
77
72
  "pack:smoke": "bash scripts/pack-smoke.sh",
78
73
  "profile:schema": "tsx scripts/generate-profile-schema.ts",
79
74
  "pretest": "bash ../scripts/ensure-worktree-bootstrap.sh",