@patronage/software-factory 1.0.0-alpha.31 → 1.0.0-alpha.32

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
@@ -4,7 +4,8 @@ import path from "node:path";
4
4
  import crypto, { createHash, randomBytes, randomUUID } from "node:crypto";
5
5
  import { z } from "zod";
6
6
  import { execFileSync, spawnSync } from "node:child_process";
7
- import { appendFileSync, constants, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
7
+ import * as nodeFs from "node:fs";
8
+ import { appendFileSync, closeSync, constants, cpSync, existsSync, fsyncSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
8
9
  import { Buffer as Buffer$1 } from "node:buffer";
9
10
  import picomatch from "picomatch";
10
11
  import { link, lstat, mkdir, open, readFile, readdir, realpath, rename, stat, unlink } from "node:fs/promises";
@@ -19,7 +20,7 @@ import http from "node:http";
19
20
  import { pathToFileURL } from "node:url";
20
21
  //#region package.json
21
22
  var name = "@patronage/software-factory";
22
- var version = "1.0.0-alpha.31";
23
+ var version = "1.0.0-alpha.32";
23
24
  //#endregion
24
25
  //#region src/cli-entry.ts
25
26
  /**
@@ -1648,20 +1649,854 @@ const authorizeDemandWaiver = ({ authenticatedLogin, session }) => {
1648
1649
  } };
1649
1650
  };
1650
1651
  //#endregion
1652
+ //#region src/sha256-hex.ts
1653
+ const ROUND_CONSTANTS = new Uint32Array([
1654
+ 1116352408,
1655
+ 1899447441,
1656
+ 3049323471,
1657
+ 3921009573,
1658
+ 961987163,
1659
+ 1508970993,
1660
+ 2453635748,
1661
+ 2870763221,
1662
+ 3624381080,
1663
+ 310598401,
1664
+ 607225278,
1665
+ 1426881987,
1666
+ 1925078388,
1667
+ 2162078206,
1668
+ 2614888103,
1669
+ 3248222580,
1670
+ 3835390401,
1671
+ 4022224774,
1672
+ 264347078,
1673
+ 604807628,
1674
+ 770255983,
1675
+ 1249150122,
1676
+ 1555081692,
1677
+ 1996064986,
1678
+ 2554220882,
1679
+ 2821834349,
1680
+ 2952996808,
1681
+ 3210313671,
1682
+ 3336571891,
1683
+ 3584528711,
1684
+ 113926993,
1685
+ 338241895,
1686
+ 666307205,
1687
+ 773529912,
1688
+ 1294757372,
1689
+ 1396182291,
1690
+ 1695183700,
1691
+ 1986661051,
1692
+ 2177026350,
1693
+ 2456956037,
1694
+ 2730485921,
1695
+ 2820302411,
1696
+ 3259730800,
1697
+ 3345764771,
1698
+ 3516065817,
1699
+ 3600352804,
1700
+ 4094571909,
1701
+ 275423344,
1702
+ 430227734,
1703
+ 506948616,
1704
+ 659060556,
1705
+ 883997877,
1706
+ 958139571,
1707
+ 1322822218,
1708
+ 1537002063,
1709
+ 1747873779,
1710
+ 1955562222,
1711
+ 2024104815,
1712
+ 2227730452,
1713
+ 2361852424,
1714
+ 2428436474,
1715
+ 2756734187,
1716
+ 3204031479,
1717
+ 3329325298
1718
+ ]);
1719
+ const INITIAL_STATE = new Uint32Array([
1720
+ 1779033703,
1721
+ 3144134277,
1722
+ 1013904242,
1723
+ 2773480762,
1724
+ 1359893119,
1725
+ 2600822924,
1726
+ 528734635,
1727
+ 1541459225
1728
+ ]);
1729
+ const BLOCK_BYTES = 64;
1730
+ const LENGTH_FIELD_BYTES = 8;
1731
+ const WORD_BITS = 32;
1732
+ const rotateRight = (value, bits) => (value >>> bits | value << WORD_BITS - bits) >>> 0;
1733
+ /** Message bytes plus the FIPS 180-4 padding and 64-bit length suffix. */
1734
+ const padMessage = (message) => {
1735
+ const paddedLength = Math.ceil((message.length + 1 + LENGTH_FIELD_BYTES) / BLOCK_BYTES) * BLOCK_BYTES;
1736
+ const padded = new Uint8Array(paddedLength);
1737
+ padded.set(message);
1738
+ padded[message.length] = 128;
1739
+ const bitLength = BigInt(message.length) * 8n;
1740
+ new DataView(padded.buffer).setBigUint64(paddedLength - LENGTH_FIELD_BYTES, bitLength);
1741
+ return padded;
1742
+ };
1743
+ const ROUNDS = 64;
1744
+ const SCHEDULE_SEED_WORDS = 16;
1745
+ const STATE_WORDS = 8;
1746
+ const wordAt = (words, index) => words[index];
1747
+ /** One 64-byte block folded into the running state, in place. */
1748
+ const compress = (state, view, offset) => {
1749
+ const schedule = new Uint32Array(ROUNDS);
1750
+ for (let index = 0; index < SCHEDULE_SEED_WORDS; index += 1) schedule[index] = view.getUint32(offset + index * 4);
1751
+ for (let index = SCHEDULE_SEED_WORDS; index < ROUNDS; index += 1) {
1752
+ const previous = wordAt(schedule, index - 15);
1753
+ const recent = wordAt(schedule, index - 2);
1754
+ const sigma0 = rotateRight(previous, 7) ^ rotateRight(previous, 18) ^ previous >>> 3;
1755
+ const sigma1 = rotateRight(recent, 17) ^ rotateRight(recent, 19) ^ recent >>> 10;
1756
+ schedule[index] = wordAt(schedule, index - 16) + sigma0 + wordAt(schedule, index - 7) + sigma1 >>> 0;
1757
+ }
1758
+ const working = new Uint32Array(state);
1759
+ for (let index = 0; index < ROUNDS; index += 1) {
1760
+ const a = wordAt(working, 0);
1761
+ const b = wordAt(working, 1);
1762
+ const c = wordAt(working, 2);
1763
+ const d = wordAt(working, 3);
1764
+ const e = wordAt(working, 4);
1765
+ const f = wordAt(working, 5);
1766
+ const g = wordAt(working, 6);
1767
+ const h = wordAt(working, 7);
1768
+ const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
1769
+ const choose = e & f ^ ~e & g;
1770
+ const temp1 = h + sum1 + choose + wordAt(ROUND_CONSTANTS, index) + wordAt(schedule, index) >>> 0;
1771
+ const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
1772
+ const majority = a & b ^ a & c ^ b & c;
1773
+ working[7] = g;
1774
+ working[6] = f;
1775
+ working[5] = e;
1776
+ working[4] = d + temp1 >>> 0;
1777
+ working[3] = c;
1778
+ working[2] = b;
1779
+ working[1] = a;
1780
+ working[0] = temp1 + (sum0 + majority >>> 0) >>> 0;
1781
+ }
1782
+ for (let index = 0; index < STATE_WORDS; index += 1) state[index] = wordAt(state, index) + wordAt(working, index) >>> 0;
1783
+ };
1784
+ const toHex = (state) => {
1785
+ let hex = "";
1786
+ for (const word of state) hex += word.toString(16).padStart(8, "0");
1787
+ return hex;
1788
+ };
1789
+ /** The lowercase hex SHA-256 digest of a UTF-8 string. */
1790
+ const sha256Hex = (input) => {
1791
+ const padded = padMessage(new TextEncoder().encode(input));
1792
+ const view = new DataView(padded.buffer);
1793
+ const state = new Uint32Array(INITIAL_STATE);
1794
+ for (let offset = 0; offset < padded.length; offset += BLOCK_BYTES) compress(state, view, offset);
1795
+ return toHex(state);
1796
+ };
1797
+ //#endregion
1798
+ //#region src/review-finding-identity.ts
1799
+ const normalizeCitedSpan = (text) => text.split(/\r?\n/u).map((line) => line.trim().replaceAll(/\s+/gu, " ")).filter((line) => line.length > 0).join("\n");
1800
+ const PRESCRIBED_ACTION_STOPWORDS = new Set([
1801
+ "a",
1802
+ "an",
1803
+ "the",
1804
+ "to",
1805
+ "of",
1806
+ "in",
1807
+ "on",
1808
+ "and",
1809
+ "or",
1810
+ "is",
1811
+ "are",
1812
+ "be",
1813
+ "this",
1814
+ "that",
1815
+ "it",
1816
+ "for",
1817
+ "with",
1818
+ "as",
1819
+ "by",
1820
+ "from",
1821
+ "at",
1822
+ "into",
1823
+ "should",
1824
+ "must",
1825
+ "will",
1826
+ "would",
1827
+ "can",
1828
+ "could",
1829
+ "not",
1830
+ "but",
1831
+ "so",
1832
+ "if",
1833
+ "then",
1834
+ "than",
1835
+ "when",
1836
+ "which",
1837
+ "who",
1838
+ "whom",
1839
+ "its",
1840
+ "their",
1841
+ "there",
1842
+ "here",
1843
+ "also",
1844
+ "use",
1845
+ "using",
1846
+ "instead",
1847
+ "rather",
1848
+ "over",
1849
+ "up",
1850
+ "out",
1851
+ "have",
1852
+ "has",
1853
+ "had",
1854
+ "been"
1855
+ ]);
1856
+ const tokenizePrescribedAction = (text) => {
1857
+ const words = text.toLowerCase().replaceAll(/[^a-z0-9\s]/gu, " ").split(/\s+/u).filter((word) => word.length > 0 && !PRESCRIBED_ACTION_STOPWORDS.has(word));
1858
+ return [...new Set(words)].toSorted();
1859
+ };
1860
+ const normalizeTitle = (title) => title.trim().toLowerCase().replaceAll(/\s+/gu, " ");
1861
+ const normalizeFile = (file) => file.trim();
1862
+ const findingKey = (finding) => {
1863
+ const path = finding.file ?? "";
1864
+ const normalizedSpan = normalizeCitedSpan(finding.citedSpan ?? "");
1865
+ const actionTokens = tokenizePrescribedAction(finding.prescribedAction ?? "");
1866
+ const legacyDiscriminator = normalizedSpan.length > 0 || actionTokens.length > 0 ? "" : normalizeTitle(finding.title ?? "");
1867
+ return sha256Hex(`${path}\0${sha256Hex(normalizedSpan)}\0${actionTokens.join(" ")}\0${legacyDiscriminator}`);
1868
+ };
1869
+ const declarationMatchesEntry = (entry, target) => normalizeTitle(entry.title) === normalizeTitle(target.title) && (target.file === void 0 || entry.file !== void 0 && normalizeFile(entry.file) === normalizeFile(target.file));
1870
+ //#endregion
1871
+ //#region src/review-ladder-policy.ts
1872
+ const REVIEW_SEVERITIES = [
1873
+ "critical",
1874
+ "high",
1875
+ "medium",
1876
+ "low",
1877
+ "unknown"
1878
+ ];
1879
+ const REVIEW_CATEGORIES = [
1880
+ "correctness",
1881
+ "safety",
1882
+ "coordination",
1883
+ "maintainability",
1884
+ "unknown"
1885
+ ];
1886
+ const resolveReviewFindingSeverity = (priority) => {
1887
+ const normalized = priority?.trim().toLowerCase();
1888
+ if (!normalized) return "unknown";
1889
+ if ([
1890
+ "p0",
1891
+ "critical",
1892
+ "crit",
1893
+ "blocker",
1894
+ "blocking"
1895
+ ].includes(normalized)) return "critical";
1896
+ if ([
1897
+ "p1",
1898
+ "high",
1899
+ "severe",
1900
+ "major"
1901
+ ].includes(normalized)) return "high";
1902
+ if ([
1903
+ "p2",
1904
+ "medium",
1905
+ "moderate",
1906
+ "normal",
1907
+ "refinement"
1908
+ ].includes(normalized)) return "medium";
1909
+ if ([
1910
+ "p3",
1911
+ "p4",
1912
+ "low",
1913
+ "minor",
1914
+ "info",
1915
+ "informational"
1916
+ ].includes(normalized)) return "low";
1917
+ return "unknown";
1918
+ };
1919
+ const countFindingsBySeverity = (findings) => {
1920
+ const counts = {
1921
+ critical: 0,
1922
+ high: 0,
1923
+ low: 0,
1924
+ medium: 0,
1925
+ unknown: 0
1926
+ };
1927
+ for (const finding of findings) counts[resolveReviewFindingSeverity(finding.priority)] += 1;
1928
+ return counts;
1929
+ };
1930
+ const resolveReviewFindingCategory = (category) => REVIEW_CATEGORIES.find((value) => value === category?.trim().toLowerCase()) ?? "unknown";
1931
+ const resolveFindingBlocking = (finding, disposition = "open") => {
1932
+ if (disposition !== "open") return false;
1933
+ if (resolveReviewFindingCategory(finding.category) !== "maintainability") return true;
1934
+ if (finding.blockingAfterCap !== void 0) return finding.blockingAfterCap;
1935
+ const severity = resolveReviewFindingSeverity(finding.priority);
1936
+ return severity === "critical" || severity === "high" || severity === "unknown";
1937
+ };
1938
+ const FACTORY_REVIEW_CYCLE_CAP = 5;
1939
+ const resolveReviewLadderPolicy = () => ({ gate: { cap: FACTORY_REVIEW_CYCLE_CAP } });
1940
+ //#endregion
1941
+ //#region src/review-ladder-ledger.ts
1942
+ const OCCURRENCE_ADDRESS_PATTERN = /^c[1-9][0-9]*f[1-9][0-9]*$/u;
1943
+ const occurrenceAddress = (cycle, finding) => `c${cycle}f${finding}`;
1944
+ /** The 1-based recording position an address names. */
1945
+ const cycleOfAddress = (address) => Number(/^c(?<cycle>\d+)f/u.exec(address)?.groups?.cycle);
1946
+ const POLICY_DECLARATION_RULES = [
1947
+ "cycle-2-severity-floor",
1948
+ "identity-repeat",
1949
+ "schema-2-import"
1950
+ ];
1951
+ const definedAttributes = (finding) => Object.fromEntries([
1952
+ "blockingAfterCap",
1953
+ "category",
1954
+ "citedSpan",
1955
+ "file",
1956
+ "prescribedAction",
1957
+ "priority"
1958
+ ].flatMap((attribute) => finding[attribute] === void 0 ? [] : [[attribute, finding[attribute]]]));
1959
+ const toLadderFindingReport = (finding) => ({
1960
+ ...definedAttributes(finding),
1961
+ ...finding.supersedes ? { supersedes: finding.supersedes } : {},
1962
+ title: finding.title
1963
+ });
1964
+ const AUTHORITY_RANK = {
1965
+ operator: 3,
1966
+ policy: 1,
1967
+ reviewer: 2
1968
+ };
1969
+ const outranked = (occurrence, declaration) => occurrence.authority !== void 0 && AUTHORITY_RANK[occurrence.authority] > AUTHORITY_RANK[declaration.authority];
1970
+ const IDENTITY_REPEAT_DISPOSITION = {
1971
+ "fixed-in-thread": void 0,
1972
+ "follow-up-filed": "stale-repeat",
1973
+ open: void 0,
1974
+ "policy-accepted": "stale-repeat",
1975
+ "stale-repeat": "stale-repeat",
1976
+ waived: "stale-repeat"
1977
+ };
1978
+ const projectedGroupDisposition = (members) => members.some((member) => member.disposition === "open") ? "open" : members.at(-1)?.disposition ?? "open";
1979
+ const invalid = (message) => {
1980
+ throw new TypeError(`Invalid ladder history: ${message}`);
1981
+ };
1982
+ const entryFor = (members, reopenedCount) => {
1983
+ const [root] = members;
1984
+ const latest = members.at(-1);
1985
+ if (!root || !latest) return invalid("a finding group has no members.");
1986
+ const disposition = projectedGroupDisposition(members);
1987
+ const settled = members.findLast((member) => member.disposition !== "stale-repeat" && member.disposition !== "open");
1988
+ const referenced = disposition === "open" ? void 0 : settled ?? latest;
1989
+ return {
1990
+ ...definedAttributes(latest.finding),
1991
+ address: root.address,
1992
+ disposition,
1993
+ firstFlagged: root.cycle,
1994
+ flagCount: members.length,
1995
+ lastFlagged: latest.cycle,
1996
+ occurrences: [...members],
1997
+ ...disposition === "stale-repeat" && settled ? { priorDisposition: settled.disposition } : {},
1998
+ ...referenced === void 0 ? {} : { reference: referenced.reference ?? referenced.rule },
1999
+ reopenedCount,
2000
+ staleRepeatCount: members.filter((member) => member.disposition === "stale-repeat").length,
2001
+ title: root.finding.title.trim()
2002
+ };
2003
+ };
2004
+ const recordOccurrences = (cycle, cycleIndex, ref, occurrences, recordedIn, groups, reopened) => {
2005
+ const { recording } = cycle;
2006
+ if (!recording) return invalid(`cycle ${cycleIndex + 1} carries no recording; the ledger reads recorded occurrences and declarations only.`);
2007
+ if (recording.occurrences.length !== cycle.findings.length) return invalid(`cycle ${cycleIndex + 1} records ${recording.occurrences.length} occurrence(s) for ${cycle.findings.length} finding(s).`);
2008
+ for (const [findingIndex, record] of recording.occurrences.entries()) {
2009
+ const address = occurrenceAddress(cycleIndex + 1, findingIndex + 1);
2010
+ const finding = cycle.findings[findingIndex];
2011
+ if (record.address !== address || finding === void 0) return invalid(`cycle ${cycleIndex + 1} finding ${findingIndex + 1} is recorded at ${record.address}, not ${address}.`);
2012
+ let root = address;
2013
+ if (record.repeatOf !== void 0) {
2014
+ const target = occurrences.get(record.repeatOf);
2015
+ if (!target || (recordedIn.get(record.repeatOf) ?? cycleIndex) >= cycleIndex) return invalid(`${address} repeats ${record.repeatOf}, which names no occurrence recorded in an earlier cycle.`);
2016
+ ({root} = target);
2017
+ }
2018
+ const members = groups.get(root) ?? [];
2019
+ if (members.length > 0 && projectedGroupDisposition(members) === "fixed-in-thread") reopened.set(root, (reopened.get(root) ?? 0) + 1);
2020
+ const occurrence = {
2021
+ address,
2022
+ cycle: ref,
2023
+ disposition: "open",
2024
+ finding,
2025
+ historical: cycle.historical === true,
2026
+ root
2027
+ };
2028
+ occurrences.set(address, occurrence);
2029
+ recordedIn.set(address, cycleIndex);
2030
+ groups.set(root, [...members, occurrence]);
2031
+ }
2032
+ };
2033
+ const applyDeclaration = (declaration, cycleIndex, occurrences) => {
2034
+ const target = occurrences.get(declaration.target);
2035
+ if (!target) return invalid(`cycle ${cycleIndex + 1} declares ${declaration.disposition} on ${declaration.target}, which names no occurrence recorded at or before that cycle.`);
2036
+ if (outranked(target, declaration)) return;
2037
+ target.disposition = declaration.disposition;
2038
+ target.authority = declaration.authority;
2039
+ if (declaration.authority === "policy") {
2040
+ target.rule = declaration.rule;
2041
+ delete target.reference;
2042
+ } else {
2043
+ delete target.rule;
2044
+ if (declaration.reference === void 0) delete target.reference;
2045
+ else target.reference = declaration.reference;
2046
+ }
2047
+ };
2048
+ const foldLedger = (cycles, refs) => {
2049
+ const occurrences = /* @__PURE__ */ new Map();
2050
+ const recordedIn = /* @__PURE__ */ new Map();
2051
+ const groups = /* @__PURE__ */ new Map();
2052
+ const reopened = /* @__PURE__ */ new Map();
2053
+ for (const [cycleIndex, cycle] of cycles.entries()) {
2054
+ const ref = refs[cycleIndex];
2055
+ if (!ref) return invalid(`cycle ${cycleIndex + 1} has no ladder position.`);
2056
+ const declarations = cycle.recording?.declarations ?? [];
2057
+ for (const declaration of declarations) if (cycleOfAddress(declaration.target) <= cycleIndex) applyDeclaration(declaration, cycleIndex, occurrences);
2058
+ recordOccurrences(cycle, cycleIndex, ref, occurrences, recordedIn, groups, reopened);
2059
+ for (const declaration of declarations) if (cycleOfAddress(declaration.target) > cycleIndex) applyDeclaration(declaration, cycleIndex, occurrences);
2060
+ }
2061
+ const ledger = /* @__PURE__ */ new Map();
2062
+ for (const [root, members] of groups) ledger.set(root, entryFor(members, reopened.get(root) ?? 0));
2063
+ return {
2064
+ ledger,
2065
+ occurrences: [...occurrences.values()]
2066
+ };
2067
+ };
2068
+ const openLadderFindings = (ledger) => ledger.filter((entry) => entry.disposition === "open");
2069
+ const occurrenceBlocks = (occurrence) => occurrence.disposition === "open" && resolveFindingBlocking(occurrence.finding, "open");
2070
+ const blockingLadderFindings = (ledger) => ledger.filter((entry) => entry.occurrences.some(occurrenceBlocks));
2071
+ const staleRepeatLadderFindings = (ledger) => ledger.filter((entry) => entry.disposition === "stale-repeat");
2072
+ //#endregion
2073
+ //#region src/review-recording-schema.ts
2074
+ const ABSENT_PROOF_DIGEST = "absent";
2075
+ const sha256Pattern = /^[0-9a-f]{64}$/u;
2076
+ const occurrenceAddressSchema$1 = z.string().regex(OCCURRENCE_ADDRESS_PATTERN);
2077
+ const declarationRecordSchema = z.union([z.object({
2078
+ authority: z.enum(["operator", "reviewer"]),
2079
+ disposition: z.enum([
2080
+ "fixed-in-thread",
2081
+ "follow-up-filed",
2082
+ "waived"
2083
+ ]),
2084
+ reference: z.string().min(1).optional(),
2085
+ target: occurrenceAddressSchema$1
2086
+ }).strict(), z.object({
2087
+ authority: z.literal("policy"),
2088
+ disposition: z.enum(["policy-accepted", "stale-repeat"]),
2089
+ rule: z.enum(POLICY_DECLARATION_RULES),
2090
+ target: occurrenceAddressSchema$1
2091
+ }).strict()]);
2092
+ const recordsShape = {
2093
+ declarations: z.array(declarationRecordSchema),
2094
+ occurrences: z.array(z.object({
2095
+ address: occurrenceAddressSchema$1,
2096
+ declaredCycle: z.number().int().positive(),
2097
+ repeatOf: occurrenceAddressSchema$1.optional()
2098
+ }).strict())
2099
+ };
2100
+ const reviewCycleRecordingSchema = z.union([z.object({
2101
+ ...recordsShape,
2102
+ appliedTo: z.union([z.string().regex(sha256Pattern), z.literal(ABSENT_PROOF_DIGEST)]),
2103
+ base: z.object({
2104
+ ref: z.string().min(1),
2105
+ sha: z.string().regex(/^[0-9a-f]{40}$/u)
2106
+ }).strict(),
2107
+ dispositionsDigest: z.string().regex(sha256Pattern).optional(),
2108
+ findingsDigest: z.string().regex(sha256Pattern),
2109
+ recordingId: z.string().regex(sha256Pattern)
2110
+ }).strict(), z.object({
2111
+ ...recordsShape,
2112
+ imported: z.object({
2113
+ proofDigest: z.string().regex(sha256Pattern),
2114
+ schemaVersion: z.literal(2)
2115
+ }).strict()
2116
+ }).strict()]);
2117
+ //#endregion
2118
+ //#region src/review-recording.ts
2119
+ /**
2120
+ * The occurrences of the cycles already on the ladder, in recording order,
2121
+ * with the write-time key each carries for grouping the next cycle. A cycle
2122
+ * whose `repeatOf` is unknown indexes every occurrence as its own root.
2123
+ */
2124
+ const occurrenceIndexFor = (cycles) => cycles.flatMap((cycle, cycleIndex) => cycle.findings.map((finding, findingIndex) => {
2125
+ const address = occurrenceAddress(cycleIndex + 1, findingIndex + 1);
2126
+ return {
2127
+ address,
2128
+ finding,
2129
+ key: findingKey(finding),
2130
+ root: cycle.recording?.occurrences[findingIndex]?.repeatOf ?? address
2131
+ };
2132
+ }));
2133
+ const soleRoot = (candidates) => {
2134
+ const roots = new Set(candidates.map((candidate) => candidate.root));
2135
+ return roots.size === 1 ? [...roots][0] : void 0;
2136
+ };
2137
+ /**
2138
+ * The occurrence records for a cycle about to be recorded at position
2139
+ * `cycleIndex` (1-based), grouped against the prior cycles once, at write
2140
+ * time, by the contract's two identity arms: exact `findingKey` equality,
2141
+ * then an explicit `supersedes` citation matched on title and file. A
2142
+ * citation that names occurrences from more than one group is ignored — the
2143
+ * occurrence becomes its own root — rather than guessed at. Span overlap is
2144
+ * never consulted (decision 3).
2145
+ */
2146
+ const occurrencesForCycle = ({ cycleIndex, declaredCycle, findings, priorCycles }) => {
2147
+ const prior = occurrenceIndexFor(priorCycles);
2148
+ return findings.map((finding, findingIndex) => {
2149
+ const key = findingKey(finding);
2150
+ const { supersedes } = finding;
2151
+ const exact = prior.filter((candidate) => candidate.key === key);
2152
+ const cited = exact.length === 0 && supersedes !== void 0 ? prior.filter((candidate) => declarationMatchesEntry(candidate.finding, supersedes)) : [];
2153
+ const repeatOf = soleRoot(exact.length > 0 ? exact : cited);
2154
+ return {
2155
+ address: occurrenceAddress(cycleIndex, findingIndex + 1),
2156
+ declaredCycle,
2157
+ ...repeatOf === void 0 ? {} : { repeatOf }
2158
+ };
2159
+ });
2160
+ };
2161
+ const occurrenceAddressSchema = z.string().regex(OCCURRENCE_ADDRESS_PATTERN);
2162
+ const declarableDisposition = z.enum([
2163
+ "fixed-in-thread",
2164
+ "follow-up-filed",
2165
+ "waived"
2166
+ ]);
2167
+ /**
2168
+ * Wire schema for `pr:review --dispositions`. A declaration names one
2169
+ * occurrence by address (`target`), or, as a convenience for the readout
2170
+ * operators have used so far, a finding by title and file; the latter is
2171
+ * translated to addresses at recording time and refused when it is ambiguous.
2172
+ */
2173
+ const dispositionDeclarationsFileSchema = z.array(z.union([z.object({
2174
+ disposition: declarableDisposition,
2175
+ reference: z.string().min(1).optional(),
2176
+ target: occurrenceAddressSchema
2177
+ }).strict(), z.object({
2178
+ disposition: declarableDisposition,
2179
+ finding: z.object({
2180
+ file: z.string().min(1).optional(),
2181
+ title: z.string().min(1)
2182
+ }).strict(),
2183
+ reference: z.string().min(1).optional()
2184
+ }).strict()]));
2185
+ const describeOccurrences = (occurrences) => occurrences.map((occurrence) => `${occurrence.address} (${occurrence.finding.title}${occurrence.finding.file === void 0 ? "" : ` in ${occurrence.finding.file}`})`).join("; ");
2186
+ /**
2187
+ * Translate this recording's declarations into the records it stores.
2188
+ * `index` covers the prior cycles and, for a historical recording, this
2189
+ * cycle's own findings (#1099). An address must name a recorded occurrence.
2190
+ * A title-plus-file declaration must resolve to exactly one repeat group; it
2191
+ * is then stored once per occurrence in that group, which is what an
2192
+ * entry-wide disposition means in the contract's per-occurrence vocabulary.
2193
+ * More than one group is the ambiguity decision 7 refuses, and the refusal
2194
+ * prints the candidate addresses so the operator can name one.
2195
+ */
2196
+ const resolveDeclaredDispositions = ({ declarations, index, resolutions }) => {
2197
+ const records = [];
2198
+ const available = () => index.length === 0 ? "no occurrence is recorded" : describeOccurrences(index);
2199
+ const assertAddress = (target, what) => {
2200
+ if (!index.some((candidate) => candidate.address === target)) throw new Error(`${what} targets ${target}, which names no recorded occurrence. Recorded occurrences: ${available()}.`);
2201
+ };
2202
+ for (const declaration of declarations) {
2203
+ const reference = declaration.reference === void 0 ? {} : { reference: declaration.reference };
2204
+ if ("target" in declaration) {
2205
+ assertAddress(declaration.target, "--dispositions entry");
2206
+ records.push({
2207
+ authority: "operator",
2208
+ disposition: declaration.disposition,
2209
+ ...reference,
2210
+ target: declaration.target
2211
+ });
2212
+ continue;
2213
+ }
2214
+ const matches = index.filter((candidate) => declarationMatchesEntry(candidate.finding, declaration.finding));
2215
+ if (matches.length === 0) throw new Error(`--dispositions entry "${declaration.finding.title}" matches no recorded finding. Recorded occurrences: ${available()}.`);
2216
+ if (soleRoot(matches) === void 0) throw new Error(`--dispositions entry "${declaration.finding.title}" names more than one recorded finding; name one by address instead. Candidates: ${describeOccurrences(matches)}.`);
2217
+ for (const occurrence of matches) records.push({
2218
+ authority: "operator",
2219
+ disposition: declaration.disposition,
2220
+ ...reference,
2221
+ target: occurrence.address
2222
+ });
2223
+ }
2224
+ for (const resolution of resolutions) {
2225
+ assertAddress(resolution.target, "findings resolution");
2226
+ records.push({
2227
+ authority: "reviewer",
2228
+ disposition: "fixed-in-thread",
2229
+ reference: resolution.reference,
2230
+ target: resolution.target
2231
+ });
2232
+ }
2233
+ return records;
2234
+ };
2235
+ //#endregion
1651
2236
  //#region src/pr-proof-io.ts
1652
2237
  const proofPathFor = (descriptor, cwd, filePath = descriptor.defaultPath) => path.resolve(cwd, filePath);
1653
2238
  const readProofFromPath = (parse, filePath) => parse(JSON.parse(readFileSync(filePath, "utf-8")));
1654
- const readProofFor = (descriptor, cwd, filePath) => readProofFromPath(descriptor.parse, proofPathFor(descriptor, cwd, filePath));
2239
+ const readProofFor = (descriptor, cwd, filePath) => {
2240
+ const target = proofPathFor(descriptor, cwd, filePath);
2241
+ return descriptor.parseBytes ? descriptor.parseBytes(readFileSync(target, "utf-8")) : readProofFromPath(descriptor.parse, target);
2242
+ };
1655
2243
  const readOptionalProofFor = (descriptor, cwd, filePath) => readOptionalProof((p) => readProofFor(descriptor, cwd, filePath ?? p), proofPathFor(descriptor, cwd, filePath));
1656
2244
  const writeProofFor = (descriptor, cwd, value, filePath) => writeProofJson(value, proofPathFor(descriptor, cwd, filePath));
1657
- function writeProofJson(proof, output) {
1658
- mkdirSync(path.dirname(output), { recursive: true });
1659
- writeFileSync(output, `${JSON.stringify(proof, null, 2)}\n`);
2245
+ const nodeProofWriteFileSystem = {
2246
+ closeSync,
2247
+ fsyncSync,
2248
+ mkdirSync,
2249
+ openSync,
2250
+ readFileSync,
2251
+ renameSync,
2252
+ statSync,
2253
+ unlinkSync,
2254
+ writeFileSync
2255
+ };
2256
+ const fsyncPath = (fs, target) => {
2257
+ const fd = fs.openSync(target, "r");
2258
+ try {
2259
+ fs.fsyncSync(fd);
2260
+ } finally {
2261
+ fs.closeSync(fd);
2262
+ }
2263
+ };
2264
+ /**
2265
+ * Write a proof so that the canonical path is never observable half-written:
2266
+ * the bytes land in a sibling temp file, are fsynced, are renamed over the
2267
+ * target in one step, the directory entry is fsynced, and the target is read
2268
+ * back and compared to what was meant to be written (#1131). A fault at any
2269
+ * step leaves the previous file (or its absence) intact, removes the temp
2270
+ * file, and throws. A read-back mismatch throws too, so a "success" is never
2271
+ * reported for bytes that are not on disk.
2272
+ *
2273
+ * The temp file sits beside the target because rename is only atomic within
2274
+ * one file system. The directory fsync is skipped on Windows, which cannot
2275
+ * open a directory for fsync.
2276
+ */
2277
+ function writeProofJson(proof, output, fs = nodeProofWriteFileSystem) {
2278
+ const serialized = `${JSON.stringify(proof, null, 2)}\n`;
2279
+ const directory = path.dirname(output);
2280
+ const temp = path.join(directory, `.${path.basename(output)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
2281
+ fs.mkdirSync(directory, { recursive: true });
2282
+ let renamed = false;
2283
+ try {
2284
+ fs.writeFileSync(temp, serialized);
2285
+ fsyncPath(fs, temp);
2286
+ fs.renameSync(temp, output);
2287
+ renamed = true;
2288
+ if (process.platform !== "win32") fsyncPath(fs, directory);
2289
+ } finally {
2290
+ if (!renamed) try {
2291
+ fs.unlinkSync(temp);
2292
+ } catch {}
2293
+ }
2294
+ const readBack = fs.readFileSync(output, "utf-8");
2295
+ if (readBack !== serialized) throw new Error(`Proof write to ${output} did not read back as written (${readBack.length} bytes on disk, ${serialized.length} expected); the file cannot be trusted.`);
1660
2296
  }
1661
2297
  const writeProofToPaths = (writeOne, canonical, additional) => {
1662
2298
  writeOne(canonical);
1663
2299
  if (additional !== void 0 && additional !== canonical) writeOne(additional);
1664
2300
  };
2301
+ const readProofFileState = (filePath, fs = nodeProofWriteFileSystem) => {
2302
+ try {
2303
+ const bytes = fs.readFileSync(filePath, "utf-8");
2304
+ return {
2305
+ bytes,
2306
+ digest: sha256Hex(bytes),
2307
+ present: true
2308
+ };
2309
+ } catch (error) {
2310
+ if (error?.code === "ENOENT") return {
2311
+ digest: ABSENT_PROOF_DIGEST,
2312
+ present: false
2313
+ };
2314
+ throw error;
2315
+ }
2316
+ };
2317
+ /**
2318
+ * The retryable `concurrent-write` refusal: nothing was written, and the
2319
+ * recording must be retried against the current proof. Either the proof's
2320
+ * digest moved between the recording's read and its write, another writer
2321
+ * held the proof lock for the whole wait window, or this writer's lock was
2322
+ * removed or replaced before its rename (#1132).
2323
+ */
2324
+ var ConcurrentProofWriteError = class extends Error {
2325
+ constructor(output, cause) {
2326
+ super(concurrentWriteMessage(output, cause));
2327
+ this.name = "ConcurrentProofWriteError";
2328
+ }
2329
+ };
2330
+ const concurrentWriteMessage = (output, cause) => {
2331
+ if ("expected" in cause) return `concurrent-write: ${output} changed while this recording was being prepared (applied to proof state ${cause.expected}, found ${cause.found}). Nothing was written; retry the recording against the current proof.`;
2332
+ if ("held" in cause) return `concurrent-write: ${describeHeldLock(cause.held)}; ${LOCK_REMOVAL_INSTRUCTION}. Nothing was written to ${output}.`;
2333
+ return `concurrent-write: proof lock ${cause.lockPath} is no longer this writer's (it was removed or replaced while the recording was being written). Nothing was written to ${output}; retry the recording against the current proof.`;
2334
+ };
2335
+ /**
2336
+ * The operator step for a crashed writer's lock. It is the same sentence in
2337
+ * the refusal, the release report and the doctor preflight warning, so a
2338
+ * grep finds every place it is stated.
2339
+ */
2340
+ const LOCK_REMOVAL_INSTRUCTION = "if that writer is gone, remove the file and retry";
2341
+ const DEFAULT_LOCK_WAIT_MS = 5e3;
2342
+ const LOCK_POLL_MS = 20;
2343
+ const errorCode = (error) => error?.code;
2344
+ const sleepSync = (ms) => {
2345
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
2346
+ };
2347
+ const lockPathFor = (output) => `${output}.lock`;
2348
+ const lockContents = (token) => `${JSON.stringify({
2349
+ at: (/* @__PURE__ */ new Date()).toISOString(),
2350
+ pid: process.pid,
2351
+ token
2352
+ })}\n`;
2353
+ const parseLockContents = (bytes) => {
2354
+ try {
2355
+ const parsed = JSON.parse(bytes);
2356
+ if (parsed && typeof parsed === "object") {
2357
+ const { pid, token } = parsed;
2358
+ return {
2359
+ pid: typeof pid === "number" ? String(pid) : "unknown",
2360
+ token: typeof token === "string" && token !== "" ? token : "unknown"
2361
+ };
2362
+ }
2363
+ } catch {}
2364
+ return {
2365
+ pid: "unknown",
2366
+ token: "unknown"
2367
+ };
2368
+ };
2369
+ const describeHeldLock = (held) => `proof lock ${held.lockPath} held since ${held.since} (token ${held.token}, pid ${held.pid})`;
2370
+ /**
2371
+ * The proof lock beside `output`, if one exists, as a holder description
2372
+ * (#1132). Used by the refusal message and by `doctor --preflight`, which
2373
+ * warns about a lock older than the wait window because no writer will ever
2374
+ * remove it on the operator's behalf. `undefined` when there is no lock.
2375
+ */
2376
+ const describeHeldProofLock = (output, fs = nodeProofWriteFileSystem, now = Date.now()) => {
2377
+ const lockPath = lockPathFor(output);
2378
+ let mtimeMs;
2379
+ try {
2380
+ ({mtimeMs} = fs.statSync(lockPath));
2381
+ } catch (error) {
2382
+ if (errorCode(error) === "ENOENT") return;
2383
+ throw error;
2384
+ }
2385
+ let contents = "";
2386
+ try {
2387
+ contents = fs.readFileSync(lockPath, "utf-8");
2388
+ } catch {}
2389
+ return {
2390
+ ageMs: Math.max(0, now - mtimeMs),
2391
+ lockPath,
2392
+ since: new Date(mtimeMs).toISOString(),
2393
+ ...parseLockContents(contents)
2394
+ };
2395
+ };
2396
+ /** True when `doctor --preflight` should warn about this lock (#1132). */
2397
+ const proofLockOutlivesWait = (held) => held.ageMs > DEFAULT_LOCK_WAIT_MS;
2398
+ /**
2399
+ * Take the exclusive proof lock — a sibling `<proof>.lock` created with
2400
+ * O_EXCL and stamped with a fresh token, so exactly one writer holds it and
2401
+ * the holder can recognise its own file (#1132). A held lock is waited on for
2402
+ * `lockWaitMs`, then refused naming the holder. Nothing here ever removes a
2403
+ * lock it did not create. Returns the token and the release.
2404
+ */
2405
+ const acquireProofLock = (output, fs, { lockWaitMs = DEFAULT_LOCK_WAIT_MS, report = (line) => process.stderr.write(`${line}\n`) }) => {
2406
+ const lockPath = lockPathFor(output);
2407
+ const deadline = Date.now() + lockWaitMs;
2408
+ const created = () => {
2409
+ try {
2410
+ fs.closeSync(fs.openSync(lockPath, "wx"));
2411
+ return true;
2412
+ } catch (error) {
2413
+ if (errorCode(error) !== "EEXIST") throw error;
2414
+ return false;
2415
+ }
2416
+ };
2417
+ while (!created()) if (Date.now() < deadline) sleepSync(LOCK_POLL_MS);
2418
+ else {
2419
+ const held = describeHeldProofLock(output, fs);
2420
+ if (held !== void 0) throw new ConcurrentProofWriteError(output, { held });
2421
+ }
2422
+ const mine = lockContents(randomUUID());
2423
+ fs.writeFileSync(lockPath, mine);
2424
+ return {
2425
+ mine,
2426
+ release: () => {
2427
+ let current;
2428
+ try {
2429
+ current = fs.readFileSync(lockPath, "utf-8");
2430
+ } catch (error) {
2431
+ report(errorCode(error) === "ENOENT" ? `pr-proof: lock ${lockPath} was removed by someone else before this writer released it; nothing to release.` : `pr-proof: lock ${lockPath} could not be read on release (${String(error)}); left in place.`);
2432
+ return;
2433
+ }
2434
+ if (current !== mine) {
2435
+ const { pid, token } = parseLockContents(current);
2436
+ report(`pr-proof: lock ${lockPath} is not this writer's (token ${token}, pid ${pid}); left in place.`);
2437
+ return;
2438
+ }
2439
+ try {
2440
+ fs.unlinkSync(lockPath);
2441
+ } catch (error) {
2442
+ report(`pr-proof: lock ${lockPath} could not be removed on release (${String(error)}); ${LOCK_REMOVAL_INSTRUCTION}.`);
2443
+ }
2444
+ }
2445
+ };
2446
+ };
2447
+ /**
2448
+ * The file system a locked write renames through: immediately before the
2449
+ * proof rename it re-reads the lock and refuses unless the contents are this
2450
+ * writer's token. Nothing is renamed on a mismatch, and `writeProofJson`
2451
+ * removes the temp file as for any other fault.
2452
+ */
2453
+ const ownershipCheckedFileSystem = (fs, output, mine) => ({
2454
+ ...fs,
2455
+ renameSync: (from, to) => {
2456
+ if (to === output) {
2457
+ let current;
2458
+ try {
2459
+ current = fs.readFileSync(lockPathFor(output), "utf-8");
2460
+ } catch {
2461
+ current = void 0;
2462
+ }
2463
+ if (current !== mine) throw new ConcurrentProofWriteError(output, {
2464
+ lockPath: lockPathFor(output),
2465
+ replaced: true
2466
+ });
2467
+ }
2468
+ fs.renameSync(from, to);
2469
+ }
2470
+ });
2471
+ /**
2472
+ * Compare-and-swap proof write (#1131, decision 6; serialized by #1132). The
2473
+ * file is re-read under an exclusive lock and replaced only when its bytes
2474
+ * still digest to `appliedTo`, the state the recording was computed against.
2475
+ * The lock covers read → compare → temp write → fsync → ownership check →
2476
+ * rename → directory fsync → read-back, so two writers that both read the
2477
+ * same prior state cannot both succeed: the second one in waits, then gets
2478
+ * the retryable `concurrent-write` refusal before any temp file exists.
2479
+ *
2480
+ * Scope: writers on one host sharing one file system (the O_EXCL create is
2481
+ * the mutual exclusion; no cross-host guarantee). A writer that dies holding
2482
+ * the lock leaves it behind, and every later writer refuses naming it: the
2483
+ * lock is removed by an operator, never reclaimed (see the invariants above).
2484
+ * The canonical path is never observable half-written (`writeProofJson`).
2485
+ */
2486
+ function writeProofJsonApplied(proof, output, appliedTo, fs = nodeProofWriteFileSystem, options = {}) {
2487
+ fs.mkdirSync(path.dirname(output), { recursive: true });
2488
+ const { mine, release } = acquireProofLock(output, fs, options);
2489
+ try {
2490
+ const current = readProofFileState(output, fs);
2491
+ if (current.digest !== appliedTo) throw new ConcurrentProofWriteError(output, {
2492
+ expected: appliedTo,
2493
+ found: current.digest
2494
+ });
2495
+ writeProofJson(proof, output, ownershipCheckedFileSystem(fs, output, mine));
2496
+ } finally {
2497
+ release();
2498
+ }
2499
+ }
1665
2500
  function tryReadProof(read, filePath) {
1666
2501
  try {
1667
2502
  return { proof: read(filePath) };
@@ -1713,6 +2548,249 @@ function withPinnedNodeOnPath(command, nodeBinDir) {
1713
2548
  return `export PATH=${singleQuote(nodeBinDir)}:"$PATH"\n${command}`;
1714
2549
  }
1715
2550
  //#endregion
2551
+ //#region src/github-issue-comments.ts
2552
+ const isCommentPage = (value) => Array.isArray(value[0]);
2553
+ const normalizeIssueComments = (value) => {
2554
+ return (isCommentPage(value) ? value.flat() : value).map((comment) => ({
2555
+ author: { login: comment.user?.login },
2556
+ body: comment.body,
2557
+ createdAt: comment.created_at,
2558
+ id: comment.node_id,
2559
+ updatedAt: comment.updated_at,
2560
+ url: comment.html_url
2561
+ }));
2562
+ };
2563
+ //#endregion
2564
+ //#region src/github-client.ts
2565
+ /**
2566
+ * Shared `gh` CLI client for the factory.
2567
+ *
2568
+ * Every factory `gh` invocation routes through {@link runCapture} so it inherits
2569
+ * the shared-stash and worker-control-checkout guards plus the git-env scrub,
2570
+ * and so spawn configuration + JSON parsing live in exactly one place. The
2571
+ * guards are inert for `gh` (they only trip on `git stash` / worker-checkout
2572
+ * mutations), and `runCapture`'s failure message is byte-identical to the
2573
+ * hand-rolled wrappers this module replaces (`gh <args> failed:\n<stderr>`).
2574
+ *
2575
+ * Field projections (`PR_VIEW_JSON_FIELDS`) and telemetry-wrapped GraphQL
2576
+ * queries deliberately stay with their consumers — they are per-consumer
2577
+ * shapes, not shared plumbing.
2578
+ */
2579
+ const runGhCapture = (args, cwd = process.cwd(), acceptableExitCodes = [0]) => runCapture("gh", args, cwd, { acceptableExitCodes });
2580
+ const runGhJsonAt = (args, cwd) => JSON.parse(runGhCapture(args, cwd).stdout);
2581
+ const runGhJson = (args) => runGhJsonAt(args, process.cwd());
2582
+ const gitReferenceSchema = z.object({ object: z.object({ sha: z.string().regex(/^[0-9a-f]{40}$/u) }) });
2583
+ /** Parse GitHub's git-ref JSON (`GET .../git/ref/heads/<name>`). */
2584
+ const parseGitHubBranchTipResponse = (value) => gitReferenceSchema.parse(value).object.sha;
2585
+ const githubBranchTipApiPath = (repository, branch) => `/repos/${repository.owner}/${repository.name}/git/ref/heads/${encodeURIComponent(branch)}`;
2586
+ /**
2587
+ * Read a branch tip from GitHub rather than a checkout's cached remote ref.
2588
+ * `gh` is injectable so a test can hand the real read a fake GitHub.
2589
+ */
2590
+ const fetchGitHubBranchTipSha = (repository, branch, cwd, gh = runGhJsonAt) => parseGitHubBranchTipResponse(gh(["api", githubBranchTipApiPath(repository, branch)], cwd));
2591
+ const repositoryFileSchema = z.object({
2592
+ content: z.string(),
2593
+ encoding: z.literal("base64"),
2594
+ type: z.literal("file")
2595
+ });
2596
+ /**
2597
+ * Read one file's content at an exact commit from GitHub.
2598
+ *
2599
+ * #922: the authoritative base policy is the profile GitHub serves at the base
2600
+ * tip. A checkout is candidate-controlled, so it is not an authority for it.
2601
+ * There is exactly one implementation on purpose — no local-first read with an
2602
+ * API fallback, which would let a checkout answer the authority's question.
2603
+ *
2604
+ * Throws for every failure — an absent path, a directory, a file too large for
2605
+ * the contents endpoint to inline, an unreachable or unauthorized API. The
2606
+ * caller decides what an unreadable authority means; it is never an absent one.
2607
+ */
2608
+ const fetchGitHubFileAtRef = (repository, filePath, ref, cwd) => {
2609
+ const encodedPath = filePath.split("/").map((segment) => encodeURIComponent(segment)).join("/");
2610
+ const response = repositoryFileSchema.parse(runGhJsonAt(["api", `/repos/${repository.owner}/${repository.name}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`], cwd));
2611
+ return Buffer$1.from(response.content, "base64").toString("utf-8");
2612
+ };
2613
+ /**
2614
+ * Fetch and normalize the issue/PR comment thread for `<owner>/<repo>`.
2615
+ *
2616
+ * `--slurp` postdates gh 2.46; with `--paginate` alone gh returns a single
2617
+ * merged array. `normalizeIssueComments` accepts both the merged and slurped
2618
+ * (array-of-arrays) shapes, so behavior is identical on either gh version
2619
+ * (patronage/internal#380).
2620
+ */
2621
+ const fetchIssueComments = (fullName, issueNumber) => normalizeIssueComments(runGhJson([
2622
+ "api",
2623
+ "--paginate",
2624
+ `/repos/${fullName}/issues/${issueNumber}/comments`
2625
+ ]));
2626
+ const livePullRequestBaseSchema = z.object({
2627
+ baseRefName: z.string().min(1),
2628
+ isCrossRepository: z.unknown(),
2629
+ number: z.number().int().positive()
2630
+ });
2631
+ const failureText$3 = (error) => error instanceof Error ? error.message : String(error);
2632
+ /** `gh pr view` exits non-zero when the branch has no pull request (#323). */
2633
+ const isNoPullRequestYet$1 = (message) => /no pull requests found for branch/iu.test(message);
2634
+ /**
2635
+ * The pull request GitHub associates with the checkout's current branch.
2636
+ * Only its number, base ref name and fork status are read: the base SHA
2637
+ * comes from the live ref, never from this projection.
2638
+ */
2639
+ const lookupLivePullRequestBase = (cwd, gh = runGhJsonAt) => {
2640
+ try {
2641
+ const parsed = livePullRequestBaseSchema.safeParse(gh([
2642
+ "pr",
2643
+ "view",
2644
+ "--json",
2645
+ "number,baseRefName,isCrossRepository"
2646
+ ], cwd));
2647
+ return parsed.success ? {
2648
+ pullRequest: parsed.data,
2649
+ status: "found"
2650
+ } : {
2651
+ reason: `GitHub did not report a usable base ref for this branch's pull request (${parsed.error.issues.map((issue) => issue.message).join("; ")}).`,
2652
+ status: "unavailable"
2653
+ };
2654
+ } catch (error) {
2655
+ const message = failureText$3(error);
2656
+ return isNoPullRequestYet$1(message) ? { status: "none" } : {
2657
+ reason: `the pull request lookup for this branch failed: ${message}`,
2658
+ status: "unavailable"
2659
+ };
2660
+ }
2661
+ };
2662
+ /**
2663
+ * The live tip of a base branch, read from GitHub for the trusted checkout's
2664
+ * repository through the reader `pr:ready` uses.
2665
+ */
2666
+ const liveBranchTipSha = (cwd, branch, gh = runGhJsonAt, repository = checkoutRepository(cwd)) => fetchGitHubBranchTipSha(repository, branch, cwd, gh);
2667
+ const fetchOriginRef = (cwd, refName) => {
2668
+ runCapture("git", [
2669
+ "fetch",
2670
+ "--no-tags",
2671
+ "origin",
2672
+ refName
2673
+ ], cwd);
2674
+ };
2675
+ const resolveCommitSha = (cwd, ref) => {
2676
+ try {
2677
+ return runCapture("git", [
2678
+ "rev-parse",
2679
+ "--verify",
2680
+ `${ref}^{commit}`
2681
+ ], cwd).stdout.trim();
2682
+ } catch {
2683
+ return;
2684
+ }
2685
+ };
2686
+ /**
2687
+ * The production dependencies, over one `gh` reader so a test can hand the
2688
+ * real lookups a fake GitHub and prove which read the binding trusts.
2689
+ */
2690
+ const candidateBaseDependencies = ({ gh = runGhJsonAt, repository } = {}) => ({
2691
+ fetchOriginRef,
2692
+ liveBranchTipSha: (cwd, branch) => liveBranchTipSha(cwd, branch, gh, repository ?? checkoutRepository(cwd)),
2693
+ lookupPullRequestBase: (cwd) => lookupLivePullRequestBase(cwd, gh),
2694
+ resolveCommitSha
2695
+ });
2696
+ const ORIGIN_PREFIX = "origin/";
2697
+ /**
2698
+ * The GitHub branch a candidate base ref names. Proofs and `--base` carry the
2699
+ * local remote-tracking form (`origin/<branch>`); GitHub creates and compares
2700
+ * pull requests by `<branch>`. A ref without the remote qualifier is returned
2701
+ * as given.
2702
+ */
2703
+ const candidateBaseBranch = (ref) => ref.startsWith(ORIGIN_PREFIX) ? ref.slice(7) : ref;
2704
+ const bindToPullRequest = ({ cwd, dependencies, pullRequest, requestedBase }) => {
2705
+ const ref = `${ORIGIN_PREFIX}${pullRequest.baseRefName}`;
2706
+ let tip;
2707
+ try {
2708
+ tip = dependencies.liveBranchTipSha(cwd, pullRequest.baseRefName);
2709
+ } catch (error) {
2710
+ throw new Error(`PR #${pullRequest.number} targets ${pullRequest.baseRefName}, but its live tip could not be read from GitHub: ${failureText$3(error)} pr:ready measures against that tip, so the candidate is not bound to a guessed base; restore ${pullRequest.baseRefName} or rerun once GitHub is reachable.`, { cause: error });
2711
+ }
2712
+ try {
2713
+ dependencies.fetchOriginRef(cwd, pullRequest.baseRefName);
2714
+ } catch {}
2715
+ if (!dependencies.resolveCommitSha(cwd, tip)) throw new Error(`The live base of PR #${pullRequest.number} (${ref} at ${tip}) is not available locally; fetch it and rerun.`);
2716
+ if (requestedBase !== void 0) {
2717
+ const requestedSha = dependencies.resolveCommitSha(cwd, requestedBase);
2718
+ if (requestedSha !== tip) throw new Error(`--base ${requestedBase} (${requestedSha ?? "unresolvable"}) does not resolve to the live base of PR #${pullRequest.number}, ${ref} at ${tip}. Omit --base to measure against the pull request's base, or pass ${ref}.`);
2719
+ }
2720
+ return {
2721
+ ref,
2722
+ sha: tip,
2723
+ source: {
2724
+ kind: "pull-request",
2725
+ number: pullRequest.number
2726
+ }
2727
+ };
2728
+ };
2729
+ const bindToRef = ({ cwd, dependencies, pullRequest, ref }) => {
2730
+ let fetched = false;
2731
+ if (ref.startsWith(ORIGIN_PREFIX)) try {
2732
+ dependencies.fetchOriginRef(cwd, ref.slice(7));
2733
+ fetched = true;
2734
+ } catch {}
2735
+ const sha = dependencies.resolveCommitSha(cwd, ref);
2736
+ if (!sha) throw new Error(`Base ${ref} does not resolve to a commit in this checkout; fetch it or pass --base <ref>.`);
2737
+ return {
2738
+ ref,
2739
+ sha,
2740
+ source: {
2741
+ fetched,
2742
+ kind: "ref",
2743
+ pullRequest
2744
+ }
2745
+ };
2746
+ };
2747
+ /**
2748
+ * Bind the candidate to its base. A pull request for the branch wins and its
2749
+ * live tip is the base; a `requestedBase` that disagrees with it is refused.
2750
+ * Without one, the requested or default ref is fetched fresh and resolved.
2751
+ * A blank `requestedBase` asserts nothing: `pr:publish` forwards its own
2752
+ * omitted `--base` as the empty string to the stages it runs (#1132).
2753
+ */
2754
+ const resolveCandidateBase = ({ cwd, dependencies = candidateBaseDependencies(), requestedBase: requestedBaseInput }) => {
2755
+ const requestedBase = requestedBaseInput === void 0 || requestedBaseInput.trim() === "" ? void 0 : requestedBaseInput.trim();
2756
+ const lookup = dependencies.lookupPullRequestBase(cwd);
2757
+ return lookup.status === "found" ? bindToPullRequest({
2758
+ cwd,
2759
+ dependencies,
2760
+ pullRequest: lookup.pullRequest,
2761
+ requestedBase
2762
+ }) : bindToRef({
2763
+ cwd,
2764
+ dependencies,
2765
+ pullRequest: lookup,
2766
+ ref: requestedBase ?? "origin/main"
2767
+ });
2768
+ };
2769
+ /**
2770
+ * The policy-authority lookup, read from the pull request the base binding
2771
+ * already found (#1131): one GitHub read, one pull request, so the base the
2772
+ * candidate is measured against and the policy it is judged under can never
2773
+ * come from two different pull requests. `pr:review` and `pr:verify` both
2774
+ * feed `resolveStageReviewDemand` through this.
2775
+ */
2776
+ const policyLookupFromBaseLookup = (lookup) => lookup.status === "found" ? {
2777
+ pullRequest: {
2778
+ baseRefName: lookup.pullRequest.baseRefName,
2779
+ isCrossRepository: lookup.pullRequest.isCrossRepository
2780
+ },
2781
+ status: "found"
2782
+ } : {
2783
+ reason: lookup.status === "none" ? "no pull request is open for this branch." : lookup.reason,
2784
+ status: "unavailable"
2785
+ };
2786
+ /** One line stating which base was bound and why. */
2787
+ const describeCandidateBase = (binding) => {
2788
+ if (binding.source.kind === "pull-request") return `base ${binding.ref} at ${binding.sha}, the live base of PR #${binding.source.number}`;
2789
+ const fetched = binding.source.fetched ? "fetched from origin" : "local ref (origin not fetched)";
2790
+ const pullRequest = binding.source.pullRequest.status === "none" ? "no pull request is open for this branch" : `the pull request lookup was unavailable, so no live PR base could be read (${binding.source.pullRequest.reason})`;
2791
+ return `base ${binding.ref} at ${binding.sha}, ${fetched}; ${pullRequest}`;
2792
+ };
2793
+ //#endregion
1716
2794
  //#region src/candidate-identity.ts
1717
2795
  /**
1718
2796
  * The same fallback for a patch id taken at an explicit ref rather than at
@@ -1924,102 +3002,29 @@ function resolveCwdOption(cwd) {
1924
3002
  return path.resolve(value);
1925
3003
  }
1926
3004
  function collectCwdOption(rawValue, previous) {
1927
- if (previous === void 0) return rawValue;
1928
- const previousResolved = resolveCwdOption(previous);
1929
- const currentResolved = resolveCwdOption(rawValue);
1930
- if (previousResolved !== currentResolved) throw new Error(`--cwd was passed multiple times with conflicting values: "${previous}" (resolves to ${previousResolved}) and "${rawValue}" (resolves to ${currentResolved})`);
1931
- return rawValue;
1932
- }
1933
- function markCwdOptionDefault(command) {
1934
- const option = command.options.find((candidate) => candidate.attributeName() === "cwd");
1935
- if (option) {
1936
- option.defaultValue = ".";
1937
- option.defaultValueDescription = ".";
1938
- }
1939
- return command;
1940
- }
1941
- function resolveCheckoutPath(cwd, target) {
1942
- return path.resolve(cwd, target);
1943
- }
1944
- function resolveProofOutputPaths(cwd, defaultPath, requested) {
1945
- return {
1946
- additional: requested ? resolveCheckoutPath(cwd, requested) : void 0,
1947
- canonical: resolveCheckoutPath(cwd, defaultPath)
1948
- };
1949
- }
1950
- //#endregion
1951
- //#region src/github-issue-comments.ts
1952
- const isCommentPage = (value) => Array.isArray(value[0]);
1953
- const normalizeIssueComments = (value) => {
1954
- return (isCommentPage(value) ? value.flat() : value).map((comment) => ({
1955
- author: { login: comment.user?.login },
1956
- body: comment.body,
1957
- createdAt: comment.created_at,
1958
- id: comment.node_id,
1959
- updatedAt: comment.updated_at,
1960
- url: comment.html_url
1961
- }));
1962
- };
1963
- //#endregion
1964
- //#region src/github-client.ts
1965
- /**
1966
- * Shared `gh` CLI client for the factory.
1967
- *
1968
- * Every factory `gh` invocation routes through {@link runCapture} so it inherits
1969
- * the shared-stash and worker-control-checkout guards plus the git-env scrub,
1970
- * and so spawn configuration + JSON parsing live in exactly one place. The
1971
- * guards are inert for `gh` (they only trip on `git stash` / worker-checkout
1972
- * mutations), and `runCapture`'s failure message is byte-identical to the
1973
- * hand-rolled wrappers this module replaces (`gh <args> failed:\n<stderr>`).
1974
- *
1975
- * Field projections (`PR_VIEW_JSON_FIELDS`) and telemetry-wrapped GraphQL
1976
- * queries deliberately stay with their consumers — they are per-consumer
1977
- * shapes, not shared plumbing.
1978
- */
1979
- const runGhCapture = (args, cwd = process.cwd(), acceptableExitCodes = [0]) => runCapture("gh", args, cwd, { acceptableExitCodes });
1980
- const runGhJsonAt = (args, cwd) => JSON.parse(runGhCapture(args, cwd).stdout);
1981
- const runGhJson = (args) => runGhJsonAt(args, process.cwd());
1982
- const gitReferenceSchema = z.object({ object: z.object({ sha: z.string().regex(/^[0-9a-f]{40}$/u) }) });
1983
- /** Parse GitHub's git-ref JSON (`GET .../git/ref/heads/<name>`). */
1984
- const parseGitHubBranchTipResponse = (value) => gitReferenceSchema.parse(value).object.sha;
1985
- const githubBranchTipApiPath = (repository, branch) => `/repos/${repository.owner}/${repository.name}/git/ref/heads/${encodeURIComponent(branch)}`;
1986
- /** Read a branch tip from GitHub rather than a checkout's cached remote ref. */
1987
- const fetchGitHubBranchTipSha = (repository, branch, cwd) => parseGitHubBranchTipResponse(runGhJsonAt(["api", githubBranchTipApiPath(repository, branch)], cwd));
1988
- const repositoryFileSchema = z.object({
1989
- content: z.string(),
1990
- encoding: z.literal("base64"),
1991
- type: z.literal("file")
1992
- });
1993
- /**
1994
- * Read one file's content at an exact commit from GitHub.
1995
- *
1996
- * #922: the authoritative base policy is the profile GitHub serves at the base
1997
- * tip. A checkout is candidate-controlled, so it is not an authority for it.
1998
- * There is exactly one implementation on purpose — no local-first read with an
1999
- * API fallback, which would let a checkout answer the authority's question.
2000
- *
2001
- * Throws for every failure — an absent path, a directory, a file too large for
2002
- * the contents endpoint to inline, an unreachable or unauthorized API. The
2003
- * caller decides what an unreadable authority means; it is never an absent one.
2004
- */
2005
- const fetchGitHubFileAtRef = (repository, filePath, ref, cwd) => {
2006
- const encodedPath = filePath.split("/").map((segment) => encodeURIComponent(segment)).join("/");
2007
- const response = repositoryFileSchema.parse(runGhJsonAt(["api", `/repos/${repository.owner}/${repository.name}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`], cwd));
2008
- return Buffer$1.from(response.content, "base64").toString("utf-8");
2009
- };
2010
- /**
2011
- * Fetch and normalize the issue/PR comment thread for `<owner>/<repo>`.
2012
- *
2013
- * `--slurp` postdates gh 2.46; with `--paginate` alone gh returns a single
2014
- * merged array. `normalizeIssueComments` accepts both the merged and slurped
2015
- * (array-of-arrays) shapes, so behavior is identical on either gh version
2016
- * (patronage/internal#380).
2017
- */
2018
- const fetchIssueComments = (fullName, issueNumber) => normalizeIssueComments(runGhJson([
2019
- "api",
2020
- "--paginate",
2021
- `/repos/${fullName}/issues/${issueNumber}/comments`
2022
- ]));
3005
+ if (previous === void 0) return rawValue;
3006
+ const previousResolved = resolveCwdOption(previous);
3007
+ const currentResolved = resolveCwdOption(rawValue);
3008
+ if (previousResolved !== currentResolved) throw new Error(`--cwd was passed multiple times with conflicting values: "${previous}" (resolves to ${previousResolved}) and "${rawValue}" (resolves to ${currentResolved})`);
3009
+ return rawValue;
3010
+ }
3011
+ function markCwdOptionDefault(command) {
3012
+ const option = command.options.find((candidate) => candidate.attributeName() === "cwd");
3013
+ if (option) {
3014
+ option.defaultValue = ".";
3015
+ option.defaultValueDescription = ".";
3016
+ }
3017
+ return command;
3018
+ }
3019
+ function resolveCheckoutPath(cwd, target) {
3020
+ return path.resolve(cwd, target);
3021
+ }
3022
+ function resolveProofOutputPaths(cwd, defaultPath, requested) {
3023
+ return {
3024
+ additional: requested ? resolveCheckoutPath(cwd, requested) : void 0,
3025
+ canonical: resolveCheckoutPath(cwd, defaultPath)
3026
+ };
3027
+ }
2023
3028
  //#endregion
2024
3029
  //#region src/glob-match.ts
2025
3030
  function isValidGlob(pattern) {
@@ -2378,254 +3383,38 @@ function tryParseProfileContent(content) {
2378
3383
  } catch {
2379
3384
  return;
2380
3385
  }
2381
- }
2382
- /**
2383
- * Derive the HQ lane-permalink base (`<origin>/lanes/by-ref`) from an HQ ingest
2384
- * endpoint. Reuses the same host the non-blocking gate sink already targets, so
2385
- * branded factory check runs deep-link to HQ without a second config source.
2386
- * Returns `undefined` for a malformed endpoint (callers keep the PR fallback).
2387
- */
2388
- function hqLaneRefBaseUrl(endpoint) {
2389
- try {
2390
- return `${new URL(endpoint).origin}/lanes/by-ref`;
2391
- } catch {
2392
- return;
2393
- }
2394
- }
2395
- /**
2396
- * The HQ lane-permalink base for a repository profile, or `undefined` when HQ is
2397
- * not enabled/configured. Gated identically to the HQ gate sink
2398
- * (`profile.hq.enabled`) so the check-run deep link and the ingest sink stay in
2399
- * lockstep.
2400
- */
2401
- function hqLaneRefBaseUrlFromProfile(profile) {
2402
- if (!profile?.hq?.enabled) return;
2403
- return hqLaneRefBaseUrl(profile.hq.endpoint);
2404
- }
2405
- function formatLegacyProfileError(profilePath, foundVersion, migration) {
2406
- return [`Profile is invalid: ${profilePath}: schemaVersion ${foundVersion} is not supported; migrate to schemaVersion 6 (ADR 0021).`, ...migration.map((line) => ` ${line}`)].join("\n");
2407
- }
2408
- function formatProfileError(profilePath, error) {
2409
- return `Profile is invalid: ${profilePath}: ${error.issues.map((issue) => {
2410
- return `${issue.path.length > 0 ? issue.path.join(".") : "root"}: ${issue.message}`;
2411
- }).join("; ")}`;
2412
- }
2413
- //#endregion
2414
- //#region src/review-ladder-policy.ts
2415
- const REVIEW_SEVERITIES = [
2416
- "critical",
2417
- "high",
2418
- "medium",
2419
- "low",
2420
- "unknown"
2421
- ];
2422
- const REVIEW_CATEGORIES = [
2423
- "correctness",
2424
- "safety",
2425
- "coordination",
2426
- "maintainability",
2427
- "unknown"
2428
- ];
2429
- const resolveReviewFindingSeverity = (priority) => {
2430
- const normalized = priority?.trim().toLowerCase();
2431
- if (!normalized) return "unknown";
2432
- if ([
2433
- "p0",
2434
- "critical",
2435
- "crit",
2436
- "blocker",
2437
- "blocking"
2438
- ].includes(normalized)) return "critical";
2439
- if ([
2440
- "p1",
2441
- "high",
2442
- "severe",
2443
- "major"
2444
- ].includes(normalized)) return "high";
2445
- if ([
2446
- "p2",
2447
- "medium",
2448
- "moderate",
2449
- "normal",
2450
- "refinement"
2451
- ].includes(normalized)) return "medium";
2452
- if ([
2453
- "p3",
2454
- "p4",
2455
- "low",
2456
- "minor",
2457
- "info",
2458
- "informational"
2459
- ].includes(normalized)) return "low";
2460
- return "unknown";
2461
- };
2462
- const countFindingsBySeverity = (findings) => {
2463
- const counts = {
2464
- critical: 0,
2465
- high: 0,
2466
- low: 0,
2467
- medium: 0,
2468
- unknown: 0
2469
- };
2470
- for (const finding of findings) counts[resolveReviewFindingSeverity(finding.priority)] += 1;
2471
- return counts;
2472
- };
2473
- const resolveReviewFindingCategory = (category) => REVIEW_CATEGORIES.find((value) => value === category?.trim().toLowerCase()) ?? "unknown";
2474
- const resolveFindingBlocking = (finding, disposition = "open") => {
2475
- if (disposition !== "open") return false;
2476
- if (resolveReviewFindingCategory(finding.category) !== "maintainability") return true;
2477
- if (finding.blockingAfterCap !== void 0) return finding.blockingAfterCap;
2478
- const severity = resolveReviewFindingSeverity(finding.priority);
2479
- return severity === "critical" || severity === "high" || severity === "unknown";
2480
- };
2481
- const FACTORY_REVIEW_CYCLE_CAP = 5;
2482
- const resolveReviewLadderPolicy = () => ({ gate: { cap: FACTORY_REVIEW_CYCLE_CAP } });
2483
- //#endregion
2484
- //#region src/sha256-hex.ts
2485
- const ROUND_CONSTANTS = new Uint32Array([
2486
- 1116352408,
2487
- 1899447441,
2488
- 3049323471,
2489
- 3921009573,
2490
- 961987163,
2491
- 1508970993,
2492
- 2453635748,
2493
- 2870763221,
2494
- 3624381080,
2495
- 310598401,
2496
- 607225278,
2497
- 1426881987,
2498
- 1925078388,
2499
- 2162078206,
2500
- 2614888103,
2501
- 3248222580,
2502
- 3835390401,
2503
- 4022224774,
2504
- 264347078,
2505
- 604807628,
2506
- 770255983,
2507
- 1249150122,
2508
- 1555081692,
2509
- 1996064986,
2510
- 2554220882,
2511
- 2821834349,
2512
- 2952996808,
2513
- 3210313671,
2514
- 3336571891,
2515
- 3584528711,
2516
- 113926993,
2517
- 338241895,
2518
- 666307205,
2519
- 773529912,
2520
- 1294757372,
2521
- 1396182291,
2522
- 1695183700,
2523
- 1986661051,
2524
- 2177026350,
2525
- 2456956037,
2526
- 2730485921,
2527
- 2820302411,
2528
- 3259730800,
2529
- 3345764771,
2530
- 3516065817,
2531
- 3600352804,
2532
- 4094571909,
2533
- 275423344,
2534
- 430227734,
2535
- 506948616,
2536
- 659060556,
2537
- 883997877,
2538
- 958139571,
2539
- 1322822218,
2540
- 1537002063,
2541
- 1747873779,
2542
- 1955562222,
2543
- 2024104815,
2544
- 2227730452,
2545
- 2361852424,
2546
- 2428436474,
2547
- 2756734187,
2548
- 3204031479,
2549
- 3329325298
2550
- ]);
2551
- const INITIAL_STATE = new Uint32Array([
2552
- 1779033703,
2553
- 3144134277,
2554
- 1013904242,
2555
- 2773480762,
2556
- 1359893119,
2557
- 2600822924,
2558
- 528734635,
2559
- 1541459225
2560
- ]);
2561
- const BLOCK_BYTES = 64;
2562
- const LENGTH_FIELD_BYTES = 8;
2563
- const WORD_BITS = 32;
2564
- const rotateRight = (value, bits) => (value >>> bits | value << WORD_BITS - bits) >>> 0;
2565
- /** Message bytes plus the FIPS 180-4 padding and 64-bit length suffix. */
2566
- const padMessage = (message) => {
2567
- const paddedLength = Math.ceil((message.length + 1 + LENGTH_FIELD_BYTES) / BLOCK_BYTES) * BLOCK_BYTES;
2568
- const padded = new Uint8Array(paddedLength);
2569
- padded.set(message);
2570
- padded[message.length] = 128;
2571
- const bitLength = BigInt(message.length) * 8n;
2572
- new DataView(padded.buffer).setBigUint64(paddedLength - LENGTH_FIELD_BYTES, bitLength);
2573
- return padded;
2574
- };
2575
- const ROUNDS = 64;
2576
- const SCHEDULE_SEED_WORDS = 16;
2577
- const STATE_WORDS = 8;
2578
- const wordAt = (words, index) => words[index];
2579
- /** One 64-byte block folded into the running state, in place. */
2580
- const compress = (state, view, offset) => {
2581
- const schedule = new Uint32Array(ROUNDS);
2582
- for (let index = 0; index < SCHEDULE_SEED_WORDS; index += 1) schedule[index] = view.getUint32(offset + index * 4);
2583
- for (let index = SCHEDULE_SEED_WORDS; index < ROUNDS; index += 1) {
2584
- const previous = wordAt(schedule, index - 15);
2585
- const recent = wordAt(schedule, index - 2);
2586
- const sigma0 = rotateRight(previous, 7) ^ rotateRight(previous, 18) ^ previous >>> 3;
2587
- const sigma1 = rotateRight(recent, 17) ^ rotateRight(recent, 19) ^ recent >>> 10;
2588
- schedule[index] = wordAt(schedule, index - 16) + sigma0 + wordAt(schedule, index - 7) + sigma1 >>> 0;
2589
- }
2590
- const working = new Uint32Array(state);
2591
- for (let index = 0; index < ROUNDS; index += 1) {
2592
- const a = wordAt(working, 0);
2593
- const b = wordAt(working, 1);
2594
- const c = wordAt(working, 2);
2595
- const d = wordAt(working, 3);
2596
- const e = wordAt(working, 4);
2597
- const f = wordAt(working, 5);
2598
- const g = wordAt(working, 6);
2599
- const h = wordAt(working, 7);
2600
- const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
2601
- const choose = e & f ^ ~e & g;
2602
- const temp1 = h + sum1 + choose + wordAt(ROUND_CONSTANTS, index) + wordAt(schedule, index) >>> 0;
2603
- const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
2604
- const majority = a & b ^ a & c ^ b & c;
2605
- working[7] = g;
2606
- working[6] = f;
2607
- working[5] = e;
2608
- working[4] = d + temp1 >>> 0;
2609
- working[3] = c;
2610
- working[2] = b;
2611
- working[1] = a;
2612
- working[0] = temp1 + (sum0 + majority >>> 0) >>> 0;
2613
- }
2614
- for (let index = 0; index < STATE_WORDS; index += 1) state[index] = wordAt(state, index) + wordAt(working, index) >>> 0;
2615
- };
2616
- const toHex = (state) => {
2617
- let hex = "";
2618
- for (const word of state) hex += word.toString(16).padStart(8, "0");
2619
- return hex;
2620
- };
2621
- /** The lowercase hex SHA-256 digest of a UTF-8 string. */
2622
- const sha256Hex = (input) => {
2623
- const padded = padMessage(new TextEncoder().encode(input));
2624
- const view = new DataView(padded.buffer);
2625
- const state = new Uint32Array(INITIAL_STATE);
2626
- for (let offset = 0; offset < padded.length; offset += BLOCK_BYTES) compress(state, view, offset);
2627
- return toHex(state);
2628
- };
3386
+ }
3387
+ /**
3388
+ * Derive the HQ lane-permalink base (`<origin>/lanes/by-ref`) from an HQ ingest
3389
+ * endpoint. Reuses the same host the non-blocking gate sink already targets, so
3390
+ * branded factory check runs deep-link to HQ without a second config source.
3391
+ * Returns `undefined` for a malformed endpoint (callers keep the PR fallback).
3392
+ */
3393
+ function hqLaneRefBaseUrl(endpoint) {
3394
+ try {
3395
+ return `${new URL(endpoint).origin}/lanes/by-ref`;
3396
+ } catch {
3397
+ return;
3398
+ }
3399
+ }
3400
+ /**
3401
+ * The HQ lane-permalink base for a repository profile, or `undefined` when HQ is
3402
+ * not enabled/configured. Gated identically to the HQ gate sink
3403
+ * (`profile.hq.enabled`) so the check-run deep link and the ingest sink stay in
3404
+ * lockstep.
3405
+ */
3406
+ function hqLaneRefBaseUrlFromProfile(profile) {
3407
+ if (!profile?.hq?.enabled) return;
3408
+ return hqLaneRefBaseUrl(profile.hq.endpoint);
3409
+ }
3410
+ function formatLegacyProfileError(profilePath, foundVersion, migration) {
3411
+ return [`Profile is invalid: ${profilePath}: schemaVersion ${foundVersion} is not supported; migrate to schemaVersion 6 (ADR 0021).`, ...migration.map((line) => ` ${line}`)].join("\n");
3412
+ }
3413
+ function formatProfileError(profilePath, error) {
3414
+ return `Profile is invalid: ${profilePath}: ${error.issues.map((issue) => {
3415
+ return `${issue.path.length > 0 ? issue.path.join(".") : "root"}: ${issue.message}`;
3416
+ }).join("; ")}`;
3417
+ }
2629
3418
  //#endregion
2630
3419
  //#region src/demand-resolution.ts
2631
3420
  /**
@@ -2835,46 +3624,6 @@ const authoritativePolicyResolution = ({ base, demand }) => ({
2835
3624
  reviewPolicyChanged: demand.reviewPolicyChanged,
2836
3625
  status: "authoritative"
2837
3626
  });
2838
- const candidatePullRequestViewSchema = z.object({
2839
- baseRefName: z.string().min(1),
2840
- isCrossRepository: z.unknown()
2841
- });
2842
- /**
2843
- * The pull request GitHub associates with the checkout's current branch.
2844
- *
2845
- * `pr:verify` and `pr:review` are handed a git ref, never a PR number, so this
2846
- * is how they reach the authority at all. A checkout with no pull request open
2847
- * is normal — verification runs before publication — so a failed lookup is
2848
- * `unavailable` with the reason, never a throw.
2849
- *
2850
- * The lookup names no PR number, so GitHub resolves it from the branch. A
2851
- * branch with more than one open pull request could therefore resolve a base
2852
- * ref that is not the one readiness will use. That does not create a downgrade
2853
- * path: readiness resolves the authority from the PR it was given and refuses
2854
- * evidence whose recorded policy identity disagrees (`policyIdentityRefusal`).
2855
- */
2856
- const lookupCandidatePullRequest = (cwd) => {
2857
- try {
2858
- const parsed = candidatePullRequestViewSchema.safeParse(runGhJsonAt([
2859
- "pr",
2860
- "view",
2861
- "--json",
2862
- "baseRefName,isCrossRepository"
2863
- ], cwd));
2864
- return parsed.success ? {
2865
- pullRequest: parsed.data,
2866
- status: "found"
2867
- } : {
2868
- reason: `GitHub did not report a usable base ref for this branch's pull request (${parsed.error.issues.map((issue) => issue.message).join("; ")}).`,
2869
- status: "unavailable"
2870
- };
2871
- } catch (error) {
2872
- return {
2873
- reason: `no pull request could be read for this branch: ${failureText$2(error)}`,
2874
- status: "unavailable"
2875
- };
2876
- }
2877
- };
2878
3627
  /**
2879
3628
  * The ONE way a factory stage resolves review demand (#922 Slice B).
2880
3629
  *
@@ -6153,6 +6902,33 @@ const exchangeFactoryAppInstallationToken = async (input) => {
6153
6902
  //#endregion
6154
6903
  //#region src/github-app-access.ts
6155
6904
  const NO_APP_SKIP_MESSAGE = "no factory GitHub App configured; skipping durable check-run republish.\n";
6905
+ /**
6906
+ * Fail-closed App credential mint/exchange that never completed. Distinct from
6907
+ * an evaluated candidate refusal: the branded check must stay unpublished
6908
+ * (#1125), while the command still exits nonzero (#949).
6909
+ */
6910
+ var FactoryAppUnevaluableError = class extends Error {
6911
+ constructor(message) {
6912
+ super(message);
6913
+ this.name = "FactoryAppUnevaluableError";
6914
+ }
6915
+ };
6916
+ const FACTORY_APP_UNEVALUABLE_MARKERS = [
6917
+ "Cursor OIDC mint",
6918
+ "Factory App broker exchange",
6919
+ "unable to mint Factory App token"
6920
+ ];
6921
+ /**
6922
+ * True when the error is an App-credential miss — mint timeout, broker
6923
+ * exchange, or PEM mint — not a proof-chain refusal. `pr:ready` uses this to
6924
+ * skip a red `patronage-factory/pr-ready` that would read as evaluated-blocked
6925
+ * (#1125). Message markers cover injected doubles that throw a plain `Error`.
6926
+ */
6927
+ const isFactoryAppUnevaluableError = (error) => {
6928
+ if (error instanceof FactoryAppUnevaluableError || error instanceof CursorOidcMintError) return true;
6929
+ const message = error instanceof Error ? error.message : String(error);
6930
+ return FACTORY_APP_UNEVALUABLE_MARKERS.some((marker) => message.includes(marker));
6931
+ };
6156
6932
  const exchangeFromCursorVm = async (repository, dependencies, env, request, timeoutMs) => {
6157
6933
  const mint = dependencies.mintCursorOidc ?? mintCursorOidcToken;
6158
6934
  const exchange = dependencies.exchangeBroker ?? exchangeFactoryAppInstallationToken;
@@ -6339,7 +7115,7 @@ const isOutcome = (value) => OUTCOMES.includes(value);
6339
7115
  * comes from `verifyProofPassed`: a proof with no recorded outcome is
6340
7116
  * unfinished (fail closed).
6341
7117
  */
6342
- const outcomeFor = (value) => isOutcome(value.outcome) ? value.outcome : "aborted";
7118
+ const outcomeFor$1 = (value) => isOutcome(value.outcome) ? value.outcome : "aborted";
6343
7119
  const asClassification = (value) => DIFF_CLASSIFICATIONS.includes(value) ? value : "unknown";
6344
7120
  /**
6345
7121
  * Project a local pr:verify proof onto the durable check-run payload. Accepts
@@ -6359,7 +7135,7 @@ const prVerifyCheckPayloadFor = (proof) => {
6359
7135
  kind: PR_VERIFY_CHECK_PAYLOAD_KIND,
6360
7136
  mode: value.mode,
6361
7137
  ...carriesReleases ? { notRequiredCommands: value.notRequiredCommands } : {},
6362
- outcome: outcomeFor(value),
7138
+ outcome: outcomeFor$1(value),
6363
7139
  proofSchemaVersion: typeof value.schemaVersion === "number" ? value.schemaVersion : 0,
6364
7140
  schemaVersion: PR_VERIFY_CHECK_PAYLOAD_SCHEMA_VERSION,
6365
7141
  ...typeof value.patchId === "string" ? { patchId: value.patchId } : {},
@@ -6835,9 +7611,11 @@ async function confirmPublishedCheckRun({ config, createdId, dependencies, input
6835
7611
  * Never posts a commit-status fallback: the commit status is a human-readable
6836
7612
  * mirror, not a proof surface, so a caller that needs an App-verified check
6837
7613
  * run must be told plainly whether it got one. Returns `true` only when the
6838
- * App-owned check run landed. Throws when Factory App access is fail-closed
6839
- * (missing mint or Cursor OIDC exchange, #949): `pr:ready` must not notice
6840
- * that miss and exit 0. An optional-App skip still returns `false`.
7614
+ * App-owned check run landed. Throws {@link FactoryAppUnevaluableError} when
7615
+ * Factory App access is fail-closed (missing mint or Cursor OIDC exchange,
7616
+ * #949): `pr:ready` must not notice that miss and exit 0, and must not publish
7617
+ * a red check that reads as candidate refusal (#1125). An optional-App skip
7618
+ * still returns `false`.
6841
7619
  *
6842
7620
  * "Landed" means GitHub serves it, not that the POST was accepted (#520). On
6843
7621
  * PR #519 the POST was accepted, `pr:ready` reported ready and armed, and the
@@ -6867,7 +7645,7 @@ async function ensureFactoryCheckRunPublished(input, dependencies = {}) {
6867
7645
  const access = await resolveFactoryAppAccess(input, dependencies);
6868
7646
  if (access.kind !== "ok") {
6869
7647
  onDiagnostic?.(`${checkName}: ${access.message}`);
6870
- if (access.failClosed) throw new Error(`${checkName}: ${access.message.trim()}`);
7648
+ if (access.failClosed) throw new FactoryAppUnevaluableError(`${checkName}: ${access.message.trim()}`);
6871
7649
  return false;
6872
7650
  }
6873
7651
  try {
@@ -7498,6 +8276,7 @@ const prVerifySchemaVersionSchema = z.literal(4);
7498
8276
  const prVerifyProofSchema = z.object({
7499
8277
  authoringSession: z.string().trim().min(1),
7500
8278
  base: z.string().min(1),
8279
+ baseSha: z.string().regex(/^[0-9a-f]{40}$/u).optional(),
7501
8280
  baselineFullProofs: z.array(z.object({
7502
8281
  base: z.string().min(1),
7503
8282
  changedFiles: z.array(z.string()),
@@ -8115,8 +8894,10 @@ function defaultPrVerifyGit() {
8115
8894
  changedFiles,
8116
8895
  currentHeadSha,
8117
8896
  emptyTreeHash,
8897
+ fetchOriginRef,
8118
8898
  mergeBaseSha,
8119
8899
  recognizeCatchUp: recognizeCatchUpCandidate,
8900
+ resolveCommitSha,
8120
8901
  runVerificationCommand(command, cwd, env) {
8121
8902
  return verificationOutcomeOf(runInheritedTee("bash", ["-lc", withPinnedNodeOnPath(command, path.dirname(process.execPath))], cwd, env));
8122
8903
  },
@@ -8150,10 +8931,11 @@ function baselineFullProofsForDocsOnly({ previousProof, profilePath, projectKey,
8150
8931
  seedFilter: matchesProfile
8151
8932
  });
8152
8933
  }
8153
- function buildPrVerifyProof({ authoringSession, base, baselineFullProofs, catchUpRecognition, classification, commands, endedAt, executedCommands, files, headSha, impactStamp, mode, mergeBaseSha: proofMergeBaseSha, notRequiredCommands, outcome, patchId, policyResolution, profilePath, projectKey, repository, startedAt }) {
8934
+ function buildPrVerifyProof({ authoringSession, base, baseSha, baselineFullProofs, catchUpRecognition, classification, commands, endedAt, executedCommands, files, headSha, impactStamp, mode, mergeBaseSha: proofMergeBaseSha, notRequiredCommands, outcome, patchId, policyResolution, profilePath, projectKey, repository, startedAt }) {
8154
8935
  return {
8155
8936
  authoringSession,
8156
8937
  base,
8938
+ baseSha,
8157
8939
  ...baselineFullProofs ? { baselineFullProofs } : {},
8158
8940
  ...catchUpRecognition ? { catchUpRecognition } : {},
8159
8941
  changedFiles: files,
@@ -8245,18 +9027,30 @@ function runPrVerify(args, dependencies = {}) {
8245
9027
  cwd,
8246
9028
  profilePath: args.profilePath
8247
9029
  });
8248
- const files = git.changedFiles(cwd, args.base);
9030
+ const pullRequestLookup = (dependencies.lookupPullRequestBase ?? lookupLivePullRequestBase)(cwd);
9031
+ const base = resolveCandidateBase({
9032
+ cwd,
9033
+ dependencies: {
9034
+ fetchOriginRef: git.fetchOriginRef ?? fetchOriginRef,
9035
+ liveBranchTipSha: dependencies.liveBranchTipSha ?? liveBranchTipSha,
9036
+ lookupPullRequestBase: () => pullRequestLookup,
9037
+ resolveCommitSha: git.resolveCommitSha ?? resolveCommitSha
9038
+ },
9039
+ requestedBase: args.base
9040
+ });
9041
+ console.log(`pr:verify ${describeCandidateBase(base)}`);
9042
+ const files = git.changedFiles(cwd, base.sha);
8249
9043
  const relativeProfile = repositoryRelativeProfilePath({
8250
9044
  cwd,
8251
9045
  profilePath
8252
9046
  });
8253
9047
  const { headSha, mergeBaseSha: proofMergeBaseSha, patchId } = resolveCandidateIdentity({
8254
- base: args.base,
9048
+ base: base.sha,
8255
9049
  cwd,
8256
9050
  git
8257
9051
  });
8258
9052
  const catchUpRecognitionResult = (git.recognizeCatchUp ?? recognizeCatchUpCandidate)({
8259
- base: args.base,
9053
+ base: base.sha,
8260
9054
  cwd
8261
9055
  });
8262
9056
  const catchUpRecognition = catchUpRecognitionResult.recognized ? catchUpRecognitionResult.recognition : void 0;
@@ -8279,17 +9073,17 @@ function runPrVerify(args, dependencies = {}) {
8279
9073
  });
8280
9074
  const { policyResolution } = resolveStageReviewDemand({
8281
9075
  authority: dependencies.basePolicy ?? defaultBaseReviewPolicyAuthority(),
8282
- callerBaseRef: args.base,
9076
+ callerBaseRef: base.ref,
8283
9077
  candidatePolicy: profile.review,
8284
9078
  changedFiles: files,
8285
9079
  cwd,
8286
- lookupPullRequest: dependencies.lookupPullRequest ?? lookupCandidatePullRequest,
9080
+ lookupPullRequest: dependencies.lookupPullRequest ?? (() => policyLookupFromBaseLookup(pullRequestLookup)),
8287
9081
  profilePath: relativeProfile,
8288
9082
  readCheckoutRepository: git.checkoutRepository ?? checkoutRepository
8289
9083
  });
8290
9084
  const rootSharedGlobs = [...DEFAULT_ROOT_SHARED_GLOBS, relativeProfile];
8291
9085
  const context = createVerificationRunContext({
8292
- baseRef: args.base,
9086
+ baseRef: base.sha,
8293
9087
  changedFiles: files,
8294
9088
  fullSuiteBaseRef: git.emptyTreeHash(cwd),
8295
9089
  rootSharedGlobs
@@ -8351,7 +9145,8 @@ function runPrVerify(args, dependencies = {}) {
8351
9145
  };
8352
9146
  const proofContext = {
8353
9147
  authoringSession,
8354
- base: args.base,
9148
+ base: base.ref,
9149
+ baseSha: base.sha,
8355
9150
  policyResolution,
8356
9151
  ...catchUpRecognition ? { catchUpRecognition } : {},
8357
9152
  classification,
@@ -8464,7 +9259,7 @@ function runPrVerify(args, dependencies = {}) {
8464
9259
  if (!command.requiredCheck) continue;
8465
9260
  if (scopedOutNames.has(command.name)) continue;
8466
9261
  runEvidenceEmit({
8467
- base: args.base,
9262
+ base: base.sha,
8468
9263
  check: command.requiredCheck,
8469
9264
  cwd,
8470
9265
  outcome: "pass",
@@ -8662,246 +9457,24 @@ const blockedReasonIssues = (proof) => {
8662
9457
  if (named.length !== reasons.length) return blockedReasonIssue(`blockedReasons must name every blocking reason: ${reasons.length} reason(s), ${named.length} named`);
8663
9458
  const drifted = named.findIndex((reason, index) => reason.detail !== blockedReasonDetail(reasons[index]));
8664
9459
  return drifted === -1 ? [] : blockedReasonIssue(`blockedReasons[${drifted}] does not carry blocking reason ${drifted}; the two projections must tell one story`);
8665
- };
8666
- //#endregion
8667
- //#region src/packages/review-prompt-sections/schema.ts
8668
- const REVIEW_PROMPT_SECTION_PROVENANCES = [
8669
- "coding-standards",
8670
- "prior-review-ledger",
8671
- "issue-review-focus"
8672
- ];
8673
- const nonBlankString$2 = z.string().refine((value) => value.trim().length > 0, { message: "must not be blank" });
8674
- const reviewPromptSectionSchema = z.object({
8675
- provenance: z.enum(REVIEW_PROMPT_SECTION_PROVENANCES),
8676
- source: nonBlankString$2,
8677
- text: nonBlankString$2
8678
- }).strict();
8679
- const promptSectionRank = (section) => REVIEW_PROMPT_SECTION_PROVENANCES.indexOf(section.provenance);
8680
- const hasDuplicateProvenance = (sections) => new Set(sections.map((section) => section.provenance)).size !== sections.length;
8681
- const isOutOfAssemblyOrder = (sections) => sections.some((section, index) => index > 0 && promptSectionRank(section) < promptSectionRank(sections[index - 1]));
8682
- const reviewPromptSectionsSchema = z.array(reviewPromptSectionSchema).min(1).refine((sections) => !hasDuplicateProvenance(sections) && !isOutOfAssemblyOrder(sections), { message: "prompt sections must be in assembly order without duplicates" });
8683
- //#endregion
8684
- //#region src/review-ladder-ledger.ts
8685
- const CYCLE_2_SEVERITY_FLOOR_REFERENCE = "cycle-2-severity-floor";
8686
- const normalizeCitedSpan = (text) => text.split(/\r?\n/u).map((line) => line.trim().replaceAll(/\s+/gu, " ")).filter((line) => line.length > 0).join("\n");
8687
- const PRESCRIBED_ACTION_STOPWORDS = new Set([
8688
- "a",
8689
- "an",
8690
- "the",
8691
- "to",
8692
- "of",
8693
- "in",
8694
- "on",
8695
- "and",
8696
- "or",
8697
- "is",
8698
- "are",
8699
- "be",
8700
- "this",
8701
- "that",
8702
- "it",
8703
- "for",
8704
- "with",
8705
- "as",
8706
- "by",
8707
- "from",
8708
- "at",
8709
- "into",
8710
- "should",
8711
- "must",
8712
- "will",
8713
- "would",
8714
- "can",
8715
- "could",
8716
- "not",
8717
- "but",
8718
- "so",
8719
- "if",
8720
- "then",
8721
- "than",
8722
- "when",
8723
- "which",
8724
- "who",
8725
- "whom",
8726
- "its",
8727
- "their",
8728
- "there",
8729
- "here",
8730
- "also",
8731
- "use",
8732
- "using",
8733
- "instead",
8734
- "rather",
8735
- "than",
8736
- "over",
8737
- "up",
8738
- "out",
8739
- "have",
8740
- "has",
8741
- "had",
8742
- "been"
8743
- ]);
8744
- const tokenizePrescribedAction = (text) => {
8745
- const words = text.toLowerCase().replaceAll(/[^a-z0-9\s]/gu, " ").split(/\s+/u).filter((word) => word.length > 0 && !PRESCRIBED_ACTION_STOPWORDS.has(word));
8746
- return [...new Set(words)].toSorted();
8747
- };
8748
- const normalizeTitle = (title) => title.trim().toLowerCase().replaceAll(/\s+/gu, " ");
8749
- const normalizeFile = (file) => file.trim();
8750
- const findingKey = (finding) => {
8751
- const path = finding.file ?? "";
8752
- const normalizedSpan = normalizeCitedSpan(finding.citedSpan ?? "");
8753
- const actionTokens = tokenizePrescribedAction(finding.prescribedAction ?? "");
8754
- const legacyDiscriminator = normalizedSpan.length > 0 || actionTokens.length > 0 ? "" : normalizeTitle(finding.title ?? "");
8755
- return sha256Hex(`${path}\0${sha256Hex(normalizedSpan)}\0${actionTokens.join(" ")}\0${legacyDiscriminator}`);
8756
- };
8757
- const declarationMatchesEntry = (entry, target) => normalizeTitle(entry.title) === normalizeTitle(target.title) && (target.file === void 0 || entry.file !== void 0 && normalizeFile(entry.file) === normalizeFile(target.file));
8758
- const applyDisposition = (ledger, declaration) => {
8759
- const entry = declaration.findingReport ? resolveEntryForFinding([...ledger.values()], declaration.findingReport) : [...ledger.values()].find((candidate) => declarationMatchesEntry(candidate, declaration.finding));
8760
- if (!entry) return false;
8761
- entry.disposition = declaration.disposition;
8762
- entry.priorDisposition = void 0;
8763
- if (declaration.reference !== void 0) entry.reference = declaration.reference;
8764
- return true;
8765
- };
8766
- const definedAttributes = (finding) => Object.fromEntries([
8767
- "blockingAfterCap",
8768
- "category",
8769
- "citedSpan",
8770
- "file",
8771
- "prescribedAction",
8772
- "priority"
8773
- ].flatMap((attribute) => finding[attribute] === void 0 ? [] : [[attribute, finding[attribute]]]));
8774
- const toLadderFindingReport = (finding) => ({
8775
- ...definedAttributes(finding),
8776
- ...finding.supersedes ? { supersedes: finding.supersedes } : {},
8777
- title: finding.title
8778
- });
8779
- const MIN_MEANINGFUL_OVERLAP_LINE_LENGTH = 20;
8780
- const spansOverlap = (a, b) => {
8781
- if (a.length === 0 || b.length === 0) return false;
8782
- if (a === b || a.includes(b) || b.includes(a)) return true;
8783
- const linesA = new Set(a.split("\n").filter((line) => line.length >= MIN_MEANINGFUL_OVERLAP_LINE_LENGTH));
8784
- return b.split("\n").some((line) => line.length >= MIN_MEANINGFUL_OVERLAP_LINE_LENGTH && linesA.has(line));
8785
- };
8786
- const resolveEntryForFinding = (entries, finding) => {
8787
- const primaryKey = findingKey(finding);
8788
- const exact = entries.find((entry) => entry.key === primaryKey);
8789
- if (exact) return exact;
8790
- const { supersedes } = finding;
8791
- if (supersedes) {
8792
- const superseded = entries.find((entry) => declarationMatchesEntry(entry, supersedes));
8793
- if (superseded) return superseded;
8794
- }
8795
- if (finding.file !== void 0 && finding.citedSpan !== void 0) {
8796
- const candidateSpan = normalizeCitedSpan(finding.citedSpan);
8797
- if (candidateSpan.length > 0) {
8798
- const overlapping = entries.find((entry) => entry.disposition === "open" && entry.file === finding.file && entry.citedSpan !== void 0 && spansOverlap(normalizeCitedSpan(entry.citedSpan), candidateSpan));
8799
- if (overlapping) return overlapping;
8800
- }
8801
- }
8802
- };
8803
- const REFLAG_TRANSITIONS = {
8804
- "fixed-in-thread": (entry) => {
8805
- entry.disposition = "open";
8806
- entry.reopenedCount += 1;
8807
- },
8808
- "follow-up-filed": (entry) => {
8809
- entry.priorDisposition = entry.disposition;
8810
- entry.disposition = "stale-repeat";
8811
- entry.staleRepeatCount += 1;
8812
- },
8813
- open: () => {},
8814
- "rerun-noise": (entry) => {
8815
- entry.staleRepeatCount += 1;
8816
- },
8817
- "stale-repeat": (entry) => {
8818
- entry.staleRepeatCount += 1;
8819
- },
8820
- waived: (entry) => {
8821
- entry.priorDisposition = entry.disposition;
8822
- entry.disposition = "stale-repeat";
8823
- entry.staleRepeatCount += 1;
8824
- }
8825
- };
8826
- const applyFlag = (ledger, finding, ref, demoteNew) => {
8827
- const matched = resolveEntryForFinding([...ledger.values()], finding);
8828
- if (!matched) {
8829
- const key = findingKey(finding);
8830
- ledger.set(key, {
8831
- ...definedAttributes(finding),
8832
- disposition: demoteNew ? "rerun-noise" : "open",
8833
- firstFlagged: ref,
8834
- flagCount: 1,
8835
- key,
8836
- lastFlagged: ref,
8837
- reopenedCount: 0,
8838
- staleRepeatCount: 0,
8839
- title: finding.title.trim()
8840
- });
8841
- return;
8842
- }
8843
- matched.flagCount += 1;
8844
- matched.lastFlagged = ref;
8845
- Object.assign(matched, definedAttributes(finding));
8846
- REFLAG_TRANSITIONS[matched.disposition](matched);
8847
- };
8848
- const findingKeySet = (findings) => new Set(findings.map((finding) => findingKey(finding)));
8849
- const isSameHeadRerun = (previous, current) => previous?.identity !== void 0 && current.identity !== void 0 && evidenceFresh({
8850
- candidate: current.identity,
8851
- recorded: previous.identity,
8852
- rule: "patch-id"
8853
- });
8854
- const isDisjointSameHeadRerun = (previous, current) => {
8855
- if (current.correlatedRerun) return false;
8856
- if (!isSameHeadRerun(previous, current) || previous?.findings.length === 0 || current.findings.length === 0) return false;
8857
- const previousKeys = findingKeySet(previous?.findings ?? []);
8858
- return current.findings.every((finding) => !previousKeys.has(findingKey(finding)));
8859
- };
8860
- const foldLedger = (cycles, refs) => {
8861
- const ledger = /* @__PURE__ */ new Map();
8862
- const unmatchedDispositions = [];
8863
- for (const [index, entry] of cycles.entries()) {
8864
- const dispositions = entry.dispositions ?? [];
8865
- const severityFloorDispositions = dispositions.filter((declaration) => declaration.findingReport !== void 0);
8866
- for (const declaration of dispositions.filter((candidate) => candidate.findingReport === void 0)) if (!applyDisposition(ledger, declaration)) unmatchedDispositions.push(declaration);
8867
- const demoteNew = isDisjointSameHeadRerun(cycles[index - 1], entry);
8868
- for (const finding of entry.findings) applyFlag(ledger, finding, refs[index], demoteNew);
8869
- for (const declaration of severityFloorDispositions) if (!applyDisposition(ledger, declaration)) unmatchedDispositions.push(declaration);
8870
- }
8871
- return {
8872
- ledger,
8873
- unmatchedDispositions
8874
- };
8875
- };
8876
- const openLadderFindings = (ledger) => ledger.filter((entry) => entry.disposition === "open");
8877
- const blockingLadderFindings = (ledger) => openLadderFindings(ledger).filter((entry) => resolveFindingBlocking(entry, entry.disposition));
8878
- const staleRepeatLadderFindings = (ledger) => ledger.filter((entry) => entry.disposition === "stale-repeat");
8879
- const inferFixedInThreadDispositions = (ledger, findings, options = {}) => {
8880
- if (options.sameHeadAsPriorCycle) return [];
8881
- const flaggedKeys = new Set(findings.map((finding) => {
8882
- const match = resolveEntryForFinding(ledger, finding);
8883
- return match ? match.key : findingKey(finding);
8884
- }));
8885
- return openLadderFindings(ledger).filter((entry) => !flaggedKeys.has(entry.key)).map((entry) => ({
8886
- disposition: "fixed-in-thread",
8887
- finding: {
8888
- title: entry.title,
8889
- ...entry.file === void 0 ? {} : { file: entry.file }
8890
- }
8891
- }));
8892
- };
8893
- const ladderDispositionDeclarationsSchema = z.array(z.object({
8894
- disposition: z.enum([
8895
- "fixed-in-thread",
8896
- "follow-up-filed",
8897
- "waived"
8898
- ]),
8899
- finding: z.object({
8900
- file: z.string().min(1).optional(),
8901
- title: z.string().min(1)
8902
- }).strict(),
8903
- reference: z.string().min(1).optional()
8904
- }).strict());
9460
+ };
9461
+ //#endregion
9462
+ //#region src/packages/review-prompt-sections/schema.ts
9463
+ const REVIEW_PROMPT_SECTION_PROVENANCES = [
9464
+ "coding-standards",
9465
+ "prior-review-ledger",
9466
+ "issue-review-focus"
9467
+ ];
9468
+ const nonBlankString$2 = z.string().refine((value) => value.trim().length > 0, { message: "must not be blank" });
9469
+ const reviewPromptSectionSchema = z.object({
9470
+ provenance: z.enum(REVIEW_PROMPT_SECTION_PROVENANCES),
9471
+ source: nonBlankString$2,
9472
+ text: nonBlankString$2
9473
+ }).strict();
9474
+ const promptSectionRank = (section) => REVIEW_PROMPT_SECTION_PROVENANCES.indexOf(section.provenance);
9475
+ const hasDuplicateProvenance = (sections) => new Set(sections.map((section) => section.provenance)).size !== sections.length;
9476
+ const isOutOfAssemblyOrder = (sections) => sections.some((section, index) => index > 0 && promptSectionRank(section) < promptSectionRank(sections[index - 1]));
9477
+ const reviewPromptSectionsSchema = z.array(reviewPromptSectionSchema).min(1).refine((sections) => !hasDuplicateProvenance(sections) && !isOutOfAssemblyOrder(sections), { message: "prompt sections must be in assembly order without duplicates" });
8905
9478
  //#endregion
8906
9479
  //#region src/review-ladder.ts
8907
9480
  const LADDER_NEXT_ACTION_KINDS = [
@@ -8963,7 +9536,7 @@ const resolveLadderPosition = ({ blockingFindings, gateCount, openFindings, poli
8963
9536
  };
8964
9537
  return {
8965
9538
  forcedTransition: {
8966
- blockingFindings: blockingFindings.map((entry) => entry.key),
9539
+ blockingFindings: blockingFindings.map((entry) => entry.address),
8967
9540
  cap,
8968
9541
  kind: "gate-cap-exhausted"
8969
9542
  },
@@ -9001,7 +9574,7 @@ const eventFor = ({ cycles, openFindings, blockingFindings, position, staleRepea
9001
9574
  };
9002
9575
  const evaluateReviewLadder = ({ cycles, policy }) => {
9003
9576
  const refs = ladderCycleRefsFor(cycles);
9004
- const { ledger, unmatchedDispositions } = foldLedger(cycles, refs);
9577
+ const { ledger, occurrences } = foldLedger(cycles, refs);
9005
9578
  const ledgerEntries = [...ledger.values()];
9006
9579
  const openFindings = openLadderFindings(ledgerEntries);
9007
9580
  const blockingFindings = blockingLadderFindings(ledgerEntries);
@@ -9029,18 +9602,349 @@ const evaluateReviewLadder = ({ cycles, policy }) => {
9029
9602
  staleRepeats
9030
9603
  }),
9031
9604
  ledger: ledgerEntries,
9032
- unmatchedDispositions
9605
+ occurrences
9606
+ };
9607
+ };
9608
+ //#endregion
9609
+ //#region src/review-policy-declarations.ts
9610
+ const identityRepeatDeclarations = (occurrences, cycleIndex) => occurrences.flatMap((occurrence) => {
9611
+ if (cycleOfAddress(occurrence.address) !== cycleIndex || occurrence.root === occurrence.address) return [];
9612
+ const disposition = IDENTITY_REPEAT_DISPOSITION[projectedGroupDisposition(occurrences.filter((member) => member.root === occurrence.root && cycleOfAddress(member.address) < cycleIndex))];
9613
+ return disposition === void 0 ? [] : [{
9614
+ authority: "policy",
9615
+ disposition,
9616
+ rule: "identity-repeat",
9617
+ target: occurrence.address
9618
+ }];
9619
+ });
9620
+ const severityFloorDeclarations = (occurrences, liveCycleCount) => {
9621
+ if (liveCycleCount < 2) return [];
9622
+ return occurrences.flatMap((occurrence) => {
9623
+ const severity = resolveReviewFindingSeverity(occurrence.finding.priority);
9624
+ return occurrence.disposition === "open" && (severity === "medium" || severity === "low") ? [{
9625
+ authority: "policy",
9626
+ disposition: "policy-accepted",
9627
+ rule: "cycle-2-severity-floor",
9628
+ target: occurrence.address
9629
+ }] : [];
9630
+ });
9631
+ };
9632
+ //#endregion
9633
+ //#region src/review-proof-import.ts
9634
+ const REVIEW_PROOF_IMPORT_REFERENCE = "schema-2 import: declaration recorded without a reference";
9635
+ const CYCLE_2_SEVERITY_FLOOR_REFERENCE = "cycle-2-severity-floor";
9636
+ const legacyFindingSchema = z.object({
9637
+ blockingAfterCap: z.boolean().optional(),
9638
+ category: z.string().optional(),
9639
+ citedSpan: z.string().optional(),
9640
+ file: z.string().optional(),
9641
+ prescribedAction: z.string().optional(),
9642
+ priority: z.string().optional(),
9643
+ supersedes: z.object({
9644
+ file: z.string().optional(),
9645
+ title: z.string()
9646
+ }).passthrough().optional(),
9647
+ title: z.string()
9648
+ }).passthrough();
9649
+ const legacyDeclarationSchema = z.object({
9650
+ disposition: z.enum([
9651
+ "fixed-in-thread",
9652
+ "follow-up-filed",
9653
+ "waived"
9654
+ ]),
9655
+ finding: z.object({
9656
+ file: z.string().optional(),
9657
+ title: z.string()
9658
+ }).passthrough(),
9659
+ findingReport: legacyFindingSchema.optional(),
9660
+ reference: z.string().optional()
9661
+ }).passthrough();
9662
+ const legacyCycleSchema = z.object({
9663
+ correlatedRerun: z.boolean().optional(),
9664
+ dispositions: z.array(legacyDeclarationSchema).optional(),
9665
+ engine: z.string().optional(),
9666
+ findings: z.array(legacyFindingSchema),
9667
+ historical: z.literal(true).optional(),
9668
+ identity: z.object({
9669
+ headSha: z.string(),
9670
+ patchId: z.string()
9671
+ }).passthrough().optional(),
9672
+ model: z.string().optional(),
9673
+ session: z.record(z.string(), z.unknown()).optional(),
9674
+ stage: z.enum(["interior", "gate"]),
9675
+ usage: z.record(z.string(), z.unknown()).optional()
9676
+ }).passthrough();
9677
+ const legacyLadderSchema = z.object({ cycles: z.array(legacyCycleSchema) }).passthrough();
9678
+ const MIN_MEANINGFUL_OVERLAP_LINE_LENGTH = 20;
9679
+ const spansOverlap = (a, b) => {
9680
+ if (a.length === 0 || b.length === 0) return false;
9681
+ if (a === b || a.includes(b) || b.includes(a)) return true;
9682
+ const linesA = new Set(a.split("\n").filter((line) => line.length >= MIN_MEANINGFUL_OVERLAP_LINE_LENGTH));
9683
+ return b.split("\n").some((line) => line.length >= MIN_MEANINGFUL_OVERLAP_LINE_LENGTH && linesA.has(line));
9684
+ };
9685
+ const resolveLegacyEntry = (entries, finding) => {
9686
+ const primaryKey = findingKey(finding);
9687
+ const exact = entries.find((entry) => entry.key === primaryKey);
9688
+ if (exact) return exact;
9689
+ const { supersedes } = finding;
9690
+ if (supersedes) {
9691
+ const superseded = entries.find((entry) => declarationMatchesEntry(entry, supersedes));
9692
+ if (superseded) return superseded;
9693
+ }
9694
+ if (finding.file !== void 0 && finding.citedSpan !== void 0) {
9695
+ const candidateSpan = normalizeCitedSpan(finding.citedSpan);
9696
+ if (candidateSpan.length > 0) return entries.find((entry) => entry.disposition === "open" && entry.file === finding.file && entry.citedSpan !== void 0 && spansOverlap(normalizeCitedSpan(entry.citedSpan), candidateSpan));
9697
+ }
9698
+ };
9699
+ const LEGACY_REFLAG = {
9700
+ "fixed-in-thread": "open",
9701
+ "follow-up-filed": "stale-repeat",
9702
+ open: "open",
9703
+ "rerun-noise": "rerun-noise",
9704
+ "stale-repeat": "stale-repeat",
9705
+ waived: "stale-repeat"
9706
+ };
9707
+ const isDisjointSameHeadRerun = (previous, current) => {
9708
+ if (current.correlatedRerun || previous?.identity === void 0 || current.identity === void 0 || previous.identity.patchId !== current.identity.patchId || previous.findings.length === 0 || current.findings.length === 0) return false;
9709
+ const previousKeys = new Set(previous.findings.map((finding) => findingKey(finding)));
9710
+ return current.findings.every((finding) => !previousKeys.has(findingKey(finding)));
9711
+ };
9712
+ const applyLegacyDeclaration = (entries, declaration) => {
9713
+ const entry = declaration.findingReport ? resolveLegacyEntry(entries, declaration.findingReport) : entries.find((candidate) => declarationMatchesEntry(candidate, declaration.finding));
9714
+ if (entry) entry.disposition = declaration.disposition;
9715
+ };
9716
+ const isFloorDeclaration = (declaration) => declaration.findingReport !== void 0 && declaration.reference === CYCLE_2_SEVERITY_FLOOR_REFERENCE;
9717
+ const outcomeFor = (entries, declaration) => {
9718
+ const matches = entries.filter((entry) => declarationMatchesEntry(entry, declaration.finding));
9719
+ if (matches.length !== 1) return {
9720
+ candidates: matches.flatMap((entry) => entry.members),
9721
+ kind: "refused",
9722
+ reason: matches.length === 0 ? "title+file matches no entry" : `title+file matches ${matches.length} entries`
9723
+ };
9724
+ const [entry] = matches;
9725
+ if (!entry) throw new Error("unreachable: one match");
9726
+ if (entry.keys.size !== 1) return {
9727
+ candidates: [...entry.members],
9728
+ kind: "refused",
9729
+ reason: `the matched entry absorbed ${entry.keys.size} distinct finding keys (${entry.members.join(", ")}); the schema-2 outcome was similarity-grounded`
9730
+ };
9731
+ return {
9732
+ kind: "converted",
9733
+ targets: [...entry.members]
9734
+ };
9735
+ };
9736
+ const replayLegacyFold = (cycles) => {
9737
+ const entries = [];
9738
+ return cycles.map((cycle, cycleIndex) => {
9739
+ const decided = [];
9740
+ const stored = cycle.dispositions ?? [];
9741
+ const beforeFlags = stored.filter((declaration) => declaration.findingReport === void 0);
9742
+ const afterFlags = stored.filter((declaration) => declaration.findingReport !== void 0);
9743
+ for (const declaration of beforeFlags) {
9744
+ decided.push({
9745
+ declaration,
9746
+ outcome: outcomeFor(entries, declaration)
9747
+ });
9748
+ applyLegacyDeclaration(entries, declaration);
9749
+ }
9750
+ const demoteNew = isDisjointSameHeadRerun(cycles[cycleIndex - 1], cycle);
9751
+ for (const [findingIndex, raw] of cycle.findings.entries()) {
9752
+ const finding = toLadderFindingReport(raw);
9753
+ const address = occurrenceAddress(cycleIndex + 1, findingIndex + 1);
9754
+ const key = findingKey(finding);
9755
+ const matched = resolveLegacyEntry(entries, finding);
9756
+ if (matched) {
9757
+ matched.members.push(address);
9758
+ matched.keys.add(key);
9759
+ if (finding.citedSpan !== void 0) matched.citedSpan = finding.citedSpan;
9760
+ if (finding.file !== void 0) matched.file = finding.file;
9761
+ matched.disposition = LEGACY_REFLAG[matched.disposition];
9762
+ continue;
9763
+ }
9764
+ entries.push({
9765
+ ...finding.citedSpan === void 0 ? {} : { citedSpan: finding.citedSpan },
9766
+ disposition: demoteNew ? "rerun-noise" : "open",
9767
+ ...finding.file === void 0 ? {} : { file: finding.file },
9768
+ key,
9769
+ keys: new Set([key]),
9770
+ members: [address],
9771
+ title: finding.title.trim()
9772
+ });
9773
+ }
9774
+ for (const declaration of afterFlags) {
9775
+ if (!isFloorDeclaration(declaration)) decided.push({
9776
+ declaration,
9777
+ outcome: outcomeFor(entries, declaration)
9778
+ });
9779
+ applyLegacyDeclaration(entries, declaration);
9780
+ }
9781
+ return { declarations: decided };
9782
+ });
9783
+ };
9784
+ const convertedDeclaration = (declaration, target) => ({
9785
+ authority: "operator",
9786
+ disposition: declaration.disposition,
9787
+ reference: declaration.reference ?? REVIEW_PROOF_IMPORT_REFERENCE,
9788
+ target
9789
+ });
9790
+ const isUnattestedFix = (declaration) => declaration.disposition === "fixed-in-thread" && declaration.reference === void 0 && declaration.findingReport === void 0;
9791
+ const INFERRED_FIX_REASON = "a fixed-in-thread the schema-2 writer inferred from absence on a blocking-severity occurrence is not a verified fix";
9792
+ const unattestedFixOutcome = (target, finding) => {
9793
+ const severity = resolveReviewFindingSeverity(finding?.priority);
9794
+ return severity === "medium" || severity === "low" ? {
9795
+ authority: "policy",
9796
+ disposition: "policy-accepted",
9797
+ rule: "schema-2-import",
9798
+ target
9799
+ } : void 0;
9800
+ };
9801
+ const recoveryItem = (declaration, cycle, outcome) => ({
9802
+ candidates: outcome.candidates,
9803
+ cycle,
9804
+ declaration: {
9805
+ disposition: declaration.disposition,
9806
+ ...declaration.finding.file === void 0 ? {} : { file: declaration.finding.file },
9807
+ ...declaration.reference === void 0 ? {} : { reference: declaration.reference },
9808
+ title: declaration.finding.title
9809
+ },
9810
+ reason: outcome.reason
9811
+ });
9812
+ const convertCycles = (legacy, proofDigest) => {
9813
+ const replay = replayLegacyFold(legacy);
9814
+ const cycles = [];
9815
+ const recovery = [];
9816
+ let liveCycleCount = 0;
9817
+ for (const [index, cycle] of legacy.entries()) {
9818
+ const cycleIndex = index + 1;
9819
+ if (!(cycle.historical === true)) liveCycleCount += 1;
9820
+ const findings = cycle.findings.map((finding) => toLadderFindingReport(finding));
9821
+ const occurrences = occurrencesForCycle({
9822
+ cycleIndex,
9823
+ declaredCycle: ladderCycleRefsFor([...cycles, { stage: cycle.stage }]).at(-1)?.cycle ?? cycleIndex,
9824
+ findings,
9825
+ priorCycles: cycles
9826
+ });
9827
+ const findingAt = (address) => {
9828
+ const groups = /^c(?<cycle>\d+)f(?<finding>\d+)$/u.exec(address)?.groups;
9829
+ const at = Number(groups?.cycle) - 1;
9830
+ return (at === index ? findings : cycles[at]?.findings)?.[Number(groups?.finding) - 1];
9831
+ };
9832
+ const explicit = [];
9833
+ for (const { declaration, outcome } of replay[index]?.declarations ?? []) {
9834
+ if (outcome.kind === "refused") {
9835
+ recovery.push(recoveryItem(declaration, cycleIndex, outcome));
9836
+ continue;
9837
+ }
9838
+ for (const target of outcome.targets) {
9839
+ if (!isUnattestedFix(declaration)) {
9840
+ explicit.push(convertedDeclaration(declaration, target));
9841
+ continue;
9842
+ }
9843
+ const accepted = unattestedFixOutcome(target, findingAt(target));
9844
+ if (accepted) explicit.push(accepted);
9845
+ else recovery.push(recoveryItem(declaration, cycleIndex, {
9846
+ candidates: [target],
9847
+ kind: "refused",
9848
+ reason: INFERRED_FIX_REASON
9849
+ }));
9850
+ }
9851
+ }
9852
+ const cycleWith = (declarations) => {
9853
+ const recording = {
9854
+ declarations,
9855
+ imported: {
9856
+ proofDigest,
9857
+ schemaVersion: 2
9858
+ },
9859
+ occurrences
9860
+ };
9861
+ const { correlatedRerun: _correlatedRerun, dispositions: _dispositions, findings: _findings, ...rest } = cycle;
9862
+ return {
9863
+ ...rest,
9864
+ findings,
9865
+ recording
9866
+ };
9867
+ };
9868
+ const foldWith = (candidate) => {
9869
+ const all = [...cycles, candidate];
9870
+ return foldLedger(all, ladderCycleRefsFor(all)).occurrences;
9871
+ };
9872
+ const repeats = identityRepeatDeclarations(foldWith(cycleWith(explicit)), cycleIndex);
9873
+ const floor = severityFloorDeclarations(foldWith(cycleWith([...explicit, ...repeats])), liveCycleCount);
9874
+ cycles.push(cycleWith([
9875
+ ...explicit,
9876
+ ...repeats,
9877
+ ...floor
9878
+ ]));
9879
+ }
9880
+ return {
9881
+ cycles,
9882
+ recovery
9883
+ };
9884
+ };
9885
+ /**
9886
+ * The operator-facing readout of a proof's unconverted declarations: one line
9887
+ * per item naming the declaration, why it was refused and the candidate
9888
+ * addresses `pr:review --dispositions` can name. Printed by pr:review,
9889
+ * pr:ready and the admission preflight (doctor) alike.
9890
+ */
9891
+ const describeRecoveryRequired = (items) => items.map((item) => `Recovery required: schema-2 declaration ${item.declaration.disposition} "${item.declaration.title}"${item.declaration.file === void 0 ? "" : ` in ${item.declaration.file}`} (cycle ${item.cycle}) needs re-declaring by address; candidates: ${item.candidates.length === 0 ? "none" : item.candidates.join(", ")}. It could not be converted: ${item.reason}. Use pr:review --dispositions on the next recording; the proof admits nothing until then.`);
9892
+ /** The digest of a proof as the factory serializes it. */
9893
+ const proofSerializationDigest = (raw) => sha256Hex(`${JSON.stringify(raw, null, 2)}\n`);
9894
+ /**
9895
+ * Convert a schema-2 proof value into the schema-3 shape. `proofDigest` names
9896
+ * the original bytes (retained by the recording path for audit); when the
9897
+ * caller holds no bytes, the digest of the factory serialization stands in.
9898
+ * The result still goes through the schema-3 validator.
9899
+ */
9900
+ const importSchema2Proof = (raw, proofDigest = proofSerializationDigest(raw)) => {
9901
+ const parsed = z.object({
9902
+ ladder: legacyLadderSchema.optional(),
9903
+ schemaVersion: z.literal(2)
9904
+ }).passthrough().safeParse(raw);
9905
+ if (!parsed.success) throw new TypeError(`Invalid pr:review proof JSON: schema-2 proof cannot be imported: ${parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`);
9906
+ const { ladder, ...rest } = parsed.data;
9907
+ if (!ladder) {
9908
+ const { schemaVersion: _legacyVersion, ...fields } = rest;
9909
+ return {
9910
+ ...fields,
9911
+ schemaVersion: 3
9912
+ };
9913
+ }
9914
+ const { cycles, recovery } = convertCycles(ladder.cycles, proofDigest);
9915
+ evaluateReviewLadder({
9916
+ cycles,
9917
+ policy: { gate: { cap: 1 } }
9918
+ });
9919
+ const { schemaVersion: _legacyVersion, ...fields } = rest;
9920
+ return {
9921
+ ...fields,
9922
+ importedFrom: {
9923
+ proofDigest,
9924
+ schemaVersion: 2
9925
+ },
9926
+ ladder: { cycles },
9927
+ recoveryRequired: recovery,
9928
+ schemaVersion: 3
9033
9929
  };
9034
9930
  };
9035
9931
  //#endregion
9036
9932
  //#region src/pr-readiness/ladder-proof-state.ts
9037
- const prReviewLadderStateSchema = z.object({ cycles: z.array(z.custom()) }).superRefine((ladder, context) => {
9933
+ const persistedCycleSchema = z.custom((cycle) => typeof cycle === "object" && cycle !== null, "ladder cycle must be an object").superRefine((cycle, context) => {
9934
+ const parsed = reviewCycleRecordingSchema.safeParse(cycle.recording);
9935
+ if (!parsed.success) context.addIssue({
9936
+ code: "custom",
9937
+ message: `ladder cycle carries no valid recording (schema 3): ${parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`,
9938
+ path: ["recording"]
9939
+ });
9940
+ });
9941
+ const prReviewLadderStateSchema = z.object({ cycles: z.array(persistedCycleSchema) }).superRefine((ladder, context) => {
9038
9942
  try {
9039
9943
  ladderLedgerFor(ladder);
9040
- } catch {
9944
+ } catch (error) {
9041
9945
  context.addIssue({
9042
9946
  code: "custom",
9043
- message: "ladder cycles do not fold into a valid disposition ledger"
9947
+ message: `ladder cycles do not fold into a valid disposition ledger: ${error instanceof Error ? error.message : String(error)}`
9044
9948
  });
9045
9949
  }
9046
9950
  });
@@ -9102,13 +10006,12 @@ const openStateFromReviews = (reviews) => {
9102
10006
  openEntries
9103
10007
  };
9104
10008
  };
9105
- const openStateForCycle = (proof, epoch, adjudicatedLedger) => {
9106
- if (adjudicatedLedger) return openStateFromLedger(adjudicatedLedger);
10009
+ const openStateForCycle = (proof, epoch) => {
9107
10010
  if (epoch.kind === "ladder") return epoch.snapshot ? epoch.snapshot.cycleState : openStateFromLadder(epoch.ladder);
9108
10011
  return openStateFromReviews(proof.reviews);
9109
10012
  };
9110
- const reviewCycleStateForEpoch = (proof, epoch, adjudicatedLedger) => {
9111
- const openState = openStateForCycle(proof, epoch, adjudicatedLedger);
10013
+ const reviewCycleStateForEpoch = (proof, epoch) => {
10014
+ const openState = openStateForCycle(proof, epoch);
9112
10015
  const countsBySeverity = countFindingsBySeverity(openState.openEntries);
9113
10016
  const highestOpenSeverity = REVIEW_SEVERITIES.find((severity) => countsBySeverity[severity] > 0);
9114
10017
  const countsByBlockingSeverity = countFindingsBySeverity(openState.blockingEntries);
@@ -9128,7 +10031,7 @@ const reviewCycleStateForEpoch = (proof, epoch, adjudicatedLedger) => {
9128
10031
  windowExhausted
9129
10032
  };
9130
10033
  };
9131
- const reviewCycleStateFor = (proof, policy, adjudicatedLedger) => reviewCycleStateForEpoch(proof, reviewEpochModeFor(proof, policy), adjudicatedLedger);
10034
+ const reviewCycleStateFor = (proof, policy) => reviewCycleStateForEpoch(proof, reviewEpochModeFor(proof, policy));
9132
10035
  const reviewAcceptedAfterCycleCap = (proof, review) => {
9133
10036
  if (review.outcome !== "failed") return false;
9134
10037
  const state = reviewCycleStateFor({
@@ -9137,51 +10040,40 @@ const reviewAcceptedAfterCycleCap = (proof, review) => {
9137
10040
  });
9138
10041
  return state.windowExhausted && state.autoBlockingFindings === 0;
9139
10042
  };
9140
- const ladderRefoldDisagreement = (epoch) => {
9141
- if (epoch.kind !== "ladder") return {
9142
- autoBlockingFindings: 0,
9143
- unmatchedDispositions: 0
9144
- };
9145
- if (epoch.snapshot) return {
9146
- autoBlockingFindings: epoch.snapshot.cycleState.autoBlockingFindings,
9147
- unmatchedDispositions: epoch.snapshot.evaluation.unmatchedDispositions.length
9148
- };
9149
- const { ledger, unmatchedDispositions } = foldLedger(epoch.ladder.cycles, ladderCycleRefsFor(epoch.ladder.cycles));
9150
- return {
9151
- autoBlockingFindings: blockingLadderFindings([...ledger.values()]).length,
9152
- unmatchedDispositions: unmatchedDispositions.length
9153
- };
10043
+ const concealedBlockingFindings = (epoch) => {
10044
+ if (epoch.kind !== "ladder") return 0;
10045
+ if (epoch.snapshot) return epoch.snapshot.cycleState.autoBlockingFindings;
10046
+ return blockingLadderFindings(ladderLedgerFor(epoch.ladder)).length;
9154
10047
  };
9155
10048
  const resolveReviewAcceptanceForEpoch = (proof, review, epoch) => {
9156
- if (review.outcome === "passed") {
9157
- const disagreement = ladderRefoldDisagreement(epoch);
9158
- if (disagreement.autoBlockingFindings > 0 || disagreement.unmatchedDispositions > 0) return "blocked";
9159
- return "accepted-clean";
9160
- }
10049
+ if (proof.recoveryRequired !== void 0 && proof.recoveryRequired.length > 0) return "recovery-required";
10050
+ if (review.outcome === "passed") return concealedBlockingFindings(epoch) > 0 ? "blocked" : "accepted-clean";
9161
10051
  if (review.outcome !== "failed") return "blocked";
9162
10052
  if (epoch.kind === "ladder" && epoch.snapshot && reviewAcceptedThroughSnapshot(epoch.snapshot)) return "accepted-through-ladder";
9163
10053
  if (epoch.kind === "declared-window" && reviewAcceptedAfterCycleCap(proof, review)) return "accepted-after-cap";
9164
10054
  return "blocked";
9165
10055
  };
9166
10056
  const resolveReviewAcceptance = (proof, review, policy) => resolveReviewAcceptanceForEpoch(proof, review, reviewEpochModeFor(proof, policy));
9167
- const resolveReviewTerminalStateForEpoch = (proof, review, epoch, adjudicatedLedger) => {
10057
+ const resolveReviewTerminalStateForEpoch = (proof, review, epoch) => {
9168
10058
  const acceptance = resolveReviewAcceptanceForEpoch(proof, review, epoch);
9169
10059
  if (acceptance === "accepted-clean") return "clean";
9170
- const { autoBlockingFindings, openFindings } = reviewCycleStateForEpoch(proof, epoch, adjudicatedLedger);
9171
- if (acceptance === "blocked") {
9172
- if (ladderRefoldDisagreement(epoch).unmatchedDispositions > 0) return "blocked";
9173
- return openFindings === 0 && autoBlockingFindings === 0 ? "clean" : "blocked";
9174
- }
10060
+ if (acceptance === "recovery-required") return "blocked";
10061
+ const { autoBlockingFindings, openFindings } = reviewCycleStateForEpoch(proof, epoch);
10062
+ if (acceptance === "blocked") return openFindings === 0 && autoBlockingFindings === 0 ? "clean" : "blocked";
9175
10063
  return openFindings === 0 ? "clean" : "accepted-with-findings";
9176
10064
  };
9177
10065
  const resolveReviewTerminalState = (proof, review, policy) => resolveReviewTerminalStateForEpoch(proof, review, reviewEpochModeFor(proof, policy));
9178
- const resolveReviewTerminalStateAcrossReviews = (proof, epoch, adjudicatedLedger) => {
9179
- const states = proof.reviews.map((review) => resolveReviewTerminalStateForEpoch(proof, review, epoch, adjudicatedLedger));
10066
+ const resolveReviewTerminalStateAcrossReviews = (proof, epoch) => {
10067
+ const states = proof.reviews.map((review) => resolveReviewTerminalStateForEpoch(proof, review, epoch));
9180
10068
  if (states.includes("blocked")) return "blocked";
9181
10069
  if (states.includes("accepted-with-findings")) return "accepted-with-findings";
9182
10070
  return states.length > 0 ? "clean" : void 0;
9183
10071
  };
9184
10072
  const resolveReviewTerminalStateAcrossReviewsForPolicy = (proof, policy) => resolveReviewTerminalStateAcrossReviews(proof, reviewEpochModeFor(proof, policy));
10073
+ const accepted = (proof, review, epoch) => {
10074
+ const acceptance = resolveReviewAcceptanceForEpoch(proof, review, epoch);
10075
+ return acceptance !== "blocked" && acceptance !== "recovery-required";
10076
+ };
9185
10077
  const reviewProofForEpoch = (proof, kind, epoch, current) => {
9186
10078
  const review = proof?.reviews.find((entry) => entry.kind === kind);
9187
10079
  if (proof && review && review.typedVerdict !== true) return {
@@ -9189,7 +10081,7 @@ const reviewProofForEpoch = (proof, kind, epoch, current) => {
9189
10081
  reviewedPatchId: proof.patchId,
9190
10082
  untypedVerdict: true
9191
10083
  };
9192
- if (proof && review && resolveReviewAcceptanceForEpoch(proof, review, epoch) !== "blocked") {
10084
+ if (proof && review && accepted(proof, review, epoch)) {
9193
10085
  if (current && !evidenceFresh({
9194
10086
  candidate: current,
9195
10087
  recorded: { patchId: proof.patchId },
@@ -9216,8 +10108,9 @@ var review_proof_exports = /* @__PURE__ */ __exportAll({
9216
10108
  PR_REVIEW_FINAL_READINESS_NOTICE: () => PR_REVIEW_FINAL_READINESS_NOTICE,
9217
10109
  PR_REVIEW_FINDING_PROVENANCE_VERSION: () => 1,
9218
10110
  PR_REVIEW_PROOF_DESCRIPTOR: () => PR_REVIEW_PROOF_DESCRIPTOR,
9219
- PR_REVIEW_SCHEMA_VERSION: () => 2,
10111
+ PR_REVIEW_SCHEMA_VERSION: () => 3,
9220
10112
  REVIEW_SEVERITIES: () => REVIEW_SEVERITIES,
10113
+ parseReviewProofBytes: () => parseReviewProofBytes,
9221
10114
  prReviewFindingSchema: () => prReviewFindingSchema,
9222
10115
  prReviewProofSchema: () => prReviewProofSchema,
9223
10116
  prReviewResultSchema: () => prReviewResultSchema,
@@ -9309,18 +10202,42 @@ const prReviewProofSchema = z.object({
9309
10202
  cleanedPaths: z.array(z.string()),
9310
10203
  findingProvenanceVersion: z.literal(1),
9311
10204
  headSha: z.string().regex(/^[0-9a-f]{40}$/u),
10205
+ importedFrom: z.object({
10206
+ proofDigest: z.string().regex(/^[0-9a-f]{64}$/u),
10207
+ schemaVersion: z.literal(2)
10208
+ }).strict().optional(),
9312
10209
  ladder: prReviewLadderStateSchema.optional(),
9313
10210
  maxReviewCycles: z.number().int().positive().optional(),
9314
10211
  patchId: z.string().regex(/^[0-9a-f]{40,64}$/u),
9315
10212
  policyResolution: policyResolutionSchema.optional(),
10213
+ recoveryRequired: z.array(z.object({
10214
+ candidates: z.array(z.string().regex(OCCURRENCE_ADDRESS_PATTERN)),
10215
+ cycle: z.number().int().positive(),
10216
+ declaration: z.object({
10217
+ disposition: z.enum([
10218
+ "fixed-in-thread",
10219
+ "follow-up-filed",
10220
+ "waived"
10221
+ ]),
10222
+ file: z.string().optional(),
10223
+ reference: z.string().optional(),
10224
+ title: z.string()
10225
+ }).strict(),
10226
+ reason: z.string().min(1)
10227
+ }).strict()).optional(),
9316
10228
  reviewCycle: z.number().int().positive().optional(),
9317
10229
  reviewRequirement: z.object({
9318
10230
  reason: z.enum(["no-applicable-mode", "faithful-merge"]),
9319
10231
  status: z.literal("not-required")
9320
10232
  }).strict().optional(),
9321
10233
  reviews: z.array(prReviewResultSchema),
9322
- schemaVersion: z.literal(2)
10234
+ schemaVersion: z.literal(3)
9323
10235
  }).passthrough().superRefine((proof, context) => {
10236
+ if (proof.importedFrom !== void 0 && proof.recoveryRequired === void 0) context.addIssue({
10237
+ code: "custom",
10238
+ message: "a proof converted from schema 2 (importedFrom) must carry recoveryRequired, an array that may be empty",
10239
+ path: ["recoveryRequired"]
10240
+ });
9324
10241
  if (proof.reviewRequirement && proof.reviews.length > 0) context.addIssue({
9325
10242
  code: "custom",
9326
10243
  message: "not-required review proofs must not contain review runs",
@@ -9337,20 +10254,23 @@ const validatePrReviewResult = (value) => {
9337
10254
  if (!parsed.success) throw new TypeError("Invalid pr:review result JSON");
9338
10255
  return parsed.data;
9339
10256
  };
9340
- const validatePrReviewProof = (value) => {
9341
- if (!value || typeof value !== "object" || value.schemaVersion !== 2) throw new Error("Unsupported pr:review proof schemaVersion");
9342
- const parsed = prReviewProofSchema.safeParse(value);
10257
+ const validatePrReviewProof = (value, proofDigest) => {
10258
+ const imported = value && typeof value === "object" && value.schemaVersion === 2 ? importSchema2Proof(value, proofDigest) : value;
10259
+ if (!imported || typeof imported !== "object" || imported.schemaVersion !== 3) throw new Error("Unsupported pr:review proof schemaVersion");
10260
+ const parsed = prReviewProofSchema.safeParse(imported);
9343
10261
  if (!parsed.success) {
9344
10262
  const inReviews = parsed.error.issues.some((issue) => issue.path[0] === "reviews");
9345
- throw new TypeError(inReviews ? "Invalid pr:review review entry" : "Invalid pr:review proof JSON");
10263
+ throw new TypeError(inReviews ? "Invalid pr:review review entry" : `Invalid pr:review proof JSON: ${parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`);
9346
10264
  }
9347
10265
  return parsed.data;
9348
10266
  };
10267
+ const parseReviewProofBytes = (bytes) => validatePrReviewProof(JSON.parse(bytes), sha256Hex(bytes));
9349
10268
  const PR_REVIEW_PROOF_DESCRIPTOR = {
9350
10269
  defaultPath: DEFAULT_PR_REVIEW_PROOF_PATH,
9351
10270
  label: "pr:review proof",
9352
10271
  parse: validatePrReviewProof,
9353
- schemaVersion: 2
10272
+ parseBytes: parseReviewProofBytes,
10273
+ schemaVersion: 3
9354
10274
  };
9355
10275
  //#endregion
9356
10276
  //#region src/review-proof-applicability.ts
@@ -13380,6 +14300,34 @@ function hqIngestCredentialCheck({ env, resolve = defaultSecretReferenceProbe, r
13380
14300
  //#endregion
13381
14301
  //#region src/admission-preflight.ts
13382
14302
  const errorMessage$1 = (error) => error instanceof Error ? error.message : String(error);
14303
+ /**
14304
+ * The base every row below measures against (#1131, #1121). Bound the same
14305
+ * way `pr:review` binds it, so doctor and publish read one candidate: the
14306
+ * live pull request base when a pull request exists, otherwise the requested
14307
+ * or default ref fetched fresh. A binding failure is reported here and leaves
14308
+ * the candidate unresolved, so every other row says so rather than measuring
14309
+ * against a guessed base.
14310
+ */
14311
+ const candidateBaseCheck = (bound) => "binding" in bound ? {
14312
+ message: `Candidate ${describeCandidateBase(bound.binding)}; pr:review and pr:publish measure against this same base.`,
14313
+ name: "admission:candidate-base",
14314
+ status: "ok"
14315
+ } : {
14316
+ message: `Candidate base could not be bound: ${bound.error}`,
14317
+ name: "admission:candidate-base",
14318
+ status: "warning"
14319
+ };
14320
+ const bindCandidateBase$1 = ({ base, candidateBase, cwd }) => {
14321
+ try {
14322
+ return { binding: resolveCandidateBase({
14323
+ cwd,
14324
+ ...candidateBase === void 0 ? {} : { dependencies: candidateBase },
14325
+ ...base === void 0 ? {} : { requestedBase: base }
14326
+ }) };
14327
+ } catch (error) {
14328
+ return { error: errorMessage$1(error) };
14329
+ }
14330
+ };
13383
14331
  const resolveCandidate = ({ base, cwd, verifyProof }) => {
13384
14332
  try {
13385
14333
  const files = changedFiles(cwd, base);
@@ -13456,11 +14404,38 @@ const verifyProofCheck = ({ candidate, candidateError, preread, verifyProofPath
13456
14404
  };
13457
14405
  };
13458
14406
  /**
14407
+ * Why a review proof file yielded no proof: the file is absent, or it is
14408
+ * present and unreadable (#1121, #1131). The two are different diagnoses with
14409
+ * different repairs, so preflight never reports one as the other.
14410
+ */
14411
+ const reviewProofReadFailure = (error, proofPath) => error?.code === "ENOENT" ? { kind: "absent" } : {
14412
+ kind: "invalid",
14413
+ message: `pr:review proof at ${proofPath} is present but invalid, not absent: ${errorMessage$1(error)}. A proof that fails to parse is treated by readiness as missing evidence; re-record it with pr:review --findings <path>.`
14414
+ };
14415
+ /**
14416
+ * A review-proof lock older than the write wait window (#1132). No writer
14417
+ * ever removes a lock it did not create, so a crashed writer's lock refuses
14418
+ * every later `pr:review` until an operator removes it; this row names the
14419
+ * step. Absent (no row) when there is no lock or it is young enough to be a
14420
+ * live writer's.
14421
+ */
14422
+ const reviewProofLockCheck = (reviewProofPath) => {
14423
+ const held = describeHeldProofLock(reviewProofPath);
14424
+ if (held === void 0 || !proofLockOutlivesWait(held)) return [];
14425
+ return [{
14426
+ message: `pr:review proof lock ${held.lockPath} has been held for ${Math.round(held.ageMs / 1e3)}s since ${held.since} (token ${held.token}, pid ${held.pid}); every recording will refuse with concurrent-write until it is released: ${LOCK_REMOVAL_INSTRUCTION}.`,
14427
+ name: "admission:review-proof-lock",
14428
+ status: "warning"
14429
+ }];
14430
+ };
14431
+ /**
13459
14432
  * Independent review proof. Requiredness comes from the profile-resolved review
13460
14433
  * modes; currency and acceptance come from the same identity and
13461
- * terminal-state helpers `pr:publish` uses.
14434
+ * terminal-state helpers `pr:publish` uses. The proof is read through
14435
+ * `tryReadProof`, which keeps the read error: an unparseable file is reported
14436
+ * as invalid, never as absent (#1121).
13462
14437
  */
13463
- const reviewProofCheck = ({ candidate, candidateError, proof, reviewModes }) => {
14438
+ const reviewProofCheck = ({ candidate, candidateError, read, reviewModes, reviewProofPath }) => {
13464
14439
  const name = "admission:review-proof";
13465
14440
  if (!candidate) return {
13466
14441
  message: `Review proof state is unknown: no git candidate is resolvable here (${candidateError ?? "unknown error"}).`,
@@ -13472,21 +14447,44 @@ const reviewProofCheck = ({ candidate, candidateError, proof, reviewModes }) =>
13472
14447
  name,
13473
14448
  status: "ok"
13474
14449
  };
14450
+ const required = `${reviewModes.join(", ")} review(s) are required. Have an independent clean session write findings and pass them to pr:review --findings <path>.`;
14451
+ if (read.proof === void 0) {
14452
+ const failure = reviewProofReadFailure(read.error, reviewProofPath);
14453
+ return {
14454
+ message: failure.kind === "absent" ? `pr:review proof is absent: no file at ${reviewProofPath}. ${required}` : `${failure.message} ${required}`,
14455
+ name,
14456
+ status: "warning"
14457
+ };
14458
+ }
14459
+ const { proof } = read;
14460
+ const recorded = latestRecordingSentence(proof);
14461
+ const recovery = describeRecoveryRequired(proof.recoveryRequired ?? []).map((line) => ` ${line}`).join("");
13475
14462
  const status = reviewStatus({
13476
14463
  headSha: candidate.headSha,
13477
14464
  patchId: candidate.patchId,
13478
14465
  required: true,
13479
- reviewedHeadSha: proof?.headSha,
13480
- reviewedPatchId: proof?.patchId
14466
+ reviewedHeadSha: proof.headSha,
14467
+ reviewedPatchId: proof.patchId
13481
14468
  });
13482
- const terminal = proof ? resolveReviewTerminalStateAcrossReviewsForPolicy(proof, resolveReviewLadderPolicy()) : void 0;
13483
- const accepted = status === "current" && (terminal === "clean" || terminal === "accepted-with-findings");
14469
+ const terminal = resolveReviewTerminalStateAcrossReviewsForPolicy(proof, resolveReviewLadderPolicy());
14470
+ if (status === "current" && (terminal === "clean" || terminal === "accepted-with-findings")) return {
14471
+ message: `pr:review proof is current for this candidate (${terminal}) across ${reviewModes.join(", ")}.${recorded}`,
14472
+ name,
14473
+ status: "ok"
14474
+ };
13484
14475
  return {
13485
- message: accepted ? `pr:review proof is current for this candidate (${terminal}) across ${reviewModes.join(", ")}.` : `pr:review proof is ${status}${terminal ? ` (${terminal})` : ""}; ${reviewModes.join(", ")} review(s) are required. Have an independent clean session write findings and pass them to pr:publish --findings <path>.`,
14476
+ message: status === "stale" ? `pr:review proof is stale: it reviewed patch ${proof.patchId} at head ${proof.headSha}, and the candidate is patch ${candidate.patchId} at head ${candidate.headSha} (${terminal}).${recorded}${recovery} ${required}` : `pr:review proof is ${status} (${terminal}).${recorded}${recovery} ${required}`,
13486
14477
  name,
13487
- status: accepted ? "ok" : "warning"
14478
+ status: "warning"
13488
14479
  };
13489
14480
  };
14481
+ const latestRecordingSentence = (proof) => {
14482
+ const latest = proof.ladder?.cycles.at(-1)?.recording;
14483
+ const cycle = proof.ladder?.cycles.length ?? 0;
14484
+ if (latest === void 0) return "";
14485
+ if ("recordingId" in latest) return ` Latest recording ${latest.recordingId} (cycle ${cycle}, base ${latest.base.ref} at ${latest.base.sha}).`;
14486
+ return ` Latest cycle ${cycle} was imported from a schema-2 proof (${latest.imported.proofDigest}).`;
14487
+ };
13490
14488
  /**
13491
14489
  * Resolve the demand this candidate actually owes.
13492
14490
  *
@@ -13599,19 +14597,26 @@ const requiredCheckChecks = ({ candidate, candidateError, cwd, profile, profileP
13599
14597
  * requirement is reported by the doctor `github-app` check, which states the
13600
14598
  * consequence of its absence.
13601
14599
  */
13602
- const admissionPreflightChecks = ({ base = "origin/main", cwd, env = process.env, hqIngestCredentials, profile, profilePath, reviewProofPath = DEFAULT_PR_REVIEW_PROOF_PATH, verifyProofPath = DEFAULT_PR_VERIFY_PROOF_PATH }) => {
14600
+ const admissionPreflightChecks = ({ base, candidateBase, cwd, env = process.env, hqIngestCredentials, profile, profilePath, reviewProofPath = DEFAULT_PR_REVIEW_PROOF_PATH, verifyProofPath = DEFAULT_PR_VERIFY_PROOF_PATH }) => {
13603
14601
  const resolvedVerifyProofPath = path.resolve(cwd, verifyProofPath);
13604
14602
  const preread = tryReadProof(readPrVerifyProof, resolvedVerifyProofPath);
13605
- const resolved = resolveCandidate({
14603
+ const bound = bindCandidateBase$1({
13606
14604
  base,
14605
+ candidateBase,
14606
+ cwd
14607
+ });
14608
+ const resolved = "binding" in bound ? resolveCandidate({
14609
+ base: bound.binding.sha,
13607
14610
  cwd,
13608
14611
  verifyProof: preread.proof
13609
- });
14612
+ }) : bound;
13610
14613
  const candidate = "candidate" in resolved ? resolved.candidate : void 0;
13611
14614
  const candidateError = "error" in resolved ? resolved.error : void 0;
13612
- const reviewProof = readOptionalProofFor(PR_REVIEW_PROOF_DESCRIPTOR, cwd, reviewProofPath);
14615
+ const resolvedReviewProofPath = path.resolve(cwd, reviewProofPath);
14616
+ const reviewRead = tryReadProof((proofPath) => readProofFor(PR_REVIEW_PROOF_DESCRIPTOR, cwd, proofPath), resolvedReviewProofPath);
13613
14617
  return [
13614
14618
  authoringSessionCheck(env),
14619
+ candidateBaseCheck(bound),
13615
14620
  verifyProofCheck({
13616
14621
  candidate,
13617
14622
  candidateError,
@@ -13621,9 +14626,11 @@ const admissionPreflightChecks = ({ base = "origin/main", cwd, env = process.env
13621
14626
  reviewProofCheck({
13622
14627
  candidate,
13623
14628
  candidateError,
13624
- proof: reviewProof,
13625
- reviewModes: candidate ? resolveApplicableReviewModes(profile, candidate.files) : ["correctness"]
14629
+ read: reviewRead,
14630
+ reviewModes: candidate ? resolveApplicableReviewModes(profile, candidate.files) : ["correctness"],
14631
+ reviewProofPath: resolvedReviewProofPath
13626
14632
  }),
14633
+ ...reviewProofLockCheck(resolvedReviewProofPath),
13627
14634
  ...requiredCheckChecks({
13628
14635
  candidate,
13629
14636
  candidateError,
@@ -13875,7 +14882,7 @@ function repositoryUrlMatches(origin, profile) {
13875
14882
  //#endregion
13876
14883
  //#region src/commands/doctor.ts
13877
14884
  function createDoctorCommand(output) {
13878
- return markCwdOptionDefault(new Command("doctor").description("Validate a project profile without mutating GitHub or local files").option("--base <ref>", "base branch or ref for --preflight", "origin/main").option("--cwd <path>", "working directory to validate", collectCwdOption).option("--json", "print the doctor report as JSON").option("--preflight", "also list the pre-checkable admission requirements for the current candidate (read-only; never blocks); each required check states whether this candidate is demanded to satisfy it, resolved by candidate:impact; findings-file shape and wave membership have no read-only pre-check").option("--profile <path>", "path to the project profile JSON file").action(async (options) => {
14885
+ return markCwdOptionDefault(new Command("doctor").description("Validate a project profile without mutating GitHub or local files").option("--base <ref>", "base ref to assert for --preflight. With a pull request open for this branch its live base is the base and a disagreeing value is reported; without one, this ref (default origin/main) is fetched fresh").option("--cwd <path>", "working directory to validate", collectCwdOption).option("--json", "print the doctor report as JSON").option("--preflight", "also list the pre-checkable admission requirements for the current candidate (read-only; never blocks); each required check states whether this candidate is demanded to satisfy it, resolved by candidate:impact; findings-file shape and wave membership have no read-only pre-check").option("--profile <path>", "path to the project profile JSON file").action(async (options) => {
13879
14886
  const report = await doctorProjectProfile({
13880
14887
  base: options.base,
13881
14888
  cwd: resolveCwdOption(options.cwd),
@@ -15637,7 +16644,7 @@ const prVerifyEvaluation = ({ acceptedBaselineHeadShas, currentHeadSha, docsOnly
15637
16644
  prVerifyStatus: "missing"
15638
16645
  };
15639
16646
  };
15640
- const addReviewBlockingReasons = ({ blockers, correctnessRequired, correctnessStatus, correctnessUntyped }) => {
16647
+ const addReviewBlockingReasons = ({ blockers, correctnessRequired, correctnessStatus, correctnessUntyped, recovering }) => {
15641
16648
  const demand = reviewModeDemand("correctness");
15642
16649
  if (correctnessRequired && correctnessUntyped) blockers.push({
15643
16650
  demand,
@@ -15651,12 +16658,19 @@ const addReviewBlockingReasons = ({ blockers, correctnessRequired, correctnessSt
15651
16658
  demand,
15652
16659
  reason: "Correctness review proof is stale because the PR-owned patch-id changed."
15653
16660
  });
15654
- else if (correctnessStatus === "blocked") blockers.push({
16661
+ else if (correctnessStatus === "blocked" && !recovering) blockers.push({
15655
16662
  demand,
15656
16663
  reason: "Correctness review proof ran and explicitly failed (open blocking findings or errored run); resolve the findings and re-run pr:review."
15657
16664
  });
15658
16665
  };
15659
- const addSecurityBlockingReasons = ({ blockers, securityRequired, securityStatus, securityUntyped }) => {
16666
+ const recoveryPending = (reviewProof) => (reviewProof?.recoveryRequired?.length ?? 0) > 0;
16667
+ const addRecoveryBlockingReasons = ({ blockers, reviewProof }) => {
16668
+ for (const reason of describeRecoveryRequired(reviewProof?.recoveryRequired ?? [])) blockers.push({
16669
+ demand: reviewModeDemand("correctness"),
16670
+ reason
16671
+ });
16672
+ };
16673
+ const addSecurityBlockingReasons = ({ blockers, recovering, securityRequired, securityStatus, securityUntyped }) => {
15660
16674
  const demand = reviewModeDemand("security");
15661
16675
  if (securityRequired && securityUntyped) blockers.push({
15662
16676
  demand,
@@ -15670,7 +16684,7 @@ const addSecurityBlockingReasons = ({ blockers, securityRequired, securityStatus
15670
16684
  demand,
15671
16685
  reason: "Security review proof is stale because the PR-owned patch-id changed."
15672
16686
  });
15673
- else if (securityStatus === "blocked") blockers.push({
16687
+ else if (securityStatus === "blocked" && !recovering) blockers.push({
15674
16688
  demand,
15675
16689
  reason: "Security review proof ran and explicitly failed (open blocking findings or errored run); resolve or disposition the findings and re-run pr:review."
15676
16690
  });
@@ -15770,6 +16784,7 @@ const awaitPostUndraftChecksRepair = (input) => {
15770
16784
  };
15771
16785
  const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUntyped, input, prVerifyStatus, verificationProof, securityRequired, securityStatus, securityUntyped }) => {
15772
16786
  const blockers = [];
16787
+ const recovering = recoveryPending(input.reviewProof);
15773
16788
  if (prVerifyStatus === "missing") blockers.push({
15774
16789
  demand: DEMAND_KEYS.prVerify,
15775
16790
  reason: "Missing passing typed pr:verify proof for the current PR head."
@@ -15787,10 +16802,16 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
15787
16802
  blockers,
15788
16803
  correctnessRequired,
15789
16804
  correctnessStatus,
15790
- correctnessUntyped
16805
+ correctnessUntyped,
16806
+ recovering
16807
+ });
16808
+ addRecoveryBlockingReasons({
16809
+ blockers,
16810
+ reviewProof: input.reviewProof
15791
16811
  });
15792
16812
  addSecurityBlockingReasons({
15793
16813
  blockers,
16814
+ recovering,
15794
16815
  securityRequired,
15795
16816
  securityStatus,
15796
16817
  securityUntyped
@@ -17027,6 +18048,40 @@ const armingNotice = (arming) => {
17027
18048
  if (arming.outcome === "merged") return `This candidate merged during the arming call: ${arming.detail ?? "GitHub found it already mergeable."}`;
17028
18049
  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.`;
17029
18050
  };
18051
+ /**
18052
+ * #1125: a throw before the branded check is published used to POST a red
18053
+ * stub (`ledger head unknown`) even when the miss was Cursor OIDC mint or
18054
+ * broker exchange. That reads as evaluated-refused. Absence is the #1037
18055
+ * not-yet shape: the required check does not pass, and a retry is not a
18056
+ * candidate repair. Proof-chain refusals still publish red.
18057
+ */
18058
+ const publishThrownFailureCheck = async (input) => {
18059
+ const message = input.error instanceof Error ? input.error.message : String(input.error);
18060
+ if (isFactoryAppUnevaluableError(input.error)) {
18061
+ console.warn(`pr:ready could not evaluate; no ${FACTORY_CHECK_NAMES["pr-ready"]} check run was published for ${input.sha}. ${message.trim()} Retry. This is not a candidate refusal.`);
18062
+ return;
18063
+ }
18064
+ const failureCheckInput = {
18065
+ conclusion: "failure",
18066
+ cwd: input.cwd,
18067
+ gate: "pr-ready",
18068
+ owner: input.owner,
18069
+ pr: input.pr,
18070
+ proof: {
18071
+ blockingReasons: [message],
18072
+ schemaVersion: 3,
18073
+ status: "blocked"
18074
+ },
18075
+ repo: input.repo,
18076
+ sha: input.sha
18077
+ };
18078
+ try {
18079
+ if (!await (input.publishFinalCheckRun ? input.publishFinalCheckRun(failureCheckInput) : ensureFactoryCheckRunPublished(failureCheckInput, { onDiagnostic: (diagnostic) => process.stderr.write(diagnostic) }))) console.warn(`pr:ready could not publish the failing ${FACTORY_CHECK_NAMES["pr-ready"]} check run for ${input.sha}; the required check is absent rather than red.`);
18080
+ } catch (publishError) {
18081
+ const publishMessage = publishError instanceof Error ? publishError.message : String(publishError);
18082
+ console.warn(`pr:ready could not publish the failing ${FACTORY_CHECK_NAMES["pr-ready"]} check run for ${input.sha}; the required check is absent rather than red. ${publishMessage}`);
18083
+ }
18084
+ };
17030
18085
  async function runPrReady(args, dependencies = {}) {
17031
18086
  const cwd = path.resolve(args.cwd ?? process.cwd());
17032
18087
  const profilePath = readinessProfilePath(cwd, args.profilePath);
@@ -17309,27 +18364,15 @@ async function runPrReady(args, dependencies = {}) {
17309
18364
  } catch (error) {
17310
18365
  if (error instanceof UnreadableLiveBaseTipError && presentedHeadSha === void 0) presentedHeadSha = error.headRefOid;
17311
18366
  if (!finalCheckPublished && presentedHeadSha) {
17312
- const message = error instanceof Error ? error.message : String(error);
17313
- const failureCheckInput = {
17314
- conclusion: "failure",
18367
+ await publishThrownFailureCheck({
17315
18368
  cwd,
17316
- gate: "pr-ready",
18369
+ error,
17317
18370
  owner: repository.owner,
17318
18371
  pr: args.pr,
17319
- proof: {
17320
- blockingReasons: [message],
17321
- schemaVersion: 3,
17322
- status: "blocked"
17323
- },
18372
+ publishFinalCheckRun: dependencies.publishFinalCheckRun,
17324
18373
  repo: repository.name,
17325
18374
  sha: presentedHeadSha
17326
- };
17327
- try {
17328
- if (!await (dependencies.publishFinalCheckRun ? dependencies.publishFinalCheckRun(failureCheckInput) : ensureFactoryCheckRunPublished(failureCheckInput, { onDiagnostic: (diagnostic) => process.stderr.write(diagnostic) }))) console.warn(`pr:ready could not publish the failing ${FACTORY_CHECK_NAMES["pr-ready"]} check run for ${presentedHeadSha}; the required check is absent rather than red.`);
17329
- } catch (publishError) {
17330
- const publishMessage = publishError instanceof Error ? publishError.message : String(publishError);
17331
- console.warn(`pr:ready could not publish the failing ${FACTORY_CHECK_NAMES["pr-ready"]} check run for ${presentedHeadSha}; the required check is absent rather than red. ${publishMessage}`);
17332
- }
18375
+ });
17333
18376
  await presentHud();
17334
18377
  }
17335
18378
  throw error;
@@ -17373,7 +18416,37 @@ const tryAppendReviewGateNotRequiredTraceEvent = ({ identity, proof, repoRoot })
17373
18416
  }
17374
18417
  };
17375
18418
  //#endregion
17376
- //#region src/review-findings-file.ts
18419
+ //#region src/review-fault-injection.ts
18420
+ const REVIEW_FAULT_ENV = "FACTORY_REVIEW_FAULT";
18421
+ const REVIEW_FAULT_POINTS = [
18422
+ "after-retain",
18423
+ "proof-rename",
18424
+ "after-proof-write"
18425
+ ];
18426
+ var InjectedReviewFaultError = class extends Error {
18427
+ point;
18428
+ constructor(point) {
18429
+ super(`injected fault at ${point} (${REVIEW_FAULT_ENV})`);
18430
+ this.name = "InjectedReviewFaultError";
18431
+ this.point = point;
18432
+ }
18433
+ };
18434
+ const isFaultPoint = (value) => REVIEW_FAULT_POINTS.includes(value);
18435
+ /**
18436
+ * The fault point this run injects, or undefined. Throws when the variable
18437
+ * is set but injection is not allowed, or names an unknown point.
18438
+ */
18439
+ const resolveReviewFault = ({ allowFaultInjection = false, env = process.env } = {}) => {
18440
+ const requested = env[REVIEW_FAULT_ENV];
18441
+ if (requested === void 0 || requested === "") return;
18442
+ if (env.NODE_ENV !== "test" && !allowFaultInjection) throw new Error(`${REVIEW_FAULT_ENV} is set (${requested}) but fault injection is test-only: unset it, or pass --allow-fault-injection to run the recoverable-recording demonstration deliberately.`);
18443
+ if (!isFaultPoint(requested)) throw new Error(`${REVIEW_FAULT_ENV} names an unknown fault point "${requested}"; known points: ${REVIEW_FAULT_POINTS.join(", ")}.`);
18444
+ return requested;
18445
+ };
18446
+ /** Throw the injected fault when this is its point. */
18447
+ const faultAt = (injected, point) => {
18448
+ if (injected === point) throw new InjectedReviewFaultError(point);
18449
+ };
17377
18450
  const nonBlankString = z.string().trim().min(1);
17378
18451
  const reviewFindingSchema = z.object({
17379
18452
  blockingAfterCap: z.boolean().optional(),
@@ -17413,13 +18486,22 @@ const findingsReviewSchema = z.object({
17413
18486
  path: ["findings"]
17414
18487
  });
17415
18488
  });
18489
+ const reviewResolutionSchema = z.object({
18490
+ reference: nonBlankString,
18491
+ target: z.string().regex(OCCURRENCE_ADDRESS_PATTERN)
18492
+ }).strict();
17416
18493
  const reviewFindingsFileSchema = z.object({
17417
18494
  model: nonBlankString,
17418
18495
  patchId: nonBlankString,
17419
18496
  producer: nonBlankString.optional(),
18497
+ resolutions: z.array(reviewResolutionSchema).optional(),
17420
18498
  reviews: z.array(findingsReviewSchema),
17421
18499
  rung: z.enum(EVIDENCE_REVIEW_RUNGS).optional(),
17422
- schemaVersion: z.union([z.literal(1), z.literal(2)]),
18500
+ schemaVersion: z.union([
18501
+ z.literal(1),
18502
+ z.literal(2),
18503
+ z.literal(3)
18504
+ ]),
17423
18505
  sessionId: nonBlankString
17424
18506
  }).strict().superRefine((file, context) => {
17425
18507
  if (file.schemaVersion === 1) {
@@ -17436,12 +18518,12 @@ const reviewFindingsFileSchema = z.object({
17436
18518
  } else {
17437
18519
  if (file.producer === void 0) context.addIssue({
17438
18520
  code: "custom",
17439
- message: "schemaVersion 2 findings require producer",
18521
+ message: `schemaVersion ${file.schemaVersion} findings require producer`,
17440
18522
  path: ["producer"]
17441
18523
  });
17442
18524
  if (file.rung === void 0) context.addIssue({
17443
18525
  code: "custom",
17444
- message: "schemaVersion 2 findings require rung",
18526
+ message: `schemaVersion ${file.schemaVersion} findings require rung`,
17445
18527
  path: ["rung"]
17446
18528
  });
17447
18529
  }
@@ -17460,13 +18542,92 @@ const reviewFindingsFileSchema = z.object({
17460
18542
  }
17461
18543
  });
17462
18544
  const validateReviewFindingsFile = (value) => reviewFindingsFileSchema.parse(value);
17463
- const parseReviewFindingsFile = (findingsPath, cwd = process.cwd()) => {
17464
- const resolved = path.resolve(cwd, findingsPath);
18545
+ const parseReviewFindingsBytes = (bytes, resolvedPath) => {
18546
+ try {
18547
+ return validateReviewFindingsFile(JSON.parse(bytes));
18548
+ } catch (error) {
18549
+ throw new Error(`--findings file is invalid: ${resolvedPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
18550
+ }
18551
+ };
18552
+ const exampleLeafValues = {
18553
+ blockingAfterCap: true,
18554
+ body: "<what is wrong, where, and how to reproduce it>",
18555
+ category: "correctness",
18556
+ citedSpan: "<the exact code text the finding is about, quoted verbatim from the diff>",
18557
+ file: "<repository-relative path of the file the finding is about>",
18558
+ issuesFlagged: 1,
18559
+ kind: "correctness",
18560
+ line: 12,
18561
+ model: "<your model identifier>",
18562
+ outcome: "failed",
18563
+ patchId: "<the stable patch-id from Review scope above>",
18564
+ prescribedAction: "<a concise, standalone statement of the required fix>",
18565
+ priority: "P2",
18566
+ producer: "<the engine and model that ran this review, for example codex-cli/gpt-5.6-sol>",
18567
+ reference: "<why the named prior occurrence is resolved: the commit or the verified fix>",
18568
+ sessionId: "<this session's id>",
18569
+ summary: "<one concise sentence; use \"No actionable findings.\" only when issuesFlagged is 0>",
18570
+ target: "c1f1",
18571
+ title: "<short finding title>"
18572
+ };
18573
+ const exampleForSchema = (schema, key, overrides, omit) => {
18574
+ if (schema instanceof z.ZodOptional) return exampleForSchema(schema.unwrap(), key, overrides, omit);
18575
+ if (schema instanceof z.ZodObject) return Object.fromEntries(Object.entries(schema.shape).filter(([field]) => !omit.has(field)).map(([field, fieldSchema]) => [field, exampleForSchema(fieldSchema, field, overrides, omit)]));
18576
+ if (schema instanceof z.ZodArray) return [exampleForSchema(schema.element, key, overrides, omit)];
18577
+ const value = overrides[key] ?? exampleLeafValues[key];
18578
+ if (value === void 0) throw new Error(`review-findings-file.ts: no example value for schema field "${key}"; add one beside the schema so the assembled prompt can print it.`);
18579
+ return value;
18580
+ };
18581
+ /**
18582
+ * The findings file the assembled prompt asks a reviewer to write, rendered as
18583
+ * pretty-printed JSON derived from the schema above (#1131). It is a valid
18584
+ * instance, not a description: one failed review with one finding carrying
18585
+ * every optional attribute. `waveRung` selects schemaVersion 3 with that rung,
18586
+ * a producer and one resolution; absent, the schemaVersion 1 shape omits all
18587
+ * three (resolutions are still accepted at version 1; the version-3 example
18588
+ * is the one that shows the field).
18589
+ */
18590
+ const renderReviewFindingsFileExample = (waveRung) => JSON.stringify(exampleForSchema(reviewFindingsFileSchema, "file", waveRung === void 0 ? { schemaVersion: 1 } : {
18591
+ rung: waveRung,
18592
+ schemaVersion: 3
18593
+ }, new Set(waveRung === void 0 ? [
18594
+ "producer",
18595
+ "resolutions",
18596
+ "rung"
18597
+ ] : [])), null, 2);
18598
+ //#endregion
18599
+ //#region src/review-findings-retention.ts
18600
+ const REVIEW_FINDINGS_RETENTION_DIR = ".factory-memory/review-findings";
18601
+ const REVIEW_PROOF_IMPORT_RETENTION_DIR = ".factory-memory/review-proof-imports";
18602
+ const retainReviewBytes = ({ bytes, cwd, directory: relativeDirectory = REVIEW_FINDINGS_RETENTION_DIR }) => {
18603
+ const digest = sha256Hex(bytes);
18604
+ const directory = path.resolve(cwd, relativeDirectory);
18605
+ const target = path.join(directory, `${digest}.json`);
18606
+ mkdirSync(directory, { recursive: true });
18607
+ let existing;
18608
+ try {
18609
+ existing = readFileSync(target, "utf-8");
18610
+ } catch {
18611
+ existing = void 0;
18612
+ }
18613
+ if (existing === bytes) return {
18614
+ digest,
18615
+ path: target
18616
+ };
18617
+ const temp = path.join(directory, `.${digest}.${process.pid}.tmp`);
17465
18618
  try {
17466
- return validateReviewFindingsFile(JSON.parse(readFileSync(resolved, "utf-8")));
18619
+ writeFileSync(temp, bytes);
18620
+ renameSync(temp, target);
17467
18621
  } catch (error) {
17468
- throw new Error(`--findings file is invalid: ${resolved}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
18622
+ try {
18623
+ unlinkSync(temp);
18624
+ } catch {}
18625
+ throw error;
17469
18626
  }
18627
+ return {
18628
+ digest,
18629
+ path: target
18630
+ };
17470
18631
  };
17471
18632
  //#endregion
17472
18633
  //#region src/issue-body-sections.ts
@@ -17578,8 +18739,7 @@ const tryAppendReviewLadderStageTraceEvent = (input) => {
17578
18739
  const isReviewLineageContinuous = (prior, current, isAncestor) => prior.base === current.base && (prior.headSha === current.headSha || isAncestor(prior.headSha, current.headSha));
17579
18740
  //#endregion
17580
18741
  //#region src/review-ladder-proof.ts
17581
- const priorLadderStateFor = ({ base, cwd, headSha, isAncestor, output }) => {
17582
- const prior = readOptionalProofFor(PR_REVIEW_PROOF_DESCRIPTOR, cwd, output);
18742
+ const priorLadderStateFor = ({ base, headSha, isAncestor, prior }) => {
17583
18743
  if (!prior?.ladder) return;
17584
18744
  return isReviewLineageContinuous({
17585
18745
  base: prior.base,
@@ -17589,76 +18749,76 @@ const priorLadderStateFor = ({ base, cwd, headSha, isAncestor, output }) => {
17589
18749
  headSha
17590
18750
  }, isAncestor) ? prior.ladder : void 0;
17591
18751
  };
17592
- const normalizedDispositionKey = ({ file, title }) => `(title=${JSON.stringify(normalizeTitle(title))}, file=${file === void 0 ? "<unspecified>" : JSON.stringify(normalizeFile(file))})`;
17593
- const assertDeclaredDispositionsMatch = (priorLedger, declarations, currentFindings = []) => {
17594
- const unmatched = declarations.filter((declaration) => !priorLedger.some((entry) => declarationMatchesEntry(entry, declaration.finding)) && !currentFindings.some((finding) => declarationMatchesEntry(finding, declaration.finding)));
17595
- if (unmatched.length > 0) {
17596
- const unmatchedKeys = unmatched.map((declaration) => normalizedDispositionKey(declaration.finding)).join("; ");
17597
- const availableTargets = [...priorLedger.map((entry) => normalizedDispositionKey(entry)), ...currentFindings.map((finding) => normalizedDispositionKey(finding))];
17598
- const availableKeys = availableTargets.length === 0 ? "prior ledger is empty" : availableTargets.join("; ");
17599
- throw new Error(`--dispositions entries match no ledger finding. Unmatched normalized declaration keys: ${unmatchedKeys}. Available normalized ledger keys: ${availableKeys}. Declarations are keyed by normalized title plus file; check both against the persisted ladder ledger.`);
17600
- }
17601
- };
17602
- const declaredDispositionsFor = (dispositionsPath, cwd) => {
17603
- if (!dispositionsPath) return [];
18752
+ const readDeclaredDispositions = (dispositionsPath, cwd) => {
18753
+ if (!dispositionsPath) return;
17604
18754
  const resolved = path.resolve(cwd, dispositionsPath);
17605
- const parsed = ladderDispositionDeclarationsSchema.safeParse(JSON.parse(readFileSync(resolved, "utf-8")));
18755
+ const bytes = readFileSync(resolved, "utf-8");
18756
+ let parsed;
18757
+ try {
18758
+ parsed = dispositionDeclarationsFileSchema.safeParse(JSON.parse(bytes));
18759
+ } catch (error) {
18760
+ throw new Error(`--dispositions file is invalid: ${resolved}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
18761
+ }
17606
18762
  if (!parsed.success) throw new Error(`--dispositions file is invalid: ${resolved}: ${parsed.error.issues.map((issue) => issue.message).join("; ")}`);
17607
- return parsed.data;
18763
+ return {
18764
+ bytes,
18765
+ declarations: parsed.data
18766
+ };
17608
18767
  };
17609
18768
  const priorLedgerFor = (priorLadder) => priorLadder ? ladderLedgerFor(priorLadder) : [];
17610
- function ladderStateForRun({ declaredDispositions, cycle, historical, identity, model, policy, priorLadder, priorLedger, resumedSession, runs, sessionAttribution }) {
18769
+ const foldWith = (priorCycles, cycle) => {
18770
+ const cycles = [...priorCycles, cycle];
18771
+ return foldLedger(cycles, ladderCycleRefsFor(cycles)).occurrences;
18772
+ };
18773
+ function ladderStateForRun({ historical, identity, model, policy, priorLadder, recording, runs, sessionAttribution }) {
17611
18774
  const findings = runs.flatMap((run) => run.review.findings.map((finding) => toLadderFindingReport(finding)));
17612
18775
  const priorCycles = priorLadder?.cycles ?? [];
17613
- const inferenceSafe = historical !== true && runs.every((run) => run.review.outcome !== "error");
17614
- const sameHeadAsPriorCycle = isSameHeadRerun(priorCycles.at(-1), { identity });
18776
+ const cycleIndex = priorCycles.length + 1;
18777
+ const declaredCycle = ladderCycleRefsFor([...priorCycles, { stage: "gate" }]).at(-1)?.cycle ?? cycleIndex;
18778
+ const liveCycleCount = priorCycles.filter((cycle) => cycle.historical !== true).length + (historical === true ? 0 : 1);
17615
18779
  const session = {
17616
- ...resumedSession === void 0 ? {} : { resumedSession },
17617
18780
  ...sessionAttribution?.sessionId ? { sessionId: sessionAttribution.sessionId } : {},
17618
18781
  ...sessionAttribution?.runtime ? { runtime: sessionAttribution.runtime } : {}
17619
18782
  };
17620
- const sameCycleDeclared = historical === true ? declaredDispositions.flatMap((declaration) => {
17621
- const finding = findings.find((candidate) => declarationMatchesEntry(candidate, declaration.finding));
17622
- return finding === void 0 ? [] : [{
17623
- ...declaration,
17624
- findingReport: finding
17625
- }];
17626
- }) : [];
17627
- const priorDeclared = historical === true ? declaredDispositions.filter((declaration) => !findings.some((candidate) => declarationMatchesEntry(candidate, declaration.finding))) : declaredDispositions;
17628
- const currentCycle = {
17629
- dispositions: [
17630
- ...inferenceSafe ? inferFixedInThreadDispositions(priorLedger, findings, { sameHeadAsPriorCycle }) : [],
17631
- ...priorDeclared,
17632
- ...sameCycleDeclared,
17633
- ...priorCycles.length >= 1 || (cycle ?? 1) >= 2 ? findings.filter((finding) => {
17634
- const severity = resolveReviewFindingSeverity(finding.priority);
17635
- return severity === "medium" || severity === "low";
17636
- }).map((finding) => ({
17637
- disposition: "follow-up-filed",
17638
- finding: {
17639
- ...finding.file ? { file: finding.file } : {},
17640
- title: finding.title
17641
- },
17642
- findingReport: finding,
17643
- reference: CYCLE_2_SEVERITY_FLOOR_REFERENCE
17644
- })) : []
17645
- ],
17646
- findings,
17647
- ...historical === true ? { historical: true } : {},
17648
- identity,
17649
- model,
17650
- stage: "gate",
17651
- ...Object.keys(session).length > 0 ? { session } : {},
17652
- ...resumedSession ? { correlatedRerun: true } : {}
18783
+ const cycleWith = (declarations) => {
18784
+ const record = {
18785
+ appliedTo: recording.appliedTo,
18786
+ base: recording.base,
18787
+ declarations,
18788
+ ...recording.dispositionsDigest === void 0 ? {} : { dispositionsDigest: recording.dispositionsDigest },
18789
+ findingsDigest: recording.findingsDigest,
18790
+ occurrences: occurrencesForCycle({
18791
+ cycleIndex,
18792
+ declaredCycle,
18793
+ findings,
18794
+ priorCycles
18795
+ }),
18796
+ recordingId: recording.recordingId
18797
+ };
18798
+ return {
18799
+ findings,
18800
+ ...historical === true ? { historical: true } : {},
18801
+ identity,
18802
+ model,
18803
+ recording: record,
18804
+ stage: "gate",
18805
+ ...Object.keys(session).length > 0 ? { session } : {}
18806
+ };
17653
18807
  };
18808
+ const explicit = recording.declarations;
18809
+ const repeats = identityRepeatDeclarations(foldWith(priorCycles, cycleWith(explicit)), cycleIndex);
18810
+ const floor = severityFloorDeclarations(foldWith(priorCycles, cycleWith([...explicit, ...repeats])), liveCycleCount);
18811
+ const currentCycle = cycleWith([
18812
+ ...explicit,
18813
+ ...repeats,
18814
+ ...floor
18815
+ ]);
17654
18816
  const cycles = [...priorCycles, currentCycle];
17655
- const evaluation = evaluateReviewLadder({
17656
- cycles,
17657
- policy
17658
- });
17659
- if (evaluation.unmatchedDispositions.length > 0) throw new Error(`Dispositions were dropped at fold time — they matched no flagged finding in the ladder history: ${evaluation.unmatchedDispositions.map((declaration) => declaration.finding.title).join("; ")}. The fold and the pre-run --dispositions validation disagree; nothing was persisted.`);
17660
18817
  return {
17661
- evaluation,
18818
+ evaluation: evaluateReviewLadder({
18819
+ cycles,
18820
+ policy
18821
+ }),
17662
18822
  ladder: { cycles }
17663
18823
  };
17664
18824
  }
@@ -17680,7 +18840,7 @@ function emitLadderStageTrace({ cwd, evaluation, headSha, issue, patchId, profil
17680
18840
  //#endregion
17681
18841
  //#region src/review-contract.ts
17682
18842
  const SESSION_ID_RULE = `"sessionId" is this review session's own id. It must differ from the authoring session id the candidate was verified under (FACTORY_SESSION_ID): pr:review --findings refuses a findings file whose sessionId equals the authoring session.`;
17683
- const authorityRule = (waveRung) => waveRung === void 0 ? `Emit schemaVersion 1 and omit "producer" and "rung": this candidate resolves no boundary-wave review rung. If your dispatch instructions do name a rung, emit schemaVersion 2 with that rung and with "producer" instead.` : `This candidate belongs to a boundary wave that demands the ${waveRung} review rung, so schemaVersion 2 is required. Record "rung": "${waveRung}" exactly as shown, and set "producer" to the engine and model that ran this review (for example "codex-cli/gpt-5.6-sol"). Findings without both fields are recorded without authority, and pr:publish then refuses with "no passing review at that authority".`;
18843
+ const authorityRule = (waveRung) => waveRung === void 0 ? `Emit schemaVersion 1 and omit "producer" and "rung": this candidate resolves no boundary-wave review rung. If your dispatch instructions do name a rung, emit schemaVersion 3 with that rung and with "producer" instead. "resolutions" is accepted at every schemaVersion: when the prior-review ledger below shows an open occurrence you verified fixed, add "resolutions": [{"target": "<its address, for example c1f1>", "reference": "<the commit or the verified fix>"}]; silence is not resolution, and an occurrence you do not name stays open.` : `This candidate belongs to a boundary wave that demands the ${waveRung} review rung, so schemaVersion 3 is required. Record "rung": "${waveRung}" exactly as shown, and set "producer" to the engine and model that ran this review (for example "codex-cli/gpt-5.6-sol"). Findings without both fields are recorded without authority, and pr:publish then refuses with "no passing review at that authority". "resolutions" is optional: when the prior-review ledger below shows an open occurrence you verified fixed, name it by its address (for example "c1f1") with a reference; silence is not resolution, and an occurrence you do not name stays open.`;
17684
18844
  /**
17685
18845
  * The findings-file instructions for one candidate.
17686
18846
  *
@@ -17688,23 +18848,9 @@ const authorityRule = (waveRung) => waveRung === void 0 ? `Emit schemaVersion 1
17688
18848
  * resolves one. It selects the findings schema version the gate will accept.
17689
18849
  */
17690
18850
  const reviewFooterInstructions = (waveRung) => {
17691
- const schemaVersion = waveRung === void 0 ? 1 : 2;
17692
- return `Do not print a machine-readable footer in your chat response. Write your findings to the typed findings file at the path given in your dispatch/task instructions (the file pr:review reads via --findings <path>); that file is the verdict. The file must be valid JSON matching review-findings-file.ts's schema (schemaVersion ${schemaVersion}):
17693
- {
17694
- "schemaVersion": ${schemaVersion},
17695
- ${waveRung === void 0 ? "" : ` "producer": "<the engine and model that ran this review, for example codex-cli/gpt-5.6-sol>",\n "rung": "${waveRung}",\n`} "model": "<your model identifier>",
17696
- "sessionId": "<this session's id>",
17697
- "patchId": "<the stable patch-id from Review scope above>",
17698
- "reviews": [
17699
- {
17700
- "kind": "correctness" or "security" (this review's mode, matching the mode-floor instructions below),
17701
- "outcome": "passed" or "failed",
17702
- "issuesFlagged": an integer count of actionable findings (must equal findings.length),
17703
- "summary": one concise sentence; use "No actionable findings." only when issuesFlagged is 0,
17704
- "findings": [] when issuesFlagged is 0, otherwise an array of objects with title, body, and optional file, line, priority, category, blockingAfterCap, citedSpan, prescribedAction, and supersedes fields
17705
- }
17706
- ]
17707
- }
18851
+ return `Do not print a machine-readable footer in your chat response. Write your findings to the typed findings file at the path given in your dispatch/task instructions (the file pr:review reads via --findings <path>); that file is the verdict. The file must be valid JSON matching review-findings-file.ts's schema (schemaVersion ${waveRung === void 0 ? 1 : 3}). This example is printed from that schema and validates as written:
18852
+ ${renderReviewFindingsFileExample(waveRung)}
18853
+ Field rules the validator enforces: "kind" is this review's mode ("correctness" or "security", matching the mode-floor instructions below); "outcome" is "passed" or "failed"; "issuesFlagged" must equal findings.length, so a passed review has "issuesFlagged": 0, "findings": [] and "summary": "No actionable findings."; a failed review has at least one finding. Each finding requires title and body; file, line, priority, category, blockingAfterCap, citedSpan, prescribedAction, and supersedes are optional. Do not add fields the example does not show.
17708
18854
  ${authorityRule(waveRung)}
17709
18855
  ${SESSION_ID_RULE}
17710
18856
  Use category "correctness", "safety", "coordination", or "maintainability"; set blockingAfterCap true for correctness, safety, or coordination findings. citedSpan must be the exact code text the finding is about, quoted verbatim from the diff (identity is derived from this, not from the title, so quote precisely). prescribedAction must be a concise, standalone statement of the required fix (identity is derived from this text too).`;
@@ -17785,21 +18931,26 @@ const issueFocusSegment = (focus) => ({
17785
18931
  text: `Per-issue review focus (source: issue #${focus.issueNumber} "${REVIEW_FOCUS_SECTION}" section, appended verbatim):
17786
18932
  ${focus.text}`
17787
18933
  });
17788
- const priorLedgerEntryLine = (entry) => {
17789
- const location = entry.file ? ` (${entry.file})` : "";
17790
- const reference = entry.reference ? ` — ${entry.reference}` : "";
17791
- return `- [${entry.disposition === "open" ? "open, unadjudicated" : entry.disposition}] "${entry.title}"${location}${reference} (flagged ${entry.flagCount}x)`;
17792
- };
18934
+ const priorOccurrenceLine = (entry, occurrence) => {
18935
+ const location = occurrence.finding.file ? ` (${occurrence.finding.file})` : "";
18936
+ const repeat = occurrence.root === occurrence.address ? "" : ` — repeats ${occurrence.root}`;
18937
+ const why = occurrence.reference ?? occurrence.rule;
18938
+ const reference = why === void 0 ? "" : ` — ${why}`;
18939
+ const disposition = occurrence.disposition === "open" ? "open, unadjudicated" : `${occurrence.disposition}, ${occurrence.authority ?? "recorded"}`;
18940
+ const title = occurrence.finding.title.trim() || entry.title;
18941
+ return `- ${occurrence.address} [${disposition}] "${title}"${location}${repeat}${reference}`;
18942
+ };
18943
+ const priorLedgerLines = (entries) => entries.flatMap((entry) => entry.occurrences.map((occurrence) => priorOccurrenceLine(entry, occurrence))).join("\n");
17793
18944
  const priorLedgerSegment = (entries) => ({
17794
18945
  section: {
17795
18946
  provenance: "prior-review-ledger",
17796
18947
  source: "persisted pr:review disposition ledger (prior cycles, #233)",
17797
- text: entries.map(priorLedgerEntryLine).join("\n")
18948
+ text: priorLedgerLines(entries)
17798
18949
  },
17799
- text: `Prior review ledger (source: persisted pr:review disposition ledger from earlier cycles on this PR):
17800
- ${entries.map(priorLedgerEntryLine).join("\n")}
18950
+ text: `Prior review ledger (source: persisted pr:review disposition ledger from earlier cycles on this PR; one line per recorded occurrence, by address):
18951
+ ${priorLedgerLines(entries)}
17801
18952
 
17802
- Open entries above are unadjudicated: they still need a disposition or a supersedes citation. Do not re-raise any already-adjudicated finding (waived, filed as a follow-up, or fixed in this thread) under a new title. If this finding is a restatement of a prior finding shown in this ledger, set supersedes to {"title": "<exact prior title>", "file": "<its file>"} instead of minting a new title for the same theme.`
18953
+ Open occurrences above are unadjudicated: each still needs a disposition. If your review verifies that an open occurrence is resolved at this patch, name it in the findings file's resolutions by address with the reason (resolutions are accepted at every findings schemaVersion); an occurrence you do not name stays open, because silence is never resolution. Do not re-raise any already-adjudicated occurrence (waived, filed as a follow-up, policy-accepted, or fixed in this thread) under a new title. If a finding restates a prior occurrence shown here, set supersedes to {"title": "<exact prior title>", "file": "<its file>"} instead of minting a new title for the same theme.`
17803
18954
  });
17804
18955
  const assembleReviewPrompt = ({ base, changedFiles, cwd = process.cwd(), headSha, issueFocus, kind, patchId, priorLedgerEntries, profile, waveRung }, dependencies = {}) => {
17805
18956
  const priorLedger = priorLedgerEntries ?? [];
@@ -17836,6 +18987,20 @@ const assembleReviewPrompt = ({ base, changedFiles, cwd = process.cwd(), headSha
17836
18987
  };
17837
18988
  };
17838
18989
  //#endregion
18990
+ //#region src/review-recording-id.ts
18991
+ const lengthPrefixed = (field) => `${Buffer.byteLength(field, "utf-8")}:${field}`;
18992
+ const reviewRecordingId = ({ baseSha, dispositionsBytes, findingsBytes, historical, model, patchId, producer, rung, sessionId }) => sha256Hex([
18993
+ patchId,
18994
+ baseSha,
18995
+ sessionId,
18996
+ producer ?? "",
18997
+ rung ?? "",
18998
+ model,
18999
+ historical ? "1" : "0",
19000
+ sha256Hex(findingsBytes),
19001
+ sha256Hex(dispositionsBytes ?? "")
19002
+ ].map((field) => lengthPrefixed(field)).join(""));
19003
+ //#endregion
17839
19004
  //#region src/pr-review.ts
17840
19005
  const DEFAULT_ASSEMBLED_REVIEW_PROMPT_PATH = ".factory-memory/pr-review-prompt.json";
17841
19006
  const MISSING_ASSEMBLED_PROMPT_NOTICE = "pr:review notice: assembled review prompt not found at .factory-memory/pr-review-prompt.json; accepting findings without promptSections.";
@@ -17966,7 +19131,7 @@ const emptyProof = (input) => ({
17966
19131
  reviewCycle: input.cycle,
17967
19132
  ...input.reviewRequirement ? { reviewRequirement: input.reviewRequirement } : {},
17968
19133
  reviews: [],
17969
- schemaVersion: 2
19134
+ schemaVersion: 3
17970
19135
  });
17971
19136
  const assertIndependentSession = ({ headSha, patchId, reviewSessionId, verifyProof }) => {
17972
19137
  if (reviewSessionId === "unknown") throw new Error("Review independence cannot be confirmed: findings sessionId is unknown.");
@@ -18021,7 +19186,12 @@ const reviewResults = (findingsFile, kinds, startedAt, endedAt) => {
18021
19186
  });
18022
19187
  };
18023
19188
  const assertReviewOutcomes = (proof, policy, cycle, maxCycles) => {
18024
- if (!proof.reviews.some((review) => resolveReviewAcceptance(proof, review, policy) === "blocked")) return;
19189
+ const acceptances = new Set(proof.reviews.map((review) => resolveReviewAcceptance(proof, review, policy)));
19190
+ if (acceptances.has("recovery-required")) {
19191
+ for (const line of describeRecoveryRequired(proof.recoveryRequired ?? [])) console.error(line);
19192
+ throw new Error("pr:review recorded the cycle, but the proof requires recovery of a schema-2 declaration before it can admit.");
19193
+ }
19194
+ if (!acceptances.has("blocked")) return;
18025
19195
  const state = reviewCycleStateFor(proof, policy);
18026
19196
  if (proof.reviews.some((review) => review.outcome === "passed")) console.error(`Stored review outcome is passed, but a re-fold of persisted cycles yields ${state.autoBlockingFindings} auto-blocking finding(s) and ${state.openFindings} open finding(s). The stored pass and the ladder disagree.`);
18027
19197
  else if (cycle >= maxCycles) console.error(`Review cycle limit reached (${cycle}/${maxCycles}). Stop and request human direction before rerunning.`);
@@ -18043,19 +19213,18 @@ const assembleGitDependencies = (git) => git.repositoryRoot !== void 0 && git.sh
18043
19213
  repositoryRoot: git.repositoryRoot,
18044
19214
  showFileAtRef: git.showFileAtRef
18045
19215
  } : void 0;
18046
- const writeAssembledReviewPrompt = ({ args, cwd, fetchIssueBody, files, git, headSha, kinds, output, patchId, profile, waveRung }) => {
19216
+ const writeAssembledReviewPrompt = ({ args, baseRef, cwd, fetchIssueBody, files, git, headSha, kinds, output, patchId, profile, waveRung }) => {
18047
19217
  const priorLedger = priorLedgerFor(priorLadderStateFor({
18048
- base: args.base,
18049
- cwd,
19218
+ base: baseRef,
18050
19219
  headSha,
18051
19220
  isAncestor: (ancestor, descendant) => git.isAncestor(cwd, ancestor, descendant),
18052
- output
19221
+ prior: readOptionalProofFor(PR_REVIEW_PROOF_DESCRIPTOR, cwd, output)
18053
19222
  }));
18054
19223
  const issueFocus = args.issue === void 0 ? void 0 : loadIssueFocus(args.issue, profile, fetchIssueBody);
18055
19224
  const assembleGit = assembleGitDependencies(git);
18056
19225
  const assembledReviews = kinds.map((kind) => {
18057
19226
  const assembled = assembleReviewPrompt({
18058
- base: args.base,
19227
+ base: baseRef,
18059
19228
  changedFiles: files,
18060
19229
  cwd,
18061
19230
  headSha,
@@ -18129,13 +19298,13 @@ const withAssembledPromptSections = (reviews, sectionsByKind) => {
18129
19298
  * this, a candidate that removed `review` from its own profile briefed the
18130
19299
  * reviewer on correctness alone and then presented that evidence as complete.
18131
19300
  */
18132
- const reviewPolicyIdentity = ({ args, changedFiles: files, cwd, dependencies, git, profile, profilePath }) => resolveStageReviewDemand({
19301
+ const reviewPolicyIdentity = ({ baseRef, changedFiles: files, cwd, dependencies, git, profile, profilePath, pullRequestLookup }) => resolveStageReviewDemand({
18133
19302
  authority: dependencies.basePolicy ?? defaultBaseReviewPolicyAuthority(),
18134
- callerBaseRef: args.base,
19303
+ callerBaseRef: baseRef,
18135
19304
  candidatePolicy: profile.review,
18136
19305
  changedFiles: files,
18137
19306
  cwd,
18138
- lookupPullRequest: dependencies.lookupPullRequest ?? lookupCandidatePullRequest,
19307
+ lookupPullRequest: dependencies.lookupPullRequest ?? (() => policyLookupFromBaseLookup(pullRequestLookup)),
18139
19308
  profilePath: repositoryRelativeProfilePath({
18140
19309
  cwd,
18141
19310
  profilePath
@@ -18147,7 +19316,7 @@ const reviewPolicyIdentity = ({ args, changedFiles: files, cwd, dependencies, gi
18147
19316
  * nothing extra here; `pr:ready` is the stage that refuses on it (#922).
18148
19317
  */
18149
19318
  const demandedReviewKinds = (policy) => policy.status === "authoritative" ? policy.reviewModes : [];
18150
- const resolveFindingsBinding = ({ args, cwd, findingsPatchId, git, headSha, patchId }) => {
19319
+ const resolveFindingsBinding = ({ args, base, cwd, findingsPatchId, git, headSha, patchId }) => {
18151
19320
  const atCandidate = evidenceFresh({
18152
19321
  candidate: {
18153
19322
  headSha,
@@ -18162,20 +19331,19 @@ const resolveFindingsBinding = ({ args, cwd, findingsPatchId, git, headSha, patc
18162
19331
  }
18163
19332
  if (atCandidate) throw new Error(`--historical findings already match the candidate stable patch-id ${patchId}. Record them as the current cycle, without --historical.`);
18164
19333
  const ancestors = resolveAncestorCandidatePatchIds({
18165
- base: args.base,
19334
+ base: base.sha,
18166
19335
  cwd,
18167
19336
  firstParentCommits: git.firstParentCommits ?? firstParentCommits,
18168
19337
  headSha,
18169
19338
  stablePatchIdAtRef: git.stablePatchIdAtRef ?? defaultStablePatchIdAtRef
18170
19339
  });
18171
19340
  const matched = ancestors.find((ancestor) => ancestor.patchId === findingsPatchId);
18172
- if (!matched) throw new Error(`Review findings patchId ${findingsPatchId} matches no commit on this branch. The candidate stable patch-id is ${patchId}, and ${ancestors.length} earlier head(s) of this branch above ${args.base} were checked. A historical cycle must name a patch this branch really had; a commit a merge brought in from the base is not one of them. A cycle recorded against a different base cannot be matched here, because every patch-id is computed against ${args.base}.`);
19341
+ if (!matched) throw new Error(`Review findings patchId ${findingsPatchId} matches no commit on this branch. The candidate stable patch-id is ${patchId}, and ${ancestors.length} earlier head(s) of this branch above ${base.ref} (${base.sha}) were checked. A historical cycle must name a patch this branch really had; a commit a merge brought in from the base is not one of them. A cycle recorded against a different base cannot be matched here, because every patch-id is computed against ${base.ref} at ${base.sha}.`);
18173
19342
  return {
18174
19343
  kind: "historical",
18175
19344
  ref: matched.ref
18176
19345
  };
18177
19346
  };
18178
- const currentCycleDispositionTargets = (historical, findingsFile) => historical ? findingsFile.reviews.flatMap((review) => review.findings) : [];
18179
19347
  /** The flag combinations that describe no single run. */
18180
19348
  const assertReviewFlagsCombine = (args) => {
18181
19349
  if (args.assemble === true && args.findings !== void 0) throw new Error("--assemble and --findings cannot be combined; they are sequential steps.");
@@ -18225,8 +19393,7 @@ const cycleRecordFor = ({ binding, cwd, findingsFile, identity, kinds, startedAt
18225
19393
  * with, cannot show which policy produced it: the verdict drops, exactly as the
18226
19394
  * other-patch case does.
18227
19395
  */
18228
- const carriedVerdictFor = ({ cwd, output, patchId, policy }) => {
18229
- const prior = readOptionalProofFor(PR_REVIEW_PROOF_DESCRIPTOR, cwd, output);
19396
+ const carriedVerdictFor = ({ patchId, policy, prior }) => {
18230
19397
  if (!prior || !evidenceFresh({
18231
19398
  candidate: { patchId },
18232
19399
  recorded: { patchId: prior.patchId },
@@ -18240,11 +19407,324 @@ const carriedVerdictFor = ({ cwd, output, patchId, policy }) => {
18240
19407
  stage: "pr:review"
18241
19408
  }) ? [] : prior.reviews;
18242
19409
  };
19410
+ /**
19411
+ * Bind the candidate to its base before anything is measured (#1131). A pull
19412
+ * request's live base wins and a disagreeing --base is refused; the SHA is
19413
+ * what every diff is computed against, the ref is what the proof records
19414
+ * (review-lineage keys on it). The pull request read here also serves the
19415
+ * policy-authority lookup, so one GitHub read answers both.
19416
+ */
19417
+ const bindCandidateBase = ({ args, cwd, dependencies, git }) => {
19418
+ const pullRequestLookup = (dependencies.lookupPullRequestBase ?? lookupLivePullRequestBase)(cwd);
19419
+ const base = resolveCandidateBase({
19420
+ cwd,
19421
+ dependencies: {
19422
+ fetchOriginRef: git.fetchOriginRef ?? fetchOriginRef,
19423
+ liveBranchTipSha: dependencies.liveBranchTipSha ?? liveBranchTipSha,
19424
+ lookupPullRequestBase: () => pullRequestLookup,
19425
+ resolveCommitSha: git.resolveCommitSha ?? resolveCommitSha
19426
+ },
19427
+ requestedBase: args.base
19428
+ });
19429
+ console.log(`pr:review ${describeCandidateBase(base)}`);
19430
+ return {
19431
+ base,
19432
+ pullRequestLookup
19433
+ };
19434
+ };
19435
+ /**
19436
+ * The line that states a review cycle was recorded (#1131). Printed before
19437
+ * admission is judged, so "recorded" and "refused" are reported as two facts.
19438
+ */
19439
+ const recordedReviewProofLine = ({ cycle, headSha, historical, output, patchId, recordingId }) => `pr:review recorded ${historical ? "historical " : ""}cycle ${cycle} findings for patch ${patchId} at ${headSha}: proof written to ${output} (recording ${recordingId}).`;
19440
+ /**
19441
+ * The line an identical retry prints (#1131, decision 6): the recording is
19442
+ * already on the ladder, so nothing is written and no cycle is consumed.
19443
+ */
19444
+ const ALREADY_RECORDED_PREFIX = "already-recorded";
19445
+ const alreadyRecordedLine = ({ output, recordingId }) => `${ALREADY_RECORDED_PREFIX}: recording ${recordingId} is already on the ladder in ${output}; nothing written, no cycle consumed.`;
19446
+ /**
19447
+ * The findings file, read once as bytes and parsed from those bytes (#1131):
19448
+ * the recording identity digests, retains and records exactly what was
19449
+ * validated.
19450
+ */
19451
+ const readFindingsInput = (findingsPath, cwd) => {
19452
+ const resolved = path.resolve(cwd, findingsPath);
19453
+ const bytes = readFileSync(resolved, "utf-8");
19454
+ return {
19455
+ bytes,
19456
+ file: parseReviewFindingsBytes(bytes, resolved)
19457
+ };
19458
+ };
19459
+ /**
19460
+ * The proof already on disk, parsed from the bytes whose digest this
19461
+ * recording is applied to. A file that does not parse is refused: recording
19462
+ * over it would replace history this run cannot read. A schema-2 proof is
19463
+ * converted here by the bounded import (#1068); its original bytes are
19464
+ * retained before the converted proof is written.
19465
+ */
19466
+ const parsePriorProof = (bytes, output) => {
19467
+ try {
19468
+ return parseReviewProofBytes(bytes);
19469
+ } catch (error) {
19470
+ throw new Error(`pr:review refuses to record over an unreadable proof at ${output}: ${error instanceof Error ? error.message : String(error)} Move the file aside to start fresh ladder history, or re-record once it can be read.`, { cause: error });
19471
+ }
19472
+ };
19473
+ /**
19474
+ * The --output copy is optional (decision 6): a failure is reported on its
19475
+ * own line and never unrecords the canonical proof.
19476
+ */
19477
+ const writeOptionalProofCopy = (proof, additional, canonical) => {
19478
+ if (additional === void 0 || additional === canonical) return;
19479
+ try {
19480
+ writeProofJson(proof, additional);
19481
+ } catch (error) {
19482
+ console.error(`pr:review optional copy not written to ${additional}: ${error instanceof Error ? error.message : String(error)}. The canonical proof at ${canonical} is recorded.`);
19483
+ }
19484
+ };
19485
+ /**
19486
+ * A proof file system whose rename raises the injected fault (#1131): the
19487
+ * temp file is written and fsynced, the canonical path is never replaced.
19488
+ */
19489
+ const faultingProofFileSystem = (point) => ({
19490
+ closeSync: nodeFs.closeSync,
19491
+ fsyncSync: nodeFs.fsyncSync,
19492
+ mkdirSync: nodeFs.mkdirSync,
19493
+ openSync: nodeFs.openSync,
19494
+ readFileSync: nodeFs.readFileSync,
19495
+ renameSync: () => {
19496
+ throw new InjectedReviewFaultError(point);
19497
+ },
19498
+ statSync: nodeFs.statSync,
19499
+ unlinkSync: nodeFs.unlinkSync,
19500
+ writeFileSync: nodeFs.writeFileSync
19501
+ });
19502
+ /**
19503
+ * The cycle number to report on the recorded-proof line (#1131). This is the
19504
+ * ladder's own assignment — the same value `eventFor` in review-ladder.ts
19505
+ * stamps onto the trace event's `cycle` field — never a recomputation of it.
19506
+ * A requested `--cycle` can under-report when a prior ladder carries forward
19507
+ * onto this run, so it is only the fallback for the (practically unreachable)
19508
+ * case where a cycle was just appended but the evaluator emitted no event.
19509
+ */
19510
+ const reportedLadderCycle = (evaluation, requestedCycle) => evaluation.event?.cycle ?? requestedCycle;
19511
+ /**
19512
+ * A schema-2 proof the bounded import just converted (#1068): its original
19513
+ * bytes are retained, content-addressed under the digest the converted proof
19514
+ * names, before that proof replaces them. Nothing is retained when the
19515
+ * lineage broke and the imported history is not being carried forward.
19516
+ */
19517
+ const retainImportedProofBytes = ({ cwd, prior, priorLadder, proofState }) => {
19518
+ if (priorLadder === void 0 || !prior?.importedFrom || !proofState.present) return;
19519
+ retainReviewBytes({
19520
+ bytes: proofState.bytes,
19521
+ cwd,
19522
+ directory: REVIEW_PROOF_IMPORT_RETENTION_DIR
19523
+ });
19524
+ };
19525
+ /**
19526
+ * Import provenance rides with the imported history (#1068). Recovery items
19527
+ * ride too, until a recording carries an explicit --dispositions file: that
19528
+ * file is the operator's act after reading the candidate addresses, whatever
19529
+ * it declares.
19530
+ */
19531
+ const importedHistoryFields = ({ declared, prior, priorLadder }) => {
19532
+ if (priorLadder === void 0 || !prior?.importedFrom) return {};
19533
+ const targets = new Set(declared.map((record) => record.target));
19534
+ const remaining = (prior.recoveryRequired ?? []).filter((item) => !item.candidates.some((candidate) => targets.has(candidate)));
19535
+ return {
19536
+ importedFrom: prior.importedFrom,
19537
+ recoveryRequired: remaining
19538
+ };
19539
+ };
19540
+ /**
19541
+ * Record one review cycle (#1131). The recording is a separate fact from
19542
+ * admission: this returns what was recorded (or that it already was), and
19543
+ * the caller judges admission afterwards.
19544
+ *
19545
+ * Order: read the proof bytes once (their digest is the state the recording
19546
+ * is applied to; an unreadable proof is a refusal) → compute the recording
19547
+ * identity and answer an identical retry with `already-recorded`, writing
19548
+ * nothing → translate declarations → retain the reviewer's bytes → fold →
19549
+ * compare-and-swap write → recorded line → best-effort copies.
19550
+ */
19551
+ const recordReviewCycle = ({ additionalOutput, args, base, cwd, dependencies, files, findingsInput, git, headSha, kinds, ladderPolicy, maxCycles, output, patchId, policy, profile, startedAt, verifyProof }) => {
19552
+ const findingsFile = findingsInput.file;
19553
+ const fault = resolveReviewFault({ allowFaultInjection: args.allowFaultInjection });
19554
+ const binding = resolveFindingsBinding({
19555
+ args,
19556
+ base,
19557
+ cwd,
19558
+ findingsPatchId: findingsFile.patchId,
19559
+ git,
19560
+ headSha,
19561
+ patchId
19562
+ });
19563
+ assertIndependentSession({
19564
+ headSha,
19565
+ patchId,
19566
+ reviewSessionId: findingsFile.sessionId,
19567
+ verifyProof
19568
+ });
19569
+ const proofState = readProofFileState(output);
19570
+ const prior = proofState.present ? parsePriorProof(proofState.bytes, output) : void 0;
19571
+ const historical = binding.kind === "historical";
19572
+ const dispositionsInput = readDeclaredDispositions(args.dispositions, cwd);
19573
+ const { identity, reviews } = cycleRecordFor({
19574
+ binding,
19575
+ cwd,
19576
+ findingsFile,
19577
+ identity: {
19578
+ headSha,
19579
+ patchId
19580
+ },
19581
+ kinds,
19582
+ startedAt
19583
+ });
19584
+ const recordingId = reviewRecordingId({
19585
+ baseSha: base.sha,
19586
+ dispositionsBytes: dispositionsInput?.bytes,
19587
+ findingsBytes: findingsInput.bytes,
19588
+ historical,
19589
+ model: findingsFile.model,
19590
+ patchId: identity.patchId,
19591
+ producer: findingsFile.producer,
19592
+ rung: findingsFile.rung,
19593
+ sessionId: findingsFile.sessionId
19594
+ });
19595
+ const alreadyRecorded = prior?.ladder?.cycles.find((cycle) => "recordingId" in cycle.recording && cycle.recording.recordingId === recordingId);
19596
+ if (prior && alreadyRecorded) {
19597
+ console.log(alreadyRecordedLine({
19598
+ output,
19599
+ recordingId
19600
+ }));
19601
+ return {
19602
+ outcome: "already-recorded",
19603
+ proof: prior
19604
+ };
19605
+ }
19606
+ const priorLadder = priorLadderStateFor({
19607
+ base: base.ref,
19608
+ headSha,
19609
+ isAncestor: (ancestor, descendant) => git.isAncestor(cwd, ancestor, descendant),
19610
+ prior
19611
+ });
19612
+ const currentFindings = reviews.flatMap((review) => review.findings.map((finding) => toLadderFindingReport(finding)));
19613
+ const declared = resolveDeclaredDispositions({
19614
+ declarations: dispositionsInput?.declarations ?? [],
19615
+ index: occurrenceIndexFor([...priorLadder?.cycles ?? [], ...historical ? [{ findings: currentFindings }] : []]),
19616
+ resolutions: findingsFile.resolutions ?? []
19617
+ });
19618
+ retainImportedProofBytes({
19619
+ cwd,
19620
+ prior,
19621
+ priorLadder,
19622
+ proofState
19623
+ });
19624
+ const retainedFindings = retainReviewBytes({
19625
+ bytes: findingsInput.bytes,
19626
+ cwd
19627
+ });
19628
+ const retainedDispositions = dispositionsInput ? retainReviewBytes({
19629
+ bytes: dispositionsInput.bytes,
19630
+ cwd
19631
+ }) : void 0;
19632
+ faultAt(fault, "after-retain");
19633
+ const runs = reviews.map((review) => ({ review }));
19634
+ const { evaluation, ladder } = ladderStateForRun({
19635
+ historical,
19636
+ identity,
19637
+ model: findingsFile.model,
19638
+ policy: ladderPolicy,
19639
+ priorLadder,
19640
+ recording: {
19641
+ appliedTo: proofState.digest,
19642
+ base: {
19643
+ ref: base.ref,
19644
+ sha: base.sha
19645
+ },
19646
+ declarations: declared,
19647
+ ...retainedDispositions === void 0 ? {} : { dispositionsDigest: retainedDispositions.digest },
19648
+ findingsDigest: retainedFindings.digest,
19649
+ recordingId
19650
+ },
19651
+ runs,
19652
+ sessionAttribution: { sessionId: findingsFile.sessionId }
19653
+ });
19654
+ const proof = {
19655
+ base: base.ref,
19656
+ changedFiles: files,
19657
+ cleanedPaths: [],
19658
+ findingProvenanceVersion: 1,
19659
+ headSha,
19660
+ ...importedHistoryFields({
19661
+ declared,
19662
+ prior,
19663
+ priorLadder
19664
+ }),
19665
+ ladder,
19666
+ maxReviewCycles: maxCycles,
19667
+ patchId,
19668
+ policyResolution: policy.policyResolution,
19669
+ reviewCycle: args.cycle,
19670
+ reviews: historical ? carriedVerdictFor({
19671
+ patchId,
19672
+ policy,
19673
+ prior
19674
+ }) : reviews,
19675
+ schemaVersion: 3
19676
+ };
19677
+ dependencies.beforeProofWrite?.();
19678
+ writeProofJsonApplied(proof, output, proofState.digest, fault === "proof-rename" ? faultingProofFileSystem(fault) : void 0);
19679
+ faultAt(fault, "after-proof-write");
19680
+ console.log(recordedReviewProofLine({
19681
+ cycle: reportedLadderCycle(evaluation, args.cycle),
19682
+ headSha: identity.headSha,
19683
+ historical,
19684
+ output,
19685
+ patchId: identity.patchId,
19686
+ recordingId
19687
+ }));
19688
+ writeOptionalProofCopy(proof, additionalOutput, output);
19689
+ enqueueHqIngest({
19690
+ cwd,
19691
+ kind: "pr-review-proof",
19692
+ payload: proof,
19693
+ payloadPath: output,
19694
+ profile,
19695
+ ...args.issue === void 0 ? {} : { subject: { issueNumber: args.issue } }
19696
+ }, dependencies.hq);
19697
+ emitLadderStageTrace({
19698
+ cwd,
19699
+ evaluation,
19700
+ headSha,
19701
+ issue: args.issue,
19702
+ patchId,
19703
+ profile
19704
+ });
19705
+ if (binding.kind === "historical") {
19706
+ console.error(`pr:review recorded a historical cycle for patch ${findingsFile.patchId} at commit ${binding.ref}. It renders no verdict of its own; admission needs a clean cycle at ${patchId}.`);
19707
+ return {
19708
+ outcome: "historical",
19709
+ proof
19710
+ };
19711
+ }
19712
+ return {
19713
+ outcome: "recorded",
19714
+ proof
19715
+ };
19716
+ };
18243
19717
  async function runPrReview(args, dependencies = {}) {
18244
19718
  assertReviewFlagsCombine(args);
18245
19719
  const cwd = path.resolve(args.cwd ?? process.cwd());
18246
19720
  const git = dependencies.git ?? defaultPrReviewGit();
18247
- const files = git.changedFiles(cwd, args.base);
19721
+ const { base, pullRequestLookup } = bindCandidateBase({
19722
+ args,
19723
+ cwd,
19724
+ dependencies,
19725
+ git
19726
+ });
19727
+ const files = git.changedFiles(cwd, base.sha);
18248
19728
  assertCleanWorktreeForProof({
18249
19729
  changedFiles: files,
18250
19730
  cwd,
@@ -18261,7 +19741,7 @@ async function runPrReview(args, dependencies = {}) {
18261
19741
  const { headSha, patchId } = createCandidateIdentityResolver({
18262
19742
  cwd,
18263
19743
  git
18264
- })(args.base);
19744
+ })(base.sha);
18265
19745
  const { additional: additionalOutput, canonical: output } = resolveProofOutputPaths(cwd, DEFAULT_PR_REVIEW_PROOF_PATH, args.output);
18266
19746
  const verifyProof = readOptionalProofFor(PR_VERIFY_PROOF_DESCRIPTOR$1, cwd, args.verifyProof ?? ".factory-memory/pr-verify.json");
18267
19747
  const writeProofCopies = (proof) => {
@@ -18276,20 +19756,22 @@ async function runPrReview(args, dependencies = {}) {
18276
19756
  }, dependencies.hq);
18277
19757
  };
18278
19758
  const startedAt = /* @__PURE__ */ new Date();
18279
- const findingsFile = args.findings ? parseReviewFindingsFile(args.findings, cwd) : void 0;
19759
+ const findingsInput = args.findings ? readFindingsInput(args.findings, cwd) : void 0;
19760
+ const findingsFile = findingsInput?.file;
18280
19761
  const policy = reviewPolicyIdentity({
18281
- args,
19762
+ baseRef: base.ref,
18282
19763
  changedFiles: files,
18283
19764
  cwd,
18284
19765
  dependencies,
18285
19766
  git,
18286
19767
  profile,
18287
- profilePath
19768
+ profilePath,
19769
+ pullRequestLookup
18288
19770
  });
18289
19771
  const kinds = enabledKinds(profile, args.mode, files, findingsFile?.reviews.map((review) => review.kind) ?? [], demandedReviewKinds(policy));
18290
19772
  if (args.assemble === true) {
18291
19773
  const assembleProof = () => emptyProof({
18292
- base: args.base,
19774
+ base: base.ref,
18293
19775
  cycle: args.cycle,
18294
19776
  files,
18295
19777
  headSha,
@@ -18302,7 +19784,7 @@ async function runPrReview(args, dependencies = {}) {
18302
19784
  return assembleProof();
18303
19785
  }
18304
19786
  if ((git.recognizeCatchUp ?? recognizeCatchUpCandidate)({
18305
- base: args.base,
19787
+ base: base.sha,
18306
19788
  cwd
18307
19789
  }).recognized) {
18308
19790
  console.log(ASSEMBLE_FAITHFUL_MERGE_NOTICE);
@@ -18311,6 +19793,7 @@ async function runPrReview(args, dependencies = {}) {
18311
19793
  const fetchIssueBody = dependencies.fetchIssueBody ?? defaultFetchIssueBody;
18312
19794
  writeAssembledReviewPrompt({
18313
19795
  args,
19796
+ baseRef: base.ref,
18314
19797
  cwd,
18315
19798
  fetchIssueBody,
18316
19799
  files,
@@ -18333,7 +19816,7 @@ async function runPrReview(args, dependencies = {}) {
18333
19816
  }
18334
19817
  const mintNotRequiredProof = (reviewRequirement) => {
18335
19818
  const proof = emptyProof({
18336
- base: args.base,
19819
+ base: base.ref,
18337
19820
  cycle: args.cycle,
18338
19821
  files,
18339
19822
  headSha,
@@ -18367,7 +19850,7 @@ async function runPrReview(args, dependencies = {}) {
18367
19850
  });
18368
19851
  if (!findingsFile) {
18369
19852
  if ((git.recognizeCatchUp ?? recognizeCatchUpCandidate)({
18370
- base: args.base,
19853
+ base: base.sha,
18371
19854
  cwd
18372
19855
  }).recognized) return mintNotRequiredProof({
18373
19856
  reason: "faithful-merge",
@@ -18375,89 +19858,29 @@ async function runPrReview(args, dependencies = {}) {
18375
19858
  });
18376
19859
  throw new Error("--findings <path> is required when review kinds are enabled.");
18377
19860
  }
18378
- const binding = resolveFindingsBinding({
19861
+ const recorded = recordReviewCycle({
19862
+ additionalOutput,
18379
19863
  args,
19864
+ base,
18380
19865
  cwd,
18381
- findingsPatchId: findingsFile.patchId,
19866
+ dependencies,
19867
+ files,
19868
+ findingsInput,
18382
19869
  git,
18383
19870
  headSha,
18384
- patchId
18385
- });
18386
- assertIndependentSession({
18387
- headSha,
18388
- patchId,
18389
- reviewSessionId: findingsFile.sessionId,
18390
- verifyProof
18391
- });
18392
- const priorLadder = priorLadderStateFor({
18393
- base: args.base,
18394
- cwd,
18395
- headSha,
18396
- isAncestor: (ancestor, descendant) => git.isAncestor(cwd, ancestor, descendant),
18397
- output
18398
- });
18399
- const priorLedger = priorLedgerFor(priorLadder);
18400
- const declaredDispositions = declaredDispositionsFor(args.dispositions, cwd);
18401
- const historical = binding.kind === "historical";
18402
- assertDeclaredDispositionsMatch(priorLedger, declaredDispositions, currentCycleDispositionTargets(historical, findingsFile));
18403
- const { identity, reviews } = cycleRecordFor({
18404
- binding,
18405
- cwd,
18406
- findingsFile,
18407
- identity: {
18408
- headSha,
18409
- patchId
18410
- },
18411
19871
  kinds,
18412
- startedAt
18413
- });
18414
- const runs = reviews.map((review) => ({ review }));
18415
- const { evaluation, ladder } = ladderStateForRun({
18416
- cycle: args.cycle,
18417
- declaredDispositions,
18418
- historical,
18419
- identity,
18420
- model: findingsFile.model,
18421
- policy: ladderPolicy,
18422
- priorLadder,
18423
- priorLedger,
18424
- runs,
18425
- sessionAttribution: { sessionId: findingsFile.sessionId }
18426
- });
18427
- const proof = {
18428
- base: args.base,
18429
- changedFiles: files,
18430
- cleanedPaths: [],
18431
- findingProvenanceVersion: 1,
18432
- headSha,
18433
- ladder,
18434
- maxReviewCycles: maxCycles,
18435
- patchId,
18436
- policyResolution: policy.policyResolution,
18437
- reviewCycle: args.cycle,
18438
- reviews: historical ? carriedVerdictFor({
18439
- cwd,
18440
- output,
18441
- patchId,
18442
- policy
18443
- }) : reviews,
18444
- schemaVersion: 2
18445
- };
18446
- writeProofCopies(proof);
18447
- emitLadderStageTrace({
18448
- cwd,
18449
- evaluation,
18450
- headSha,
18451
- issue: args.issue,
19872
+ ladderPolicy,
19873
+ maxCycles,
19874
+ output,
18452
19875
  patchId,
18453
- profile
19876
+ policy,
19877
+ profile,
19878
+ startedAt,
19879
+ verifyProof
18454
19880
  });
18455
- if (binding.kind === "historical") {
18456
- console.error(`pr:review recorded a historical cycle for patch ${findingsFile.patchId} at commit ${binding.ref}. It renders no verdict of its own; admission needs a clean cycle at ${patchId}.`);
18457
- return proof;
18458
- }
18459
- assertReviewOutcomes(proof, ladderPolicy, args.cycle, maxCycles);
18460
- return proof;
19881
+ if (recorded.outcome !== "recorded") return recorded.proof;
19882
+ assertReviewOutcomes(recorded.proof, ladderPolicy, args.cycle, maxCycles);
19883
+ return recorded.proof;
18461
19884
  }
18462
19885
  //#endregion
18463
19886
  //#region src/pr-publish.ts
@@ -18518,16 +19941,52 @@ const defaultFollowUpRunner = (factoryCliInvocation) => (followUp, cwd) => {
18518
19941
  const prPublishCliInvocation = (explicit) => explicit ?? defaultFactoryCliInvocation(factoryCliEntry());
18519
19942
  const errorMessage = (error) => error instanceof Error ? error.message : String(error);
18520
19943
  /**
18521
- * GitHub creates a pull request against a branch name, while the rest of the
18522
- * publish transaction treats `--base` as an exact local Git-ref assertion.
18523
- * Keep those representations separate: only the creation adapter drops the
18524
- * canonical remote qualifier, and callers downstream retain the original ref.
19944
+ * The base one on-disk proof recorded, keeping the doctor's absent/invalid
19945
+ * split (#1121, #1151): no file is no recorded base, but a file that cannot
19946
+ * be read — a schema-version mismatch, corrupt JSON — refuses, because
19947
+ * reading it as absent would create the PR against the repository default,
19948
+ * the #1153 failure again.
18525
19949
  */
18526
- function githubCreationBranchForBaseAssertion(base) {
18527
- return base.startsWith("origin/") ? base.slice(7) : base;
18528
- }
18529
- function discoverOrCreatePullRequestNumber({ args, branch, cwd, gh }) {
18530
- if (args.pr !== void 0) return args.pr;
19950
+ const recordedProofBase = (read, proofPath) => {
19951
+ if (read.proof) return read.proof.base;
19952
+ if (read.error?.code === "ENOENT") return;
19953
+ throw new Error(`pr:publish refuses to create the pull request: the proof at ${proofPath} is present but cannot be read (${errorMessage(read.error)}), so the base it recorded is unknown. Re-record it with the current CLI, or remove the file to create against the repository default branch.`);
19954
+ };
19955
+ /**
19956
+ * The base a missing pull request is created against (#1153).
19957
+ *
19958
+ * `pr:verify` and `pr:review` record the base they measured on their proofs
19959
+ * as the local ref (`origin/<branch>`). Before this, publish created the PR
19960
+ * with no `--base` unless the operator repeated it, so every each-to-epic
19961
+ * member of #1130 opened against `main`, was measured against `main` (an
19962
+ * empty diff and a zero patch id), and had to be re-based by hand. The
19963
+ * precedence is: an explicit `--base` on publish, else the base the on-disk
19964
+ * proofs recorded, else no `--base` at all — GitHub then targets the
19965
+ * repository default branch. Two bases that disagree are refused with both
19966
+ * printed, before anything is created; agreement is by branch name, because
19967
+ * that is what GitHub creates the PR against.
19968
+ */
19969
+ function pullRequestCreationBase({ args, cwd, reviewProofPath, verifyProofPath }) {
19970
+ const verifyBase = recordedProofBase(tryReadProof((proofPath) => readProofFor(PR_VERIFY_PROOF_DESCRIPTOR$1, cwd, proofPath), verifyProofPath), verifyProofPath);
19971
+ const reviewBase = recordedProofBase(tryReadProof((proofPath) => readProofFor(PR_REVIEW_PROOF_DESCRIPTOR, cwd, proofPath), reviewProofPath), reviewProofPath);
19972
+ if (verifyBase !== void 0 && reviewBase !== void 0 && candidateBaseBranch(verifyBase) !== candidateBaseBranch(reviewBase)) throw new Error(`pr:publish refuses to create the pull request: the on-disk proofs disagree on the base — pr:verify recorded ${verifyBase} and pr:review recorded ${reviewBase}. Re-run the chain against one base, then publish.`);
19973
+ let recorded;
19974
+ if (verifyBase !== void 0) recorded = {
19975
+ ref: verifyBase,
19976
+ source: "the pr:verify proof"
19977
+ };
19978
+ else if (reviewBase !== void 0) recorded = {
19979
+ ref: reviewBase,
19980
+ source: "the pr:review proof"
19981
+ };
19982
+ if (args.base.length > 0) {
19983
+ if (recorded && candidateBaseBranch(args.base) !== candidateBaseBranch(recorded.ref)) throw new Error(`pr:publish refuses to create the pull request: --base ${args.base} disagrees with the base recorded on ${recorded.source}, ${recorded.ref}. Omit --base to create against the recorded base, or re-run the chain against ${args.base}.`);
19984
+ return candidateBaseBranch(args.base);
19985
+ }
19986
+ return recorded ? candidateBaseBranch(recorded.ref) : void 0;
19987
+ }
19988
+ function discoverOrCreatePullRequest({ args, branch, cwd, gh, reviewProofPath, verifyProofPath }) {
19989
+ if (args.pr !== void 0) return { number: args.pr };
18531
19990
  if (!branch) throw new Error("pr:publish cannot discover a PR from a detached HEAD; pass --pr.");
18532
19991
  const lookupArgs = [
18533
19992
  "pr",
@@ -18535,24 +19994,43 @@ function discoverOrCreatePullRequestNumber({ args, branch, cwd, gh }) {
18535
19994
  "--head",
18536
19995
  branch,
18537
19996
  "--json",
18538
- "number",
19997
+ "number,baseRefName",
18539
19998
  "--limit",
18540
19999
  "1"
18541
20000
  ];
18542
- const existing = JSON.parse(gh(lookupArgs, cwd));
18543
- if (existing[0]) return existing[0].number;
20001
+ const lookup = () => JSON.parse(gh(lookupArgs, cwd));
20002
+ const [existing] = lookup();
20003
+ if (existing) return { number: existing.number };
20004
+ const creationBase = pullRequestCreationBase({
20005
+ args,
20006
+ cwd,
20007
+ reviewProofPath,
20008
+ verifyProofPath
20009
+ });
18544
20010
  const createArgs = [
18545
20011
  "pr",
18546
20012
  "create",
18547
20013
  "--draft",
18548
20014
  "--fill"
18549
20015
  ];
18550
- if (args.base.length > 0) createArgs.push("--base", githubCreationBranchForBaseAssertion(args.base));
20016
+ if (creationBase !== void 0) createArgs.push("--base", creationBase);
18551
20017
  gh(createArgs, cwd);
18552
- const created = JSON.parse(gh(lookupArgs, cwd));
18553
- if (!created[0]) throw new Error(`gh pr create completed but no PR was found for branch ${branch}.`);
18554
- return created[0].number;
20018
+ const [created] = lookup();
20019
+ if (!created) throw new Error(`gh pr create completed but no PR was found for branch ${branch}.`);
20020
+ const base = created.baseRefName ?? creationBase ?? "the repository default branch";
20021
+ process.stderr.write(`${createdPullRequestLine({
20022
+ base,
20023
+ number: created.number
20024
+ })}\n`);
20025
+ return {
20026
+ created: {
20027
+ base,
20028
+ number: created.number
20029
+ },
20030
+ number: created.number
20031
+ };
18555
20032
  }
20033
+ const createdPullRequestLine = ({ base, number }) => `pr:publish created PR #${number} against ${base}`;
18556
20034
  const readOptionalVerifyProof = (cwd, proofPath) => readOptionalProofFor(PR_VERIFY_PROOF_DESCRIPTOR$1, cwd, proofPath);
18557
20035
  const followUpSummaryLine = (outcome) => {
18558
20036
  if (outcome.status === "succeeded") return `Follow-up succeeded: ${outcome.action.command} — ${outcome.reason}.`;
@@ -18563,6 +20041,7 @@ const verificationReuseSummaryLine = (reuse) => reuse.reused ? `Verification reu
18563
20041
  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.";
18564
20042
  const renderPublishSummary = (result) => {
18565
20043
  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}`)];
20044
+ if (result.createdPullRequest) lines.unshift(createdPullRequestLine(result.createdPullRequest));
18566
20045
  if (result.verificationReuse) lines.push(verificationReuseSummaryLine(result.verificationReuse));
18567
20046
  lines.push(followUpSummaryLine(result.followUp));
18568
20047
  const binding = result.verifyProofBinding ? verifyProofBindingSummaryLine(result.verifyProofBinding) : void 0;
@@ -18992,12 +20471,17 @@ async function runPrPublish(args, dependencies = {}) {
18992
20471
  cwd,
18993
20472
  profilePath: args.profilePath
18994
20473
  });
18995
- const prNumber = discoverOrCreatePullRequestNumber({
20474
+ const verifyProofPath = path.resolve(cwd, args.verifyProof ?? ".factory-memory/pr-verify.json");
20475
+ const reviewProofPath = path.resolve(cwd, args.reviewProof ?? ".factory-memory/pr-review.json");
20476
+ const discovered = discoverOrCreatePullRequest({
18996
20477
  args,
18997
20478
  branch: args.pr === void 0 ? (dependencies.currentBranch ?? currentBranchName)(cwd) : void 0,
18998
20479
  cwd,
18999
- gh: dependencies.gh ?? defaultGhRunner$1
20480
+ gh: dependencies.gh ?? defaultGhRunner$1,
20481
+ reviewProofPath,
20482
+ verifyProofPath
19000
20483
  });
20484
+ const prNumber = discovered.number;
19001
20485
  const readyArgs = {
19002
20486
  ...args,
19003
20487
  pr: prNumber
@@ -19020,8 +20504,6 @@ async function runPrPublish(args, dependencies = {}) {
19020
20504
  pr: fetchPr()
19021
20505
  })
19022
20506
  };
19023
- const verifyProofPath = path.resolve(cwd, args.verifyProof ?? ".factory-memory/pr-verify.json");
19024
- const reviewProofPath = path.resolve(cwd, args.reviewProof ?? ".factory-memory/pr-review.json");
19025
20507
  const headSha = (dependencies.git?.currentHeadSha ?? currentHeadSha)(cwd);
19026
20508
  const verified = await ensureVerifyProof({
19027
20509
  args: publishArgs,
@@ -19115,6 +20597,7 @@ async function runPrPublish(args, dependencies = {}) {
19115
20597
  onFailure: (failed, error) => {
19116
20598
  throw new PrPublishFollowUpError({
19117
20599
  bodyPatched,
20600
+ createdPullRequest: discovered.created,
19118
20601
  followUp: failed,
19119
20602
  handoff: initialHandoff,
19120
20603
  proof: initialProof,
@@ -19140,6 +20623,7 @@ async function runPrPublish(args, dependencies = {}) {
19140
20623
  const handoff = summarizeHumanHandoff(proof);
19141
20624
  const result = {
19142
20625
  bodyPatched,
20626
+ createdPullRequest: discovered.created,
19143
20627
  followUp,
19144
20628
  handoff,
19145
20629
  proof,
@@ -19247,7 +20731,7 @@ function assertForegroundGate({ gate, env = process.env }) {
19247
20731
  //#endregion
19248
20732
  //#region src/commands/pr-review.ts
19249
20733
  function createPrReviewCommand(output, action) {
19250
- return markCwdOptionDefault(new Command("pr:review").description("Assemble a factory-owned review prompt, or validate clean-session findings and write typed review proof").option("--base <ref>", "base branch or ref", "origin/main").option("--cwd <path>", "working directory to review", collectCwdOption).option("--cycle <number>", "review cycle number", positiveInteger$1("--cycle"), 1).option("--mode <mode>", "correctness, security, or all", "all").option("--assemble", "write the factory-owned review prompt for a clean session; do not validate findings").option("--epic <number>", "issue carrying the factory-boundary manifest, which may be the work issue itself; --assemble then asks the reviewer for the findings schema the wave-demanded review rung accepts (#1001)", positiveInteger$1("--epic")).option("--findings <path>", "typed clean-session findings JSON file").option("--historical", "record findings that read an earlier commit on this branch: the cycle joins the ladder as prior history and renders no verdict of its own, so it never admits. It leaves the candidate's recorded verdict untouched. This recording may carry --dispositions for the findings it flags; otherwise declare them on the next cycle once they are persisted ledger entries").option("--dispositions <path>", "JSON file of ladder disposition declarations (waived / follow-up-filed / fixed-in-thread) for previously flagged findings, or for the findings a --historical recording itself flags").option("--issue <number>", "issue number recorded on the review ladder trace; --assemble also loads Review focus from this issue", positiveInteger$1("--issue")).option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--verify-proof <path>", "pr:verify proof JSON path").action(withGateTiming({
20734
+ return markCwdOptionDefault(new Command("pr:review").description("Assemble a factory-owned review prompt, or validate clean-session findings and write typed review proof").option("--base <ref>", "base ref to assert. With a pull request open for this branch its live base is the base and a disagreeing value is refused; without one, this ref (default origin/main) is fetched fresh and its SHA recorded").option("--cwd <path>", "working directory to review", collectCwdOption).option("--cycle <number>", "review cycle number", positiveInteger$1("--cycle"), 1).option("--mode <mode>", "correctness, security, or all", "all").option("--assemble", "write the factory-owned review prompt for a clean session; do not validate findings").option("--epic <number>", "issue carrying the factory-boundary manifest, which may be the work issue itself; --assemble then asks the reviewer for the findings schema the wave-demanded review rung accepts (#1001)", positiveInteger$1("--epic")).option("--findings <path>", "typed clean-session findings JSON file").option("--historical", "record findings that read an earlier commit on this branch: the cycle joins the ladder as prior history and renders no verdict of its own, so it never admits. It leaves the candidate's recorded verdict untouched. This recording may carry --dispositions for the findings it flags; otherwise declare them on the next cycle once they are persisted ledger entries").option("--dispositions <path>", "JSON file of ladder disposition declarations (waived / follow-up-filed / fixed-in-thread) for previously flagged findings, or for the findings a --historical recording itself flags").option("--issue <number>", "issue number recorded on the review ladder trace; --assemble also loads Review focus from this issue", positiveInteger$1("--issue")).option("--output <path>", "write proof JSON to a file").option("--allow-fault-injection", "test-only: honour FACTORY_REVIEW_FAULT (after-retain | proof-rename | after-proof-write) to interrupt the recording at that point and demonstrate that a retry resolves to already-recorded or a clean re-record (#1131)").option("--profile <path>", "path to the project profile JSON file").option("--verify-proof <path>", "pr:verify proof JSON path").action(withGateTiming({
19251
20735
  gate: "pr:review",
19252
20736
  resolveCycle: (options) => options.cycle,
19253
20737
  resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
@@ -19260,8 +20744,9 @@ function createPrReviewCommand(output, action) {
19260
20744
  "all"
19261
20745
  ].includes(options.mode)) throw new Error("--mode requires correctness, security, or all");
19262
20746
  const args = {
20747
+ ...options.allowFaultInjection === true ? { allowFaultInjection: true } : {},
19263
20748
  ...options.assemble === true ? { assemble: true } : {},
19264
- base: options.base,
20749
+ ...options.base === void 0 ? {} : { base: options.base },
19265
20750
  cwd: resolveCwdOption(options.cwd),
19266
20751
  cycle: options.cycle,
19267
20752
  dispositions: options.dispositions,
@@ -19280,7 +20765,7 @@ function createPrReviewCommand(output, action) {
19280
20765
  //#endregion
19281
20766
  //#region src/commands/pr-verify.ts
19282
20767
  function createPrVerifyCommand(output, action = runPrVerify) {
19283
- return markCwdOptionDefault(new Command("pr:verify").description("Run project-profile verification commands and write typed proof").option("--base <ref>", "base branch or ref", "origin/main").option("--cwd <path>", "working directory to verify", collectCwdOption).option("--docs-only", "run the docs/process verification gate").option("--full", "run the full verification gate").option("--authoring-session <id>", "explicit authoring session identity (required for review evidence)").option("--json", "print the proof as JSON after verification").option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--no-status", "skip posting the patronage-factory/pr-verify commit status").action(withGateTiming({
20768
+ return markCwdOptionDefault(new Command("pr:verify").description("Run project-profile verification commands and write typed proof").option("--base <ref>", "base ref to assert. With a pull request open for this branch its live base is the base and a disagreeing value is refused; without one, this ref (default origin/main) is fetched fresh and its SHA recorded").option("--cwd <path>", "working directory to verify", collectCwdOption).option("--docs-only", "run the docs/process verification gate").option("--full", "run the full verification gate").option("--authoring-session <id>", "explicit authoring session identity (required for review evidence)").option("--json", "print the proof as JSON after verification").option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--no-status", "skip posting the patronage-factory/pr-verify commit status").action(withGateTiming({
19284
20769
  gate: "pr:verify",
19285
20770
  resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
19286
20771
  stderr: output.stderr