@patronage/software-factory 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -9,16 +9,16 @@ import crypto, { createHash, createSign, randomUUID } from "node:crypto";
9
9
  import { z } from "zod";
10
10
  import { execFileSync, spawnSync } from "node:child_process";
11
11
  import { link, lstat, mkdir, open, readFile, readdir, realpath, rename, stat, unlink } from "node:fs/promises";
12
+ import os, { homedir } from "node:os";
12
13
  import { createInterface } from "node:readline";
13
14
  import { setImmediate } from "node:timers";
14
15
  import { setImmediate as setImmediate$1, setTimeout as setTimeout$1 } from "node:timers/promises";
15
16
  import { Worker } from "node:worker_threads";
16
17
  import picomatch from "picomatch";
17
- import os, { homedir } from "node:os";
18
18
  import { promisify } from "node:util";
19
19
  import { parse } from "yaml";
20
20
  //#region package.json
21
- var version = "0.23.0";
21
+ var version = "0.25.0";
22
22
  //#endregion
23
23
  //#region src/review-rungs.ts
24
24
  const EVIDENCE_REVIEW_RUNGS$1 = [
@@ -936,6 +936,14 @@ const fetchPullRequestReviews = (fullName, prNumber) => {
936
936
  const CF_ACCESS_CLIENT_ID_ENV = "CF-Access-Client-Id";
937
937
  const CF_ACCESS_CLIENT_SECRET_ENV = "CF-Access-Client-Secret";
938
938
  /**
939
+ * True in a hosted runner, where secret-manager resolution must not be
940
+ * tried (#312). Lives here — a dependency-free leaf both
941
+ * `hq-ingest-preflight.ts` and `hq-credentials.ts` already import from for
942
+ * the env var names above — so neither of those two needs to import the
943
+ * other for it; that import, either direction, would close a cycle (#394).
944
+ */
945
+ const isHostedRunner = (env) => env.CI === "true" || env.CI === "1" || env.GITHUB_ACTIONS === "true";
946
+ /**
939
947
  * Builds a credential-bearing HQ request that cannot follow redirects. Access
940
948
  * credentials must never leave the origin selected by the caller.
941
949
  */
@@ -1387,8 +1395,52 @@ function formatUserConfigError(configPath, error) {
1387
1395
  return `User config is invalid: ${configPath}: ${formatZodIssues(error)}`;
1388
1396
  }
1389
1397
  const DEFAULT_HQ_TRANSPORT_TIMEOUT_MS = 2500;
1390
- const DEFAULT_HQ_RETRY_JOURNAL_PATH = ".factory-memory/hq-retry-journal.jsonl";
1391
- const DEFAULT_HQ_RETRY_SPOOL_PATH = ".factory-memory/hq-retry-spool";
1398
+ const HQ_RETRY_SPOOL_DIRNAME = "hq-retry-spool";
1399
+ const HQ_RETRY_JOURNAL_BASENAME = "hq-retry-journal.jsonl";
1400
+ /**
1401
+ * Appended to a spooled event that can never be delivered (#445).
1402
+ *
1403
+ * The suffix *is* the disposition: every reader in this module selects work by
1404
+ * `*.json`, so a renamed file leaves the drain candidates and the pending
1405
+ * counters at once, without a second state file to keep in step with it. The
1406
+ * bytes are untouched and the original name is one `.undeliverable` away, so
1407
+ * this discards a claim about the file, not the file.
1408
+ */
1409
+ const UNDELIVERABLE_SUFFIX = ".undeliverable";
1410
+ /**
1411
+ * Names the repository whose evidence a spool directory holds (#447).
1412
+ *
1413
+ * The sweep's problem has always been that a directory on a shared root
1414
+ * carries no statement about who wrote it. #420 walked the root and called
1415
+ * everything unfamiliar an orphan, which described another repository's
1416
+ * ordinary spool exactly as well as this repository's obsolete one. #446
1417
+ * replaced the walk with keys derived from this checkout's own identity, which
1418
+ * cannot lie — but also cannot generate a key *encoding* it no longer produces,
1419
+ * losing the retired non-injective era.
1420
+ *
1421
+ * A marker written at enqueue time ends that guessing: the directory says whose
1422
+ * it is, so "an old encoding of mine" and "a current key of someone else's"
1423
+ * stop being indistinguishable. It is written beside the events by the one path
1424
+ * that knows the answer from the profile, and read by the sweep.
1425
+ *
1426
+ * It does **not** cover renames or owner changes. The marker holds the name
1427
+ * that was current when the directory was written, and `markerClaims` compares
1428
+ * it to the name that is current now, so a pre-rename directory does not match.
1429
+ * See `sweepHqSpoolOrphans` — #447 stays open for that.
1430
+ *
1431
+ * Deliberately not `*.json`: every reader in this module selects events by that
1432
+ * suffix, and a marker that looked like an event would be drained as one.
1433
+ */
1434
+ const SPOOL_REPOSITORY_MARKER = ".repository-identity";
1435
+ /**
1436
+ * Depth cap for the marker walk. `<owner>/<repo>/hq-retry-spool` is depth 3,
1437
+ * and the extra level is slack for a key scheme that nests one deeper.
1438
+ */
1439
+ const HQ_SPOOL_SWEEP_MAX_DEPTH = 4;
1440
+ `${HQ_RETRY_JOURNAL_BASENAME}`;
1441
+ `${HQ_RETRY_SPOOL_DIRNAME}`;
1442
+ const LEGACY_FACTORY_MEMORY_DIRNAME = ".factory-memory";
1443
+ const HQ_SPOOL_STATE_SEGMENTS = ["patronage-factory", "hq-spool"];
1392
1444
  const MAX_HQ_CONFIG_BYTES = 1024 * 1024;
1393
1445
  const MAX_HQ_INGEST_PAYLOAD_BYTES = 256 * 1024;
1394
1446
  const MAX_HQ_REPLAY_ENTRIES = 32;
@@ -1429,45 +1481,152 @@ const secureStateDirectory = async (directory, deadline) => {
1429
1481
  return false;
1430
1482
  }
1431
1483
  };
1432
- const secureRetrySpoolPath = async (cwd, deadline) => {
1484
+ const layoutPath = (layout) => path.join(layout.root, ...layout.segments);
1485
+ /**
1486
+ * Where the spool tree lives. Under test, the implicit answer is refused
1487
+ * (#445).
1488
+ *
1489
+ * Three `retro-envelope` fixtures recorded against `https://hq.example` were
1490
+ * found sitting in a real operator's `~/.local/state` — written by tests that
1491
+ * simply never redirected the state home, and invisible until #420's sweep
1492
+ * made stranded evidence loud. The vitest package has a global setup that
1493
+ * redirects it; `scripts/factory-cli.test.mjs` runs under `node --test`, never
1494
+ * sees that setup, and spawns the CLI with `...process.env`, so the real home
1495
+ * flows straight through. That leaves the property "tests do not touch
1496
+ * operator state" resting on discipline at every callsite — and the callsite
1497
+ * that forgets is silent, because falling back to the real home is exactly
1498
+ * what this function is supposed to do.
1499
+ *
1500
+ * So the fallback itself is what a test may not have. Under `NODE_ENV=test`,
1501
+ * an absent `XDG_STATE_HOME` is an error rather than a default, and the error
1502
+ * names the fix. One guard covers both runners and any runner added later,
1503
+ * which per-callsite discipline demonstrably cannot.
1504
+ *
1505
+ * What this guarantees is that the *write cannot happen*, not that anyone sees
1506
+ * it: emit-path HQ delivery is advisory and swallows its own failures on
1507
+ * purpose, so a caller inside that path turns this throw into a silent no-op.
1508
+ * That is the right trade here — the property being defended is "tests do not
1509
+ * touch operator state", and a refused write defends it whether or not the
1510
+ * refusal is announced. Tests that resolve the spool directly, like the one in
1511
+ * `hq-spool-inspection.test.ts`, do see the error.
1512
+ */
1513
+ const stateHomeDirectory = (env) => {
1514
+ const stateHome = env.XDG_STATE_HOME;
1515
+ if (stateHome !== void 0 && stateHome !== "") return stateHome;
1516
+ if ((env.NODE_ENV ?? process.env.NODE_ENV) === "test") throw new Error("Refusing to resolve the HQ spool state home from HOME under NODE_ENV=test: a test that writes there writes into the real operator's state (#445). Set XDG_STATE_HOME to a temporary directory for this test.");
1517
+ const home = env.HOME !== void 0 && env.HOME !== "" ? env.HOME : os.homedir();
1518
+ return path.join(home, ".local", "state");
1519
+ };
1520
+ /**
1521
+ * One filesystem-safe segment per repository *component*.
1522
+ *
1523
+ * Owner and repo are separate path segments, and each is escaped reversibly:
1524
+ * joining them into a single `<owner>-<repo>` name is ambiguous (owner `a-b`
1525
+ * repo `c` and owner `a` repo `b-c` produce the same string), and a lossy
1526
+ * `[^A-Za-z0-9._-] → _` substitution is ambiguous for the same reason. Two
1527
+ * repositories that collide would interleave spools and drain each other's
1528
+ * events to the wrong endpoint, so the encoding has to be injective:
1529
+ * percent-escape every byte outside the safe set, `%` included, and escape a
1530
+ * segment made only of dots so no component can become `.` or `..`.
1531
+ */
1532
+ const hqSpoolSegment = (component) => {
1533
+ const escaped = component.replaceAll(/[^A-Za-z0-9._-]/gu, (character) => [...Buffer.from(character, "utf-8")].map((byte) => `%${byte.toString(16).toUpperCase().padStart(2, "0")}`).join(""));
1534
+ return /^\.+$/u.test(escaped) ? escaped.replaceAll(".", "%2E") : escaped;
1535
+ };
1536
+ /** The repository's own two segments, outermost first. */
1537
+ const repositorySegments = (repository) => [hqSpoolSegment(repository.owner), hqSpoolSegment(repository.repo)];
1538
+ /** The state directory holding one spool tree per repository. */
1539
+ const hqSpoolRoot = (env = process.env) => path.join(stateHomeDirectory(env), ...HQ_SPOOL_STATE_SEGMENTS);
1540
+ /** The repo-keyed spool directory: outlives any worktree that produced it. */
1541
+ const hqSpoolDirectory = (repository, env = process.env) => path.join(hqSpoolRoot(env), ...repositorySegments(repository), HQ_RETRY_SPOOL_DIRNAME);
1542
+ /** The only location current producers write. */
1543
+ const repositorySpoolLayout = (repository, env = process.env) => ({
1544
+ create: true,
1545
+ createRoot: true,
1546
+ repository,
1547
+ root: stateHomeDirectory(env),
1548
+ segments: [
1549
+ ...HQ_SPOOL_STATE_SEGMENTS,
1550
+ ...repositorySegments(repository),
1551
+ HQ_RETRY_SPOOL_DIRNAME
1552
+ ]
1553
+ });
1554
+ /** Read-only drain source for spools written before the relocation. */
1555
+ const legacySpoolLayout = (cwd) => ({
1556
+ create: false,
1557
+ root: cwd,
1558
+ segments: [LEGACY_FACTORY_MEMORY_DIRNAME, HQ_RETRY_SPOOL_DIRNAME]
1559
+ });
1560
+ /**
1561
+ * An operator-named directory (`hq:flush --dir`). Accepts either the spool
1562
+ * itself or the directory holding it, so a `.factory-memory` path and a
1563
+ * `.../hq-retry-spool` path both drain.
1564
+ */
1565
+ const explicitSpoolLayout = (directory) => {
1566
+ const resolved = path.resolve(directory);
1567
+ const spool = path.basename(resolved) === "hq-retry-spool" ? resolved : path.join(resolved, HQ_RETRY_SPOOL_DIRNAME);
1568
+ const memory = path.dirname(spool);
1569
+ return {
1570
+ create: false,
1571
+ root: path.dirname(memory),
1572
+ segments: [path.basename(memory), path.basename(spool)]
1573
+ };
1574
+ };
1575
+ const secureChain = async (layout, segments, deadline) => {
1433
1576
  let root;
1434
1577
  try {
1435
- const canonical = await settleWithin(realpath(cwd), remainingMs(deadline));
1578
+ if (layout.createRoot === true) await settleWithin(mkdir(layout.root, { recursive: true }), remainingMs(deadline));
1579
+ const canonical = await settleWithin(realpath(layout.root), remainingMs(deadline));
1436
1580
  if (canonical.status !== "fulfilled") return;
1437
1581
  root = canonical.value;
1438
1582
  } catch {
1439
1583
  return;
1440
1584
  }
1441
1585
  const rootIdentity = await readDirectoryIdentity(root, deadline);
1442
- if (rootIdentity === void 0) return;
1443
- const memory = path.join(root, ".factory-memory");
1444
- const spool = path.join(root, DEFAULT_HQ_RETRY_SPOOL_PATH);
1445
- if (!(await secureStateDirectory(memory, deadline) && await secureStateDirectory(spool, deadline))) return;
1446
- const plainMemoryIdentity = await readDirectoryIdentity(memory, deadline);
1447
- const plainSpoolIdentity = await readDirectoryIdentity(spool, deadline);
1448
- if (plainMemoryIdentity === void 0 || plainSpoolIdentity === void 0) return;
1449
- const rootAnchor = {
1586
+ if (rootIdentity === void 0 || segments.length === 0) return;
1587
+ const ancestors = [{
1450
1588
  directory: root,
1451
1589
  identity: {
1452
1590
  dev: rootIdentity.dev,
1453
1591
  ino: rootIdentity.ino
1454
1592
  }
1455
- };
1456
- const memoryIdentity = {
1457
- ...plainMemoryIdentity,
1458
- ancestors: [rootAnchor]
1459
- };
1460
- const spoolIdentity = {
1461
- ...plainSpoolIdentity,
1462
- ancestors: [rootAnchor, {
1463
- directory: memory,
1593
+ }];
1594
+ const identities = [];
1595
+ const directories = [];
1596
+ let current = root;
1597
+ for (const segment of segments) {
1598
+ current = path.join(current, segment);
1599
+ if (layout.create && !await secureStateDirectory(current, deadline)) return;
1600
+ const plain = await readDirectoryIdentity(current, deadline);
1601
+ if (plain === void 0) return;
1602
+ identities.push({
1603
+ ...plain,
1604
+ ancestors: [...ancestors]
1605
+ });
1606
+ ancestors.push({
1607
+ directory: current,
1464
1608
  identity: {
1465
- dev: plainMemoryIdentity.dev,
1466
- ino: plainMemoryIdentity.ino
1609
+ dev: plain.dev,
1610
+ ino: plain.ino
1467
1611
  }
1468
- }]
1469
- };
1470
- if (!await directoryIdentityMatches(memory, memoryIdentity, deadline) || !await directoryIdentityMatches(spool, spoolIdentity, deadline)) return;
1612
+ });
1613
+ directories.push(current);
1614
+ }
1615
+ return {
1616
+ directories,
1617
+ identities
1618
+ };
1619
+ };
1620
+ const secureSpoolLayout = async (layout, deadline) => {
1621
+ if (layout.segments.length < 2) return;
1622
+ const chain = await secureChain(layout, layout.segments, deadline);
1623
+ if (chain === void 0) return;
1624
+ const { directories, identities } = chain;
1625
+ const spool = directories.at(-1);
1626
+ const memory = directories.at(-2);
1627
+ const spoolIdentity = identities.at(-1);
1628
+ const memoryIdentity = identities.at(-2);
1629
+ if (spool === void 0 || memory === void 0 || spoolIdentity === void 0 || memoryIdentity === void 0 || !await directoryIdentityMatches(memory, memoryIdentity, deadline) || !await directoryIdentityMatches(spool, spoolIdentity, deadline)) return;
1471
1630
  return {
1472
1631
  memory,
1473
1632
  memoryIdentity,
@@ -1475,6 +1634,24 @@ const secureRetrySpoolPath = async (cwd, deadline) => {
1475
1634
  spoolIdentity
1476
1635
  };
1477
1636
  };
1637
+ /**
1638
+ * The legacy JSONL journal lives one level above the spool. Securing it on its
1639
+ * own lets a pre-spool `.factory-memory` (journal but no spool directory)
1640
+ * drain without the read path creating anything inside the worktree.
1641
+ */
1642
+ const secureJournalDirectory = async (layout, deadline) => {
1643
+ const segments = layout.segments.slice(0, -1);
1644
+ if (segments.length === 0) return;
1645
+ const chain = await secureChain(layout, segments, deadline);
1646
+ if (chain === void 0) return;
1647
+ const directory = chain.directories.at(-1);
1648
+ const identity = chain.identities.at(-1);
1649
+ if (directory === void 0 || identity === void 0 || !await directoryIdentityMatches(directory, identity, deadline)) return;
1650
+ return {
1651
+ directory,
1652
+ identity
1653
+ };
1654
+ };
1478
1655
  const readDirectoryIdentity = async (directory, deadline) => {
1479
1656
  const opened = await openWithin(directory, constants.O_RDONLY + constants.O_DIRECTORY + constants.O_NOFOLLOW, remainingMs(deadline));
1480
1657
  if (opened.status !== "fulfilled") return;
@@ -1524,19 +1701,58 @@ const publishImmutableFile = async (directory, directoryIdentity, sourcePath, de
1524
1701
  await mutateBoundDirectory(directory, directoryIdentity, deadline, async () => unlink(sourcePath));
1525
1702
  return true;
1526
1703
  };
1527
- const removeRetrySpoolEvent = async (cwd, eventPath, deadline) => {
1528
- const secured = await settleWithin(secureRetrySpoolPath(cwd, deadline), remainingMs(deadline));
1704
+ const removeRetrySpoolEvent = async (layout, eventPath, deadline) => {
1705
+ const secured = await settleWithin(secureSpoolLayout(layout, deadline), remainingMs(deadline));
1529
1706
  if (secured.status !== "fulfilled" || secured.value === void 0 || path.dirname(eventPath) !== secured.value.spool) return false;
1530
1707
  const { spool, spoolIdentity } = secured.value;
1531
1708
  const metadata = await settleWithin(lstat(eventPath), remainingMs(deadline));
1532
1709
  if (metadata.status !== "fulfilled" || !metadata.value.isFile() || metadata.value.isSymbolicLink()) return false;
1533
1710
  return mutateBoundDirectory(spool, spoolIdentity, deadline, async () => unlink(eventPath));
1534
1711
  };
1712
+ /**
1713
+ * Stamps the spool directory with the repository writing into it (#447).
1714
+ *
1715
+ * Best-effort on purpose, and never on the delivery path's critical line: a
1716
+ * marker that cannot be written costs future coverage, never this event. An
1717
+ * enqueue that failed because the provenance file could not be created would
1718
+ * trade evidence for bookkeeping, which is the wrong way round.
1719
+ *
1720
+ * Written once and then left alone — the first enqueue under a key stamps it,
1721
+ * and later ones find it present. Rewriting it per enqueue would churn a file
1722
+ * that never changes while the identity does not, for no gain: the sweep reads
1723
+ * it, and the sweep runs later.
1724
+ *
1725
+ * Note what write-once means for a rename: the marker keeps the *old* name,
1726
+ * and `markerClaims` matches on the current one, so the stamp does not survive
1727
+ * a rename as a claim. That is a known limit, not an intended property (#447).
1728
+ */
1729
+ const writeRepositoryMarker = async (layout, spool, identity, deadline) => {
1730
+ if (layout.repository === void 0) return;
1731
+ const markerPath = path.join(spool, SPOOL_REPOSITORY_MARKER);
1732
+ const probed = await settleWithin(lstat(markerPath), remainingMs(deadline));
1733
+ if (probed.status !== "rejected" || !isMissingEntryError(probed.error)) return;
1734
+ const body = JSON.stringify({
1735
+ owner: layout.repository.owner,
1736
+ repo: layout.repository.repo,
1737
+ schemaVersion: 1
1738
+ });
1739
+ const temporary = path.join(spool, `${SPOOL_REPOSITORY_MARKER}.${randomUUID()}.tmp`);
1740
+ const opened = await openWithin(temporary, constants.O_CREAT + constants.O_EXCL + constants.O_WRONLY + constants.O_NOFOLLOW, remainingMs(deadline), 384);
1741
+ if (opened.status !== "fulfilled") return;
1742
+ const handle = opened.value;
1743
+ const written = await settleWithin(handle.writeFile(body, "utf-8"), remainingMs(deadline));
1744
+ await settleWithin(handle.close(), remainingMs(deadline));
1745
+ if (written.status !== "fulfilled") {
1746
+ await mutateBoundDirectory(spool, identity, deadline, async () => unlink(temporary));
1747
+ return;
1748
+ }
1749
+ if (!await mutateBoundDirectory(spool, identity, deadline, async () => rename(temporary, markerPath))) await mutateBoundDirectory(spool, identity, deadline, async () => unlink(temporary));
1750
+ };
1535
1751
  const spoolFileName = (entry) => {
1536
1752
  const { eventId } = entry.event;
1537
1753
  return /^[A-Za-z0-9_-]{1,160}$/u.test(eventId) ? `${eventId}.json` : `${randomUUID()}.json`;
1538
1754
  };
1539
- const persistRetrySpool = async (cwd, entry, deadline) => {
1755
+ const persistRetrySpool = async (layout, entry, deadline) => {
1540
1756
  let body;
1541
1757
  try {
1542
1758
  body = JSON.stringify(entry);
@@ -1550,12 +1766,13 @@ const persistRetrySpool = async (cwd, entry, deadline) => {
1550
1766
  failure: "failed",
1551
1767
  status: "failed"
1552
1768
  };
1553
- const secured = await settleWithin(secureRetrySpoolPath(cwd, deadline), remainingMs(deadline));
1769
+ const secured = await settleWithin(secureSpoolLayout(layout, deadline), remainingMs(deadline));
1554
1770
  if (secured.status !== "fulfilled" || secured.value === void 0) return {
1555
1771
  failure: secured.status === "timed-out" ? "timed-out" : "failed",
1556
1772
  status: "failed"
1557
1773
  };
1558
1774
  const { spool, spoolIdentity: identity } = secured.value;
1775
+ await writeRepositoryMarker(layout, spool, identity, deadline);
1559
1776
  const finalName = `${randomUUID()}-${spoolFileName(entry)}`;
1560
1777
  const temporary = path.join(spool, `.${finalName}.tmp`);
1561
1778
  const finalPath = path.join(spool, finalName);
@@ -1603,8 +1820,8 @@ const persistRetrySpool = async (cwd, entry, deadline) => {
1603
1820
  status: "failed"
1604
1821
  };
1605
1822
  };
1606
- const appendCloseoutSpool = async (cwd, entry, deadline) => {
1607
- const result = await persistRetrySpool(cwd, entry, deadline);
1823
+ const appendCloseoutSpool = async (layout, entry, deadline) => {
1824
+ const result = await persistRetrySpool(layout, entry, deadline);
1608
1825
  return result.status === "failed" ? result.failure : void 0;
1609
1826
  };
1610
1827
  const positiveBudget = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
@@ -1652,8 +1869,11 @@ const settleWithin = async (operation, budgetMs) => {
1652
1869
  status: "fulfilled",
1653
1870
  value: await operation
1654
1871
  };
1655
- } catch {
1656
- return { status: "rejected" };
1872
+ } catch (error) {
1873
+ return {
1874
+ error,
1875
+ status: "rejected"
1876
+ };
1657
1877
  }
1658
1878
  };
1659
1879
  const timeoutAbort = new AbortController();
@@ -1828,21 +2048,21 @@ const validatedIngestEndpoint = (value) => {
1828
2048
  const origin = validatedEndpoint(value);
1829
2049
  return origin === void 0 ? void 0 : new URL(HQ_INGEST_PATH, origin.origin);
1830
2050
  };
1831
- const retainRetryEntry = async (cwd, entry, dependencies, deadline, options = {}) => {
2051
+ const retainRetryEntry = async (layout, entry, dependencies, deadline, options = {}) => {
1832
2052
  let customAppend;
1833
2053
  try {
1834
2054
  customAppend = dependencies.appendJournal;
1835
2055
  } catch {
1836
2056
  return "failed";
1837
2057
  }
1838
- if (customAppend === void 0) return appendCloseoutSpool(cwd, entry, deadline);
2058
+ if (customAppend === void 0) return appendCloseoutSpool(layout, entry, deadline);
1839
2059
  let row;
1840
2060
  try {
1841
2061
  row = JSON.stringify(entry);
1842
2062
  } catch {
1843
2063
  return "failed";
1844
2064
  }
1845
- const target = path.resolve(cwd, DEFAULT_HQ_RETRY_SPOOL_PATH, `${randomUUID()}-${spoolFileName(entry)}`);
2065
+ const target = path.join(path.resolve(layout.root, ...layout.segments), `${randomUUID()}-${spoolFileName(entry)}`);
1846
2066
  if (options.awaitCustom !== true) {
1847
2067
  setImmediate(async () => {
1848
2068
  try {
@@ -1956,11 +2176,38 @@ async function handoffHqIngest(input, dependencies, setupDeadline) {
1956
2176
  failedAt,
1957
2177
  reason
1958
2178
  };
1959
- const journalFailure = dependencies.appendJournal === void 0 ? await appendCloseoutSpool(input.cwd, retryEntry, flushDeadline) : await retainRetryEntry(input.cwd, retryEntry, dependencies, flushDeadline, { awaitCustom: true });
2179
+ const writeLayout = repositorySpoolLayout({
2180
+ owner: input.profile.repository.owner,
2181
+ repo: input.profile.repository.name
2182
+ }, env);
2183
+ const journalFailure = dependencies.appendJournal === void 0 ? await appendCloseoutSpool(writeLayout, retryEntry, flushDeadline) : await retainRetryEntry(writeLayout, retryEntry, dependencies, flushDeadline, { awaitCustom: true });
1960
2184
  if (journalFailure) await reportDiagnostic(dependencies, flushDeadline, `closeout advisory handoff local spool ${journalFailure}`);
1961
2185
  return journalFailure === void 0 && !dropped ? "deferred" : "held";
1962
2186
  }
1963
- async function attemptTransport(request, endpoint, clientId, clientSecret, body, transportBudgetMs) {
2187
+ /**
2188
+ * A bounded read of the ingest receipt body. Only the explicit drain asks for
2189
+ * it: the gate path must not add a body read to its transport leg. Nothing but
2190
+ * HQ's own `duplicate` flag and a truncated error string is retained.
2191
+ */
2192
+ const readIngestReceipt = async (response, budgetMs) => {
2193
+ const text = await settleWithin(Promise.resolve().then(async () => response.text()), budgetMs);
2194
+ if (text.status !== "fulfilled") return {};
2195
+ const source = text.value.slice(0, 4096);
2196
+ let parsed;
2197
+ try {
2198
+ parsed = JSON.parse(source);
2199
+ } catch {
2200
+ return source.trim() === "" ? {} : { detail: source.slice(0, 200).trim() };
2201
+ }
2202
+ if (parsed === null || typeof parsed !== "object") return {};
2203
+ const record = parsed;
2204
+ const message = [record.error, record.message].filter((value) => typeof value === "string").join(": ");
2205
+ return {
2206
+ ...message === "" ? {} : { detail: message.slice(0, 200) },
2207
+ ...typeof record.duplicate === "boolean" ? { duplicate: record.duplicate } : {}
2208
+ };
2209
+ };
2210
+ async function attemptTransport(request, endpoint, clientId, clientSecret, body, transportBudgetMs, receipt = false) {
1964
2211
  if (transportBudgetMs <= 0) return {
1965
2212
  ok: false,
1966
2213
  reason: "transport-timeout",
@@ -1994,13 +2241,20 @@ async function attemptTransport(request, endpoint, clientId, clientSecret, body,
1994
2241
  };
1995
2242
  if (!transport.value.ok) {
1996
2243
  const { status } = transport.value;
2244
+ const rejection = receipt ? await readIngestReceipt(transport.value, transportBudgetMs) : {};
1997
2245
  return {
2246
+ ...rejection.detail === void 0 ? {} : { detail: rejection.detail },
1998
2247
  ok: false,
1999
2248
  reason: typeof status === "number" && Number.isFinite(status) ? `http-status-${status}` : "http-error",
2000
2249
  unconfirmed: false
2001
2250
  };
2002
2251
  }
2003
- return { ok: true };
2252
+ if (!receipt) return { ok: true };
2253
+ const accepted = await readIngestReceipt(transport.value, transportBudgetMs);
2254
+ return {
2255
+ ok: true,
2256
+ ...accepted.duplicate === void 0 ? {} : { duplicate: accepted.duplicate }
2257
+ };
2004
2258
  }
2005
2259
  const isReplayableEntry = (value) => {
2006
2260
  if (value === null || typeof value !== "object") return false;
@@ -2011,8 +2265,27 @@ const isReplayableEntry = (value) => {
2011
2265
  const eventRecord = event;
2012
2266
  return "payload" in eventRecord && typeof eventRecord.eventId === "string" && typeof eventRecord.kind === "string";
2013
2267
  };
2014
- const replayCloseoutSpool = async (cwd, endpoint, clientId, clientSecret, request, transportBudgetMs, setupDeadline, replayDeadline, flushBudgetMs, excludedEventPath) => {
2015
- const secured = await settleWithin(secureRetrySpoolPath(cwd, setupDeadline), remainingMs(setupDeadline));
2268
+ /**
2269
+ * 4xx is HQ refusing the content; anything else is the transport failing.
2270
+ * Non-content client statuses are transport-shaped — the content was never
2271
+ * judged — so classifying them as rejected would let a drain exit clean while
2272
+ * automation stops retrying: 401/403 (Access refusing expired or wrong
2273
+ * service-token credentials), 408 (request timeout), 429 (rate limit).
2274
+ */
2275
+ const NON_CONTENT_CLIENT_STATUSES = new Set([
2276
+ 401,
2277
+ 403,
2278
+ 408,
2279
+ 429
2280
+ ]);
2281
+ const drainFailureStatus = (reason) => {
2282
+ const status = Number(reason.replace("http-status-", ""));
2283
+ return reason.startsWith("http-status-") && Number.isFinite(status) && status >= 400 && status < 500 && !NON_CONTENT_CLIENT_STATUSES.has(status) ? "rejected" : "unreachable";
2284
+ };
2285
+ const replayCloseoutSpool = async (layout, endpoint, clientId, clientSecret, request, transportBudgetMs, setupDeadline, replayDeadline, flushBudgetMs, excludedEventPath, options = {}) => {
2286
+ const maxEntries = options.maxEntries ?? MAX_HQ_REPLAY_ENTRIES;
2287
+ const { report } = options;
2288
+ const secured = await settleWithin(secureSpoolLayout(layout, setupDeadline), remainingMs(setupDeadline));
2016
2289
  if (secured.status !== "fulfilled" || secured.value === void 0) return;
2017
2290
  const { spool, spoolIdentity } = secured.value;
2018
2291
  const initialListing = await settleWithin(readdir(spool), remainingMs(setupDeadline));
@@ -2053,7 +2326,7 @@ const replayCloseoutSpool = async (cwd, endpoint, clientId, clientSecret, reques
2053
2326
  let attempted = 0;
2054
2327
  for (const eventPath of candidates) {
2055
2328
  if (!await directoryIdentityMatches(spool, spoolIdentity, setupDeadline)) return;
2056
- if (attempted >= MAX_HQ_REPLAY_ENTRIES || remainingMs(replayDeadline) <= 0) break;
2329
+ if (attempted >= maxEntries || remainingMs(replayDeadline) <= 0) break;
2057
2330
  const metadata = await settleWithin(lstat(eventPath), remainingMs(setupDeadline));
2058
2331
  if (metadata.status !== "fulfilled" || !metadata.value.isFile() || metadata.value.isSymbolicLink() || metadata.value.size > MAX_HQ_INGEST_PAYLOAD_BYTES + 16 * 1024) continue;
2059
2332
  const claimPath = `${eventPath}.draining-${randomUUID()}`;
@@ -2071,7 +2344,14 @@ const replayCloseoutSpool = async (cwd, endpoint, clientId, clientSecret, reques
2071
2344
  }
2072
2345
  const entryEndpoint = validatedEndpoint(entry.endpoint);
2073
2346
  if (!entryEndpoint || entryEndpoint.origin !== endpoint.origin) {
2074
- await mutateBoundDirectory(spool, spoolIdentity, setupDeadline, async () => rename(claimPath, eventPath));
2347
+ if (!await mutateBoundDirectory(spool, spoolIdentity, performance.now() + flushBudgetMs, async () => rename(claimPath, `${eventPath}.undeliverable`))) return;
2348
+ report?.({
2349
+ detail: `recorded endpoint ${entryEndpoint?.origin ?? "(unusable)"} is not this repository's authorized HQ origin ${endpoint.origin}; dispositioned as ${path.basename(eventPath)}${UNDELIVERABLE_SUFFIX}`,
2350
+ eventId: entry.event.eventId,
2351
+ kind: entry.event.kind,
2352
+ spool,
2353
+ status: "undeliverable"
2354
+ });
2075
2355
  continue;
2076
2356
  }
2077
2357
  let body;
@@ -2082,7 +2362,22 @@ const replayCloseoutSpool = async (cwd, endpoint, clientId, clientSecret, reques
2082
2362
  continue;
2083
2363
  }
2084
2364
  attempted += 1;
2085
- const outcome = await attemptTransport(request, endpoint, clientId, clientSecret, body, Math.min(transportBudgetMs, remainingMs(replayDeadline)));
2365
+ const outcome = await attemptTransport(request, endpoint, clientId, clientSecret, body, Math.min(transportBudgetMs, remainingMs(replayDeadline)), report !== void 0);
2366
+ if (report) {
2367
+ const common = {
2368
+ eventId: entry.event.eventId,
2369
+ kind: entry.event.kind,
2370
+ spool
2371
+ };
2372
+ report(outcome.ok ? {
2373
+ ...common,
2374
+ status: outcome.duplicate === true ? "duplicate" : "delivered"
2375
+ } : {
2376
+ ...common,
2377
+ detail: outcome.detail === void 0 ? outcome.reason : `${outcome.reason}: ${outcome.detail}`,
2378
+ status: drainFailureStatus(outcome.reason)
2379
+ });
2380
+ }
2086
2381
  if (!await mutateBoundDirectory(spool, spoolIdentity, performance.now() + flushBudgetMs, () => outcome.ok ? unlink(claimPath) : rename(claimPath, eventPath))) return;
2087
2382
  }
2088
2383
  };
@@ -2125,11 +2420,13 @@ const releaseLegacyClaim = async (claimPath, target, directory, directoryIdentit
2125
2420
  const readyPath = `${target}.legacy-ready-${randomUUID()}`;
2126
2421
  return mutateBoundDirectory(directory, directoryIdentity, deadline, async () => rename(claimPath, readyPath));
2127
2422
  };
2128
- async function replayRetryJournal(cwd, endpoint, clientId, clientSecret, request, dependencies, transportBudgetMs, setupDeadline, replayDeadline) {
2129
- const secured = await settleWithin(secureRetrySpoolPath(cwd, setupDeadline), remainingMs(setupDeadline));
2423
+ async function replayRetryJournal(layout, writeLayout, endpoint, clientId, clientSecret, request, dependencies, transportBudgetMs, setupDeadline, replayDeadline, options = {}) {
2424
+ const maxEntries = options.maxEntries ?? MAX_HQ_REPLAY_ENTRIES;
2425
+ const { report } = options;
2426
+ const secured = await settleWithin(secureJournalDirectory(layout, setupDeadline), remainingMs(setupDeadline));
2130
2427
  if (secured.status !== "fulfilled" || secured.value === void 0) return;
2131
- const { memory: directory, memoryIdentity: directoryIdentity } = secured.value;
2132
- const target = path.join(directory, path.basename(DEFAULT_HQ_RETRY_JOURNAL_PATH));
2428
+ const { directory, identity: directoryIdentity } = secured.value;
2429
+ const target = path.join(directory, HQ_RETRY_JOURNAL_BASENAME);
2133
2430
  const baseName = path.basename(target);
2134
2431
  const listing = await settleWithin(readdir(directory), remainingMs(setupDeadline));
2135
2432
  if (listing.status !== "fulfilled") return;
@@ -2137,7 +2434,7 @@ async function replayRetryJournal(cwd, endpoint, clientId, clientSecret, request
2137
2434
  let corruptDropped = 0;
2138
2435
  const sources = listing.value.filter((name) => name === baseName || name.startsWith(`${baseName}.legacy-ready-`) || name.startsWith(`${baseName}.draining-`) || name.startsWith(`${baseName}.legacy-claim-`)).filter((name) => !name.includes(".cursor")).toSorted();
2139
2436
  for (const name of sources) {
2140
- if (delivered >= MAX_HQ_REPLAY_ENTRIES || remainingMs(replayDeadline) <= 0) break;
2437
+ if (delivered >= maxEntries || remainingMs(replayDeadline) <= 0) break;
2141
2438
  const sourcePath = path.join(directory, name);
2142
2439
  if (name.startsWith(`${baseName}.draining-`) || name.startsWith(`${baseName}.legacy-claim-`)) {
2143
2440
  const metadata = await settleWithin(lstat(sourcePath), remainingMs(setupDeadline));
@@ -2185,7 +2482,7 @@ async function replayRetryJournal(cwd, endpoint, clientId, clientSecret, request
2185
2482
  let reachedEof = true;
2186
2483
  try {
2187
2484
  for await (const line of lines) {
2188
- if (delivered >= MAX_HQ_REPLAY_ENTRIES || remainingMs(replayDeadline) <= 0 || remainingMs(setupDeadline) <= 0) {
2485
+ if (delivered >= maxEntries || remainingMs(replayDeadline) <= 0 || remainingMs(setupDeadline) <= 0) {
2189
2486
  reachedEof = false;
2190
2487
  break;
2191
2488
  }
@@ -2220,7 +2517,21 @@ async function replayRetryJournal(cwd, endpoint, clientId, clientSecret, request
2220
2517
  }
2221
2518
  const entryEndpoint = validatedEndpoint(parsed.endpoint);
2222
2519
  let handled = false;
2520
+ const row = {
2521
+ eventId: parsed.event.eventId,
2522
+ kind: parsed.event.kind
2523
+ };
2524
+ let rowOutcome = {
2525
+ detail: `recorded endpoint ${entryEndpoint?.origin ?? "(unusable)"} is not this repository's authorized HQ origin ${endpoint.origin}`,
2526
+ ...row,
2527
+ spool: directory,
2528
+ status: "migrated"
2529
+ };
2223
2530
  if (entryEndpoint?.origin === endpoint.origin) {
2531
+ rowOutcome = {
2532
+ ...rowOutcome,
2533
+ detail: "event could not be serialized for delivery"
2534
+ };
2224
2535
  let body;
2225
2536
  try {
2226
2537
  body = JSON.stringify(parsed.event);
@@ -2228,16 +2539,35 @@ async function replayRetryJournal(cwd, endpoint, clientId, clientSecret, request
2228
2539
  body = void 0;
2229
2540
  }
2230
2541
  if (body !== void 0) {
2231
- handled = (await attemptTransport(request, endpoint, clientId, clientSecret, body, Math.min(transportBudgetMs, remainingMs(replayDeadline)))).ok;
2542
+ const outcome = await attemptTransport(request, endpoint, clientId, clientSecret, body, Math.min(transportBudgetMs, remainingMs(replayDeadline)), report !== void 0);
2543
+ handled = outcome.ok;
2232
2544
  if (handled) delivered += 1;
2545
+ rowOutcome = outcome.ok ? {
2546
+ ...row,
2547
+ spool: directory,
2548
+ status: outcome.duplicate === true ? "duplicate" : "delivered"
2549
+ } : {
2550
+ ...row,
2551
+ detail: outcome.detail === void 0 ? outcome.reason : `${outcome.reason}: ${outcome.detail}`,
2552
+ spool: directory,
2553
+ status: drainFailureStatus(outcome.reason)
2554
+ };
2233
2555
  }
2234
2556
  }
2235
2557
  if (!handled) {
2236
- if (await appendCloseoutSpool(cwd, parsed, performance.now() + journalFlushBudgetFor(dependencies)) !== void 0) {
2558
+ const retained = await appendCloseoutSpool(writeLayout, parsed, performance.now() + journalFlushBudgetFor(dependencies));
2559
+ if (retained === void 0) options.onMigrate?.();
2560
+ if (retained !== void 0) {
2561
+ report?.({
2562
+ ...rowOutcome,
2563
+ detail: `${rowOutcome.detail ?? rowOutcome.status}; migration into the current spool ${retained}`,
2564
+ status: "unreachable"
2565
+ });
2237
2566
  reachedEof = false;
2238
2567
  break;
2239
2568
  }
2240
2569
  }
2570
+ report?.(rowOutcome);
2241
2571
  offset = nextOffset;
2242
2572
  if (!await writeLegacyCursor(directory, directoryIdentity, cursorPath, offset, setupDeadline)) {
2243
2573
  reachedEof = false;
@@ -2309,6 +2639,11 @@ async function deliverHqIngest(input, dependencies, setupDeadline, timeoutMs, tr
2309
2639
  } catch {
2310
2640
  request = void 0;
2311
2641
  }
2642
+ const writeLayout = repositorySpoolLayout({
2643
+ owner: input.profile.repository.owner,
2644
+ repo: input.profile.repository.name
2645
+ }, env);
2646
+ const readLayouts = [writeLayout, legacySpoolLayout(input.cwd)];
2312
2647
  const now = dependencies.now ?? (() => /* @__PURE__ */ new Date());
2313
2648
  const makeEventId = dependencies.randomUUID ?? randomUUID;
2314
2649
  let event;
@@ -2354,7 +2689,7 @@ async function deliverHqIngest(input, dependencies, setupDeadline, timeoutMs, tr
2354
2689
  } catch {
2355
2690
  failedAt = "unknown";
2356
2691
  }
2357
- const persisted = await persistRetrySpool(input.cwd, {
2692
+ const persisted = await persistRetrySpool(writeLayout, {
2358
2693
  endpoint: endpoint.href,
2359
2694
  event,
2360
2695
  failedAt,
@@ -2379,14 +2714,16 @@ async function deliverHqIngest(input, dependencies, setupDeadline, timeoutMs, tr
2379
2714
  throw new Error(reason);
2380
2715
  }
2381
2716
  const replayDeadline = performance.now() + timeoutMs;
2382
- await replayCloseoutSpool(input.cwd, endpoint, clientId, clientSecret, request, transportBudgetMs, replayDeadline, replayDeadline, journalFlushBudgetFor(dependencies), persistedEventPath);
2383
- await replayRetryJournal(input.cwd, endpoint, clientId, clientSecret, request, dependencies, transportBudgetMs, replayDeadline, replayDeadline);
2717
+ for (const readLayout of readLayouts) {
2718
+ await replayCloseoutSpool(readLayout, endpoint, clientId, clientSecret, request, transportBudgetMs, replayDeadline, replayDeadline, journalFlushBudgetFor(dependencies), persistedEventPath);
2719
+ await replayRetryJournal(readLayout, writeLayout, endpoint, clientId, clientSecret, request, dependencies, transportBudgetMs, replayDeadline, replayDeadline);
2720
+ }
2384
2721
  const outcome = await attemptTransport(request, endpoint, clientId, clientSecret, body, transportBudgetMs);
2385
2722
  if (!outcome.ok) {
2386
2723
  ({reason, unconfirmed} = outcome);
2387
2724
  throw new Error(reason);
2388
2725
  }
2389
- if (persistedEventPath !== void 0) await removeRetrySpoolEvent(input.cwd, persistedEventPath, performance.now() + journalFlushBudgetFor(dependencies));
2726
+ if (persistedEventPath !== void 0) await removeRetrySpoolEvent(writeLayout, persistedEventPath, performance.now() + journalFlushBudgetFor(dependencies));
2390
2727
  return "delivered";
2391
2728
  } catch {}
2392
2729
  const flushDeadline = performance.now() + journalFlushBudgetFor(dependencies);
@@ -2411,16 +2748,17 @@ async function deliverHqIngest(input, dependencies, setupDeadline, timeoutMs, tr
2411
2748
  reason,
2412
2749
  ...unconfirmed ? { unconfirmed: true } : {}
2413
2750
  };
2414
- if (customJournal && persistedEventPath === void 0) journalFailure = await retainRetryEntry(input.cwd, retryEntry, dependencies, flushDeadline);
2751
+ if (customJournal && persistedEventPath === void 0) journalFailure = await retainRetryEntry(writeLayout, retryEntry, dependencies, flushDeadline);
2415
2752
  else if (!customJournal) {
2416
- const updated = await persistRetrySpool(input.cwd, retryEntry, flushDeadline);
2753
+ const updated = await persistRetrySpool(writeLayout, retryEntry, flushDeadline);
2417
2754
  if (updated.status === "persisted") {
2418
- if (persistedEventPath !== void 0) await removeRetrySpoolEvent(input.cwd, persistedEventPath, flushDeadline);
2755
+ if (persistedEventPath !== void 0) await removeRetrySpoolEvent(writeLayout, persistedEventPath, flushDeadline);
2419
2756
  persistedEventPath = updated.eventPath;
2420
2757
  journalFailure = void 0;
2421
2758
  } else journalFailure = updated.failure;
2422
2759
  }
2423
2760
  }
2761
+ if (!unconfirmed && journalFailure === void 0 && persistedEventPath !== void 0) return "deferred";
2424
2762
  const journalSuffix = journalFailure ? `; retry spool ${journalFailure}` : "";
2425
2763
  await reportDiagnostic(dependencies, flushDeadline, unconfirmed ? `sent, awaiting confirmation (${reason})${journalSuffix}` : `${reason}${journalSuffix}`, unconfirmed ? "HQ ingest unconfirmed" : "HQ ingest deferred");
2426
2764
  return "deferred";
@@ -2454,6 +2792,376 @@ const trackPendingHqIngest = (delivery) => {
2454
2792
  async function awaitPendingHqIngest() {
2455
2793
  await Promise.allSettled(pendingHqIngest);
2456
2794
  }
2795
+ /** Wall-clock ceiling for one explicit drain. Flush is not a gate; it may wait. */
2796
+ const DEFAULT_HQ_FLUSH_BUDGET_MS = 600 * 1e3;
2797
+ /** ENOENT is the only filesystem answer that means "this location is absent". */
2798
+ const isMissingEntryError = (error) => typeof error === "object" && error !== null && error.code === "ENOENT";
2799
+ const journalRowCount = (contents) => contents.split("\n").filter((line) => line.trim() !== "").length;
2800
+ const journalLineTimestamps = (contents) => {
2801
+ const timestamps = [];
2802
+ for (const line of contents.split("\n")) {
2803
+ const trimmed = line.trim();
2804
+ if (trimmed === "") continue;
2805
+ try {
2806
+ const parsed = JSON.parse(trimmed);
2807
+ if (typeof parsed.failedAt === "string") timestamps.push(parsed.failedAt);
2808
+ } catch {}
2809
+ }
2810
+ return timestamps;
2811
+ };
2812
+ const countRemainingSpoolWork = async (spools, drainLayouts, deadline) => {
2813
+ let remaining = 0;
2814
+ let unlistableScans = 0;
2815
+ let oldestMs;
2816
+ const noteTimestamp = (iso) => {
2817
+ const ms = Date.parse(iso);
2818
+ if (Number.isFinite(ms) && (oldestMs === void 0 || ms < oldestMs)) oldestMs = ms;
2819
+ };
2820
+ const countUnlistable = async (directory) => {
2821
+ if (remainingMs(deadline) <= 0) {
2822
+ unlistableScans += 1;
2823
+ return;
2824
+ }
2825
+ const probed = await settleWithin(lstat(directory), remainingMs(deadline));
2826
+ if (probed.status !== "rejected" || !isMissingEntryError(probed.error)) unlistableScans += 1;
2827
+ };
2828
+ for (const spool of spools) {
2829
+ const listing = await settleWithin(readdir(spool), remainingMs(deadline));
2830
+ if (listing.status !== "fulfilled") {
2831
+ await countUnlistable(spool);
2832
+ continue;
2833
+ }
2834
+ const pendingNames = listing.value.filter((name) => name.endsWith(".json") || name.includes(".json.draining-") || name.startsWith(".") && name.endsWith(".json.tmp"));
2835
+ remaining += pendingNames.length;
2836
+ for (const name of pendingNames) {
2837
+ const probed = await settleWithin(lstat(path.join(spool, name)), remainingMs(deadline));
2838
+ if (probed.status === "fulfilled") noteTimestamp(probed.value.mtime.toISOString());
2839
+ }
2840
+ }
2841
+ for (const layout of drainLayouts) {
2842
+ const journalDir = path.dirname(layoutPath(layout));
2843
+ const journalListing = await settleWithin(readdir(journalDir), remainingMs(deadline));
2844
+ if (journalListing.status !== "fulfilled") {
2845
+ await countUnlistable(journalDir);
2846
+ continue;
2847
+ }
2848
+ const journalNames = journalListing.value.filter((name) => name === "hq-retry-journal.jsonl" || name.startsWith(`hq-retry-journal.jsonl.legacy-ready-`) || name.startsWith(`hq-retry-journal.jsonl.legacy-claim-`) || name.startsWith(`hq-retry-journal.jsonl.draining-`));
2849
+ for (const name of journalNames) {
2850
+ const journalFilePath = path.join(journalDir, name);
2851
+ const read = await settleWithin(readFile(journalFilePath, "utf-8"), remainingMs(deadline));
2852
+ if (read.status === "fulfilled") {
2853
+ remaining += journalRowCount(read.value);
2854
+ for (const timestamp of journalLineTimestamps(read.value)) noteTimestamp(timestamp);
2855
+ continue;
2856
+ }
2857
+ remaining += 1;
2858
+ const probed = await settleWithin(lstat(journalFilePath), remainingMs(deadline));
2859
+ if (probed.status === "fulfilled") noteTimestamp(probed.value.mtime.toISOString());
2860
+ }
2861
+ }
2862
+ return {
2863
+ ...oldestMs === void 0 ? {} : { oldestQueuedAt: new Date(oldestMs).toISOString() },
2864
+ remaining,
2865
+ unlistableScans
2866
+ };
2867
+ };
2868
+ /** Wall-clock ceiling for the read-only inspection below. */
2869
+ const DEFAULT_HQ_SPOOL_INSPECT_BUDGET_MS = 5e3;
2870
+ /**
2871
+ * Counts spooled work for a repository without draining it or touching a
2872
+ * credential (#414).
2873
+ *
2874
+ * `hq:flush` used to resolve the HQ Access token before it ever looked at the
2875
+ * spool, so a lane with nothing to send still paid a secret-manager round trip
2876
+ * — and still failed, opaquely, in a sandbox that has no keychain access. The
2877
+ * same locations `flushHqSpool` drains are inspected here, read-only: no
2878
+ * directory is created, nothing is secured, and nothing is delivered.
2879
+ */
2880
+ async function countHqSpoolWork(input, dependencies = {}) {
2881
+ const env = dependencies.env ?? process.env;
2882
+ const layouts = input.explicitDirectories && input.explicitDirectories.length > 0 ? input.explicitDirectories.map(explicitSpoolLayout) : [repositorySpoolLayout(input.repository, env), legacySpoolLayout(input.cwd)];
2883
+ const deadline = performance.now() + (dependencies.budgetMs ?? DEFAULT_HQ_SPOOL_INSPECT_BUDGET_MS);
2884
+ const paths = layouts.map((layout) => layoutPath(layout));
2885
+ const { oldestQueuedAt, remaining, unlistableScans } = await countRemainingSpoolWork(paths, layouts, deadline);
2886
+ const existing = await Promise.all(paths.map(async (spool) => {
2887
+ return (await settleWithin(lstat(spool), remainingMs(deadline))).status === "fulfilled" ? spool : void 0;
2888
+ }));
2889
+ return {
2890
+ ...oldestQueuedAt === void 0 ? {} : { oldestQueuedAt },
2891
+ pending: remaining,
2892
+ spools: existing.filter((spool) => spool !== void 0),
2893
+ unlistable: unlistableScans
2894
+ };
2895
+ }
2896
+ /** Wall-clock ceiling for the orphan sweep below. */
2897
+ const DEFAULT_HQ_SPOOL_SWEEP_BUDGET_MS = 5e3;
2898
+ /**
2899
+ * The pre-#390 lossy substitution, kept only so a key written under it can
2900
+ * still be *found*. Nothing writes this scheme any more; it is ambiguous,
2901
+ * which is why `hqSpoolSegment` replaced it. It only ever appeared in the
2902
+ * single joined segment (`fb1d14a`) — never as two segments, which is why no
2903
+ * two-segment lossy candidate is derived below.
2904
+ */
2905
+ const lossyHqSpoolSegment = (component) => component.replaceAll(/[^A-Za-z0-9._-]/gu, "_");
2906
+ const hqSpoolCandidateKeys = (repository) => {
2907
+ const joined = `${repository.owner}-${repository.repo}`;
2908
+ const candidates = [
2909
+ repositorySegments(repository),
2910
+ [hqSpoolSegment(joined)],
2911
+ [lossyHqSpoolSegment(joined)]
2912
+ ];
2913
+ const seen = /* @__PURE__ */ new Set();
2914
+ return candidates.filter((segments) => {
2915
+ const key = segments.join("/");
2916
+ if (seen.has(key)) return false;
2917
+ seen.add(key);
2918
+ return true;
2919
+ });
2920
+ };
2921
+ /**
2922
+ * Does this spool directory's marker name the given repository (#447)?
2923
+ *
2924
+ * Every answer other than an unambiguous yes is no. An absent, unreadable,
2925
+ * malformed, or foreign marker all mean the same thing here — nothing on disk
2926
+ * says this directory is ours — and the sweep may only act on directories that
2927
+ * say so. Failing closed in the other direction would be #446 again:
2928
+ * prescribing `hq:flush --dir` against evidence belonging to someone else.
2929
+ */
2930
+ const markerClaims = async (spool, repository, deadline) => {
2931
+ const file = await readBoundedTextFileNoFollow(path.join(spool, SPOOL_REPOSITORY_MARKER), deadline, 4096);
2932
+ if (file === void 0) return false;
2933
+ let parsed;
2934
+ try {
2935
+ parsed = JSON.parse(file.contents);
2936
+ } catch {
2937
+ return false;
2938
+ }
2939
+ if (typeof parsed !== "object" || parsed === null) return false;
2940
+ const marker = parsed;
2941
+ return marker.owner === repository.owner && marker.repo === repository.repo;
2942
+ };
2943
+ /**
2944
+ * Walks the shared root for spool directories this repository has stamped
2945
+ * (#447).
2946
+ *
2947
+ * Symlinks are not followed and depth is capped: this is a read-only inventory
2948
+ * of one state tree, not a traversal of wherever an operator pointed something.
2949
+ *
2950
+ * A directory that cannot be listed is skipped rather than reported, which is
2951
+ * the opposite of how `countRemainingSpoolWork` treats an unreadable spool —
2952
+ * deliberately. There, "unknown" concerns a location already known to be ours,
2953
+ * so failing closed means reporting it. Here, "unknown" concerns *ownership*,
2954
+ * and failing closed means claiming a directory that may be another
2955
+ * repository's. The candidate-key probes above still fail closed for every
2956
+ * location this checkout can name on its own, so nothing that was reported
2957
+ * before stops being reported.
2958
+ */
2959
+ const findMarkedSpools = async (repository, directory, deadline, depth = 1, found = []) => {
2960
+ if (depth > HQ_SPOOL_SWEEP_MAX_DEPTH || remainingMs(deadline) <= 0) return found;
2961
+ const listing = await settleWithin(readdir(directory, { withFileTypes: true }), remainingMs(deadline));
2962
+ if (listing.status !== "fulfilled") return found;
2963
+ for (const entry of listing.value) {
2964
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
2965
+ const child = path.join(directory, entry.name);
2966
+ if (entry.name === "hq-retry-spool") {
2967
+ if (await markerClaims(child, repository, deadline)) found.push(child);
2968
+ continue;
2969
+ }
2970
+ await findMarkedSpools(repository, child, deadline, depth + 1, found);
2971
+ }
2972
+ return found;
2973
+ };
2974
+ /**
2975
+ * Reports spooled evidence sitting under an earlier key for *this* repository
2976
+ * (#420).
2977
+ *
2978
+ * The spool is keyed by owner and repo, so a change to the segment encoding
2979
+ * itself — which happened during #390's own development — moves the address
2980
+ * without moving the evidence. Both readers of the spool resolve exactly one
2981
+ * key, so the events under the old one become invisible: the drain reports
2982
+ * success, doctor reports empty, and three real proofs sat unread until an
2983
+ * attended recovery enumerated the tree by hand.
2984
+ *
2985
+ * This sweep only reports. Draining another key's events is a decision this
2986
+ * does not make — the operator gets the location and the count, and
2987
+ * `hq:flush --dir` remains the recovery path.
2988
+ *
2989
+ * **Why candidate keys and not a walk of the root (#446).** The root is shared
2990
+ * by every factory repository on the machine, so enumerating it and calling
2991
+ * everything that is not the current key an orphan describes another
2992
+ * repository's ordinary, current, correct spool exactly as well as it describes
2993
+ * this repository's obsolete one. That made doctor red in one checkout because
2994
+ * a different repository had pending work, and told the operator to drain it —
2995
+ * confidently prescribing the wrong action. Nothing on disk distinguishes the
2996
+ * two cases: an unrecognised key carries no statement about who wrote it.
2997
+ *
2998
+ * So discovery is scoped to the keys *this* repository could plausibly have
2999
+ * produced — the current scheme plus the earlier ones listed in
3000
+ * `hqSpoolCandidateKeys` — and a key outside that set is never this
3001
+ * repository's business. The #420 incident is inside it: the joined
3002
+ * single-segment key is one of the candidates.
3003
+ *
3004
+ * **And a marked walk beside them (#447).** Derivation's other blind spot is a
3005
+ * key whose *encoding* this checkout no longer produces but whose owner and
3006
+ * repo are unchanged — the retired non-injective era being the live example.
3007
+ * That era cannot be derived safely, because a candidate built from it can
3008
+ * equal a different repository's current key, so #446 dropped it rather than
3009
+ * risk the cross-repository claim again.
3010
+ *
3011
+ * `SPOOL_REPOSITORY_MARKER` supplies the proof that derivation could not. Every
3012
+ * enqueue stamps its spool with the repository writing it, so the root can be
3013
+ * walked again: a directory whose marker names *this* repository is this
3014
+ * repository's, whatever key encoding it sits under, and a directory whose
3015
+ * marker names another repository is never reported here. That is the
3016
+ * discriminator #446 correctly said did not exist — it exists now because
3017
+ * something writes it down.
3018
+ *
3019
+ * The two discoveries are complements, not alternatives. The walk sees only
3020
+ * what was stamped; directories written before this shipped have no marker, and
3021
+ * candidate keys still find those. An unmarked directory is still never
3022
+ * reported, because it still carries no statement about who wrote it.
3023
+ *
3024
+ * **What this does NOT close, despite being the marker's obvious use: renames
3025
+ * and owner changes.** A marker records the identity that was current when the
3026
+ * directory was written, so after `patronage/old` becomes `patronage/new` the
3027
+ * stranded directory is stamped `patronage/old` — and matching is equality
3028
+ * against the checkout's *present* identity, which rejects it. Making that work
3029
+ * needs an identifier that survives a rename, which neither the profile nor the
3030
+ * marker carries today; accepting a non-matching marker instead would be
3031
+ * guessing, which is the #446 defect wearing a new hat. #447 stays open for it.
3032
+ *
3033
+ * Also outside the sweep, by construction: evidence under a *different state
3034
+ * root*, if `XDG_STATE_HOME` moves. No walk of this root can reach another one.
3035
+ */
3036
+ async function sweepHqSpoolOrphans(input, dependencies = {}) {
3037
+ const env = dependencies.env ?? process.env;
3038
+ const root = hqSpoolRoot(env);
3039
+ const deadline = performance.now() + (dependencies.budgetMs ?? DEFAULT_HQ_SPOOL_SWEEP_BUDGET_MS);
3040
+ const own = hqSpoolDirectory(input.repository, env);
3041
+ const orphans = [];
3042
+ for (const segments of hqSpoolCandidateKeys(input.repository)) {
3043
+ const spool = path.join(root, ...segments, HQ_RETRY_SPOOL_DIRNAME);
3044
+ if (spool === own) continue;
3045
+ if (remainingMs(deadline) <= 0) {
3046
+ orphans.push({
3047
+ directory: spool,
3048
+ pending: 0,
3049
+ unlistable: 1
3050
+ });
3051
+ continue;
3052
+ }
3053
+ const counted = await countRemainingSpoolWork([spool], [explicitSpoolLayout(spool)], deadline);
3054
+ if (counted.remaining === 0 && counted.unlistableScans === 0) continue;
3055
+ orphans.push({
3056
+ directory: spool,
3057
+ ...counted.oldestQueuedAt === void 0 ? {} : { oldestQueuedAt: counted.oldestQueuedAt },
3058
+ pending: counted.remaining,
3059
+ unlistable: counted.unlistableScans
3060
+ });
3061
+ }
3062
+ const claimed = await findMarkedSpools(input.repository, root, deadline);
3063
+ for (const spool of claimed) {
3064
+ if (spool === own || orphans.some((found) => found.directory === spool)) continue;
3065
+ if (remainingMs(deadline) <= 0) {
3066
+ orphans.push({
3067
+ directory: spool,
3068
+ pending: 0,
3069
+ unlistable: 1
3070
+ });
3071
+ continue;
3072
+ }
3073
+ const counted = await countRemainingSpoolWork([spool], [explicitSpoolLayout(spool)], deadline);
3074
+ if (counted.remaining === 0 && counted.unlistableScans === 0) continue;
3075
+ orphans.push({
3076
+ directory: spool,
3077
+ ...counted.oldestQueuedAt === void 0 ? {} : { oldestQueuedAt: counted.oldestQueuedAt },
3078
+ pending: counted.remaining,
3079
+ unlistable: counted.unlistableScans
3080
+ });
3081
+ }
3082
+ return {
3083
+ orphans,
3084
+ root
3085
+ };
3086
+ }
3087
+ /**
3088
+ * Drains every spooled event for a repository and reports each one (#390).
3089
+ *
3090
+ * This is the deliberate counterpart to the sink's advisory emit path: the
3091
+ * caller has already resolved credentials explicitly, so there is no cap, no
3092
+ * daemon, and no background retry — one pass, bounded, over the repo-keyed
3093
+ * location plus the legacy cwd location (or the operator's `--dir`). Delivery
3094
+ * itself is the sink's own replay, so there is exactly one POST path.
3095
+ */
3096
+ async function flushHqSpool(input, dependencies = {}) {
3097
+ const endpoint = validatedIngestEndpoint(input.endpoint);
3098
+ if (endpoint === void 0) throw new Error("HQ endpoint must be an HTTPS origin without embedded credentials.");
3099
+ const env = dependencies.env ?? process.env;
3100
+ const request = dependencies.fetch ?? fetch;
3101
+ const transportBudgetMs = dependencies.transportTimeoutMs ?? DEFAULT_HQ_TRANSPORT_TIMEOUT_MS;
3102
+ const flushBudgetMs = dependencies.journalFlushBudgetMs ?? HQ_JOURNAL_FLUSH_BUDGET_MS;
3103
+ const deadline = performance.now() + (dependencies.budgetMs ?? DEFAULT_HQ_FLUSH_BUDGET_MS);
3104
+ const writeLayout = repositorySpoolLayout(input.repository, env);
3105
+ const layouts = input.explicitDirectories && input.explicitDirectories.length > 0 ? input.explicitDirectories.map(explicitSpoolLayout) : [writeLayout, legacySpoolLayout(input.cwd)];
3106
+ const outcomeById = /* @__PURE__ */ new Map();
3107
+ let rejectedFiles = 0;
3108
+ let unreachableFiles = 0;
3109
+ let unsecurableSpools = 0;
3110
+ const record = (outcome) => {
3111
+ outcomeById.set(outcome.eventId, outcome);
3112
+ dependencies.report?.(outcome);
3113
+ };
3114
+ const journalReport = (outcome) => {
3115
+ if (outcome.status === "unreachable" && outcome.detail?.includes("migration into the current spool")) unreachableFiles += 1;
3116
+ record(outcome);
3117
+ };
3118
+ const spoolReport = (outcome) => {
3119
+ if (outcome.status === "rejected") rejectedFiles += 1;
3120
+ if (outcome.status === "unreachable") unreachableFiles += 1;
3121
+ record(outcome);
3122
+ };
3123
+ const spools = [];
3124
+ let migratedIntoWriteLayout = false;
3125
+ const journalDrainOptions = {
3126
+ maxEntries: Number.POSITIVE_INFINITY,
3127
+ onMigrate: () => {
3128
+ migratedIntoWriteLayout = true;
3129
+ },
3130
+ report: journalReport
3131
+ };
3132
+ for (const layout of layouts) await replayRetryJournal(layout, writeLayout, endpoint, input.clientId, input.clientSecret, request, {
3133
+ env,
3134
+ fetch: request,
3135
+ journalFlushBudgetMs: flushBudgetMs
3136
+ }, transportBudgetMs, deadline, deadline, journalDrainOptions);
3137
+ const drainLayouts = migratedIntoWriteLayout && !layouts.some((layout) => layoutPath(layout) === layoutPath(writeLayout)) ? [...layouts, writeLayout] : layouts;
3138
+ for (const layout of drainLayouts) {
3139
+ await replayCloseoutSpool(layout, endpoint, input.clientId, input.clientSecret, request, transportBudgetMs, deadline, deadline, flushBudgetMs, void 0, {
3140
+ maxEntries: Number.POSITIVE_INFINITY,
3141
+ report: spoolReport
3142
+ });
3143
+ const secured = await secureSpoolLayout(layout, deadline);
3144
+ if (secured === void 0) {
3145
+ if ((await settleWithin(lstat(layoutPath(layout)), remainingMs(deadline))).status === "fulfilled") unsecurableSpools += 1;
3146
+ } else spools.push(secured.spool);
3147
+ }
3148
+ const { remaining, unlistableScans } = await countRemainingSpoolWork(spools, drainLayouts, deadline);
3149
+ const outcomes = [...outcomeById.values()];
3150
+ const count = (status) => outcomes.filter((outcome) => outcome.status === status).length;
3151
+ const rejected = count("rejected");
3152
+ return {
3153
+ delivered: count("delivered"),
3154
+ duplicate: count("duplicate"),
3155
+ incomplete: remainingMs(deadline) <= 0 || unsecurableSpools > 0 || unlistableScans > 0 || remaining > rejectedFiles + unreachableFiles,
3156
+ outcomes,
3157
+ rejected,
3158
+ remaining,
3159
+ spools,
3160
+ undeliverable: count("undeliverable"),
3161
+ unreachable: count("unreachable"),
3162
+ unreachableFiles
3163
+ };
3164
+ }
2457
3165
  /**
2458
3166
  * Schedules one bounded HQ delivery after the current command stack. Producers
2459
3167
  * provide the already-written proof through payloadPath (or bounded payloadJson);
@@ -2647,22 +3355,32 @@ const ISOLATED_CLOSEOUT_FS_WORKER_SOURCE = String.raw`
2647
3355
  if (Buffer.byteLength(workerData.body, "utf8") > 278528) {
2648
3356
  reply({ status: "failed" });
2649
3357
  } else {
2650
- const root = fs.realpathSync(workerData.cwd);
2651
- const memory = path.join(root, ".factory-memory");
2652
- const spool = path.join(memory, "hq-retry-spool");
3358
+ try { fs.mkdirSync(workerData.root, { recursive: true }); } catch {}
3359
+ const root = fs.realpathSync(workerData.root);
2653
3360
  const rootIdentity = inspectDirectory(root);
2654
- const memoryIdentity =
2655
- rootIdentity === undefined ? undefined : secureDirectory(memory);
2656
- const spoolIdentity =
2657
- memoryIdentity === undefined ? undefined : secureDirectory(spool);
2658
- if (
2659
- rootIdentity === undefined ||
2660
- memoryIdentity === undefined ||
2661
- spoolIdentity === undefined ||
2662
- !sameDirectory(root, rootIdentity) ||
2663
- !sameDirectory(memory, memoryIdentity) ||
2664
- !sameDirectory(spool, spoolIdentity)
2665
- ) {
3361
+ const directories = [];
3362
+ const identities = [];
3363
+ let current = root;
3364
+ let secured = rootIdentity !== undefined;
3365
+ for (const segment of workerData.segments) {
3366
+ current = path.join(current, segment);
3367
+ const identity = secured ? secureDirectory(current) : undefined;
3368
+ if (identity === undefined) {
3369
+ secured = false;
3370
+ break;
3371
+ }
3372
+ directories.push(current);
3373
+ identities.push(identity);
3374
+ }
3375
+ const spool = directories[directories.length - 1];
3376
+ const verifyChain = () =>
3377
+ rootIdentity !== undefined &&
3378
+ sameDirectory(root, rootIdentity) &&
3379
+ directories.every((directory, index) =>
3380
+ sameDirectory(directory, identities[index])
3381
+ );
3382
+ const spoolIdentity = identities[identities.length - 1];
3383
+ if (!secured || spool === undefined || !verifyChain()) {
2666
3384
  reply({ status: "failed" });
2667
3385
  } else {
2668
3386
  const safeEventId = /^[A-Za-z0-9_-]{1,160}$/.test(workerData.eventId)
@@ -2686,18 +3404,12 @@ const ISOLATED_CLOSEOUT_FS_WORKER_SOURCE = String.raw`
2686
3404
  fs.fsyncSync(descriptor);
2687
3405
  fs.closeSync(descriptor);
2688
3406
  descriptor = undefined;
2689
- if (
2690
- !sameDirectory(root, rootIdentity) ||
2691
- !sameDirectory(memory, memoryIdentity) ||
2692
- !sameDirectory(spool, spoolIdentity)
2693
- ) {
3407
+ if (!verifyChain()) {
2694
3408
  reply({ status: "failed" });
2695
3409
  } else {
2696
3410
  fs.linkSync(temporary, finalPath);
2697
3411
  if (
2698
- !sameDirectory(root, rootIdentity) ||
2699
- !sameDirectory(memory, memoryIdentity) ||
2700
- !sameDirectory(spool, spoolIdentity) ||
3412
+ !verifyChain() ||
2701
3413
  !syncDirectory(spool, spoolIdentity)
2702
3414
  ) {
2703
3415
  reply({ status: "failed" });
@@ -2835,10 +3547,15 @@ const deferProjectHqIngestIsolated = async (input) => {
2835
3547
  } catch {
2836
3548
  return "held";
2837
3549
  }
3550
+ const layout = repositorySpoolLayout({
3551
+ owner: parsedProfile.repository.owner,
3552
+ repo: parsedProfile.repository.name
3553
+ }, process.env);
2838
3554
  const persisted = await runCloseoutFsWorker({
2839
3555
  body,
2840
- cwd: input.cwd,
2841
3556
  eventId,
3557
+ root: layout.root,
3558
+ segments: layout.segments,
2842
3559
  type: "persist"
2843
3560
  }, HQ_JOURNAL_FLUSH_BUDGET_MS);
2844
3561
  return persisted !== null && typeof persisted === "object" && persisted.status === "persisted" ? "deferred" : "held";
@@ -2895,6 +3612,191 @@ function assertCleanWorktreeForProof({ changedFiles, cwd, dirtyMessage, statusPo
2895
3612
  if (offendingLines.length > 0) throw new Error(`${dirtyMessage}\n${offendingLines.join("\n")}`);
2896
3613
  }
2897
3614
  //#endregion
3615
+ //#region src/demand-keys.ts
3616
+ /** The demands that exist at most once per candidate. */
3617
+ const DEMAND_KEYS = {
3618
+ /** The PR is still a draft. */
3619
+ draft: "draft",
3620
+ /** The candidate is not the intended final human review point. */
3621
+ finalReviewPoint: "final-review-point",
3622
+ /** GitHub's own check rollup for the candidate head. */
3623
+ githubChecks: "github-checks",
3624
+ /** Local HEAD and the GitHub PR head must be the same commit. */
3625
+ headIdentity: "head-identity",
3626
+ /** An unhandled post-readiness human comment or review submission. */
3627
+ humanBlocker: "human-blocker",
3628
+ /** The repository-wide merge freeze. */
3629
+ mergeFreeze: "merge-freeze",
3630
+ /** GitHub's mergeability / merge-state rollup. */
3631
+ mergeState: "merge-state",
3632
+ /** The PR body's required rendered sections. */
3633
+ prBodySections: "pr-body-sections",
3634
+ /** A current, head-bound, passing typed `pr:verify` proof. */
3635
+ prVerify: "pr-verify",
3636
+ /** The profile-resolved review ladder policy. */
3637
+ reviewLadder: "review-ladder",
3638
+ /** Unresolved GitHub review threads. */
3639
+ reviewThreads: "review-threads",
3640
+ /** An explicit trivial waiver that the diff does not support. */
3641
+ trivialWaiver: "trivial-waiver"
3642
+ };
3643
+ /** The families whose instances are named by a resolved value. */
3644
+ const QUALIFIED_DEMAND_FAMILIES = [
3645
+ "required-check",
3646
+ "review-mode",
3647
+ "review-rung"
3648
+ ];
3649
+ const QUALIFIER_PATTERN = /^[A-Za-z0-9._%/-]{1,64}$/u;
3650
+ const percentEncode = (character) => {
3651
+ const code = character.codePointAt(0) ?? 0;
3652
+ if (code >= 55296 && code <= 57343) return `%u${code.toString(16).toUpperCase().padStart(4, "0")}`;
3653
+ return [...new TextEncoder().encode(character)].map((byte) => `%${byte.toString(16).toUpperCase().padStart(2, "0")}`).join("");
3654
+ };
3655
+ /**
3656
+ * Express a resolved value as a qualifier. Percent-encoding, not scrubbing:
3657
+ * the mapping is injective, so two differently named required checks can never
3658
+ * collapse into one key — which would let one operator waiver silently cover a
3659
+ * demand nobody waived, and make HQ count two causes as one.
3660
+ *
3661
+ * Deterministic in both directions of use: the key a `pr:ready` proof records
3662
+ * is the key a `pr:merge-check` waiver matches.
3663
+ */
3664
+ const demandQualifier = (value) => value.replaceAll(/[^A-Za-z0-9._/-]/gu, percentEncode);
3665
+ /** The demand one profile-declared external required check makes. */
3666
+ const requiredCheckDemand = (name) => `required-check:${demandQualifier(name)}`;
3667
+ /** The demand one applicable review mode makes (correctness, security). */
3668
+ const reviewModeDemand = (mode) => `review-mode:${demandQualifier(mode)}`;
3669
+ /** The demand the review rung in force makes (profile default or wave rung). */
3670
+ const reviewRungDemand = (rung) => `review-rung:${demandQualifier(rung)}`;
3671
+ const FIXED_DEMAND_KEYS = Object.values(DEMAND_KEYS);
3672
+ /** The review modes a profile can resolve, and therefore demand. */
3673
+ const REVIEW_MODE_DEMAND_VALUES = ["correctness", "security"];
3674
+ const escapePattern = /%(?:u[0-9A-F]{4}|[0-9A-F]{2})/gu;
3675
+ /**
3676
+ * Reverse `demandQualifier`. A qualifier is canonical exactly when encoding
3677
+ * its decoded form reproduces it — which rejects both malformed escapes and
3678
+ * noncanonical aliases like `%41` for `A`. Returns undefined when the
3679
+ * qualifier cannot have been minted here.
3680
+ */
3681
+ const decodeQualifier = (qualifier) => {
3682
+ const bytes = [];
3683
+ let decoded = "";
3684
+ let index = 0;
3685
+ const flush = () => {
3686
+ if (bytes.length === 0) return true;
3687
+ try {
3688
+ decoded += new TextDecoder("utf-8", {
3689
+ fatal: true,
3690
+ ignoreBOM: true
3691
+ }).decode(Uint8Array.from(bytes));
3692
+ } catch {
3693
+ return false;
3694
+ }
3695
+ bytes.length = 0;
3696
+ return true;
3697
+ };
3698
+ while (index < qualifier.length) {
3699
+ const character = qualifier[index];
3700
+ if (character !== "%") {
3701
+ if (!flush()) return;
3702
+ decoded += character;
3703
+ index += 1;
3704
+ continue;
3705
+ }
3706
+ escapePattern.lastIndex = index;
3707
+ const escape = escapePattern.exec(qualifier);
3708
+ if (!escape || escape.index !== index) return;
3709
+ if (escape[0][1] === "u") {
3710
+ if (!flush()) return;
3711
+ decoded += String.fromCodePoint(Number.parseInt(escape[0].slice(2), 16));
3712
+ } else bytes.push(Number.parseInt(escape[0].slice(1), 16));
3713
+ index += escape[0].length;
3714
+ }
3715
+ return flush() ? decoded : void 0;
3716
+ };
3717
+ const qualifierIsMintable = (family, qualifier) => {
3718
+ if (!QUALIFIER_PATTERN.test(qualifier)) return false;
3719
+ const decoded = decodeQualifier(qualifier);
3720
+ if (decoded === void 0 || demandQualifier(decoded) !== qualifier) return false;
3721
+ if (family === "review-rung") return EVIDENCE_REVIEW_RUNGS$1.includes(decoded);
3722
+ if (family === "review-mode") return REVIEW_MODE_DEMAND_VALUES.includes(decoded);
3723
+ return true;
3724
+ };
3725
+ /**
3726
+ * A demand key from the closed vocabulary: a known unqualified family, or a
3727
+ * known qualified family with a qualifier this module could actually have
3728
+ * minted. Closed on purpose — an invented family, an unresolvable rung, or a
3729
+ * noncanonical encoding would be indistinguishable from a typo to anyone
3730
+ * counting causes, which is the whole point of recording codes.
3731
+ */
3732
+ const demandKeySchema = z.string().refine((value) => {
3733
+ if (FIXED_DEMAND_KEYS.includes(value)) return true;
3734
+ const separator = value.indexOf(":");
3735
+ if (separator === -1) return false;
3736
+ const family = value.slice(0, separator);
3737
+ return QUALIFIED_DEMAND_FAMILIES.includes(family) && qualifierIsMintable(family, value.slice(separator + 1));
3738
+ }, { message: `a demand key is one of ${FIXED_DEMAND_KEYS.join(", ")} or a resolvable ${QUALIFIED_DEMAND_FAMILIES.join(" / ")} key` });
3739
+ /**
3740
+ * The key shape an operator may *name* in a waiver: syntactic, not closed.
3741
+ *
3742
+ * Deliberately distinct from `demandKeySchema` above, which is what a proof's
3743
+ * reason codes must come from. A waiver store is durable operator evidence
3744
+ * written before today's vocabulary existed, so tightening its validation
3745
+ * would make an existing record parse as no waiver and let the next write drop
3746
+ * it. A waiver naming a key nobody resolves is already inert — `pr:merge-check`
3747
+ * reports it as an unapplied waiver — so nothing is admitted by accepting it.
3748
+ */
3749
+ const waiverDemandKeySchema = z.string().regex(/^[a-z][a-z0-9-]*(?::[A-Za-z0-9._%/-]+)?$/u, "A demand key is a lowercase family, optionally qualified by its resolved value (e.g. review-rung:human).");
3750
+ /** One refusal sentence: single line, trimmed, bounded. */
3751
+ const blockedReasonDetailSchema = z.string().min(1).max(280).refine((value) => value.trim().length > 0, { message: "must not be blank" }).refine((value) => !/[\r\n]/u.test(value), { message: "must be one line, not a multi-line payload" });
3752
+ const blockedReasonSchema = z.object({
3753
+ code: demandKeySchema,
3754
+ detail: blockedReasonDetailSchema
3755
+ });
3756
+ const blockedReasonsSchema = z.array(blockedReasonSchema).max(100);
3757
+ /** Fit one refusal sentence to the wire bound without losing its head. */
3758
+ const blockedReasonDetail = (reason) => {
3759
+ const line = reason.replaceAll(/\s+/gu, " ").trim();
3760
+ return line.length <= 280 ? line : `${line.slice(0, 279)}…`;
3761
+ };
3762
+ const blockedReasonIssue = (message) => [{
3763
+ code: "custom",
3764
+ message,
3765
+ path: ["blockedReasons"]
3766
+ }];
3767
+ /**
3768
+ * The invariant every reader enforces, shared by the emitting schema and the
3769
+ * wire schema so it is one rule rather than two that can drift. It is the
3770
+ * producer's state machine, written down:
3771
+ *
3772
+ * - `ready` refused nothing, so it carries neither projection, and it is the
3773
+ * final review point;
3774
+ * - `slice-ready/not-final` was held back by exactly one demand — being a
3775
+ * slice — and names it;
3776
+ * - `blocked` refused something other than being a slice, and says so;
3777
+ * - whichever it is, `blockedReasons` names every refusal listed in
3778
+ * `blockingReasons`, in the same order, as the bounded form of that sentence.
3779
+ *
3780
+ * Naming the slice demand and the ledger's `finalReviewPoint` are the same
3781
+ * fact, so a proof that says one and not the other is refused.
3782
+ */
3783
+ const blockedReasonIssues = (proof) => {
3784
+ const named = proof.blockedReasons ?? [];
3785
+ const reasons = proof.blockingReasons ?? [];
3786
+ const slice = DEMAND_KEYS.finalReviewPoint;
3787
+ const namesSlice = named.some((reason) => reason.code === slice);
3788
+ if (proof.status === "ready") {
3789
+ if (named.length > 0 || reasons.length > 0) return blockedReasonIssue("a ready pr:ready proof must carry no blocking reasons");
3790
+ } else if (proof.status === "slice-ready/not-final") {
3791
+ if (named.length !== 1 || !namesSlice) return blockedReasonIssue(`a slice-ready/not-final pr:ready proof is held back by exactly one demand, ${slice}`);
3792
+ } else if (reasons.length === 0) return blockedReasonIssue("a blocked pr:ready proof must record what blocked it");
3793
+ else if (named.length > 0 && !named.some((r) => r.code !== slice)) return blockedReasonIssue(`a blocked pr:ready proof must name a demand other than ${slice}`);
3794
+ if (proof.finalReviewPoint !== void 0 && namesSlice === proof.finalReviewPoint) return blockedReasonIssue(`naming ${slice} and the ledger's finalReviewPoint are the same fact; this proof says both`);
3795
+ if (named.length !== reasons.length) return blockedReasonIssue(`blockedReasons must name every blocking reason: ${reasons.length} reason(s), ${named.length} named`);
3796
+ const drifted = named.findIndex((reason, index) => reason.detail !== blockedReasonDetail(reasons[index]));
3797
+ return drifted === -1 ? [] : blockedReasonIssue(`blockedReasons[${drifted}] does not carry blocking reason ${drifted}; the two projections must tell one story`);
3798
+ };
3799
+ //#endregion
2898
3800
  //#region src/factory-session.ts
2899
3801
  const FACTORY_SESSION_ID_ENV = "FACTORY_SESSION_ID";
2900
3802
  const UNKNOWN_AUTHORING_SESSION = "unknown";
@@ -2917,20 +3819,12 @@ const resolveAuthoringSessionIds = ({ override, recorded }) => {
2917
3819
  const DEMAND_WAIVER_SCHEMA_VERSION$1 = 1;
2918
3820
  const DEFAULT_DEMAND_WAIVER_PATH = ".factory-memory/demand-waivers.json";
2919
3821
  const shaSchema$6 = z.string().regex(/^[0-9a-f]{40}$/u);
2920
- /**
2921
- * A resolved demand's key: a family, optionally qualified by the resolved
2922
- * value that makes it this demand *instance* (`review-rung:human`,
2923
- * `required-check:core`, `merge-freeze`). The consumer that resolved the
2924
- * demand owns the key; this module only matches on it, which is why no
2925
- * control name is enumerated here.
2926
- */
2927
- const demandKeySchema = z.string().regex(/^[a-z][a-z0-9-]*(?::[A-Za-z0-9._/-]+)?$/u, "A demand key is a lowercase family, optionally qualified by its resolved value (e.g. review-rung:human).");
2928
3822
  const demandWaiverSchema = z.object({
2929
3823
  candidate: z.object({
2930
3824
  headSha: shaSchema$6,
2931
3825
  pr: z.number().int().positive()
2932
3826
  }),
2933
- demand: demandKeySchema,
3827
+ demand: waiverDemandKeySchema,
2934
3828
  operator: z.string().trim().min(1),
2935
3829
  rationale: z.string().trim().min(1),
2936
3830
  recordedAt: z.iso.datetime(),
@@ -2943,7 +3837,7 @@ const demandWaiverStoreSchema = z.object({
2943
3837
  });
2944
3838
  const validateDemandWaiverStore = (value) => demandWaiverStoreSchema.parse(value);
2945
3839
  const waivedDemandSchema = z.object({
2946
- demand: demandKeySchema,
3840
+ demand: waiverDemandKeySchema,
2947
3841
  operator: z.string().trim().min(1),
2948
3842
  rationale: z.string().trim().min(1),
2949
3843
  recordedAt: z.iso.datetime(),
@@ -4013,7 +4907,15 @@ const managedReadinessLedgerSchema$1 = z.object({
4013
4907
  verifiedHeadSha: z.string().optional()
4014
4908
  })
4015
4909
  });
4910
+ /**
4911
+ * The pr:ready proof versions a reader still accepts. `pr:ready` emits v2 only
4912
+ * (#391) — one current contract — but v1 events were spooled before the bump
4913
+ * and HQ must ingest them without degrading, so the wire schema parses both.
4914
+ */
4915
+ const SUPPORTED_PR_READY_SCHEMA_VERSIONS = [1, 2];
4916
+ const prReadySchemaVersionSchema = z.number().refine((value) => SUPPORTED_PR_READY_SCHEMA_VERSIONS.includes(value), { message: `schemaVersion must be one of: ${SUPPORTED_PR_READY_SCHEMA_VERSIONS.join(", ")}` });
4016
4917
  z.object({
4918
+ blockedReasons: blockedReasonsSchema.optional(),
4017
4919
  blockingReasons: z.array(z.string()),
4018
4920
  command: z.literal("patronage-factory pr:ready"),
4019
4921
  followUp: followUpActionSchema.optional(),
@@ -4023,12 +4925,25 @@ z.object({
4023
4925
  profilePath: z.string().min(1).optional(),
4024
4926
  repairs: z.array(readinessRepairSchema$1).default([]),
4025
4927
  repository: z.string().regex(/^[^/\s]+\/[^/\s]+$/u).optional(),
4026
- schemaVersion: z.literal(1),
4928
+ schemaVersion: prReadySchemaVersionSchema,
4027
4929
  status: z.enum([
4028
4930
  "ready",
4029
4931
  "blocked",
4030
4932
  "slice-ready/not-final"
4031
4933
  ])
4934
+ }).superRefine((proof, context) => {
4935
+ if (proof.schemaVersion < 2) {
4936
+ if (proof.blockedReasons !== void 0) context.addIssue({
4937
+ code: "custom",
4938
+ message: "blockedReasons is a schemaVersion 2 field; a v1 pr:ready proof must not carry it.",
4939
+ path: ["blockedReasons"]
4940
+ });
4941
+ return;
4942
+ }
4943
+ for (const issue of blockedReasonIssues({
4944
+ ...proof,
4945
+ finalReviewPoint: proof.ledger.finalReviewPoint
4946
+ })) context.addIssue(issue);
4032
4947
  });
4033
4948
  //#endregion
4034
4949
  //#region src/boundary-check.ts
@@ -9030,11 +9945,8 @@ function renderCloseoutMarkdown(artifact) {
9030
9945
  ...ledgerLines
9031
9946
  ].join("\n");
9032
9947
  }
9033
- //#endregion
9034
- //#region src/epic-closeout/write-artifact.ts
9035
- const DEFAULT_OUT_DIR = ".factory-memory/closeouts";
9036
9948
  function closeoutArtifactPaths(opts) {
9037
- const dir = path.resolve(opts.repoRoot, opts.outDir ?? DEFAULT_OUT_DIR);
9949
+ const dir = path.resolve(opts.repoRoot, opts.outDir ?? ".factory-memory/closeouts");
9038
9950
  const filename = sanitizeEpicFilename(opts.epic);
9039
9951
  return {
9040
9952
  dir,
@@ -9058,43 +9970,487 @@ function writeCloseoutArtifact(opts) {
9058
9970
  };
9059
9971
  }
9060
9972
  //#endregion
9061
- //#region src/commands/closeout.ts
9062
- const formatUtcDate = (date) => date.toISOString().slice(0, 10);
9063
- const nonEmptyRepoRoot = (value) => {
9064
- if (value.trim() === "") throw new InvalidArgumentError("--repo-root requires a non-empty path");
9065
- return value;
9066
- };
9067
- const codexThreadIdsForCloseout = (env = process.env) => {
9068
- const threadId = normalizeSessionId(env.CODEX_THREAD_ID);
9069
- return threadId === void 0 ? [] : [threadId];
9070
- };
9071
- const resolveRetroSubject = (issue, pr) => {
9072
- if (issue !== void 0) return { issueNumber: issue };
9073
- if (pr !== void 0) return { prNumber: pr };
9074
- };
9075
- const tokenFamilySummary = (tokenFamilies) => {
9076
- const available = [];
9077
- const unavailable = [];
9078
- for (const family of ["claude", "gpt"]) if (tokenFamilies[family] === void 0) unavailable.push(family);
9079
- else available.push(family);
9080
- return {
9081
- available,
9082
- unavailable
9083
- };
9084
- };
9085
- const reportLocalCloseoutOutcome = (output, input) => {
9086
- const harvest = tokenFamilySummary(input.retro.envelope.tokenFamilies);
9087
- output.stdout.write(`local artifacts: ${input.paths.markdownPath}, ${input.paths.jsonPath}, and ${input.retro.envelopePath}\n`);
9088
- output.stdout.write(`boundary thermo input: runs=${input.boundaryThermo.runs}, owner=${input.boundaryThermo.owner}, waived=${input.boundaryThermo.waived}${input.boundaryThermo.waiverRationale === void 0 ? "" : `, waiver rationale=${input.boundaryThermo.waiverRationale}`}\n`);
9089
- if (harvest.unavailable.length > 0) output.stderr.write(`harvest data unavailable: ${harvest.available.length === 0 ? "no token families were recorded" : `missing ${harvest.unavailable.join(" and ")} token family data`}; recorded gaps: ${input.retro.envelope.dataGaps.join(" | ")}\n`);
9090
- else if (input.retro.envelope.dataGaps.length > 0) output.stderr.write(`retro-envelope data gaps recorded: ${input.retro.envelope.dataGaps.join(" | ")}\n`);
9091
- };
9973
+ //#region src/epic-structure.ts
9974
+ /**
9975
+ * Producer-side `epic-structure` v1 emitter (epic #132, lane #135).
9976
+ *
9977
+ * `psf epic:publish-structure` reads a planner's ephemeral `dag.yml` (never
9978
+ * committed — see epic #132 / ADR 0018), validates the graph fail-closed, and
9979
+ * POSTs an `epic-structure` v1 event to HQ's `/api/ingest` boundary. This
9980
+ * module is the pure core: parse-to-IR validation, the wire mapping, and the
9981
+ * deterministic content-addressed eventId. All I/O (stdin/file read, fetch)
9982
+ * lives in the command wrapper.
9983
+ *
9984
+ * WIRE PARITY (do not fork the schema): the emitted payload is the exact shape
9985
+ * HQ's `EpicGraphSchema` accepts (`software-factory-hq/src/contracts/
9986
+ * epic-schemas.ts`) and is built by hand the same way `seed-dev.ts` builds it
9987
+ * no re-declared zod twin. HQ's `epic-structure-parity.test.ts` imports
9988
+ * {@link buildEpicStructureEvent} and runs its output through the real
9989
+ * `EpicGraphSchema`, so any drift (field, enum, bound, strictness) fails CI.
9990
+ *
9991
+ * The `dag.yml` schema itself is documented with the epic skill (lane #137);
9992
+ * this module is the executable contract, not a second source of truth.
9993
+ */
9994
+ const EPIC_STRUCTURE_SCHEMA_VERSION = 1;
9995
+ /**
9996
+ * DAG-node PLANNING statuses — the planning-plane vocabulary a planner authors
9997
+ * on a `dag.yml` node. Carried forward by ADR 0018 §4 and defined by the epic
9998
+ * skill (`reference/epic-artifacts.md`) and both `CONTEXT.md` glossaries.
9999
+ *
10000
+ * A DAG node is a unit of *plan*; an HQ lane is a unit of *runtime execution*.
10001
+ * These are two planes with two vocabularies and must not be conflated the
10002
+ * producer validates and emits ONLY planning statuses:
10003
+ *
10004
+ * - `open` authored, not yet done
10005
+ * - `closed` done (a PR merged, or otherwise resolved)
10006
+ * - `satisfied-on-main` already true on main without a dedicated PR
10007
+ * - `parked` real node, scope still moving — relabeled off
10008
+ * `ready-for-agent`, never closed
10009
+ *
10010
+ * HQ's `DagNodeSchema` (`software-factory-hq/src/contracts/epic-schemas.ts`)
10011
+ * accepts this planning vocabulary on ingest AND overlays a runtime LANE status
10012
+ * onto a node once a lane is linked (`syncNodesWithLanes` /
10013
+ * `projectEpicMembership`) — the overlay is HQ-internal; the producer never
10014
+ * emits a lane status. HQ's `epic-structure-parity.test.ts` fails CI if the
10015
+ * producer emits any status HQ won't accept.
10016
+ */
10017
+ const EPIC_STRUCTURE_NODE_STATUSES = [
10018
+ "open",
10019
+ "closed",
10020
+ "satisfied-on-main",
10021
+ "parked"
10022
+ ];
10023
+ /**
10024
+ * Fail-closed validation error carrying every offending node/edge diagnostic.
10025
+ */
10026
+ var EpicStructureValidationError = class extends Error {
10027
+ diagnostics;
10028
+ constructor(diagnostics) {
10029
+ super(`epic:publish-structure refused: dag graph is invalid\n${diagnostics.map((line) => ` - ${line}`).join("\n")}`);
10030
+ this.name = "EpicStructureValidationError";
10031
+ this.diagnostics = diagnostics;
10032
+ }
10033
+ };
10034
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
10035
+ const nonEmptyString = (value) => typeof value === "string" && value.trim().length > 0;
10036
+ const isPositiveInteger = (value) => typeof value === "number" && Number.isInteger(value) && value > 0;
10037
+ const slugify = (value) => value.trim().toLowerCase().replaceAll(/[^a-z0-9]+/gu, "-").replaceAll(/^-+|-+$/gu, "") || "epic";
10038
+ const laneIdFor = (repo, issue) => `lane-${repo}-issue-${issue}`;
10039
+ const validateNodes = (rawNodes, diagnostics) => {
10040
+ const nodes = [];
10041
+ const slugs = /* @__PURE__ */ new Set();
10042
+ for (const [index, raw] of rawNodes.entries()) {
10043
+ if (!isPlainObject(raw)) {
10044
+ diagnostics.push(`node[${index}]: expected a mapping`);
10045
+ continue;
10046
+ }
10047
+ const label = nonEmptyString(raw.slug) ? `"${raw.slug}"` : `node[${index}]`;
10048
+ if (!nonEmptyString(raw.slug)) diagnostics.push(`node[${index}]: missing required \`slug\``);
10049
+ else if (slugs.has(raw.slug)) diagnostics.push(`node ${label}: duplicate slug`);
10050
+ else slugs.add(raw.slug);
10051
+ if (!nonEmptyString(raw.title)) diagnostics.push(`node ${label}: missing required \`title\``);
10052
+ const external = raw.external === true;
10053
+ if (!external && !isPositiveInteger(raw.issue)) diagnostics.push(`node ${label}: \`issue\` number is required — non-external nodes must carry a minted issue (this runs post-ratification); set \`external: true\` to exempt an adoption/upstream node`);
10054
+ let status = "open";
10055
+ if (raw.status === void 0) status = "open";
10056
+ else if (EPIC_STRUCTURE_NODE_STATUSES.includes(raw.status)) status = raw.status;
10057
+ else diagnostics.push(`node ${label}: \`status\` must be one of ${EPIC_STRUCTURE_NODE_STATUSES.join(", ")} (DAG-node planning statuses, not HQ lane statuses)`);
10058
+ if (raw.laneId !== void 0 && !nonEmptyString(raw.laneId)) diagnostics.push(`node ${label}: \`laneId\` must be a non-empty string`);
10059
+ nodes.push({
10060
+ external,
10061
+ ...isPositiveInteger(raw.issue) ? { issue: raw.issue } : {},
10062
+ ...nonEmptyString(raw.laneId) ? { laneId: raw.laneId } : {},
10063
+ slug: nonEmptyString(raw.slug) ? raw.slug : `node[${index}]`,
10064
+ status,
10065
+ title: nonEmptyString(raw.title) ? raw.title : ""
10066
+ });
10067
+ }
10068
+ return {
10069
+ nodes,
10070
+ slugs
10071
+ };
10072
+ };
10073
+ const validateEdges = (rawEdges, slugs, diagnostics) => {
10074
+ const edges = [];
10075
+ for (const [index, raw] of rawEdges.entries()) {
10076
+ if (!isPlainObject(raw)) {
10077
+ diagnostics.push(`edge[${index}]: expected a mapping`);
10078
+ continue;
10079
+ }
10080
+ if (raw.type !== "depends-on") continue;
10081
+ if (!nonEmptyString(raw.from) || !nonEmptyString(raw.to)) {
10082
+ diagnostics.push(`edge[${index}]: depends-on edge requires string \`from\` and \`to\``);
10083
+ continue;
10084
+ }
10085
+ if (!slugs.has(raw.from)) diagnostics.push(`edge ${raw.from} -> ${raw.to}: \`from\` references unknown slug "${raw.from}"`);
10086
+ if (!slugs.has(raw.to)) diagnostics.push(`edge ${raw.from} -> ${raw.to}: \`to\` references unknown slug "${raw.to}"`);
10087
+ if (raw.from === raw.to) diagnostics.push(`edge ${raw.from} -> ${raw.to}: self-dependency (cycle)`);
10088
+ edges.push({
10089
+ from: raw.from,
10090
+ to: raw.to
10091
+ });
10092
+ }
10093
+ return edges;
10094
+ };
10095
+ /**
10096
+ * Detects a dependency cycle over the depends-on edges and returns the offending
10097
+ * cycle path (`a -> b -> a`) if one exists, else undefined. Only edges whose
10098
+ * endpoints both resolve to known slugs are walked, so unknown-slug diagnostics
10099
+ * are reported independently and never masquerade as cycles.
10100
+ */
10101
+ const findCycle = (slugs, edges) => {
10102
+ const adjacency = /* @__PURE__ */ new Map();
10103
+ for (const { from, to } of edges) if (slugs.has(from) && slugs.has(to)) {
10104
+ const list = adjacency.get(from) ?? [];
10105
+ list.push(to);
10106
+ adjacency.set(from, list);
10107
+ }
10108
+ const UNVISITED = 0;
10109
+ const IN_STACK = 1;
10110
+ const DONE = 2;
10111
+ const state = /* @__PURE__ */ new Map();
10112
+ const stack = [];
10113
+ const walk = (node) => {
10114
+ state.set(node, IN_STACK);
10115
+ stack.push(node);
10116
+ for (const next of adjacency.get(node) ?? []) {
10117
+ const marker = state.get(next) ?? UNVISITED;
10118
+ if (marker === IN_STACK) {
10119
+ const start = stack.indexOf(next);
10120
+ return [...stack.slice(start), next];
10121
+ }
10122
+ if (marker === UNVISITED) {
10123
+ const found = walk(next);
10124
+ if (found) return found;
10125
+ }
10126
+ }
10127
+ stack.pop();
10128
+ state.set(node, DONE);
10129
+ };
10130
+ for (const slug of slugs) if ((state.get(slug) ?? UNVISITED) === UNVISITED) {
10131
+ const found = walk(slug);
10132
+ if (found) return found;
10133
+ }
10134
+ };
10135
+ const resolveEpicId = (epic) => {
10136
+ if (typeof epic === "number") return String(epic);
10137
+ if (nonEmptyString(epic)) return epic.trim();
10138
+ };
10139
+ /**
10140
+ * Validates a dag document fail-closed. Throws {@link
10141
+ * EpicStructureValidationError} with a diagnostic per offending node/edge.
10142
+ */
10143
+ const validateDagDocument = (input) => {
10144
+ const doc = input.document;
10145
+ const diagnostics = [];
10146
+ if (!isPlainObject(doc)) throw new EpicStructureValidationError(["dag document must be a YAML/JSON mapping with `epic` and `nodes`"]);
10147
+ const epicId = resolveEpicId(doc.epic);
10148
+ if (epicId === void 0) diagnostics.push("dag document requires a top-level `epic` id");
10149
+ const repo = input.repo ?? (nonEmptyString(doc.repo) ? doc.repo.trim() : void 0);
10150
+ if (repo === void 0) diagnostics.push("repo is required — pass `--repo <name>` or set `repo:` in the dag document (needed for the epic record and lane-id derivation)");
10151
+ const rawNodes = Array.isArray(doc.nodes) ? doc.nodes : void 0;
10152
+ if (rawNodes === void 0 || rawNodes.length === 0) diagnostics.push("dag document requires a non-empty `nodes` array");
10153
+ const { nodes, slugs } = validateNodes(rawNodes ?? [], diagnostics);
10154
+ const edges = validateEdges(Array.isArray(doc.edges) ? doc.edges : [], slugs, diagnostics);
10155
+ const cycle = findCycle(slugs, edges);
10156
+ if (cycle) diagnostics.push(`dependency cycle detected: ${cycle.join(" -> ")}`);
10157
+ if (diagnostics.length > 0 || epicId === void 0 || repo === void 0) throw new EpicStructureValidationError(diagnostics);
10158
+ return {
10159
+ boundary: slugify(input.boundary ?? (nonEmptyString(doc.boundary) ? doc.boundary : `epic-${epicId}`)),
10160
+ edges,
10161
+ epicId,
10162
+ name: input.name ?? (nonEmptyString(doc.name) ? doc.name.trim() : `Epic ${epicId}`),
10163
+ nodes,
10164
+ repo
10165
+ };
10166
+ };
10167
+ /**
10168
+ * Builds the wire payload with deterministic ordering (nodes by slug, edges by
10169
+ * from then to) so re-emission of the same graph is byte-stable regardless of
10170
+ * the planner's source ordering — the basis for the content-addressed eventId.
10171
+ */
10172
+ const buildEpicStructurePayload = (dag) => {
10173
+ const nodes = dag.nodes.map((node) => {
10174
+ const laneId = node.laneId ?? (node.issue === void 0 ? void 0 : laneIdFor(dag.repo, node.issue));
10175
+ return {
10176
+ epicId: dag.epicId,
10177
+ ...laneId === void 0 ? {} : { laneId },
10178
+ slug: node.slug,
10179
+ status: node.status,
10180
+ title: node.title
10181
+ };
10182
+ }).toSorted((a, b) => a.slug.localeCompare(b.slug));
10183
+ return {
10184
+ edges: dag.edges.map((edge) => ({
10185
+ epicId: dag.epicId,
10186
+ from: edge.from,
10187
+ to: edge.to,
10188
+ type: "depends-on"
10189
+ })).toSorted((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)),
10190
+ epics: [{
10191
+ id: dag.epicId,
10192
+ name: dag.name,
10193
+ repo: dag.repo
10194
+ }],
10195
+ nodes
10196
+ };
10197
+ };
10198
+ /** Stable, key-sorted JSON for content hashing (values already ordered). */
10199
+ const stableStringify = (value) => {
10200
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
10201
+ if (isPlainObject(value)) return `{${Object.keys(value).toSorted().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
10202
+ return JSON.stringify(value ?? null);
10203
+ };
10204
+ /**
10205
+ * Deterministic eventId = `epic-structure-<boundary>-<sha256(payload)[0:16]>`.
10206
+ * Re-emitting identical graph content yields the same eventId (HQ dedups →
10207
+ * 200/duplicate); an amendment changes the content hash → a new eventId that
10208
+ * supersedes the prior structure wholesale (HQ keeps the latest). This mirrors
10209
+ * seed-dev's fixed-eventId idempotency, content-addressed instead of literal.
10210
+ */
10211
+ const epicStructureEventId = (boundary, payload) => `epic-structure-${slugify(boundary)}-${createHash("sha256").update(stableStringify(payload)).digest("hex").slice(0, 16)}`;
10212
+ /**
10213
+ * Validates and builds the full `epic-structure` v1 ingest event, ready to POST
10214
+ * to `/api/ingest`. Throws {@link EpicStructureValidationError} fail-closed.
10215
+ */
10216
+ const buildEpicStructureEvent = (input) => {
10217
+ const dag = validateDagDocument(input);
10218
+ const payload = buildEpicStructurePayload(dag);
10219
+ return {
10220
+ eventId: epicStructureEventId(dag.boundary, payload),
10221
+ kind: "epic-structure",
10222
+ observedAt: input.observedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
10223
+ payload,
10224
+ schemaVersion: 1
10225
+ };
10226
+ };
10227
+ /** The prod HQ worker is reachable at `hq.patronage.com` and its frozen
10228
+ * `-v1.*.workers.dev` alias. Emitting there without an Access service token is
10229
+ * fail-closed. */
10230
+ const isProductionHqUrl = (url) => url.includes("hq.patronage.com") || /-v1\.[^/]*\.workers\.dev/iu.test(url);
10231
+ /**
10232
+ * POSTs the event to HQ ingest. Fail-closed: a production URL without a
10233
+ * Cloudflare Access service token is refused before any request; a 401/403 is
10234
+ * surfaced with actionable guidance; any non-2xx throws.
10235
+ */
10236
+ const publishEpicStructure = async (args) => {
10237
+ if (!args.accessServiceToken && isProductionHqUrl(args.url)) throw new Error(`epic:publish-structure refusing to publish to production ${args.url} without a Cloudflare Access service token: set CF-Access-Client-Id and CF-Access-Client-Secret`);
10238
+ const doFetch = args.fetchImpl ?? globalThis.fetch;
10239
+ const requestInit = args.accessServiceToken ? buildCloudflareAccessRequestInit(args.accessServiceToken, {
10240
+ body: JSON.stringify(args.event),
10241
+ headers: { "content-type": "application/json" },
10242
+ method: "POST",
10243
+ ...args.signal ? { signal: args.signal } : {}
10244
+ }) : {
10245
+ body: JSON.stringify(args.event),
10246
+ headers: { "content-type": "application/json" },
10247
+ method: "POST",
10248
+ ...args.signal ? { signal: args.signal } : {}
10249
+ };
10250
+ let response;
10251
+ try {
10252
+ const normalizedUrl = args.url.replace(/\/+$/u, "");
10253
+ response = await doFetch(normalizedUrl.endsWith("/api/ingest") ? normalizedUrl : `${normalizedUrl}/api/ingest`, requestInit);
10254
+ } catch {
10255
+ throw new Error(`epic:publish-structure could not reach HQ ingest at ${args.url}: request failed. Check the endpoint and network; redirects are refused to protect Cloudflare Access credentials.`);
10256
+ }
10257
+ const text = await response.text();
10258
+ if (response.status === 401 || response.status === 403) throw new Error(`epic:publish-structure rejected by Cloudflare Access or HQ ingest (${response.status}): set valid CF-Access-Client-Id and CF-Access-Client-Secret service-token inputs. Response: ${text}`);
10259
+ if (response.status < 200 || response.status >= 300) throw new Error(`epic:publish-structure failed: HQ ingest returned ${response.status} ${text}`);
10260
+ let duplicate = false;
10261
+ try {
10262
+ duplicate = JSON.parse(text).duplicate === true;
10263
+ } catch {
10264
+ duplicate = response.status === 200;
10265
+ }
10266
+ return {
10267
+ duplicate,
10268
+ eventId: args.event.eventId,
10269
+ status: response.status
10270
+ };
10271
+ };
10272
+ //#endregion
10273
+ //#region src/commands/epic-publish-structure.ts
10274
+ const STDIN = "-";
10275
+ /** Reads a dag.yml source path, or stdin when `source` is `-`. */
10276
+ const readSource = (source) => {
10277
+ const fd = source === STDIN ? 0 : source;
10278
+ try {
10279
+ return readFileSync(fd, "utf-8");
10280
+ } catch (error) {
10281
+ const cause = error instanceof Error ? error.message : String(error);
10282
+ throw new Error(`epic:publish-structure could not read ${source === STDIN ? "stdin" : source} (${cause})`, { cause: error });
10283
+ }
10284
+ };
10285
+ /** Parses a dag.yml/JSON document read from `readSource`. */
10286
+ const parseDocument = (raw, source) => {
10287
+ if (raw.trim() === "") throw new Error(`epic:publish-structure received empty input from ${source === STDIN ? "stdin" : source}`);
10288
+ try {
10289
+ return parse(raw);
10290
+ } catch (error) {
10291
+ const cause = error instanceof Error ? error.message : String(error);
10292
+ throw new Error(`epic:publish-structure could not parse YAML: ${cause}`, { cause: error });
10293
+ }
10294
+ };
10295
+ /**
10296
+ * Reads and validates a dag.yml/JSON source into a full `epic-structure` v1
10297
+ * event, ready to POST or dry-run print. Shared with `factory:closeout`'s
10298
+ * terminal-snapshot re-emission (issue #392) so both commands build the exact
10299
+ * same wire event from the exact same source format — one current contract,
10300
+ * not a forked parser.
10301
+ */
10302
+ const buildEvent = (source, options = {}) => {
10303
+ return buildEpicStructureEvent({
10304
+ document: parseDocument(readSource(source), source),
10305
+ ...options.boundary ? { boundary: options.boundary } : {},
10306
+ ...options.name ? { name: options.name } : {},
10307
+ ...options.repo ? { repo: options.repo } : {}
10308
+ });
10309
+ };
10310
+ function createEpicPublishStructureCommand(output, deps = {}) {
10311
+ const env = deps.env ?? process.env;
10312
+ return new Command("epic:publish-structure").description("Emit-only: read a planner's ephemeral dag.yml (or - for stdin), validate the graph fail-closed (YAML parses, required fields, edges reference known slugs, acyclic, issue numbers on every non-external node), and POST an epic-structure v1 event to HQ ingest. Idempotent by a content-addressed eventId (re-emission of an amendment supersedes the prior structure). Production auth uses the CF-Access-Client-Id and CF-Access-Client-Secret environment inputs. URL: --url or HQ_INGEST_URL. The dag.yml schema is documented with the epic skill (#137).").argument("[source]", "path to dag.yml, or - for stdin", STDIN).option("--url <url>", "HQ ingest base URL (default: HQ_INGEST_URL env). The command POSTs to <url>/api/ingest").option("--boundary <slug>", "boundary slug for the eventId namespace (default: dag `boundary:`, else `epic-<id>`)").option("--repo <name>", "repository name for the epic record and lane-id derivation (default: dag `repo:`)").option("--name <title>", "epic display name (default: dag `name:`)").option("--dry-run", "validate and build the event, print it, and exit without POSTing").option("--json", "print the built event / publish result as JSON").action(async (source, options) => {
10313
+ const event = buildEvent(source, options);
10314
+ if (options.dryRun) {
10315
+ output.stdout.write(options.json ? `${JSON.stringify(event, null, 2)}\n` : `epic:publish-structure OK (dry-run) — eventId ${event.eventId}, ${event.payload.nodes.length} node(s), ${event.payload.edges.length} edge(s)\n`);
10316
+ return;
10317
+ }
10318
+ const url = options.url ?? env.HQ_INGEST_URL;
10319
+ if (!url) throw new Error("epic:publish-structure requires an HQ ingest URL: pass --url or set HQ_INGEST_URL");
10320
+ const clientId = env[CF_ACCESS_CLIENT_ID_ENV];
10321
+ const clientSecret = env[CF_ACCESS_CLIENT_SECRET_ENV];
10322
+ const result = await publishEpicStructure({
10323
+ ...clientId && clientSecret ? { accessServiceToken: {
10324
+ clientId,
10325
+ clientSecret
10326
+ } } : {},
10327
+ event,
10328
+ fetchImpl: deps.fetchImpl,
10329
+ url
10330
+ });
10331
+ output.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : `epic:publish-structure ${result.duplicate ? "no-op (duplicate)" : "accepted"} — eventId ${result.eventId} [HTTP ${result.status}]\n`);
10332
+ });
10333
+ }
10334
+ //#endregion
10335
+ //#region src/commands/closeout.ts
10336
+ const formatUtcDate = (date) => date.toISOString().slice(0, 10);
10337
+ const nonEmptyRepoRoot = (value) => {
10338
+ if (value.trim() === "") throw new InvalidArgumentError("--repo-root requires a non-empty path");
10339
+ return value;
10340
+ };
10341
+ const codexThreadIdsForCloseout = (env = process.env) => {
10342
+ const threadId = normalizeSessionId(env.CODEX_THREAD_ID);
10343
+ return threadId === void 0 ? [] : [threadId];
10344
+ };
10345
+ const resolveRetroSubject = (issue, pr) => {
10346
+ if (issue !== void 0) return { issueNumber: issue };
10347
+ if (pr !== void 0) return { prNumber: pr };
10348
+ };
10349
+ const tokenFamilySummary = (tokenFamilies) => {
10350
+ const available = [];
10351
+ const unavailable = [];
10352
+ for (const family of ["claude", "gpt"]) if (tokenFamilies[family] === void 0) unavailable.push(family);
10353
+ else available.push(family);
10354
+ return {
10355
+ available,
10356
+ unavailable
10357
+ };
10358
+ };
10359
+ const reportLocalCloseoutOutcome = (output, input) => {
10360
+ const harvest = tokenFamilySummary(input.retro.envelope.tokenFamilies);
10361
+ output.stdout.write(`local artifacts: ${input.paths.markdownPath}, ${input.paths.jsonPath}, and ${input.retro.envelopePath}\n`);
10362
+ output.stdout.write(`boundary thermo input: runs=${input.boundaryThermo.runs}, owner=${input.boundaryThermo.owner}, waived=${input.boundaryThermo.waived}${input.boundaryThermo.waiverRationale === void 0 ? "" : `, waiver rationale=${input.boundaryThermo.waiverRationale}`}\n`);
10363
+ if (harvest.unavailable.length > 0) output.stderr.write(`harvest data unavailable: ${harvest.available.length === 0 ? "no token families were recorded" : `missing ${harvest.unavailable.join(" and ")} token family data`}; recorded gaps: ${input.retro.envelope.dataGaps.join(" | ")}\n`);
10364
+ else if (input.retro.envelope.dataGaps.length > 0) output.stderr.write(`retro-envelope data gaps recorded: ${input.retro.envelope.dataGaps.join(" | ")}\n`);
10365
+ };
9092
10366
  const reportRetroHqDeliveryOutcome = (output, outcome) => {
9093
10367
  output.stderr.write(`advisory HQ handoff ${outcome} for the retro envelope; local closeout success does not depend on HQ.\n`);
9094
10368
  };
9095
10369
  const reportArtifactHqHandoffOutcome = (output, outcome) => {
9096
10370
  output.stderr.write(`advisory HQ handoff ${outcome} for the closeout artifact; local closeout success does not depend on HQ.\n`);
9097
10371
  };
10372
+ const reportStructureReemissionOutcome = (output, outcome, detail) => {
10373
+ output.stderr.write(`advisory HQ epic-structure re-emission ${outcome}${detail ? ` (${detail})` : ""}; local closeout success does not depend on HQ.\n`);
10374
+ };
10375
+ const DEFAULT_STRUCTURE_REEMISSION_TIMEOUT_MS = 5e3;
10376
+ const withTimeout = async (operation, timeoutMs, message) => {
10377
+ const raceAbort = new AbortController();
10378
+ const fetchAbort = new AbortController();
10379
+ const settleTimeout = async () => {
10380
+ try {
10381
+ await setTimeout$1(Math.max(0, timeoutMs), void 0, { signal: raceAbort.signal });
10382
+ } catch {}
10383
+ return { status: "timed-out" };
10384
+ };
10385
+ const settleOperation = async () => ({
10386
+ status: "fulfilled",
10387
+ value: await operation(fetchAbort.signal)
10388
+ });
10389
+ try {
10390
+ const result = await Promise.race([settleOperation(), settleTimeout()]);
10391
+ if (result.status === "timed-out") {
10392
+ fetchAbort.abort();
10393
+ throw new Error(message);
10394
+ }
10395
+ return result.value;
10396
+ } finally {
10397
+ raceAbort.abort();
10398
+ }
10399
+ };
10400
+ const bareRepoName = (ownerRepo) => {
10401
+ const separatorIndex = ownerRepo.lastIndexOf("/");
10402
+ return separatorIndex === -1 ? ownerRepo : ownerRepo.slice(separatorIndex + 1);
10403
+ };
10404
+ const structureIdentityMismatch = (event, expected) => {
10405
+ const expectedRepo = bareRepoName(expected.repo);
10406
+ const { epics } = event.payload;
10407
+ if (epics.length === 0) return;
10408
+ if (epics.every((epic) => epic.id !== expected.epic)) return `dag epic id(s) [${epics.map((epic) => epic.id).join(", ")}] do not include --epic ${expected.epic}`;
10409
+ if (epics.every((epic) => epic.repo !== expectedRepo)) return `dag repo(s) [${epics.map((epic) => epic.repo).join(", ")}] do not include --repo ${expected.repo} (bare "${expectedRepo}")`;
10410
+ };
10411
+ const reemitEpicStructureAtCloseout = async (input) => {
10412
+ if (input.dagSource === void 0) return;
10413
+ const build = input.buildEvent ?? buildEvent;
10414
+ const publish = input.publish ?? publishEpicStructure;
10415
+ const url = input.url ?? input.env.HQ_INGEST_URL;
10416
+ if (!url) {
10417
+ reportStructureReemissionOutcome(input.output, "skipped", "no HQ ingest URL: pass --structure-url or set HQ_INGEST_URL");
10418
+ return;
10419
+ }
10420
+ let event;
10421
+ try {
10422
+ event = build(input.dagSource);
10423
+ } catch (error) {
10424
+ reportStructureReemissionOutcome(input.output, "failed", error instanceof Error ? error.message : String(error));
10425
+ return;
10426
+ }
10427
+ const mismatch = structureIdentityMismatch(event, {
10428
+ epic: input.expectedEpic,
10429
+ repo: input.expectedRepo
10430
+ });
10431
+ if (mismatch !== void 0) {
10432
+ reportStructureReemissionOutcome(input.output, "failed", mismatch);
10433
+ return;
10434
+ }
10435
+ const clientId = input.env[CF_ACCESS_CLIENT_ID_ENV];
10436
+ const clientSecret = input.env[CF_ACCESS_CLIENT_SECRET_ENV];
10437
+ const timeoutMs = input.timeoutMs ?? DEFAULT_STRUCTURE_REEMISSION_TIMEOUT_MS;
10438
+ try {
10439
+ const result = await withTimeout((signal) => publish({
10440
+ ...clientId && clientSecret ? { accessServiceToken: {
10441
+ clientId,
10442
+ clientSecret
10443
+ } } : {},
10444
+ event,
10445
+ fetchImpl: input.fetchImpl,
10446
+ signal,
10447
+ url
10448
+ }), timeoutMs, `epic-structure re-emission exceeded its ${timeoutMs}ms advisory deadline`);
10449
+ reportStructureReemissionOutcome(input.output, result.duplicate ? "duplicate" : "accepted", `eventId ${result.eventId}`);
10450
+ } catch (error) {
10451
+ reportStructureReemissionOutcome(input.output, "failed", error instanceof Error ? error.message : String(error));
10452
+ }
10453
+ };
9098
10454
  const handoffCloseoutToHq = async (input) => {
9099
10455
  const epicIssue = Number(input.artifact.epic);
9100
10456
  let artifactOutcome = "deferred";
@@ -9139,7 +10495,7 @@ const defaultTraceWindow = () => {
9139
10495
  function createCloseoutCommand(output, dependencies = {}) {
9140
10496
  const buildRetroGate = dependencies.buildRetroEnvelope ?? buildAndPersistRetroEnvelope;
9141
10497
  const persistArtifact = dependencies.writeArtifact ?? writeCloseoutArtifact;
9142
- return new Command("factory:closeout").description("Emit an epic-close metrics + lessons closeout (markdown + HQ-ledger JSON)").requiredOption("--epic <id>", "epic identifier").option("--repo <owner/repo>", "repository", DEFAULT_FACTORY_REPOSITORY).option("--repo-root <path>", "consumer repo root for trace shards and output", nonEmptyRepoRoot, ".").option("--out-dir <path>", "output directory under repo root", ".factory-memory/closeouts").option("--from <YYYY-MM-DD>", "trace diagnostics scan window start — surfaces malformed trace shards only, no metrics (default: today minus 30 days UTC)").option("--to <YYYY-MM-DD>", "trace diagnostics scan window end (default: today UTC)").option("--lane <name>", "interior telemetry lane label", "epic-closeout").option("--chats-dir <path>", "Cursor chats directory for interior telemetry").option("--usage-csv <path>", "Cursor usage CSV for interior telemetry").option("--codex-log <path>", "Codex exec JSON log for interior telemetry").option("--epic-chat-id <id>", "allowlist a Cursor chatId for epic interior scoping (repeatable)", (value, previous) => [...previous, value], []).option("--epic-chat-cwd <path>", "allowlist a worktree cwd for epic interior scoping (repeatable)", (value, previous) => [...previous, value], []).option("--issue <n>", "optional issue-number filter for the trace diagnostics scan", Number.parseInt).option("--pr <n>", "optional pull-request-number filter for the trace diagnostics scan", Number.parseInt).option("--json", "print the artifact JSON to stdout").option("--dry-run", "run aggregation without writing files; print intended output paths").option("--boundary-thermo-runs <n>", "deep boundary-thermo run count for this wave/epic (default: 0 → tripwire)", (value) => sanitizeBoundaryThermoRuns(value)).option("--boundary-thermo-owner <name>", "named owner of the boundary-thermo gate (default: epic-closeout orchestrator)").option("--boundary-thermo-waived", "record an explicit waiver for zero boundary-thermo runs").option("--boundary-thermo-waiver <text>", "waiver rationale when --boundary-thermo-waived is set").action(withGateTiming({
10498
+ return new Command("factory:closeout").description("Emit an epic-close metrics + lessons closeout (markdown + HQ-ledger JSON)").requiredOption("--epic <id>", "epic identifier").option("--repo <owner/repo>", "repository", DEFAULT_FACTORY_REPOSITORY).option("--repo-root <path>", "consumer repo root for trace shards and output", nonEmptyRepoRoot, ".").option("--out-dir <path>", "output directory under repo root", ".factory-memory/closeouts").option("--from <YYYY-MM-DD>", "trace diagnostics scan window start — surfaces malformed trace shards only, no metrics (default: today minus 30 days UTC)").option("--to <YYYY-MM-DD>", "trace diagnostics scan window end (default: today UTC)").option("--lane <name>", "interior telemetry lane label", "epic-closeout").option("--chats-dir <path>", "Cursor chats directory for interior telemetry").option("--usage-csv <path>", "Cursor usage CSV for interior telemetry").option("--codex-log <path>", "Codex exec JSON log for interior telemetry").option("--epic-chat-id <id>", "allowlist a Cursor chatId for epic interior scoping (repeatable)", (value, previous) => [...previous, value], []).option("--epic-chat-cwd <path>", "allowlist a worktree cwd for epic interior scoping (repeatable)", (value, previous) => [...previous, value], []).option("--issue <n>", "optional issue-number filter for the trace diagnostics scan", Number.parseInt).option("--pr <n>", "optional pull-request-number filter for the trace diagnostics scan", Number.parseInt).option("--json", "print the artifact JSON to stdout").option("--dry-run", "run aggregation without writing files; print intended output paths").option("--boundary-thermo-runs <n>", "deep boundary-thermo run count for this wave/epic (default: 0 → tripwire)", (value) => sanitizeBoundaryThermoRuns(value)).option("--boundary-thermo-owner <name>", "named owner of the boundary-thermo gate (default: epic-closeout orchestrator)").option("--boundary-thermo-waived", "record an explicit waiver for zero boundary-thermo runs").option("--boundary-thermo-waiver <text>", "waiver rationale when --boundary-thermo-waived is set").option("--structure-dag <path>", "path to a terminal dag.yml/JSON document (or - for stdin) to re-emit as the closeout epic-structure snapshot, via the same builder epic:publish-structure uses. Advisory and idempotent by content-addressed eventId. Omit to skip re-emission entirely — closeout never fabricates a structure snapshot that was never authored.").option("--structure-url <url>", "HQ ingest base URL for the closeout structure re-emission (default: HQ_INGEST_URL env)").action(withGateTiming({
9143
10499
  gate: "factory:closeout",
9144
10500
  resolveLedgerRoot: (options) => resolveCwdOption(options.repoRoot),
9145
10501
  shouldRecord: (options) => options.dryRun !== true,
@@ -9219,6 +10575,18 @@ function createCloseoutCommand(output, dependencies = {}) {
9219
10575
  retro,
9220
10576
  written
9221
10577
  });
10578
+ await reemitEpicStructureAtCloseout({
10579
+ buildEvent: dependencies.buildEpicStructureEvent,
10580
+ dagSource: options.structureDag,
10581
+ env: dependencies.env ?? process.env,
10582
+ expectedEpic: options.epic,
10583
+ expectedRepo: options.repo,
10584
+ fetchImpl: dependencies.structureFetch,
10585
+ output,
10586
+ publish: dependencies.publishEpicStructure,
10587
+ timeoutMs: dependencies.structureTimeoutMs,
10588
+ url: options.structureUrl
10589
+ });
9222
10590
  output.stdout.write(`wrote ${written.markdownPath} and ${written.jsonPath}\n`);
9223
10591
  }));
9224
10592
  }
@@ -10660,7 +12028,7 @@ const runDemandWaive = (args, dependencies = {}) => {
10660
12028
  const cwd = path.resolve(args.cwd ?? process.cwd());
10661
12029
  const env = dependencies.env ?? process.env;
10662
12030
  const repository = (dependencies.checkoutRepository ?? checkoutRepository)(cwd);
10663
- const parsedDemand = demandKeySchema.safeParse(args.demand.trim());
12031
+ const parsedDemand = waiverDemandKeySchema.safeParse(args.demand.trim());
10664
12032
  const rationale = args.rationale.trim();
10665
12033
  const refusals = requestRefusals({
10666
12034
  demand: args.demand,
@@ -10854,6 +12222,145 @@ const demandedRungSatisfactionReasons = ({ demandedRung, liveHumanReviews, revie
10854
12222
  return reviewRuns?.flatMap((review) => review.outcome === "passed" && review.rung !== void 0 ? [review.rung] : []).find((candidate) => rungMeetsMinimum(candidate, demandedRung)) ? [] : [`Boundary wave requires the ${demandedRung} rung, but the recorded review evidence has no passing review at that authority.`];
10855
12223
  };
10856
12224
  //#endregion
12225
+ //#region src/hq-credentials.ts
12226
+ /**
12227
+ * Resolving the HQ Access credentials, and saying *why* when that fails.
12228
+ *
12229
+ * A deliberate command (`psf hq:flush`, and doctor's remote checks in #394) is
12230
+ * the one place allowed to ask the secret manager for the HQ Access token. It
12231
+ * used to keep a value-or-nothing answer, so a missing binary, a sandbox that
12232
+ * cannot reach the keychain, and a mistyped reference all collapsed into one
12233
+ * generic "credentials are unavailable" line. A sandboxed lane read that as
12234
+ * environmental noise while every one of its proofs stayed spooled.
12235
+ *
12236
+ * Two boundaries hold everywhere in this module:
12237
+ *
12238
+ * - **Classification comes from the failure mode, never from resolver output.**
12239
+ * No stderr is captured and no resolver text is retained or rendered: a
12240
+ * secret manager's diagnostics can quote the material it was asked for.
12241
+ * Exit status, spawn error code, and signal are the whole evidence base.
12242
+ * - **Trusted-local only.** Hosted runners never invoke a secret manager
12243
+ * (#312); references stay inert there and the failure says so.
12244
+ */
12245
+ /** Ceiling for one secret-manager probe; expiry means unresolved, never a hang. */
12246
+ const SECRET_PROBE_TIMEOUT_MS = 1e4;
12247
+ /** The operator's resolution path: `op-fast` first, official `op` as fallback. */
12248
+ const SECRET_RESOLVER_BINARIES = ["op-fast", "op"];
12249
+ const spawnFailure = (error) => {
12250
+ const code = error?.code;
12251
+ if (code === "ENOENT") return "resolver-missing";
12252
+ if (code === "EACCES" || code === "EPERM") return "resolver-blocked";
12253
+ return "resolver-timeout";
12254
+ };
12255
+ const probe = (binary, reference, capture) => {
12256
+ const result = spawnSync(binary, ["read", reference], {
12257
+ encoding: "utf-8",
12258
+ stdio: [
12259
+ "ignore",
12260
+ capture ? "pipe" : "ignore",
12261
+ "ignore"
12262
+ ],
12263
+ timeout: SECRET_PROBE_TIMEOUT_MS
12264
+ });
12265
+ if (result.error) return { status: spawnFailure(result.error) };
12266
+ if (result.signal) return { status: "resolver-timeout" };
12267
+ if (result.status !== 0) return { status: "resolver-refused" };
12268
+ if (!capture) return {
12269
+ status: "resolved",
12270
+ value: ""
12271
+ };
12272
+ const value = result.stdout.trim();
12273
+ return value === "" ? { status: "resolver-refused" } : {
12274
+ status: "resolved",
12275
+ value
12276
+ };
12277
+ };
12278
+ /**
12279
+ * `op-fast` first, official `op` as the fallback, matching `doctor
12280
+ * --preflight`'s resolvability probe. The reported failure is the first
12281
+ * *informative* one: a machine without `op-fast` that has `op` installed and
12282
+ * denied must report the denial, not the missing binary.
12283
+ */
12284
+ const resolveThrough = (reference, capture) => {
12285
+ let failure = "resolver-missing";
12286
+ for (const binary of SECRET_RESOLVER_BINARIES) {
12287
+ const outcome = probe(binary, reference, capture);
12288
+ if (outcome.status === "resolved") return outcome;
12289
+ if (failure === "resolver-missing") failure = outcome.status;
12290
+ }
12291
+ return { status: failure };
12292
+ };
12293
+ const defaultSecretReferenceResolver = (reference) => resolveThrough(reference, true);
12294
+ /**
12295
+ * Answers only *whether* a reference resolves. The secret is never read into
12296
+ * this process — stdout goes to `/dev/null`, so a `resolved` outcome here
12297
+ * carries an empty value by construction. `doctor --preflight` uses this: it
12298
+ * reports resolvability and must never materialize a credential to do it
12299
+ * (#312's boundary, restated by #414's no-retention rule).
12300
+ */
12301
+ const defaultSecretReferenceProbe = (reference) => resolveThrough(reference, false);
12302
+ /**
12303
+ * Environment first (the #312 emit-time path), then the operator's configured
12304
+ * references. The first reference that fails ends the resolution: a second
12305
+ * probe would add nothing but another chance to hang.
12306
+ */
12307
+ const resolveHqCredentials = ({ env, references, resolve = defaultSecretReferenceResolver }) => {
12308
+ const clientId = env[CF_ACCESS_CLIENT_ID_ENV];
12309
+ const clientSecret = env[CF_ACCESS_CLIENT_SECRET_ENV];
12310
+ if (clientId && clientSecret) return {
12311
+ credentials: {
12312
+ clientId,
12313
+ clientSecret
12314
+ },
12315
+ source: "environment",
12316
+ status: "resolved"
12317
+ };
12318
+ if (!references) return {
12319
+ failure: "no-reference",
12320
+ status: "unresolved"
12321
+ };
12322
+ if (isHostedRunner(env)) return {
12323
+ failure: "hosted-runner",
12324
+ status: "unresolved"
12325
+ };
12326
+ const resolvedId = resolve(references.clientIdRef);
12327
+ if (resolvedId.status !== "resolved") return {
12328
+ failure: resolvedId.status,
12329
+ reference: references.clientIdRef,
12330
+ status: "unresolved"
12331
+ };
12332
+ const resolvedSecret = resolve(references.clientSecretRef);
12333
+ if (resolvedSecret.status !== "resolved") return {
12334
+ failure: resolvedSecret.status,
12335
+ reference: references.clientSecretRef,
12336
+ status: "unresolved"
12337
+ };
12338
+ return {
12339
+ credentials: {
12340
+ clientId: resolvedId.value,
12341
+ clientSecret: resolvedSecret.value
12342
+ },
12343
+ source: "references",
12344
+ status: "resolved"
12345
+ };
12346
+ };
12347
+ const SANDBOX_REMEDY = "Run this command in a trusted local session outside the sandbox (`psf hq:flush` and `psf pr:publish` both need one).";
12348
+ /** One sentence per failure mode. Exhaustive by construction. */
12349
+ const FAILURE_SENTENCE = {
12350
+ "hosted-runner": () => `this is a hosted runner, where resolving a secret reference is never attempted (#312). Provide ${CF_ACCESS_CLIENT_ID_ENV} and ${CF_ACCESS_CLIENT_SECRET_ENV} in the workflow environment instead.`,
12351
+ "no-reference": () => `neither ${CF_ACCESS_CLIENT_ID_ENV}/${CF_ACCESS_CLIENT_SECRET_ENV} in the environment nor \`hqIngestCredentials\` references in the operator user config. Record the references there to make this resolvable.`,
12352
+ "resolver-blocked": (reference) => `this session may not execute a secret manager, so ${reference} was never read. ${SANDBOX_REMEDY}`,
12353
+ "resolver-missing": (reference) => `no secret manager was found on PATH (tried ${SECRET_RESOLVER_BINARIES.join(", ")}), so ${reference} could not be resolved.`,
12354
+ "resolver-refused": (reference) => `the secret manager ran but returned no value for ${reference}: its store is unreachable from this session (a sandbox has no keychain access) or the reference is not readable. No resolver output is captured, so these are one answer. ${SANDBOX_REMEDY} If the session is already trusted, warm the reference (\`op-fast read '<ref>' >/dev/null\`) or correct it in the user config.`,
12355
+ "resolver-timeout": (reference) => `the secret manager did not answer within ${SECRET_PROBE_TIMEOUT_MS}ms for ${reference} — typically a desktop approval nobody can grant here. ${SANDBOX_REMEDY}`
12356
+ };
12357
+ /**
12358
+ * The operator-facing reason. Names the failure mode and its remedy, and can
12359
+ * contain a reference but never a value — the resolver's own output is never
12360
+ * read, so nothing here can quote a secret.
12361
+ */
12362
+ const describeHqCredentialFailure = (resolution) => `HQ Access credentials are unavailable: ${FAILURE_SENTENCE[resolution.failure](resolution.reference ?? "the configured reference")}`;
12363
+ //#endregion
10857
12364
  //#region src/hq-ingest-preflight.ts
10858
12365
  /**
10859
12366
  * Whether the HQ ingest credentials resolve (#312).
@@ -10866,6 +12373,8 @@ const demandedRungSatisfactionReasons = ({ demandedRung, liveHumanReviews, revie
10866
12373
  * Nothing connected "this variable is missing" to "a resolvable reference for it
10867
12374
  * exists", so a silent degradation persisted because nothing surfaced the
10868
12375
  * remedy.
12376
+ * Since #369 this check is the *only* owner of that question: the runtime
12377
+ * admission commands defer silently and retain the reason in the retry spool.
10869
12378
  *
10870
12379
  * Hard boundaries, all of them decided:
10871
12380
  * - **Resolvability only, never values.** Nothing here reads, prints, logs,
@@ -10877,28 +12386,17 @@ const demandedRungSatisfactionReasons = ({ demandedRung, liveHumanReviews, revie
10877
12386
  * In CI the probe is inert — not merely unused.
10878
12387
  * - **The factory never acquires credentials.** No auto-export, no writing to
10879
12388
  * shell profiles, no auth flow. It names the remedy; the operator acts.
10880
- */
10881
- const HQ_INGEST_PREFLIGHT_CHECK_NAME = "admission:hq-ingest";
10882
- /**
10883
- * The operator's existing resolution path: `op-fast` first, official `op` as the
10884
- * fallback, consistent with the trusted-local Alchemy commands. Output is
10885
- * discarded — only the exit status is read.
10886
- */
10887
- const defaultSecretReferenceProbe = (reference) => ["op-fast", "op"].some((binary) => {
10888
- const result = spawnSync(binary, ["read", reference], {
10889
- encoding: "utf-8",
10890
- stdio: [
10891
- "ignore",
10892
- "ignore",
10893
- "ignore"
10894
- ]
10895
- });
10896
- return !result.error && result.status === 0;
10897
- });
12389
+ */
12390
+ const HQ_INGEST_PREFLIGHT_CHECK_NAME = "admission:hq-ingest";
10898
12391
  const REMEDY_SHAPE = `The variable names are literally hyphenated, so a plain \`export\` does not set them — prefix the command: \`env '${CF_ACCESS_CLIENT_ID_ENV}=…' '${CF_ACCESS_CLIENT_SECRET_ENV}=…' <command>\`. \`HQ_INGEST_URL\` wants the origin (https://hq.patronage.com), not the profile's /api/ingest endpoint.`;
10899
- /** True in a hosted runner, where secret-manager resolution must not be tried. */
10900
- const isHostedRunner = (env) => env.CI === "true" || env.CI === "1" || env.GITHUB_ACTIONS === "true";
10901
- function hqIngestCredentialCheck({ env, probe = defaultSecretReferenceProbe, references }) {
12392
+ /** One short, operator-facing hint per resolver failure mode (#394 fold). */
12393
+ const RESOLUTION_HINT = {
12394
+ "resolver-blocked": "this session may not execute a secret manager",
12395
+ "resolver-missing": "no secret manager was found on PATH",
12396
+ "resolver-refused": "the secret manager ran but returned no value (its store is unreachable from this session, or the reference is wrong)",
12397
+ "resolver-timeout": "the secret manager did not answer in time"
12398
+ };
12399
+ function hqIngestCredentialCheck({ env, resolve = defaultSecretReferenceProbe, references }) {
10902
12400
  if (Boolean(env["CF-Access-Client-Id"] && env["CF-Access-Client-Secret"])) return {
10903
12401
  message: `HQ ingest credentials are present in the environment (${CF_ACCESS_CLIENT_ID_ENV}, ${CF_ACCESS_CLIENT_SECRET_ENV}); ingest will deliver.`,
10904
12402
  name: HQ_INGEST_PREFLIGHT_CHECK_NAME,
@@ -10914,9 +12412,14 @@ function hqIngestCredentialCheck({ env, probe = defaultSecretReferenceProbe, ref
10914
12412
  name: HQ_INGEST_PREFLIGHT_CHECK_NAME,
10915
12413
  status: "warning"
10916
12414
  };
10917
- const unresolvable = [references.clientIdRef, references.clientSecretRef].filter((reference) => !probe(reference)).join(", ");
10918
- if (unresolvable) return {
10919
- message: `HQ ingest credentials are absent and ${unresolvable} did not resolve from the operator's secret store. Warm the reference (\`op-fast read '<ref>' >/dev/null\`) or correct it in the operator user config. ${REMEDY_SHAPE}`,
12415
+ const unresolved = [references.clientIdRef, references.clientSecretRef].map((reference) => ({
12416
+ reference,
12417
+ resolution: resolve(reference)
12418
+ })).filter(({ resolution }) => resolution.status !== "resolved");
12419
+ if (unresolved.length > 0) return {
12420
+ message: `HQ ingest credentials are absent and did not resolve from the operator's secret store: ${unresolved.map(({ reference, resolution }) => {
12421
+ return `${reference} (${RESOLUTION_HINT[resolution.status]})`;
12422
+ }).join("; ")}. Warm the reference (\`op-fast read '<ref>' >/dev/null\`) or correct it in the operator user config. ${REMEDY_SHAPE}`,
10920
12423
  name: HQ_INGEST_PREFLIGHT_CHECK_NAME,
10921
12424
  status: "warning"
10922
12425
  };
@@ -11565,16 +13068,42 @@ const statusFromBlockingReasons = ({ blockingReasons, finalReviewBlockingReasons
11565
13068
  return "blocked";
11566
13069
  };
11567
13070
  const addReviewBlockingReasons = ({ blockers, correctnessRequired, correctnessStatus, correctnessUntyped }) => {
11568
- if (correctnessRequired && correctnessUntyped) blockers.push({ reason: CORRECTNESS_UNTYPED_REVIEW_BLOCKER_REASON });
11569
- else if (correctnessStatus === "missing") blockers.push({ reason: "Missing current Correctness review proof." });
11570
- else if (correctnessStatus === "stale") blockers.push({ reason: "Correctness review proof is stale because the PR-owned patch-id changed." });
11571
- else if (correctnessStatus === "blocked") blockers.push({ reason: "Correctness review proof ran and explicitly failed (open blocking findings or errored run); resolve the findings and re-run pr:review." });
13071
+ const demand = reviewModeDemand("correctness");
13072
+ if (correctnessRequired && correctnessUntyped) blockers.push({
13073
+ demand,
13074
+ reason: CORRECTNESS_UNTYPED_REVIEW_BLOCKER_REASON
13075
+ });
13076
+ else if (correctnessStatus === "missing") blockers.push({
13077
+ demand,
13078
+ reason: "Missing current Correctness review proof."
13079
+ });
13080
+ else if (correctnessStatus === "stale") blockers.push({
13081
+ demand,
13082
+ reason: "Correctness review proof is stale because the PR-owned patch-id changed."
13083
+ });
13084
+ else if (correctnessStatus === "blocked") blockers.push({
13085
+ demand,
13086
+ reason: "Correctness review proof ran and explicitly failed (open blocking findings or errored run); resolve the findings and re-run pr:review."
13087
+ });
11572
13088
  };
11573
13089
  const addSecurityBlockingReasons = ({ blockers, securityRequired, securityStatus, securityUntyped }) => {
11574
- if (securityRequired && securityUntyped) blockers.push({ reason: SECURITY_UNTYPED_REVIEW_BLOCKER_REASON });
11575
- else if (securityStatus === "missing") blockers.push({ reason: "Missing current Security review proof." });
11576
- else if (securityStatus === "stale") blockers.push({ reason: "Security review proof is stale because the PR-owned patch-id changed." });
11577
- else if (securityStatus === "blocked") blockers.push({ reason: "Security review proof ran and explicitly failed (open blocking findings or errored run); resolve or disposition the findings and re-run pr:review." });
13090
+ const demand = reviewModeDemand("security");
13091
+ if (securityRequired && securityUntyped) blockers.push({
13092
+ demand,
13093
+ reason: SECURITY_UNTYPED_REVIEW_BLOCKER_REASON
13094
+ });
13095
+ else if (securityStatus === "missing") blockers.push({
13096
+ demand,
13097
+ reason: "Missing current Security review proof."
13098
+ });
13099
+ else if (securityStatus === "stale") blockers.push({
13100
+ demand,
13101
+ reason: "Security review proof is stale because the PR-owned patch-id changed."
13102
+ });
13103
+ else if (securityStatus === "blocked") blockers.push({
13104
+ demand,
13105
+ reason: "Security review proof ran and explicitly failed (open blocking findings or errored run); resolve or disposition the findings and re-run pr:review."
13106
+ });
11578
13107
  };
11579
13108
  const requiredChecksEvaluation = (input) => {
11580
13109
  const requiredChecks = input.externalRequiredChecks ?? [];
@@ -11597,14 +13126,20 @@ const requiredChecksEvaluation = (input) => {
11597
13126
  labels: input.prLabels
11598
13127
  }
11599
13128
  });
13129
+ const blockers = outcomes.flatMap((outcome) => outcome.reason ? [{
13130
+ demand: requiredCheckDemand(outcome.name),
13131
+ reason: outcome.reason
13132
+ }] : []);
13133
+ if (blockers.length !== blockingReasons.length) throw new Error("requiredChecks evaluation produced a blocking reason with no owning check; this is a factory bug.");
11600
13134
  return {
11601
- blockers: blockingReasons.map((reason) => ({ reason })),
13135
+ blockers,
11602
13136
  outcomes
11603
13137
  };
11604
13138
  };
11605
13139
  const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUntyped, input, prVerifyStatus, verificationProof, sectionReady, securityRequired, securityStatus, securityUntyped, trivialWaiverPresent }) => {
11606
13140
  const blockers = [];
11607
13141
  if (!sectionReady) blockers.push({
13142
+ demand: DEMAND_KEYS.prBodySections,
11608
13143
  reason: RENDER_PR_BODY_SECTIONS_BLOCKER_REASON,
11609
13144
  repair: {
11610
13145
  action: "Render the required Verification / Review proof / How to review sections into the PR body from typed proof data (renderPrBodySections).",
@@ -11612,10 +13147,19 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
11612
13147
  command: `gh pr edit ${input.pr} --body-file -`
11613
13148
  }
11614
13149
  });
11615
- if (prVerifyStatus === "missing") blockers.push({ reason: "Missing passing typed pr:verify proof for the current PR head." });
11616
- else if (prVerifyStatus === "stale") blockers.push({ reason: "Typed pr:verify proof is stale because it is not tied to the current PR head." });
13150
+ if (prVerifyStatus === "missing") blockers.push({
13151
+ demand: DEMAND_KEYS.prVerify,
13152
+ reason: "Missing passing typed pr:verify proof for the current PR head."
13153
+ });
13154
+ else if (prVerifyStatus === "stale") blockers.push({
13155
+ demand: DEMAND_KEYS.prVerify,
13156
+ reason: "Typed pr:verify proof is stale because it is not tied to the current PR head."
13157
+ });
11617
13158
  const proofBlockingReason = verificationProofBlockingReason(verificationProof);
11618
- if (proofBlockingReason) blockers.push({ reason: proofBlockingReason });
13159
+ if (proofBlockingReason) blockers.push({
13160
+ demand: DEMAND_KEYS.prVerify,
13161
+ reason: proofBlockingReason
13162
+ });
11619
13163
  addReviewBlockingReasons({
11620
13164
  blockers,
11621
13165
  correctnessRequired,
@@ -11628,12 +13172,28 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
11628
13172
  securityStatus,
11629
13173
  securityUntyped
11630
13174
  });
11631
- if (trivialWaiverPresent && input.classification === "non-trivial") blockers.push({ reason: "Explicit trivial waiver is incompatible with a non-trivial diff." });
11632
- if (input.unresolvedReviewThreads > 0) blockers.push({ reason: `GitHub has ${input.unresolvedReviewThreads} unresolved review thread(s).` });
11633
- if (input.localHeadSha && !sameHeadSha(input.localHeadSha, input.headSha)) blockers.push({ reason: "Local HEAD does not match the GitHub PR head." });
11634
- if (input.mergeable !== "MERGEABLE" || !["CLEAN", "BLOCKED"].includes(input.mergeStateStatus)) blockers.push({ reason: `GitHub merge state is ${input.mergeStateStatus}/${input.mergeable}; expected CLEAN or review-only BLOCKED with MERGEABLE.` });
11635
- if (input.requiredChecks === "none" && input.classification !== "docs/process-only") blockers.push({ reason: "GitHub has no current checks for a non-docs PR." });
13175
+ if (trivialWaiverPresent && input.classification === "non-trivial") blockers.push({
13176
+ demand: DEMAND_KEYS.trivialWaiver,
13177
+ reason: "Explicit trivial waiver is incompatible with a non-trivial diff."
13178
+ });
13179
+ if (input.unresolvedReviewThreads > 0) blockers.push({
13180
+ demand: DEMAND_KEYS.reviewThreads,
13181
+ reason: `GitHub has ${input.unresolvedReviewThreads} unresolved review thread(s).`
13182
+ });
13183
+ if (input.localHeadSha && !sameHeadSha(input.localHeadSha, input.headSha)) blockers.push({
13184
+ demand: DEMAND_KEYS.headIdentity,
13185
+ reason: "Local HEAD does not match the GitHub PR head."
13186
+ });
13187
+ if (input.mergeable !== "MERGEABLE" || !["CLEAN", "BLOCKED"].includes(input.mergeStateStatus)) blockers.push({
13188
+ demand: DEMAND_KEYS.mergeState,
13189
+ reason: `GitHub merge state is ${input.mergeStateStatus}/${input.mergeable}; expected CLEAN or review-only BLOCKED with MERGEABLE.`
13190
+ });
13191
+ if (input.requiredChecks === "none" && input.classification !== "docs/process-only") blockers.push({
13192
+ demand: DEMAND_KEYS.githubChecks,
13193
+ reason: "GitHub has no current checks for a non-docs PR."
13194
+ });
11636
13195
  else if (input.requiredChecks !== "passed" && input.requiredChecks !== "none") blockers.push({
13196
+ demand: DEMAND_KEYS.githubChecks,
11637
13197
  reason: `${PENDING_CHECKS_BLOCKER_REASON_PREFIX} ${input.requiredChecks}; expected passed.`,
11638
13198
  ...input.requiredChecks === "pending" ? { repair: {
11639
13199
  action: "Poll and retry until post-undraft GitHub checks settle or fail; UNSTABLE/pending is a transient route-owned state, not a human blocker.",
@@ -11642,6 +13202,7 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
11642
13202
  } } : {}
11643
13203
  });
11644
13204
  if (input.draft) blockers.push({
13205
+ demand: DEMAND_KEYS.draft,
11645
13206
  reason: DRAFT_BLOCKER_REASON,
11646
13207
  repair: {
11647
13208
  action: "Mark the PR ready for review; draft is route-owned once proof gates pass.",
@@ -11652,7 +13213,10 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
11652
13213
  return blockers;
11653
13214
  };
11654
13215
  const orderRepairs = (repairs) => repairs.toSorted((a, b) => READINESS_REPAIR_CODES.indexOf(a.code) - READINESS_REPAIR_CODES.indexOf(b.code));
11655
- const SLICE_NOT_FINAL_BLOCKER = { reason: "This PR is a slice, not the intended final human review point." };
13216
+ const SLICE_NOT_FINAL_BLOCKER = {
13217
+ demand: DEMAND_KEYS.finalReviewPoint,
13218
+ reason: "This PR is a slice, not the intended final human review point."
13219
+ };
11656
13220
  const postReadinessHumanCommentState = (input) => {
11657
13221
  const checkRunUrls = input.checkRunHandledCommentUrls ?? [];
11658
13222
  const cliHandledUrls = input.handledCommentUrls ?? [];
@@ -11675,15 +13239,21 @@ const postReadinessHumanCommentState = (input) => {
11675
13239
  reviews: input.reviews ?? []
11676
13240
  });
11677
13241
  return {
11678
- blockers: [...activeComments.map((comment) => ({ reason: postReadinessHumanCommentReason({
11679
- comment,
11680
- pr: input.pr,
11681
- prTitle: input.prTitle
11682
- }) })), ...activeReviews.map((review) => ({ reason: postReadinessHumanReviewReason({
11683
- pr: input.pr,
11684
- prTitle: input.prTitle,
11685
- review
11686
- }) }))],
13242
+ blockers: [...activeComments.map((comment) => ({
13243
+ demand: DEMAND_KEYS.humanBlocker,
13244
+ reason: postReadinessHumanCommentReason({
13245
+ comment,
13246
+ pr: input.pr,
13247
+ prTitle: input.prTitle
13248
+ })
13249
+ })), ...activeReviews.map((review) => ({
13250
+ demand: DEMAND_KEYS.humanBlocker,
13251
+ reason: postReadinessHumanReviewReason({
13252
+ pr: input.pr,
13253
+ prTitle: input.prTitle,
13254
+ review
13255
+ })
13256
+ }))],
11687
13257
  comments: activeComments,
11688
13258
  ledgerFields: {
11689
13259
  ...input.handledCommentsProducer ? { handledCommentsProducer: input.handledCommentsProducer } : {},
@@ -11691,933 +13261,850 @@ const postReadinessHumanCommentState = (input) => {
11691
13261
  ...activeComments.length > 0 ? { postReadinessHumanComments: activeComments } : {},
11692
13262
  ...activeReviews.length > 0 ? { postReadinessHumanReviews: activeReviews } : {}
11693
13263
  },
11694
- status: (status) => activeComments.length > 0 || activeReviews.length > 0 ? "blocked" : status
11695
- };
11696
- };
11697
- const verificationProofForInput = ({ input }) => {
11698
- if (input.verificationProof) return {
11699
- fullVerifiedHeadShas: input.docsOnlyVerifyBaselineHeadShas,
11700
- verificationProof: input.verificationProof
11701
- };
11702
- return {
11703
- fullVerifiedHeadShas: [],
11704
- verificationProof: {
11705
- kind: "typed-missing",
11706
- path: "",
11707
- reason: "enoent"
11708
- }
11709
- };
11710
- };
11711
- const verifyGateFor = ({ input }) => {
11712
- const verification = verificationProofForInput({ input });
11713
- const verificationProof = verificationProofForReadiness(verification.verificationProof);
11714
- return {
11715
- verification,
11716
- ...prVerifyEvaluation({
11717
- acceptedBaselineHeadShas: verification.fullVerifiedHeadShas,
11718
- currentHeadSha: input.headSha,
11719
- docsOnlyProofCanStandAlone: input.classification === "docs/process-only",
11720
- prVerifyProof: verificationProof,
11721
- trivialProofCanStandAlone: input.classification === "trivial" || input.classification === "docs/process-only"
11722
- })
11723
- };
11724
- };
11725
- const previewDeployRequired = false;
11726
- const evaluateReadiness = (input) => {
11727
- const bodyMetadata = readPrBodyMetadata(input.body);
11728
- const reviewModes = input.reviewModes ?? ["correctness"];
11729
- const epoch = reviewEpochModeFor(input.reviewProof ?? {}, input.ladderPolicy);
11730
- const verifyGate = verifyGateFor({
11731
- bodyMetadata,
11732
- input
11733
- });
11734
- const { correctnessDocsOnlyDeltaAccepted, correctnessProof, correctnessRequired, correctnessStatus, correctnessUntyped, gateCapMismatchBlockers, reviewCycleState, reviewTerminalState, reviewLadder, securityDocsOnlyDeltaAccepted, securityProof, securityRequired, securityStatus, securityUntyped } = resolveReviewRequiredness({
11735
- bodyMetadata,
11736
- classification: input.classification,
11737
- docsOnlySinceReviewProof: input.docsOnlySinceReviewProof,
11738
- epoch,
11739
- headSha: input.headSha,
11740
- ladderPolicy: input.ladderPolicy,
11741
- patchId: input.patchId,
11742
- reviewModes,
11743
- reviewProof: input.reviewProof
11744
- });
11745
- const { docsOnlyVerifiedHeadSha, docsOnlyVerifyDeltaAccepted, prVerifyStatus, trivialVerifiedHeadSha, trivialVerifyDeltaAccepted, verifiedHeadSha } = verifyGate;
11746
- const requiredChecks = requiredChecksEvaluation(input);
11747
- const blockers = [
11748
- ...collectBlockers({
11749
- correctnessRequired,
11750
- correctnessStatus,
11751
- correctnessUntyped,
11752
- input,
11753
- prVerifyStatus,
11754
- sectionReady: bodyMetadata.requiredSectionsPresent,
11755
- securityRequired,
11756
- securityStatus,
11757
- securityUntyped,
11758
- trivialWaiverPresent: bodyMetadata.trivialWaiverAccepted,
11759
- verificationProof: verifyGate.verification.verificationProof
11760
- }),
11761
- ...requiredChecks.blockers,
11762
- ...gateCapMismatchBlockers,
11763
- ...input.waveReviewDemand ? demandedRungSatisfactionReasons({
11764
- demandedRung: input.waveReviewDemand.review,
11765
- liveHumanReviews: input.reviews ?? [],
11766
- reviewRuns: input.reviewProof?.reviews
11767
- }).map((reason) => ({ reason })) : []
11768
- ];
11769
- const blockingReasons = blockers.map((blocker) => blocker.reason);
11770
- const humanBlockingReasons = blockers.flatMap((blocker) => blocker.repair ? [] : [blocker.reason]);
11771
- const repairs = orderRepairs(blockers.flatMap((blocker) => blocker.repair ? [blocker.repair] : []));
11772
- const finalReviewBlockingReasons = [...blockingReasons];
11773
- if (!bodyMetadata.finalReviewPoint) {
11774
- finalReviewBlockingReasons.push(SLICE_NOT_FINAL_BLOCKER.reason);
11775
- humanBlockingReasons.push(SLICE_NOT_FINAL_BLOCKER.reason);
11776
- }
11777
- const status = statusFromBlockingReasons({
11778
- blockingReasons,
11779
- finalReviewBlockingReasons,
11780
- finalReviewPoint: bodyMetadata.finalReviewPoint
11781
- });
11782
- const postReadiness = postReadinessHumanCommentState(input);
11783
- const ledger = {
11784
- baseSha: input.baseSha,
11785
- blockingReasons: finalReviewBlockingReasons,
11786
- classification: input.classification,
11787
- ...requiredChecks.outcomes.length > 0 ? { externalChecks: requiredChecks.outcomes.map((outcome) => ({
11788
- checkType: outcome.checkType,
11789
- inScope: outcome.inScope,
11790
- name: outcome.name,
11791
- ...outcome.reason ? { reason: outcome.reason } : {},
11792
- ...outcome.scope ? { scope: outcome.scope } : {},
11793
- scopeReason: outcome.scopeReason,
11794
- status: outcome.status
11795
- })) } : {},
11796
- finalReviewPoint: bodyMetadata.finalReviewPoint,
11797
- github: {
11798
- currentWithBase: input.currentWithBase,
11799
- draft: input.draft,
11800
- mergeStateStatus: input.mergeStateStatus,
11801
- mergeable: input.mergeable,
11802
- requiredChecks: input.requiredChecks,
11803
- unresolvedReviewThreads: input.unresolvedReviewThreads
11804
- },
11805
- headSha: input.headSha,
11806
- ...input.mergeBaseSha ? { mergeBaseSha: input.mergeBaseSha } : {},
11807
- patchId: input.patchId,
11808
- pr: input.pr,
11809
- previewDeployRequired,
11810
- ...postReadiness.ledgerFields,
11811
- reviewRuns: input.reviewProof?.reviews.map((review) => ({
11812
- durationMs: review.durationMs,
11813
- endedAt: review.endedAt,
11814
- findings: review.findings,
11815
- issuesFlagged: review.issuesFlagged,
11816
- kind: review.kind,
11817
- ...recordedReviewIdentityFields(review),
11818
- outcome: review.outcome,
11819
- startedAt: review.startedAt,
11820
- summary: review.summary
11821
- })),
11822
- ...reviewCycleState ? { reviewCycleState } : {},
11823
- ...reviewTerminalState ? { reviewTerminalState } : {},
11824
- ...reviewLadder ? { reviewLadder } : {},
11825
- repairs,
11826
- reviews: {
11827
- correctness: {
11828
- ...correctnessDocsOnlyDeltaAccepted ? { docsOnlyDeltaAccepted: true } : {},
11829
- required: correctnessRequired,
11830
- status: correctnessStatus,
11831
- ...correctnessProof
11832
- },
11833
- ...reviewModes.includes("security") ? { security: {
11834
- ...securityDocsOnlyDeltaAccepted ? { docsOnlyDeltaAccepted: true } : {},
11835
- required: securityRequired,
11836
- status: securityStatus,
11837
- ...securityProof
11838
- } } : {}
11839
- },
11840
- schemaVersion: 1,
11841
- stackRole: bodyMetadata.stackRole,
11842
- verification: verificationLedgerFields({
11843
- docsOnlyVerifiedHeadSha,
11844
- docsOnlyVerifyDeltaAccepted,
11845
- prVerifyStatus,
11846
- trivialVerifiedHeadSha,
11847
- trivialVerifyDeltaAccepted,
11848
- verifiedHeadSha
11849
- })
11850
- };
11851
- const finalBlockingReasons = [...ledger.blockingReasons, ...postReadiness.blockers.map((blocker) => blocker.reason)];
11852
- humanBlockingReasons.push(...postReadiness.blockers.map((blocker) => blocker.reason));
11853
- ledger.blockingReasons = finalBlockingReasons;
11854
- return {
11855
- blockingReasons: finalBlockingReasons,
11856
- humanBlockingReasons,
11857
- ledger,
11858
- repairs,
11859
- status: postReadiness.status(status)
11860
- };
11861
- };
11862
- //#endregion
11863
- //#region src/admission-preflight.ts
11864
- const errorMessage$1 = (error) => error instanceof Error ? error.message : String(error);
11865
- const resolveCandidate = ({ base, cwd, verifyProof }) => {
11866
- try {
11867
- const files = changedFiles(cwd, base);
11868
- const { headSha, mergeBaseSha: mergeBaseSha$1, patchId } = resolveCandidateIdentity({
11869
- base,
11870
- cwd,
11871
- git: {
11872
- currentHeadSha,
11873
- mergeBaseSha,
11874
- stablePatchId
11875
- }
11876
- });
11877
- return { candidate: {
11878
- classification: verifyProof && evidenceFresh({
11879
- candidate: { patchId },
11880
- recorded: { patchId: verifyProof.patchId },
11881
- rule: "patch-id"
11882
- }) ? verifyProof.classification : void 0,
11883
- files,
11884
- headSha,
11885
- mergeBaseSha: mergeBaseSha$1,
11886
- patchId
11887
- } };
11888
- } catch (error) {
11889
- return { error: errorMessage$1(error) };
11890
- }
11891
- };
11892
- /**
11893
- * Authoring-session identity. Not modelled by readiness: `pr:verify` enforces
11894
- * it unconditionally at the command layer (v0.13.2 on) and records it on the
11895
- * proof, so it can only fail at verification runtime — which is exactly the
11896
- * late discovery #292 exists to remove.
11897
- */
11898
- const authoringSessionCheck = (env) => {
11899
- const discovered = discoverFactorySessionId(env);
11900
- return discovered ? {
11901
- message: `Authoring session resolves from FACTORY_SESSION_ID (${discovered}); pr:verify records it on the proof.`,
11902
- name: "admission:authoring-session",
11903
- status: "ok"
11904
- } : {
11905
- message: "FACTORY_SESSION_ID is unset, so pr:verify requires --authoring-session <id>; without one it fails at verification runtime, and any review-type evidence stays unconfirmable (fail closed).",
11906
- name: "admission:authoring-session",
11907
- status: "warning"
11908
- };
11909
- };
11910
- /**
11911
- * Verification proof at the current head. The verdict comes from the single
11912
- * shared verify-once applicability entry point (#692 / ADR 0016), and a refusal
11913
- * is rendered with readiness's own blocking reason — never a second opinion.
11914
- */
11915
- const verifyProofCheck = ({ candidate, candidateError, preread, verifyProofPath }) => {
11916
- if (!candidate) return {
11917
- message: `Verification proof state is unknown: no git candidate is resolvable here (${candidateError ?? "unknown error"}).`,
11918
- name: "admission:verify-proof",
11919
- status: "warning"
11920
- };
11921
- const { verificationProof } = resolveVerifyProofApplicability({
11922
- currentVerifyIdentityForBase: () => ({ patchId: candidate.patchId }),
11923
- explicitProof: false,
11924
- files: candidate.files,
11925
- headSha: candidate.headSha,
11926
- preread,
11927
- proofPath: verifyProofPath
11928
- });
11929
- if (verificationProof.kind === "typed") return {
11930
- message: `Applicable ${verificationProof.proof.mode} pr:verify proof for head ${candidate.headSha} (verified at ${verificationProof.proof.headSha}); pr:publish will reuse it.`,
11931
- name: "admission:verify-proof",
11932
- status: "ok"
11933
- };
11934
- return {
11935
- message: verificationProofBlockingReason(verificationProof) ?? "No applicable pr:verify proof covers this candidate.",
11936
- name: "admission:verify-proof",
11937
- status: "warning"
13264
+ status: (status) => activeComments.length > 0 || activeReviews.length > 0 ? "blocked" : status
11938
13265
  };
11939
13266
  };
11940
- /**
11941
- * Independent review proof. Requiredness comes from the profile-resolved review
11942
- * modes; currency and acceptance come from the same identity and
11943
- * terminal-state helpers `pr:publish` uses.
11944
- */
11945
- const reviewProofCheck = ({ candidate, candidateError, profile, proof, reviewModes }) => {
11946
- const name = "admission:review-proof";
11947
- if (!candidate) return {
11948
- message: `Review proof state is unknown: no git candidate is resolvable here (${candidateError ?? "unknown error"}).`,
11949
- name,
11950
- status: "warning"
11951
- };
11952
- if (reviewModes.length === 0) return {
11953
- message: "Independent review is not required: no profile review mode applies to the changed files.",
11954
- name,
11955
- status: "ok"
13267
+ const verificationProofForInput = ({ input }) => {
13268
+ if (input.verificationProof) return {
13269
+ fullVerifiedHeadShas: input.docsOnlyVerifyBaselineHeadShas,
13270
+ verificationProof: input.verificationProof
11956
13271
  };
11957
- const status = reviewStatus({
11958
- headSha: candidate.headSha,
11959
- patchId: candidate.patchId,
11960
- required: true,
11961
- reviewedHeadSha: proof?.headSha,
11962
- reviewedPatchId: proof?.patchId
11963
- });
11964
- const terminal = proof ? resolveReviewTerminalStateAcrossReviewsForPolicy(proof, resolveReviewLadderPolicy(profile)) : void 0;
11965
- const accepted = status === "current" && (terminal === "clean" || terminal === "accepted-with-findings");
11966
13272
  return {
11967
- 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>.`,
11968
- name,
11969
- status: accepted ? "ok" : "warning"
13273
+ fullVerifiedHeadShas: [],
13274
+ verificationProof: {
13275
+ kind: "typed-missing",
13276
+ path: "",
13277
+ reason: "enoent"
13278
+ }
11970
13279
  };
11971
13280
  };
11972
- /**
11973
- * PR-body sections. Readiness owns the requirement and its wording; publish
11974
- * owns satisfying it by patching the managed sections from the two proofs, so
11975
- * this is named rather than evaluated — the body is a live GitHub fact a
11976
- * read-only local pass cannot see.
11977
- */
11978
- const prBodySectionsCheck = () => ({
11979
- message: `${RENDER_PR_BODY_SECTIONS_BLOCKER_REASON} pr:publish patches those managed sections from the pr:verify and pr:review proofs.`,
11980
- name: "admission:pr-body-sections",
11981
- status: "ok"
11982
- });
11983
- const requiredCheckMessage = (outcome) => {
11984
- if (outcome.status === "out-of-scope") return `Not demanded for this candidate: ${outcome.scopeReason}.`;
11985
- if (outcome.status === "satisfied") return `Satisfied by a ${outcome.checkType}-type evidence envelope${outcome.matchedPath ? ` (${outcome.matchedPath})` : ""}.`;
11986
- return outcome.reason ?? `Required ${outcome.checkType} check has no passing, current evidence envelope.`;
13281
+ const verifyGateFor = ({ input }) => {
13282
+ const verification = verificationProofForInput({ input });
13283
+ const verificationProof = verificationProofForReadiness(verification.verificationProof);
13284
+ return {
13285
+ verification,
13286
+ ...prVerifyEvaluation({
13287
+ acceptedBaselineHeadShas: verification.fullVerifiedHeadShas,
13288
+ currentHeadSha: input.headSha,
13289
+ docsOnlyProofCanStandAlone: input.classification === "docs/process-only",
13290
+ prVerifyProof: verificationProof,
13291
+ trivialProofCanStandAlone: input.classification === "trivial" || input.classification === "docs/process-only"
13292
+ })
13293
+ };
11987
13294
  };
11988
- /**
11989
- * Profile-declared external required checks — the evidence-envelope demands,
11990
- * including a preview-deploy proof when the profile declares one. Evaluated by
11991
- * readiness's own `evaluateRequiredChecks`, so scoping, freshness and
11992
- * independence stay exactly one implementation.
11993
- */
11994
- const requiredCheckChecks = ({ candidate, candidateError, cwd, profile, verifyProof }) => {
11995
- const requiredChecks = profile.requiredChecks ?? [];
11996
- if (requiredChecks.length === 0) return [{
11997
- message: "Profile declares no requiredChecks; no external evidence envelope (including a preview-deploy proof) is demanded.",
11998
- name: "admission:required-checks",
11999
- status: "ok"
12000
- }];
12001
- if (!candidate) return [{
12002
- message: `${requiredChecks.length} profile requiredCheck(s) are declared but cannot be evaluated: no git candidate is resolvable here (${candidateError ?? "unknown error"}).`,
12003
- name: "admission:required-checks",
12004
- status: "warning"
12005
- }];
12006
- const { outcomes } = evaluateRequiredChecks({
12007
- authoringSessionIds: resolveAuthoringSessionIds({ recorded: verifyProof?.authoringSession }),
12008
- candidate: {
12009
- headSha: candidate.headSha,
12010
- mergeBaseSha: candidate.mergeBaseSha,
12011
- patchId: candidate.patchId
12012
- },
12013
- envelopes: loadEvidenceEnvelopes(cwd),
12014
- requiredChecks,
12015
- scopeContext: {
12016
- classification: candidate.classification,
12017
- labels: void 0
12018
- }
12019
- });
12020
- return outcomes.map((outcome) => ({
12021
- message: requiredCheckMessage(outcome),
12022
- name: `admission:required-check:${outcome.name}`,
12023
- status: outcome.status === "unmet" ? "warning" : "ok"
13295
+ const waveRungBlockers = (input) => {
13296
+ const demanded = input.waveReviewDemand;
13297
+ if (!demanded) return [];
13298
+ return demandedRungSatisfactionReasons({
13299
+ demandedRung: demanded.review,
13300
+ liveHumanReviews: input.reviews ?? [],
13301
+ reviewRuns: input.reviewProof?.reviews
13302
+ }).map((reason) => ({
13303
+ demand: reviewRungDemand(demanded.review),
13304
+ reason
12024
13305
  }));
12025
13306
  };
12026
- /**
12027
- * The full admission checklist for the current repository and candidate, in the
12028
- * order the requirements bind: identity, verification proof, review proof,
12029
- * PR-body sections, external evidence demands. The factory GitHub App
12030
- * requirement is reported by the doctor `github-app` check, which states the
12031
- * consequence of its absence.
12032
- */
12033
- const admissionPreflightChecks = ({ base = "origin/main", cwd, env = process.env, hqIngestCredentials, profile, reviewProofPath = DEFAULT_PR_REVIEW_PROOF_PATH, verifyProofPath = DEFAULT_PR_VERIFY_PROOF_PATH }) => {
12034
- const resolvedVerifyProofPath = path.resolve(cwd, verifyProofPath);
12035
- const preread = tryReadProof(readPrVerifyProof, resolvedVerifyProofPath);
12036
- const resolved = resolveCandidate({
12037
- base,
12038
- cwd,
12039
- verifyProof: preread.proof
13307
+ const previewDeployRequired = false;
13308
+ const evaluateReadiness = (input) => {
13309
+ const bodyMetadata = readPrBodyMetadata(input.body);
13310
+ const reviewModes = input.reviewModes ?? ["correctness"];
13311
+ const epoch = reviewEpochModeFor(input.reviewProof ?? {}, input.ladderPolicy);
13312
+ const verifyGate = verifyGateFor({
13313
+ bodyMetadata,
13314
+ input
12040
13315
  });
12041
- const candidate = "candidate" in resolved ? resolved.candidate : void 0;
12042
- const candidateError = "error" in resolved ? resolved.error : void 0;
12043
- const reviewProof = readOptionalProofFor(PR_REVIEW_PROOF_DESCRIPTOR, cwd, reviewProofPath);
12044
- return [
12045
- authoringSessionCheck(env),
12046
- verifyProofCheck({
12047
- candidate,
12048
- candidateError,
12049
- preread,
12050
- verifyProofPath: resolvedVerifyProofPath
12051
- }),
12052
- reviewProofCheck({
12053
- candidate,
12054
- candidateError,
12055
- profile,
12056
- proof: reviewProof,
12057
- reviewModes: candidate ? resolveApplicableReviewModes(profile, candidate.files) : profile.review.modes
12058
- }),
12059
- prBodySectionsCheck(),
12060
- ...requiredCheckChecks({
12061
- candidate,
12062
- candidateError,
12063
- cwd,
12064
- profile,
12065
- verifyProof: preread.proof
13316
+ const { correctnessDocsOnlyDeltaAccepted, correctnessProof, correctnessRequired, correctnessStatus, correctnessUntyped, gateCapMismatchBlockers, reviewCycleState, reviewTerminalState, reviewLadder, securityDocsOnlyDeltaAccepted, securityProof, securityRequired, securityStatus, securityUntyped } = resolveReviewRequiredness({
13317
+ bodyMetadata,
13318
+ classification: input.classification,
13319
+ docsOnlySinceReviewProof: input.docsOnlySinceReviewProof,
13320
+ epoch,
13321
+ headSha: input.headSha,
13322
+ ladderPolicy: input.ladderPolicy,
13323
+ patchId: input.patchId,
13324
+ reviewModes,
13325
+ reviewProof: input.reviewProof
13326
+ });
13327
+ const { docsOnlyVerifiedHeadSha, docsOnlyVerifyDeltaAccepted, prVerifyStatus, trivialVerifiedHeadSha, trivialVerifyDeltaAccepted, verifiedHeadSha } = verifyGate;
13328
+ const requiredChecks = requiredChecksEvaluation(input);
13329
+ const blockers = [
13330
+ ...collectBlockers({
13331
+ correctnessRequired,
13332
+ correctnessStatus,
13333
+ correctnessUntyped,
13334
+ input,
13335
+ prVerifyStatus,
13336
+ sectionReady: bodyMetadata.requiredSectionsPresent,
13337
+ securityRequired,
13338
+ securityStatus,
13339
+ securityUntyped,
13340
+ trivialWaiverPresent: bodyMetadata.trivialWaiverAccepted,
13341
+ verificationProof: verifyGate.verification.verificationProof
12066
13342
  }),
12067
- hqIngestCredentialCheck({
12068
- env,
12069
- ...hqIngestCredentials === void 0 ? {} : { references: hqIngestCredentials }
12070
- })
13343
+ ...requiredChecks.blockers,
13344
+ ...gateCapMismatchBlockers.map((blocker) => ({
13345
+ demand: DEMAND_KEYS.reviewLadder,
13346
+ reason: blocker.reason
13347
+ })),
13348
+ ...waveRungBlockers(input)
12071
13349
  ];
12072
- };
12073
- //#endregion
12074
- //#region src/doctor.ts
12075
- function doctorProjectProfile(input = {}) {
12076
- const { path, profile } = loadProjectProfile(input);
12077
- const env = input.env ?? process.env;
12078
- const cwd = input.cwd ?? process.cwd();
12079
- const userConfig = resolveDoctorUserConfig(env, input.userConfig);
12080
- const checks = [...buildProfileChecks(profile, cwd, {
12081
- env,
12082
- userConfig: userConfig.loaded?.config,
12083
- userConfigCheck: userConfig.check
12084
- }), ...input.preflight ? admissionPreflightChecks({
12085
- base: input.base,
12086
- cwd,
12087
- env,
12088
- ...userConfig.loaded?.config.hqIngestCredentials === void 0 ? {} : { hqIngestCredentials: userConfig.loaded.config.hqIngestCredentials },
12089
- profile,
12090
- profilePath: path
12091
- }) : []];
12092
- return {
12093
- checks,
12094
- ok: checks.every((check) => check.status !== "error"),
12095
- profilePath: path,
12096
- projectKey: profile.project.key,
12097
- repository: {
12098
- defaultBranch: profile.repository.defaultBranch,
12099
- name: profile.repository.name,
12100
- owner: profile.repository.owner
12101
- },
12102
- schemaVersion: profile.schemaVersion
12103
- };
12104
- }
12105
- function resolveDoctorUserConfig(env, provided) {
12106
- const configPath = provided?.path ?? defaultUserConfigPath(env);
12107
- if (provided === void 0 && !existsSync(configPath)) return { check: {
12108
- message: `User config not found at ${configPath}; no optional operator credentials or HQ origins are configured.`,
12109
- name: "user-config",
12110
- status: "warning"
12111
- } };
12112
- try {
12113
- const loaded = provided ?? loadUserConfig({
12114
- configPath,
12115
- env
12116
- });
12117
- const ignoredDefaultHarness = loaded.ignoredKeys?.includes("defaultHarness") ?? false;
12118
- return {
12119
- check: {
12120
- message: ignoredDefaultHarness ? `defaultHarness is ignored; remove it from ${loaded.path}. The factory does not select an agent runtime.` : `Loaded schema version ${loaded.config.schemaVersion} user config from ${loaded.path}.`,
12121
- name: "user-config",
12122
- status: ignoredDefaultHarness ? "warning" : "ok"
12123
- },
12124
- loaded
12125
- };
12126
- } catch (error) {
12127
- return { check: {
12128
- message: error instanceof Error ? error.message : String(error),
12129
- name: "user-config",
12130
- status: "error"
12131
- } };
13350
+ const blockingReasons = blockers.map((blocker) => blocker.reason);
13351
+ const humanBlockingReasons = blockers.flatMap((blocker) => blocker.repair ? [] : [blocker.reason]);
13352
+ const repairs = orderRepairs(blockers.flatMap((blocker) => blocker.repair ? [blocker.repair] : []));
13353
+ const finalReviewBlockers = [...blockers];
13354
+ if (!bodyMetadata.finalReviewPoint) {
13355
+ finalReviewBlockers.push(SLICE_NOT_FINAL_BLOCKER);
13356
+ humanBlockingReasons.push(SLICE_NOT_FINAL_BLOCKER.reason);
12132
13357
  }
12133
- }
12134
- function buildProfileChecks(profile, cwd, userConfigInput) {
12135
- return [
12136
- {
12137
- message: `Loaded schema version ${profile.schemaVersion} profile for ${profile.project.key}.`,
12138
- name: "profile",
12139
- status: "ok"
12140
- },
12141
- {
12142
- message: `${profile.repository.owner}/${profile.repository.name} uses ${profile.repository.defaultBranch}.`,
12143
- name: "repository",
12144
- status: "ok"
12145
- },
12146
- {
12147
- message: `${profile.verification.commands.length} verification commands configured.`,
12148
- name: "verification",
12149
- status: "ok"
13358
+ const finalReviewBlockingReasons = finalReviewBlockers.map((blocker) => blocker.reason);
13359
+ const status = statusFromBlockingReasons({
13360
+ blockingReasons,
13361
+ finalReviewBlockingReasons,
13362
+ finalReviewPoint: bodyMetadata.finalReviewPoint
13363
+ });
13364
+ const postReadiness = postReadinessHumanCommentState(input);
13365
+ const ledger = {
13366
+ baseSha: input.baseSha,
13367
+ blockingReasons: finalReviewBlockingReasons,
13368
+ classification: input.classification,
13369
+ ...requiredChecks.outcomes.length > 0 ? { externalChecks: requiredChecks.outcomes.map((outcome) => ({
13370
+ checkType: outcome.checkType,
13371
+ inScope: outcome.inScope,
13372
+ name: outcome.name,
13373
+ ...outcome.reason ? { reason: outcome.reason } : {},
13374
+ ...outcome.scope ? { scope: outcome.scope } : {},
13375
+ scopeReason: outcome.scopeReason,
13376
+ status: outcome.status
13377
+ })) } : {},
13378
+ finalReviewPoint: bodyMetadata.finalReviewPoint,
13379
+ github: {
13380
+ currentWithBase: input.currentWithBase,
13381
+ draft: input.draft,
13382
+ mergeStateStatus: input.mergeStateStatus,
13383
+ mergeable: input.mergeable,
13384
+ requiredChecks: input.requiredChecks,
13385
+ unresolvedReviewThreads: input.unresolvedReviewThreads
12150
13386
  },
12151
- requiredEnvironmentCheck(profile, userConfigInput.env),
12152
- {
12153
- message: `${profile.review.modes.join(", ")} review modes configured with ${profile.review.defaultMaxCycles} cycle cap.${profile.review.standingChecklist && profile.review.standingChecklist.length > 0 ? ` Standing review checklist: ${profile.review.standingChecklist.length} line(s).` : ""}`,
12154
- name: "review-policy",
12155
- status: "ok"
13387
+ headSha: input.headSha,
13388
+ ...input.mergeBaseSha ? { mergeBaseSha: input.mergeBaseSha } : {},
13389
+ patchId: input.patchId,
13390
+ pr: input.pr,
13391
+ previewDeployRequired,
13392
+ ...postReadiness.ledgerFields,
13393
+ reviewRuns: input.reviewProof?.reviews.map((review) => ({
13394
+ durationMs: review.durationMs,
13395
+ endedAt: review.endedAt,
13396
+ findings: review.findings,
13397
+ issuesFlagged: review.issuesFlagged,
13398
+ kind: review.kind,
13399
+ ...recordedReviewIdentityFields(review),
13400
+ outcome: review.outcome,
13401
+ startedAt: review.startedAt,
13402
+ summary: review.summary
13403
+ })),
13404
+ ...reviewCycleState ? { reviewCycleState } : {},
13405
+ ...reviewTerminalState ? { reviewTerminalState } : {},
13406
+ ...reviewLadder ? { reviewLadder } : {},
13407
+ repairs,
13408
+ reviews: {
13409
+ correctness: {
13410
+ ...correctnessDocsOnlyDeltaAccepted ? { docsOnlyDeltaAccepted: true } : {},
13411
+ required: correctnessRequired,
13412
+ status: correctnessStatus,
13413
+ ...correctnessProof
13414
+ },
13415
+ ...reviewModes.includes("security") ? { security: {
13416
+ ...securityDocsOnlyDeltaAccepted ? { docsOnlyDeltaAccepted: true } : {},
13417
+ required: securityRequired,
13418
+ status: securityStatus,
13419
+ ...securityProof
13420
+ } } : {}
12156
13421
  },
12157
- reviewLadderCheck(profile),
12158
- userConfigInput.userConfigCheck,
12159
- githubAppCheck(userConfigInput.userConfig),
12160
- gitRemoteCheck(profile, cwd)
12161
- ];
12162
- }
12163
- function requiredEnvironmentCheck(profile, env) {
12164
- const required = profile.env?.required ?? [];
12165
- const unset = required.filter((name) => !env[name]);
12166
- if (unset.length > 0) return {
12167
- message: `Required environment variables are unset: ${unset.join(", ")}.`,
12168
- name: "environment",
12169
- status: "error"
12170
- };
12171
- return {
12172
- message: required.length === 0 ? "No required environment variables declared." : `${required.length} required environment variable name(s) are set.`,
12173
- name: "environment",
12174
- status: "ok"
12175
- };
12176
- }
12177
- function githubAppCheck(userConfig) {
12178
- const app = userConfig?.githubApp;
12179
- if (!app) return {
12180
- message: "Patronage Factory GitHub App credentials are not configured; gate publishing will use user-token commit statuses with Details links. Proof bindings cannot publish, so hosted required checks cannot be satisfied by local proof and must go green by their own means.",
12181
- name: "github-app",
12182
- status: "warning"
12183
- };
12184
- if (!existsSync(app.privateKeyPath)) return {
12185
- message: `Patronage Factory GitHub App id ${app.appId} is configured, but private key ${app.privateKeyPath} was not found.`,
12186
- name: "github-app",
12187
- status: "warning"
12188
- };
12189
- return {
12190
- message: `Patronage Factory GitHub App id ${app.appId} is configured${app.installationId ? ` for installation ${app.installationId}` : " with repository installation discovery"}.`,
12191
- name: "github-app",
12192
- status: "ok"
12193
- };
12194
- }
12195
- function reviewLadderCheck(profile) {
12196
- const policy = resolveReviewLadderPolicy(profile);
12197
- return {
12198
- message: `Review ladder ${profile.review.ladder ? "configured" : "defaulted"}: gate cap ${policy.gate.cap}.`,
12199
- name: "review-ladder",
12200
- status: "ok"
12201
- };
12202
- }
12203
- function gitRemoteCheck(profile, cwd) {
12204
- const origin = readGitOrigin(cwd);
12205
- if (!origin) return {
12206
- message: "No git origin remote was available for this working directory.",
12207
- name: "git-origin",
12208
- status: "warning"
12209
- };
12210
- if (repositoryUrlMatches(origin, profile)) return {
12211
- message: `Git origin matches ${profile.repository.owner}/${profile.repository.name}.`,
12212
- name: "git-origin",
12213
- status: "ok"
13422
+ schemaVersion: 1,
13423
+ stackRole: bodyMetadata.stackRole,
13424
+ verification: verificationLedgerFields({
13425
+ docsOnlyVerifiedHeadSha,
13426
+ docsOnlyVerifyDeltaAccepted,
13427
+ prVerifyStatus,
13428
+ trivialVerifiedHeadSha,
13429
+ trivialVerifyDeltaAccepted,
13430
+ verifiedHeadSha
13431
+ })
12214
13432
  };
13433
+ const finalBlockers = [...finalReviewBlockers, ...postReadiness.blockers];
13434
+ const finalBlockingReasons = finalBlockers.map((blocker) => blocker.reason);
13435
+ humanBlockingReasons.push(...postReadiness.blockers.map((blocker) => blocker.reason));
13436
+ ledger.blockingReasons = finalBlockingReasons;
12215
13437
  return {
12216
- message: `Git origin ${origin} does not match ${profile.repository.owner}/${profile.repository.name}.`,
12217
- name: "git-origin",
12218
- status: "warning"
13438
+ blockedReasons: finalBlockers.map((blocker) => ({
13439
+ code: blocker.demand,
13440
+ detail: blockedReasonDetail(blocker.reason)
13441
+ })),
13442
+ blockingReasons: finalBlockingReasons,
13443
+ humanBlockingReasons,
13444
+ ledger,
13445
+ repairs,
13446
+ status: postReadiness.status(status)
12219
13447
  };
12220
- }
12221
- function readGitOrigin(cwd) {
13448
+ };
13449
+ //#endregion
13450
+ //#region src/admission-preflight.ts
13451
+ const errorMessage$1 = (error) => error instanceof Error ? error.message : String(error);
13452
+ const resolveCandidate = ({ base, cwd, verifyProof }) => {
12222
13453
  try {
12223
- return execFileSync("git", [
12224
- "remote",
12225
- "get-url",
12226
- "origin"
12227
- ], {
13454
+ const files = changedFiles(cwd, base);
13455
+ const { headSha, mergeBaseSha: mergeBaseSha$1, patchId } = resolveCandidateIdentity({
13456
+ base,
12228
13457
  cwd,
12229
- encoding: "utf-8",
12230
- stdio: [
12231
- "ignore",
12232
- "pipe",
12233
- "ignore"
12234
- ]
12235
- }).trim();
12236
- } catch {
12237
- return null;
12238
- }
12239
- }
12240
- function repositoryUrlMatches(origin, profile) {
12241
- const normalized = origin.replace(/\.git$/u, "");
12242
- const repoPath = `${profile.repository.owner}/${profile.repository.name}`;
12243
- return normalized.endsWith(`github.com:${repoPath}`) || normalized.endsWith(`github.com/${repoPath}`) || normalized.endsWith(repoPath);
12244
- }
12245
- //#endregion
12246
- //#region src/commands/doctor.ts
12247
- function createDoctorCommand(output) {
12248
- return 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", ".").option("--json", "print the doctor report as JSON").option("--preflight", "also list every admission requirement for the current candidate (read-only; never blocks)").option("--profile <path>", "path to the project profile JSON file").action((options) => {
12249
- const report = doctorProjectProfile({
12250
- base: options.base,
12251
- cwd: resolveCwdOption(options.cwd),
12252
- preflight: options.preflight,
12253
- profilePath: options.profile
13458
+ git: {
13459
+ currentHeadSha,
13460
+ mergeBaseSha,
13461
+ stablePatchId
13462
+ }
12254
13463
  });
12255
- if (options.json) output.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
12256
- else output.stdout.write(formatHumanReport(report));
12257
- if (!report.ok) process.exitCode = 1;
12258
- });
12259
- }
12260
- function formatHumanReport(report) {
12261
- return [
12262
- `Profile: ${report.projectKey} (schema ${report.schemaVersion})`,
12263
- `Repository: ${report.repository.owner}/${report.repository.name} default ${report.repository.defaultBranch}`,
12264
- `Path: ${report.profilePath}`,
12265
- "",
12266
- ...report.checks.map((check) => `[${check.status}] ${check.name}: ${check.message}`),
12267
- ""
12268
- ].join("\n");
12269
- }
12270
- //#endregion
12271
- //#region src/epic-structure.ts
12272
- /**
12273
- * Producer-side `epic-structure` v1 emitter (epic #132, lane #135).
12274
- *
12275
- * `psf epic:publish-structure` reads a planner's ephemeral `dag.yml` (never
12276
- * committed — see epic #132 / ADR 0018), validates the graph fail-closed, and
12277
- * POSTs an `epic-structure` v1 event to HQ's `/api/ingest` boundary. This
12278
- * module is the pure core: parse-to-IR validation, the wire mapping, and the
12279
- * deterministic content-addressed eventId. All I/O (stdin/file read, fetch)
12280
- * lives in the command wrapper.
12281
- *
12282
- * WIRE PARITY (do not fork the schema): the emitted payload is the exact shape
12283
- * HQ's `EpicGraphSchema` accepts (`software-factory-hq/src/contracts/
12284
- * epic-schemas.ts`) and is built by hand the same way `seed-dev.ts` builds it
12285
- * — no re-declared zod twin. HQ's `epic-structure-parity.test.ts` imports
12286
- * {@link buildEpicStructureEvent} and runs its output through the real
12287
- * `EpicGraphSchema`, so any drift (field, enum, bound, strictness) fails CI.
12288
- *
12289
- * The `dag.yml` schema itself is documented with the epic skill (lane #137);
12290
- * this module is the executable contract, not a second source of truth.
12291
- */
12292
- const EPIC_STRUCTURE_SCHEMA_VERSION = 1;
12293
- /**
12294
- * DAG-node PLANNING statuses — the planning-plane vocabulary a planner authors
12295
- * on a `dag.yml` node. Carried forward by ADR 0018 §4 and defined by the epic
12296
- * skill (`reference/epic-artifacts.md`) and both `CONTEXT.md` glossaries.
12297
- *
12298
- * A DAG node is a unit of *plan*; an HQ lane is a unit of *runtime execution*.
12299
- * These are two planes with two vocabularies and must not be conflated — the
12300
- * producer validates and emits ONLY planning statuses:
12301
- *
12302
- * - `open` authored, not yet done
12303
- * - `closed` done (a PR merged, or otherwise resolved)
12304
- * - `satisfied-on-main` already true on main without a dedicated PR
12305
- * - `parked` real node, scope still moving — relabeled off
12306
- * `ready-for-agent`, never closed
12307
- *
12308
- * HQ's `DagNodeSchema` (`software-factory-hq/src/contracts/epic-schemas.ts`)
12309
- * accepts this planning vocabulary on ingest AND overlays a runtime LANE status
12310
- * onto a node once a lane is linked (`syncNodesWithLanes` /
12311
- * `projectEpicMembership`) — the overlay is HQ-internal; the producer never
12312
- * emits a lane status. HQ's `epic-structure-parity.test.ts` fails CI if the
12313
- * producer emits any status HQ won't accept.
13464
+ return { candidate: {
13465
+ classification: verifyProof && evidenceFresh({
13466
+ candidate: { patchId },
13467
+ recorded: { patchId: verifyProof.patchId },
13468
+ rule: "patch-id"
13469
+ }) ? verifyProof.classification : void 0,
13470
+ files,
13471
+ headSha,
13472
+ mergeBaseSha: mergeBaseSha$1,
13473
+ patchId
13474
+ } };
13475
+ } catch (error) {
13476
+ return { error: errorMessage$1(error) };
13477
+ }
13478
+ };
13479
+ /**
13480
+ * Authoring-session identity. Not modelled by readiness: `pr:verify` enforces
13481
+ * it unconditionally at the command layer (v0.13.2 on) and records it on the
13482
+ * proof, so it can only fail at verification runtime — which is exactly the
13483
+ * late discovery #292 exists to remove.
12314
13484
  */
12315
- const EPIC_STRUCTURE_NODE_STATUSES = [
12316
- "open",
12317
- "closed",
12318
- "satisfied-on-main",
12319
- "parked"
12320
- ];
13485
+ const authoringSessionCheck = (env) => {
13486
+ const discovered = discoverFactorySessionId(env);
13487
+ return discovered ? {
13488
+ message: `Authoring session resolves from FACTORY_SESSION_ID (${discovered}); pr:verify records it on the proof.`,
13489
+ name: "admission:authoring-session",
13490
+ status: "ok"
13491
+ } : {
13492
+ message: "FACTORY_SESSION_ID is unset, so pr:verify requires --authoring-session <id>; without one it fails at verification runtime, and any review-type evidence stays unconfirmable (fail closed).",
13493
+ name: "admission:authoring-session",
13494
+ status: "warning"
13495
+ };
13496
+ };
12321
13497
  /**
12322
- * Fail-closed validation error carrying every offending node/edge diagnostic.
13498
+ * Verification proof at the current head. The verdict comes from the single
13499
+ * shared verify-once applicability entry point (#692 / ADR 0016), and a refusal
13500
+ * is rendered with readiness's own blocking reason — never a second opinion.
12323
13501
  */
12324
- var EpicStructureValidationError = class extends Error {
12325
- diagnostics;
12326
- constructor(diagnostics) {
12327
- super(`epic:publish-structure refused: dag graph is invalid\n${diagnostics.map((line) => ` - ${line}`).join("\n")}`);
12328
- this.name = "EpicStructureValidationError";
12329
- this.diagnostics = diagnostics;
12330
- }
13502
+ const verifyProofCheck = ({ candidate, candidateError, preread, verifyProofPath }) => {
13503
+ if (!candidate) return {
13504
+ message: `Verification proof state is unknown: no git candidate is resolvable here (${candidateError ?? "unknown error"}).`,
13505
+ name: "admission:verify-proof",
13506
+ status: "warning"
13507
+ };
13508
+ const { verificationProof } = resolveVerifyProofApplicability({
13509
+ currentVerifyIdentityForBase: () => ({ patchId: candidate.patchId }),
13510
+ explicitProof: false,
13511
+ files: candidate.files,
13512
+ headSha: candidate.headSha,
13513
+ preread,
13514
+ proofPath: verifyProofPath
13515
+ });
13516
+ if (verificationProof.kind === "typed") return {
13517
+ message: `Applicable ${verificationProof.proof.mode} pr:verify proof for head ${candidate.headSha} (verified at ${verificationProof.proof.headSha}); pr:publish will reuse it.`,
13518
+ name: "admission:verify-proof",
13519
+ status: "ok"
13520
+ };
13521
+ return {
13522
+ message: verificationProofBlockingReason(verificationProof) ?? "No applicable pr:verify proof covers this candidate.",
13523
+ name: "admission:verify-proof",
13524
+ status: "warning"
13525
+ };
12331
13526
  };
12332
- const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
12333
- const nonEmptyString = (value) => typeof value === "string" && value.trim().length > 0;
12334
- const isPositiveInteger = (value) => typeof value === "number" && Number.isInteger(value) && value > 0;
12335
- const slugify = (value) => value.trim().toLowerCase().replaceAll(/[^a-z0-9]+/gu, "-").replaceAll(/^-+|-+$/gu, "") || "epic";
12336
- const laneIdFor = (repo, issue) => `lane-${repo}-issue-${issue}`;
12337
- const validateNodes = (rawNodes, diagnostics) => {
12338
- const nodes = [];
12339
- const slugs = /* @__PURE__ */ new Set();
12340
- for (const [index, raw] of rawNodes.entries()) {
12341
- if (!isPlainObject(raw)) {
12342
- diagnostics.push(`node[${index}]: expected a mapping`);
12343
- continue;
12344
- }
12345
- const label = nonEmptyString(raw.slug) ? `"${raw.slug}"` : `node[${index}]`;
12346
- if (!nonEmptyString(raw.slug)) diagnostics.push(`node[${index}]: missing required \`slug\``);
12347
- else if (slugs.has(raw.slug)) diagnostics.push(`node ${label}: duplicate slug`);
12348
- else slugs.add(raw.slug);
12349
- if (!nonEmptyString(raw.title)) diagnostics.push(`node ${label}: missing required \`title\``);
12350
- const external = raw.external === true;
12351
- if (!external && !isPositiveInteger(raw.issue)) diagnostics.push(`node ${label}: \`issue\` number is required — non-external nodes must carry a minted issue (this runs post-ratification); set \`external: true\` to exempt an adoption/upstream node`);
12352
- let status = "open";
12353
- if (raw.status === void 0) status = "open";
12354
- else if (EPIC_STRUCTURE_NODE_STATUSES.includes(raw.status)) status = raw.status;
12355
- else diagnostics.push(`node ${label}: \`status\` must be one of ${EPIC_STRUCTURE_NODE_STATUSES.join(", ")} (DAG-node planning statuses, not HQ lane statuses)`);
12356
- if (raw.laneId !== void 0 && !nonEmptyString(raw.laneId)) diagnostics.push(`node ${label}: \`laneId\` must be a non-empty string`);
12357
- nodes.push({
12358
- external,
12359
- ...isPositiveInteger(raw.issue) ? { issue: raw.issue } : {},
12360
- ...nonEmptyString(raw.laneId) ? { laneId: raw.laneId } : {},
12361
- slug: nonEmptyString(raw.slug) ? raw.slug : `node[${index}]`,
12362
- status,
12363
- title: nonEmptyString(raw.title) ? raw.title : ""
12364
- });
12365
- }
13527
+ /**
13528
+ * Independent review proof. Requiredness comes from the profile-resolved review
13529
+ * modes; currency and acceptance come from the same identity and
13530
+ * terminal-state helpers `pr:publish` uses.
13531
+ */
13532
+ const reviewProofCheck = ({ candidate, candidateError, profile, proof, reviewModes }) => {
13533
+ const name = "admission:review-proof";
13534
+ if (!candidate) return {
13535
+ message: `Review proof state is unknown: no git candidate is resolvable here (${candidateError ?? "unknown error"}).`,
13536
+ name,
13537
+ status: "warning"
13538
+ };
13539
+ if (reviewModes.length === 0) return {
13540
+ message: "Independent review is not required: no profile review mode applies to the changed files.",
13541
+ name,
13542
+ status: "ok"
13543
+ };
13544
+ const status = reviewStatus({
13545
+ headSha: candidate.headSha,
13546
+ patchId: candidate.patchId,
13547
+ required: true,
13548
+ reviewedHeadSha: proof?.headSha,
13549
+ reviewedPatchId: proof?.patchId
13550
+ });
13551
+ const terminal = proof ? resolveReviewTerminalStateAcrossReviewsForPolicy(proof, resolveReviewLadderPolicy(profile)) : void 0;
13552
+ const accepted = status === "current" && (terminal === "clean" || terminal === "accepted-with-findings");
12366
13553
  return {
12367
- nodes,
12368
- slugs
13554
+ 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>.`,
13555
+ name,
13556
+ status: accepted ? "ok" : "warning"
12369
13557
  };
12370
13558
  };
12371
- const validateEdges = (rawEdges, slugs, diagnostics) => {
12372
- const edges = [];
12373
- for (const [index, raw] of rawEdges.entries()) {
12374
- if (!isPlainObject(raw)) {
12375
- diagnostics.push(`edge[${index}]: expected a mapping`);
12376
- continue;
12377
- }
12378
- if (raw.type !== "depends-on") continue;
12379
- if (!nonEmptyString(raw.from) || !nonEmptyString(raw.to)) {
12380
- diagnostics.push(`edge[${index}]: depends-on edge requires string \`from\` and \`to\``);
12381
- continue;
12382
- }
12383
- if (!slugs.has(raw.from)) diagnostics.push(`edge ${raw.from} -> ${raw.to}: \`from\` references unknown slug "${raw.from}"`);
12384
- if (!slugs.has(raw.to)) diagnostics.push(`edge ${raw.from} -> ${raw.to}: \`to\` references unknown slug "${raw.to}"`);
12385
- if (raw.from === raw.to) diagnostics.push(`edge ${raw.from} -> ${raw.to}: self-dependency (cycle)`);
12386
- edges.push({
12387
- from: raw.from,
12388
- to: raw.to
12389
- });
12390
- }
12391
- return edges;
13559
+ /**
13560
+ * PR-body sections. Readiness owns the requirement and its wording; publish
13561
+ * owns satisfying it by patching the managed sections from the two proofs, so
13562
+ * this is named rather than evaluated — the body is a live GitHub fact a
13563
+ * read-only local pass cannot see.
13564
+ */
13565
+ const prBodySectionsCheck = () => ({
13566
+ message: `${RENDER_PR_BODY_SECTIONS_BLOCKER_REASON} pr:publish patches those managed sections from the pr:verify and pr:review proofs.`,
13567
+ name: "admission:pr-body-sections",
13568
+ status: "ok"
13569
+ });
13570
+ const requiredCheckMessage = (outcome) => {
13571
+ if (outcome.status === "out-of-scope") return `Not demanded for this candidate: ${outcome.scopeReason}.`;
13572
+ if (outcome.status === "satisfied") return `Satisfied by a ${outcome.checkType}-type evidence envelope${outcome.matchedPath ? ` (${outcome.matchedPath})` : ""}.`;
13573
+ return outcome.reason ?? `Required ${outcome.checkType} check has no passing, current evidence envelope.`;
12392
13574
  };
12393
13575
  /**
12394
- * Detects a dependency cycle over the depends-on edges and returns the offending
12395
- * cycle path (`a -> b -> a`) if one exists, else undefined. Only edges whose
12396
- * endpoints both resolve to known slugs are walked, so unknown-slug diagnostics
12397
- * are reported independently and never masquerade as cycles.
13576
+ * Profile-declared external required checks the evidence-envelope demands,
13577
+ * including a preview-deploy proof when the profile declares one. Evaluated by
13578
+ * readiness's own `evaluateRequiredChecks`, so scoping, freshness and
13579
+ * independence stay exactly one implementation.
12398
13580
  */
12399
- const findCycle = (slugs, edges) => {
12400
- const adjacency = /* @__PURE__ */ new Map();
12401
- for (const { from, to } of edges) if (slugs.has(from) && slugs.has(to)) {
12402
- const list = adjacency.get(from) ?? [];
12403
- list.push(to);
12404
- adjacency.set(from, list);
12405
- }
12406
- const UNVISITED = 0;
12407
- const IN_STACK = 1;
12408
- const DONE = 2;
12409
- const state = /* @__PURE__ */ new Map();
12410
- const stack = [];
12411
- const walk = (node) => {
12412
- state.set(node, IN_STACK);
12413
- stack.push(node);
12414
- for (const next of adjacency.get(node) ?? []) {
12415
- const marker = state.get(next) ?? UNVISITED;
12416
- if (marker === IN_STACK) {
12417
- const start = stack.indexOf(next);
12418
- return [...stack.slice(start), next];
12419
- }
12420
- if (marker === UNVISITED) {
12421
- const found = walk(next);
12422
- if (found) return found;
12423
- }
13581
+ const requiredCheckChecks = ({ candidate, candidateError, cwd, profile, verifyProof }) => {
13582
+ const requiredChecks = profile.requiredChecks ?? [];
13583
+ if (requiredChecks.length === 0) return [{
13584
+ message: "Profile declares no requiredChecks; no external evidence envelope (including a preview-deploy proof) is demanded.",
13585
+ name: "admission:required-checks",
13586
+ status: "ok"
13587
+ }];
13588
+ if (!candidate) return [{
13589
+ message: `${requiredChecks.length} profile requiredCheck(s) are declared but cannot be evaluated: no git candidate is resolvable here (${candidateError ?? "unknown error"}).`,
13590
+ name: "admission:required-checks",
13591
+ status: "warning"
13592
+ }];
13593
+ const { outcomes } = evaluateRequiredChecks({
13594
+ authoringSessionIds: resolveAuthoringSessionIds({ recorded: verifyProof?.authoringSession }),
13595
+ candidate: {
13596
+ headSha: candidate.headSha,
13597
+ mergeBaseSha: candidate.mergeBaseSha,
13598
+ patchId: candidate.patchId
13599
+ },
13600
+ envelopes: loadEvidenceEnvelopes(cwd),
13601
+ requiredChecks,
13602
+ scopeContext: {
13603
+ classification: candidate.classification,
13604
+ labels: void 0
12424
13605
  }
12425
- stack.pop();
12426
- state.set(node, DONE);
12427
- };
12428
- for (const slug of slugs) if ((state.get(slug) ?? UNVISITED) === UNVISITED) {
12429
- const found = walk(slug);
12430
- if (found) return found;
12431
- }
13606
+ });
13607
+ return outcomes.map((outcome) => ({
13608
+ message: requiredCheckMessage(outcome),
13609
+ name: `admission:required-check:${outcome.name}`,
13610
+ status: outcome.status === "unmet" ? "warning" : "ok"
13611
+ }));
13612
+ };
13613
+ /**
13614
+ * The full admission checklist for the current repository and candidate, in the
13615
+ * order the requirements bind: identity, verification proof, review proof,
13616
+ * PR-body sections, external evidence demands. The factory GitHub App
13617
+ * requirement is reported by the doctor `github-app` check, which states the
13618
+ * consequence of its absence.
13619
+ */
13620
+ const admissionPreflightChecks = ({ base = "origin/main", cwd, env = process.env, hqIngestCredentials, profile, reviewProofPath = DEFAULT_PR_REVIEW_PROOF_PATH, verifyProofPath = DEFAULT_PR_VERIFY_PROOF_PATH }) => {
13621
+ const resolvedVerifyProofPath = path.resolve(cwd, verifyProofPath);
13622
+ const preread = tryReadProof(readPrVerifyProof, resolvedVerifyProofPath);
13623
+ const resolved = resolveCandidate({
13624
+ base,
13625
+ cwd,
13626
+ verifyProof: preread.proof
13627
+ });
13628
+ const candidate = "candidate" in resolved ? resolved.candidate : void 0;
13629
+ const candidateError = "error" in resolved ? resolved.error : void 0;
13630
+ const reviewProof = readOptionalProofFor(PR_REVIEW_PROOF_DESCRIPTOR, cwd, reviewProofPath);
13631
+ return [
13632
+ authoringSessionCheck(env),
13633
+ verifyProofCheck({
13634
+ candidate,
13635
+ candidateError,
13636
+ preread,
13637
+ verifyProofPath: resolvedVerifyProofPath
13638
+ }),
13639
+ reviewProofCheck({
13640
+ candidate,
13641
+ candidateError,
13642
+ profile,
13643
+ proof: reviewProof,
13644
+ reviewModes: candidate ? resolveApplicableReviewModes(profile, candidate.files) : profile.review.modes
13645
+ }),
13646
+ prBodySectionsCheck(),
13647
+ ...requiredCheckChecks({
13648
+ candidate,
13649
+ candidateError,
13650
+ cwd,
13651
+ profile,
13652
+ verifyProof: preread.proof
13653
+ }),
13654
+ hqIngestCredentialCheck({
13655
+ env,
13656
+ ...hqIngestCredentials === void 0 ? {} : { references: hqIngestCredentials }
13657
+ })
13658
+ ];
12432
13659
  };
12433
- const resolveEpicId = (epic) => {
12434
- if (typeof epic === "number") return String(epic);
12435
- if (nonEmptyString(epic)) return epic.trim();
13660
+ //#endregion
13661
+ //#region src/doctor-hq-checks.ts
13662
+ const HQ_SPOOL_CHECK_NAME = "hq-spool";
13663
+ const HQ_RETRO_READBACK_CHECK_NAME = "hq-retro-readback";
13664
+ const TRUSTED_LOCAL_NOTE = "This resolves HQ Access credentials the same way `psf hq:flush` and `psf pr:publish` do; both require a trusted local session.";
13665
+ const FLUSH_REMEDY = "Run `psf hq:flush` to drain them.";
13666
+ /** One sentence per orphaned location, with the `--dir` recovery it needs. */
13667
+ const describeOrphans = (orphans) => {
13668
+ const described = orphans.map((orphan) => `${orphan.directory} (${orphan.unlistable > 0 && orphan.pending === 0 ? "could not be inspected" : `${orphan.pending} event(s)`})`).join(", ");
13669
+ return ` ${orphans.length} spool location(s) hold this repository's evidence under an earlier key it no longer resolves to — a key-format change strands evidence there where no drain looks: ${described}. Drain each with \`psf hq:flush --dir <path>\`.`;
12436
13670
  };
12437
13671
  /**
12438
- * Validates a dag document fail-closed. Throws {@link
12439
- * EpicStructureValidationError} with a diagnostic per offending node/edge.
13672
+ * Red when HQ evidence is waiting for this repository: its own spool or legacy
13673
+ * journal, or a sibling location written under an earlier key of its own that
13674
+ * it no longer resolves to (#420). Another repository's spool under the shared
13675
+ * root is that repository's business, never this check's (#446).
13676
+ *
13677
+ * Reuses `countHqSpoolWork` (#414) — the same read-only, credential-free
13678
+ * inspection `hq:flush` itself consults before ever resolving a secret — so
13679
+ * doctor and the flush command can never disagree about what "empty" means.
13680
+ *
13681
+ * The orphan sweep is the half that makes the check honest. Both readers of
13682
+ * the spool resolve exactly one key, so evidence written under an older one is
13683
+ * invisible to a drain and, before this, to doctor: the failure epic #389 was
13684
+ * chartered to end is evidence that is neither delivered nor visibly stranded.
13685
+ * An orphan holding pending events is red for the same reason the repo-keyed
13686
+ * spool is.
12440
13687
  */
12441
- const validateDagDocument = (input) => {
12442
- const doc = input.document;
12443
- const diagnostics = [];
12444
- if (!isPlainObject(doc)) throw new EpicStructureValidationError(["dag document must be a YAML/JSON mapping with `epic` and `nodes`"]);
12445
- const epicId = resolveEpicId(doc.epic);
12446
- if (epicId === void 0) diagnostics.push("dag document requires a top-level `epic` id");
12447
- const repo = input.repo ?? (nonEmptyString(doc.repo) ? doc.repo.trim() : void 0);
12448
- if (repo === void 0) diagnostics.push("repo is required pass `--repo <name>` or set `repo:` in the dag document (needed for the epic record and lane-id derivation)");
12449
- const rawNodes = Array.isArray(doc.nodes) ? doc.nodes : void 0;
12450
- if (rawNodes === void 0 || rawNodes.length === 0) diagnostics.push("dag document requires a non-empty `nodes` array");
12451
- const { nodes, slugs } = validateNodes(rawNodes ?? [], diagnostics);
12452
- const edges = validateEdges(Array.isArray(doc.edges) ? doc.edges : [], slugs, diagnostics);
12453
- const cycle = findCycle(slugs, edges);
12454
- if (cycle) diagnostics.push(`dependency cycle detected: ${cycle.join(" -> ")}`);
12455
- if (diagnostics.length > 0 || epicId === void 0 || repo === void 0) throw new EpicStructureValidationError(diagnostics);
13688
+ async function hqSpoolDoctorCheck(input, dependencies = {}) {
13689
+ const countSpool = dependencies.countSpool ?? countHqSpoolWork;
13690
+ const sweepOrphans = dependencies.sweepOrphans ?? sweepHqSpoolOrphans;
13691
+ const [counted, swept] = await Promise.all([countSpool({
13692
+ cwd: input.cwd,
13693
+ repository: input.repository
13694
+ }, { env: input.env }), sweepOrphans({ repository: input.repository }, { env: input.env })]);
13695
+ const orphanNote = swept.orphans.length === 0 ? "" : describeOrphans(swept.orphans);
13696
+ if (counted.pending === 0 && counted.unlistable === 0 && swept.orphans.length === 0) return {
13697
+ message: "HQ spool and journal are empty; no evidence is waiting to be drained.",
13698
+ name: HQ_SPOOL_CHECK_NAME,
13699
+ status: "ok"
13700
+ };
13701
+ const locationsNote = counted.spools.length > 0 ? ` Locations: ${counted.spools.join(", ")}.` : "";
13702
+ const oldestNote = counted.oldestQueuedAt === void 0 ? "" : ` Oldest entry queued ${counted.oldestQueuedAt}.`;
13703
+ if (counted.pending > 0) return {
13704
+ message: `${counted.pending} HQ event(s) are spooled locally.${oldestNote}${locationsNote} ${FLUSH_REMEDY}${orphanNote}`,
13705
+ name: HQ_SPOOL_CHECK_NAME,
13706
+ status: "error"
13707
+ };
13708
+ if (counted.unlistable > 0) return {
13709
+ message: `${counted.unlistable} spool location(s) exist but could not be listed within budget, so this cannot be reported as empty.${locationsNote} ${FLUSH_REMEDY}${orphanNote}`,
13710
+ name: HQ_SPOOL_CHECK_NAME,
13711
+ status: "error"
13712
+ };
12456
13713
  return {
12457
- boundary: slugify(input.boundary ?? (nonEmptyString(doc.boundary) ? doc.boundary : `epic-${epicId}`)),
12458
- edges,
12459
- epicId,
12460
- name: input.name ?? (nonEmptyString(doc.name) ? doc.name.trim() : `Epic ${epicId}`),
12461
- nodes,
12462
- repo
13714
+ message: `This repository's HQ spool is empty, but stranded evidence is waiting under the spool root ${swept.root}.${orphanNote}`,
13715
+ name: HQ_SPOOL_CHECK_NAME,
13716
+ status: "error"
12463
13717
  };
13718
+ }
13719
+ const readJsonFiles = async (dir, readFileImpl) => {
13720
+ let names;
13721
+ try {
13722
+ names = await readdir(dir);
13723
+ } catch {
13724
+ return [];
13725
+ }
13726
+ const jsonNames = names.filter((name) => name.endsWith(".json"));
13727
+ const files = [];
13728
+ for (const name of jsonNames) try {
13729
+ const contents = await readFileImpl(path.join(dir, name), "utf-8");
13730
+ files.push({
13731
+ contents,
13732
+ name
13733
+ });
13734
+ } catch {}
13735
+ return files;
12464
13736
  };
12465
13737
  /**
12466
- * Builds the wire payload with deterministic ordering (nodes by slug, edges by
12467
- * from then to) so re-emission of the same graph is byte-stable regardless of
12468
- * the planner's source ordering the basis for the content-addressed eventId.
13738
+ * How many closeout artifacts one check verifies, newest first. A bound keeps
13739
+ * doctor from turning a long-lived repository's closeout history into a burst
13740
+ * of HQ requests; anything past it downgrades the verdict to `warning` and is
13741
+ * named in the message, because a cap that only shows up in prose reads as
13742
+ * full coverage to every caller that reads the status.
12469
13743
  */
12470
- const buildEpicStructurePayload = (dag) => {
12471
- const nodes = dag.nodes.map((node) => {
12472
- const laneId = node.laneId ?? (node.issue === void 0 ? void 0 : laneIdFor(dag.repo, node.issue));
13744
+ const MAX_VERIFIED_EPICS = 10;
13745
+ /** Every parseable closeout artifact, newest `generatedAt` first. */
13746
+ const findCloseoutArtifacts = async (dir, readFileImpl) => {
13747
+ const files = await readJsonFiles(dir, readFileImpl);
13748
+ const artifacts = [];
13749
+ for (const file of files) try {
13750
+ const parsed = JSON.parse(file.contents);
13751
+ if (typeof parsed.epic !== "string" || typeof parsed.generatedAt !== "string") continue;
13752
+ artifacts.push({
13753
+ epic: parsed.epic,
13754
+ generatedAt: parsed.generatedAt
13755
+ });
13756
+ } catch {}
13757
+ return artifacts.toSorted((left, right) => left.generatedAt < right.generatedAt ? 1 : -1);
13758
+ };
13759
+ const DEFAULT_READBACK_TIMEOUT_MS = 5e3;
13760
+ /**
13761
+ * Reads back whether `epic`'s retro envelope landed in HQ, through the
13762
+ * epic-keyed route #423 provides (`GET /api/ingest/retro-envelope?epic=…`).
13763
+ * Unlike the eventId-keyed receipt route (#260), this never depends on a
13764
+ * locally remembered event id, so it answers for epics closed long before
13765
+ * this session — including every epic closed before this check existed.
13766
+ */
13767
+ const fetchEpicRetroReadback = async (endpointOrigin, epic, credentials, fetchImpl, timeoutMs) => {
13768
+ const url = new URL("/api/ingest/retro-envelope", endpointOrigin);
13769
+ url.searchParams.set("epic", epic);
13770
+ const controller = new AbortController();
13771
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
13772
+ try {
13773
+ const response = await fetchImpl(url.toString(), buildCloudflareAccessRequestInit(credentials, {
13774
+ method: "GET",
13775
+ signal: controller.signal
13776
+ }));
13777
+ if (!response.ok) return {
13778
+ detail: `HTTP ${response.status}`,
13779
+ status: "unreachable"
13780
+ };
12473
13781
  return {
12474
- epicId: dag.epicId,
12475
- ...laneId === void 0 ? {} : { laneId },
12476
- slug: node.slug,
12477
- status: node.status,
12478
- title: node.title
13782
+ readback: await response.json(),
13783
+ status: "ok"
12479
13784
  };
12480
- }).toSorted((a, b) => a.slug.localeCompare(b.slug));
12481
- return {
12482
- edges: dag.edges.map((edge) => ({
12483
- epicId: dag.epicId,
12484
- from: edge.from,
12485
- to: edge.to,
12486
- type: "depends-on"
12487
- })).toSorted((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)),
12488
- epics: [{
12489
- id: dag.epicId,
12490
- name: dag.name,
12491
- repo: dag.repo
12492
- }],
12493
- nodes
12494
- };
12495
- };
12496
- /** Stable, key-sorted JSON for content hashing (values already ordered). */
12497
- const stableStringify = (value) => {
12498
- if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
12499
- if (isPlainObject(value)) return `{${Object.keys(value).toSorted().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
12500
- return JSON.stringify(value ?? null);
13785
+ } catch (error) {
13786
+ return {
13787
+ detail: error instanceof Error ? error.message : String(error),
13788
+ status: "unreachable"
13789
+ };
13790
+ } finally {
13791
+ clearTimeout(timer);
13792
+ }
12501
13793
  };
12502
13794
  /**
12503
- * Deterministic eventId = `epic-structure-<boundary>-<sha256(payload)[0:16]>`.
12504
- * Re-emitting identical graph content yields the same eventId (HQ dedups →
12505
- * 200/duplicate); an amendment changes the content hash a new eventId that
12506
- * supersedes the prior structure wholesale (HQ keeps the latest). This mirrors
12507
- * seed-dev's fixed-eventId idempotency, content-addressed instead of literal.
13795
+ * Reads back each epic in turn and sorts it into confirmed, missing, or
13796
+ * unreachable. A degraded envelope counts as missing: the lookup is by epic,
13797
+ * and either way the epic has no trusted retro record.
12508
13798
  */
12509
- const epicStructureEventId = (boundary, payload) => `epic-structure-${slugify(boundary)}-${createHash("sha256").update(stableStringify(payload)).digest("hex").slice(0, 16)}`;
13799
+ const readBackEachEpic = async (artifacts, endpoint, credentials, dependencies) => {
13800
+ const tally = {
13801
+ confirmed: [],
13802
+ missing: [],
13803
+ unreachable: []
13804
+ };
13805
+ for (const artifact of artifacts) {
13806
+ const result = await fetchEpicRetroReadback(endpoint, artifact.epic, credentials, dependencies.fetch ?? fetch, dependencies.timeoutMs ?? DEFAULT_READBACK_TIMEOUT_MS);
13807
+ if (result.status === "unreachable") tally.unreachable.push(`${artifact.epic} (${result.detail})`);
13808
+ else if (!result.readback.found || result.readback.degraded === true) tally.missing.push(artifact.epic);
13809
+ else tally.confirmed.push(artifact.epic);
13810
+ }
13811
+ return tally;
13812
+ };
12510
13813
  /**
12511
- * Validates and builds the full `epic-structure` v1 ingest event, ready to POST
12512
- * to `/api/ingest`. Throws {@link EpicStructureValidationError} fail-closed.
13814
+ * For each closed epic with a local closeout artifact, confirms its retro
13815
+ * envelope actually reached HQ read through the epic-keyed
13816
+ * route #423 provides, using the same gate-sink service token `hq:flush`
13817
+ * uses.
13818
+ *
13819
+ * Epic-keyed rather than eventId-keyed (#423): `retro_envelopes.epic_ref` is a
13820
+ * durable key that survives the spool entry's own deletion, so this verifies
13821
+ * epics closed at any point in the past — not only ones closed after this
13822
+ * check started running. No local sidecar of any kind is consulted.
13823
+ *
13824
+ * Unreachable HQ (network failure, timeout, a non-2xx response) is a warn:
13825
+ * inconclusive, not proof of absence. A confirmed-missing envelope is red.
13826
+ * Absent local artifacts or unresolvable credentials are each reported
13827
+ * precisely rather than folded into one generic status. The check can only
13828
+ * report `ok` on a positive `found: true` response from HQ — never on the
13829
+ * absence of a negative signal. That rule is why absent local artifacts warn
13830
+ * rather than pass (#437): the artifact directory is worktree-local, so
13831
+ * "nothing to verify" is a statement about this checkout, not about HQ.
13832
+ *
13833
+ * Artifacts are verified newest first, not only the newest one — an older
13834
+ * epic's missing envelope must not be masked by a later successful closeout.
13835
+ * Coverage is bounded at `MAX_VERIFIED_EPICS` (10) so a long closeout history
13836
+ * cannot turn one diagnostic into a burst of HQ requests. When the bound
13837
+ * leaves an artifact unverified the verdict is `warning`, not `ok` with a
13838
+ * footnote (#446): the same rule as absent artifacts, since an unchecked epic
13839
+ * is an absence of evidence too.
13840
+ * Credentials go only to an origin the operator authorized in
13841
+ * `hqAllowedOrigins`, the same refusal `hq:flush` and the emit sink make for
13842
+ * the same token, and that refusal comes before any reference is resolved.
12513
13843
  */
12514
- const buildEpicStructureEvent = (input) => {
12515
- const dag = validateDagDocument(input);
12516
- const payload = buildEpicStructurePayload(dag);
13844
+ async function hqRetroReadbackDoctorCheck(input, dependencies = {}) {
13845
+ const readFileImpl = dependencies.readFile ?? readFile;
13846
+ if (input.endpoint === void 0) return {
13847
+ message: "HQ ingest is disabled (or unconfigured) in this project's profile; nothing to verify remotely.",
13848
+ name: HQ_RETRO_READBACK_CHECK_NAME,
13849
+ status: "ok"
13850
+ };
13851
+ const endpointOrigin = new URL(input.endpoint).origin;
13852
+ if (!(tryUserConfig({ env: input.env })?.config.hqAllowedOrigins ?? []).includes(endpointOrigin)) return {
13853
+ message: `Skipping the HQ retro-envelope read-back: endpoint origin ${endpointOrigin} is not authorized by hqAllowedOrigins in the operator user config, and this check sends the gate-sink service token. Authorize the origin to verify read-back.`,
13854
+ name: HQ_RETRO_READBACK_CHECK_NAME,
13855
+ status: "warning"
13856
+ };
13857
+ const closeoutsDir = input.closeoutsDir ?? path.join(input.cwd, ".factory-memory/closeouts");
13858
+ const artifacts = await findCloseoutArtifacts(closeoutsDir, readFileImpl);
13859
+ if (artifacts.length === 0) return {
13860
+ message: `No local closeout artifacts under ${closeoutsDir}, so HQ read-back could not be verified. This is not a pass: the artifacts are worktree-local, so a fresh checkout has none. Run this check from a worktree that ran \`factory:closeout\`, or verify the epic in HQ directly.`,
13861
+ name: HQ_RETRO_READBACK_CHECK_NAME,
13862
+ status: "warning"
13863
+ };
13864
+ const verified = artifacts.slice(0, MAX_VERIFIED_EPICS);
13865
+ const unverifiedNote = artifacts.length > verified.length ? ` (${artifacts.length - verified.length} older epic(s) not checked; this check verifies the ${MAX_VERIFIED_EPICS} most recent)` : "";
13866
+ const resolution = resolveHqCredentials({
13867
+ env: input.env,
13868
+ ...input.references === void 0 ? {} : { references: input.references },
13869
+ ...dependencies.resolve === void 0 ? {} : { resolve: dependencies.resolve }
13870
+ });
13871
+ if (resolution.status !== "resolved") return {
13872
+ message: `Skipping the HQ retro-envelope read-back for epic ${verified[0]?.epic}: ${describeHqCredentialFailure(resolution)} ${TRUSTED_LOCAL_NOTE}`,
13873
+ name: HQ_RETRO_READBACK_CHECK_NAME,
13874
+ status: "warning"
13875
+ };
13876
+ const { confirmed, missing, unreachable } = await readBackEachEpic(verified, input.endpoint, resolution.credentials, dependencies);
13877
+ if (missing.length > 0) return {
13878
+ message: `Epic(s) ${missing.join(", ")} have no trusted retro record in HQ. Run \`psf hq:flush\` if the envelope is still spooled, or re-run factory:closeout, then check again. An envelope HQ stored as degraded reports the same way: the lookup is by epic, and ingest records the epic reference only for an envelope it could parse.${unverifiedNote}`,
13879
+ name: HQ_RETRO_READBACK_CHECK_NAME,
13880
+ status: "error"
13881
+ };
13882
+ if (unreachable.length > 0) return {
13883
+ message: `HQ was unreachable while verifying epic(s) ${unreachable.join("; ")}. Treating as unverified, not missing.${confirmed.length > 0 ? ` Confirmed present: ${confirmed.join(", ")}.` : ""}${unverifiedNote}`,
13884
+ name: HQ_RETRO_READBACK_CHECK_NAME,
13885
+ status: "warning"
13886
+ };
13887
+ return {
13888
+ message: `Retro envelope(s) for epic(s) ${confirmed.join(", ")} are confirmed present in HQ.${unverifiedNote}`,
13889
+ name: HQ_RETRO_READBACK_CHECK_NAME,
13890
+ status: unverifiedNote === "" ? "ok" : "warning"
13891
+ };
13892
+ }
13893
+ //#endregion
13894
+ //#region src/doctor.ts
13895
+ async function doctorProjectProfile(input = {}) {
13896
+ const { path, profile } = loadProjectProfile(input);
13897
+ const env = input.env ?? process.env;
13898
+ const cwd = input.cwd ?? process.cwd();
13899
+ const userConfig = resolveDoctorUserConfig(env, input.userConfig);
13900
+ const [hqSpoolCheck, hqRetroReadbackCheck] = await Promise.all([hqSpoolDoctorCheck({
13901
+ cwd,
13902
+ env,
13903
+ repository: {
13904
+ owner: profile.repository.owner,
13905
+ repo: profile.repository.name
13906
+ }
13907
+ }, input.hqSpoolDependencies), hqRetroReadbackDoctorCheck({
13908
+ cwd,
13909
+ ...profile.hq?.enabled ? { endpoint: profile.hq.endpoint } : {},
13910
+ env,
13911
+ ...userConfig.loaded?.config.hqIngestCredentials === void 0 ? {} : { references: userConfig.loaded.config.hqIngestCredentials }
13912
+ }, input.hqRetroReadbackDependencies)]);
13913
+ const checks = [
13914
+ ...buildProfileChecks(profile, cwd, {
13915
+ env,
13916
+ userConfig: userConfig.loaded?.config,
13917
+ userConfigCheck: userConfig.check
13918
+ }),
13919
+ hqSpoolCheck,
13920
+ hqRetroReadbackCheck,
13921
+ ...input.preflight ? admissionPreflightChecks({
13922
+ base: input.base,
13923
+ cwd,
13924
+ env,
13925
+ ...userConfig.loaded?.config.hqIngestCredentials === void 0 ? {} : { hqIngestCredentials: userConfig.loaded.config.hqIngestCredentials },
13926
+ profile,
13927
+ profilePath: path
13928
+ }) : []
13929
+ ];
13930
+ return {
13931
+ checks,
13932
+ ok: checks.every((check) => check.status !== "error"),
13933
+ profilePath: path,
13934
+ projectKey: profile.project.key,
13935
+ repository: {
13936
+ defaultBranch: profile.repository.defaultBranch,
13937
+ name: profile.repository.name,
13938
+ owner: profile.repository.owner
13939
+ },
13940
+ schemaVersion: profile.schemaVersion
13941
+ };
13942
+ }
13943
+ function resolveDoctorUserConfig(env, provided) {
13944
+ const configPath = provided?.path ?? defaultUserConfigPath(env);
13945
+ if (provided === void 0 && !existsSync(configPath)) return { check: {
13946
+ message: `User config not found at ${configPath}; no optional operator credentials or HQ origins are configured.`,
13947
+ name: "user-config",
13948
+ status: "warning"
13949
+ } };
13950
+ try {
13951
+ const loaded = provided ?? loadUserConfig({
13952
+ configPath,
13953
+ env
13954
+ });
13955
+ const ignoredDefaultHarness = loaded.ignoredKeys?.includes("defaultHarness") ?? false;
13956
+ return {
13957
+ check: {
13958
+ message: ignoredDefaultHarness ? `defaultHarness is ignored; remove it from ${loaded.path}. The factory does not select an agent runtime.` : `Loaded schema version ${loaded.config.schemaVersion} user config from ${loaded.path}.`,
13959
+ name: "user-config",
13960
+ status: ignoredDefaultHarness ? "warning" : "ok"
13961
+ },
13962
+ loaded
13963
+ };
13964
+ } catch (error) {
13965
+ return { check: {
13966
+ message: error instanceof Error ? error.message : String(error),
13967
+ name: "user-config",
13968
+ status: "error"
13969
+ } };
13970
+ }
13971
+ }
13972
+ function buildProfileChecks(profile, cwd, userConfigInput) {
13973
+ return [
13974
+ {
13975
+ message: `Loaded schema version ${profile.schemaVersion} profile for ${profile.project.key}.`,
13976
+ name: "profile",
13977
+ status: "ok"
13978
+ },
13979
+ {
13980
+ message: `${profile.repository.owner}/${profile.repository.name} uses ${profile.repository.defaultBranch}.`,
13981
+ name: "repository",
13982
+ status: "ok"
13983
+ },
13984
+ {
13985
+ message: `${profile.verification.commands.length} verification commands configured.`,
13986
+ name: "verification",
13987
+ status: "ok"
13988
+ },
13989
+ requiredEnvironmentCheck(profile, userConfigInput.env),
13990
+ {
13991
+ message: `${profile.review.modes.join(", ")} review modes configured with ${profile.review.defaultMaxCycles} cycle cap.${profile.review.standingChecklist && profile.review.standingChecklist.length > 0 ? ` Standing review checklist: ${profile.review.standingChecklist.length} line(s).` : ""}`,
13992
+ name: "review-policy",
13993
+ status: "ok"
13994
+ },
13995
+ reviewLadderCheck(profile),
13996
+ userConfigInput.userConfigCheck,
13997
+ githubAppCheck(userConfigInput.userConfig),
13998
+ gitRemoteCheck(profile, cwd)
13999
+ ];
14000
+ }
14001
+ function requiredEnvironmentCheck(profile, env) {
14002
+ const required = profile.env?.required ?? [];
14003
+ const unset = required.filter((name) => !env[name]);
14004
+ if (unset.length > 0) return {
14005
+ message: `Required environment variables are unset: ${unset.join(", ")}.`,
14006
+ name: "environment",
14007
+ status: "error"
14008
+ };
14009
+ return {
14010
+ message: required.length === 0 ? "No required environment variables declared." : `${required.length} required environment variable name(s) are set.`,
14011
+ name: "environment",
14012
+ status: "ok"
14013
+ };
14014
+ }
14015
+ function githubAppCheck(userConfig) {
14016
+ const app = userConfig?.githubApp;
14017
+ if (!app) return {
14018
+ message: "Patronage Factory GitHub App credentials are not configured; gate publishing will use user-token commit statuses with Details links. Proof bindings cannot publish, so hosted required checks cannot be satisfied by local proof and must go green by their own means.",
14019
+ name: "github-app",
14020
+ status: "warning"
14021
+ };
14022
+ if (!existsSync(app.privateKeyPath)) return {
14023
+ message: `Patronage Factory GitHub App id ${app.appId} is configured, but private key ${app.privateKeyPath} was not found.`,
14024
+ name: "github-app",
14025
+ status: "warning"
14026
+ };
12517
14027
  return {
12518
- eventId: epicStructureEventId(dag.boundary, payload),
12519
- kind: "epic-structure",
12520
- observedAt: input.observedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
12521
- payload,
12522
- schemaVersion: 1
14028
+ message: `Patronage Factory GitHub App id ${app.appId} is configured${app.installationId ? ` for installation ${app.installationId}` : " with repository installation discovery"}.`,
14029
+ name: "github-app",
14030
+ status: "ok"
12523
14031
  };
12524
- };
12525
- /** The prod HQ worker is reachable at `hq.patronage.com` and its frozen
12526
- * `-v1.*.workers.dev` alias. Emitting there without an Access service token is
12527
- * fail-closed. */
12528
- const isProductionHqUrl = (url) => url.includes("hq.patronage.com") || /-v1\.[^/]*\.workers\.dev/iu.test(url);
12529
- /**
12530
- * POSTs the event to HQ ingest. Fail-closed: a production URL without a
12531
- * Cloudflare Access service token is refused before any request; a 401/403 is
12532
- * surfaced with actionable guidance; any non-2xx throws.
12533
- */
12534
- const publishEpicStructure = async (args) => {
12535
- if (!args.accessServiceToken && isProductionHqUrl(args.url)) throw new Error(`epic:publish-structure refusing to publish to production ${args.url} without a Cloudflare Access service token: set CF-Access-Client-Id and CF-Access-Client-Secret`);
12536
- const doFetch = args.fetchImpl ?? globalThis.fetch;
12537
- const requestInit = args.accessServiceToken ? buildCloudflareAccessRequestInit(args.accessServiceToken, {
12538
- body: JSON.stringify(args.event),
12539
- headers: { "content-type": "application/json" },
12540
- method: "POST"
12541
- }) : {
12542
- body: JSON.stringify(args.event),
12543
- headers: { "content-type": "application/json" },
12544
- method: "POST"
14032
+ }
14033
+ function reviewLadderCheck(profile) {
14034
+ const policy = resolveReviewLadderPolicy(profile);
14035
+ return {
14036
+ message: `Review ladder ${profile.review.ladder ? "configured" : "defaulted"}: gate cap ${policy.gate.cap}.`,
14037
+ name: "review-ladder",
14038
+ status: "ok"
14039
+ };
14040
+ }
14041
+ function gitRemoteCheck(profile, cwd) {
14042
+ const origin = readGitOrigin(cwd);
14043
+ if (!origin) return {
14044
+ message: "No git origin remote was available for this working directory.",
14045
+ name: "git-origin",
14046
+ status: "warning"
14047
+ };
14048
+ if (repositoryUrlMatches(origin, profile)) return {
14049
+ message: `Git origin matches ${profile.repository.owner}/${profile.repository.name}.`,
14050
+ name: "git-origin",
14051
+ status: "ok"
12545
14052
  };
12546
- let response;
12547
- try {
12548
- const normalizedUrl = args.url.replace(/\/+$/u, "");
12549
- response = await doFetch(normalizedUrl.endsWith("/api/ingest") ? normalizedUrl : `${normalizedUrl}/api/ingest`, requestInit);
12550
- } catch {
12551
- throw new Error(`epic:publish-structure could not reach HQ ingest at ${args.url}: request failed. Check the endpoint and network; redirects are refused to protect Cloudflare Access credentials.`);
12552
- }
12553
- const text = await response.text();
12554
- if (response.status === 401 || response.status === 403) throw new Error(`epic:publish-structure rejected by Cloudflare Access or HQ ingest (${response.status}): set valid CF-Access-Client-Id and CF-Access-Client-Secret service-token inputs. Response: ${text}`);
12555
- if (response.status < 200 || response.status >= 300) throw new Error(`epic:publish-structure failed: HQ ingest returned ${response.status} ${text}`);
12556
- let duplicate = false;
12557
- try {
12558
- duplicate = JSON.parse(text).duplicate === true;
12559
- } catch {
12560
- duplicate = response.status === 200;
12561
- }
12562
14053
  return {
12563
- duplicate,
12564
- eventId: args.event.eventId,
12565
- status: response.status
14054
+ message: `Git origin ${origin} does not match ${profile.repository.owner}/${profile.repository.name}.`,
14055
+ name: "git-origin",
14056
+ status: "warning"
12566
14057
  };
12567
- };
12568
- //#endregion
12569
- //#region src/commands/epic-publish-structure.ts
12570
- const STDIN = "-";
12571
- const readSource = (source) => {
12572
- const fd = source === STDIN ? 0 : source;
12573
- try {
12574
- return readFileSync(fd, "utf-8");
12575
- } catch (error) {
12576
- const cause = error instanceof Error ? error.message : String(error);
12577
- throw new Error(`epic:publish-structure could not read ${source === STDIN ? "stdin" : source} (${cause})`, { cause: error });
12578
- }
12579
- };
12580
- const parseDocument = (raw, source) => {
12581
- if (raw.trim() === "") throw new Error(`epic:publish-structure received empty input from ${source === STDIN ? "stdin" : source}`);
14058
+ }
14059
+ function readGitOrigin(cwd) {
12582
14060
  try {
12583
- return parse(raw);
12584
- } catch (error) {
12585
- const cause = error instanceof Error ? error.message : String(error);
12586
- throw new Error(`epic:publish-structure could not parse YAML: ${cause}`, { cause: error });
14061
+ return execFileSync("git", [
14062
+ "remote",
14063
+ "get-url",
14064
+ "origin"
14065
+ ], {
14066
+ cwd,
14067
+ encoding: "utf-8",
14068
+ stdio: [
14069
+ "ignore",
14070
+ "pipe",
14071
+ "ignore"
14072
+ ]
14073
+ }).trim();
14074
+ } catch {
14075
+ return null;
12587
14076
  }
12588
- };
12589
- const buildEvent = (source, options) => {
12590
- return buildEpicStructureEvent({
12591
- document: parseDocument(readSource(source), source),
12592
- ...options.boundary ? { boundary: options.boundary } : {},
12593
- ...options.name ? { name: options.name } : {},
12594
- ...options.repo ? { repo: options.repo } : {}
12595
- });
12596
- };
12597
- function createEpicPublishStructureCommand(output, deps = {}) {
12598
- const env = deps.env ?? process.env;
12599
- return new Command("epic:publish-structure").description("Emit-only: read a planner's ephemeral dag.yml (or - for stdin), validate the graph fail-closed (YAML parses, required fields, edges reference known slugs, acyclic, issue numbers on every non-external node), and POST an epic-structure v1 event to HQ ingest. Idempotent by a content-addressed eventId (re-emission of an amendment supersedes the prior structure). Production auth uses the CF-Access-Client-Id and CF-Access-Client-Secret environment inputs. URL: --url or HQ_INGEST_URL. The dag.yml schema is documented with the epic skill (#137).").argument("[source]", "path to dag.yml, or - for stdin", STDIN).option("--url <url>", "HQ ingest base URL (default: HQ_INGEST_URL env). The command POSTs to <url>/api/ingest").option("--boundary <slug>", "boundary slug for the eventId namespace (default: dag `boundary:`, else `epic-<id>`)").option("--repo <name>", "repository name for the epic record and lane-id derivation (default: dag `repo:`)").option("--name <title>", "epic display name (default: dag `name:`)").option("--dry-run", "validate and build the event, print it, and exit without POSTing").option("--json", "print the built event / publish result as JSON").action(async (source, options) => {
12600
- const event = buildEvent(source, options);
12601
- if (options.dryRun) {
12602
- output.stdout.write(options.json ? `${JSON.stringify(event, null, 2)}\n` : `epic:publish-structure OK (dry-run) — eventId ${event.eventId}, ${event.payload.nodes.length} node(s), ${event.payload.edges.length} edge(s)\n`);
12603
- return;
12604
- }
12605
- const url = options.url ?? env.HQ_INGEST_URL;
12606
- if (!url) throw new Error("epic:publish-structure requires an HQ ingest URL: pass --url or set HQ_INGEST_URL");
12607
- const clientId = env[CF_ACCESS_CLIENT_ID_ENV];
12608
- const clientSecret = env[CF_ACCESS_CLIENT_SECRET_ENV];
12609
- const result = await publishEpicStructure({
12610
- ...clientId && clientSecret ? { accessServiceToken: {
12611
- clientId,
12612
- clientSecret
12613
- } } : {},
12614
- event,
12615
- fetchImpl: deps.fetchImpl,
12616
- url
14077
+ }
14078
+ function repositoryUrlMatches(origin, profile) {
14079
+ const normalized = origin.replace(/\.git$/u, "");
14080
+ const repoPath = `${profile.repository.owner}/${profile.repository.name}`;
14081
+ return normalized.endsWith(`github.com:${repoPath}`) || normalized.endsWith(`github.com/${repoPath}`) || normalized.endsWith(repoPath);
14082
+ }
14083
+ //#endregion
14084
+ //#region src/commands/doctor.ts
14085
+ function createDoctorCommand(output) {
14086
+ return 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", ".").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); 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) => {
14087
+ const report = await doctorProjectProfile({
14088
+ base: options.base,
14089
+ cwd: resolveCwdOption(options.cwd),
14090
+ preflight: options.preflight,
14091
+ profilePath: options.profile
12617
14092
  });
12618
- output.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : `epic:publish-structure ${result.duplicate ? "no-op (duplicate)" : "accepted"} — eventId ${result.eventId} [HTTP ${result.status}]\n`);
14093
+ if (options.json) output.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
14094
+ else output.stdout.write(formatHumanReport(report));
14095
+ if (!report.ok) process.exitCode = 1;
12619
14096
  });
12620
14097
  }
14098
+ function formatHumanReport(report) {
14099
+ return [
14100
+ `Profile: ${report.projectKey} (schema ${report.schemaVersion})`,
14101
+ `Repository: ${report.repository.owner}/${report.repository.name} default ${report.repository.defaultBranch}`,
14102
+ `Path: ${report.profilePath}`,
14103
+ "",
14104
+ ...report.checks.map((check) => `[${check.status}] ${check.name}: ${check.message}`),
14105
+ ""
14106
+ ].join("\n");
14107
+ }
12621
14108
  //#endregion
12622
14109
  //#region src/commands/evidence-emit.ts
12623
14110
  const readStdin = () => {
@@ -12790,6 +14277,168 @@ function createGuardCommand(output) {
12790
14277
  return guard;
12791
14278
  }
12792
14279
  //#endregion
14280
+ //#region src/hq-flush.ts
14281
+ /**
14282
+ * Spools holding this repository's evidence under an earlier key of its own
14283
+ * (#420) — a question the drain itself cannot answer, because it resolves
14284
+ * exactly one key. Another repository's spool under the shared root is not
14285
+ * swept: it is that repository's ordinary work, not this one's orphan (#446).
14286
+ * An explicit `--dir` run is already the operator pointing at one
14287
+ * location on purpose, so it sweeps nothing. No credential is involved: this
14288
+ * is a directory listing.
14289
+ */
14290
+ const orphanSpools = async (args, repository, env, sweep) => {
14291
+ if (args.dir && args.dir.length > 0) return [];
14292
+ return (await sweep({ repository }, { env })).orphans;
14293
+ };
14294
+ /**
14295
+ * Loads the profile, and — when it will not load — says how to drain anyway
14296
+ * (#421).
14297
+ *
14298
+ * A worktree on an old branch carries that branch's committed profile, which
14299
+ * may predate the current schema. Validation fails closed before the spool is
14300
+ * ever read, so the evidence sitting in that worktree is undrainable by the
14301
+ * command documented to drain it, at exactly the moment the worktree is most
14302
+ * likely to be deleted. Failing closed is correct and stays; the profile is
14303
+ * this command's source for the repository identity and the authorized HQ
14304
+ * origin, and neither may come from an unvalidated file.
14305
+ *
14306
+ * What was missing is the remedy. `--profile` already solves this — point it
14307
+ * at a checkout whose profile is current — and the failure text is where an
14308
+ * operator holding stranded evidence is standing when they need to know that.
14309
+ *
14310
+ * The remedy is attached to every load failure, not only the stale-schema one,
14311
+ * and the copy stays failure-agnostic for that reason: a missing file and
14312
+ * malformed JSON strand evidence the same way and take the same way out.
14313
+ * Narrating the old-branch story on all of them would explain the wrong
14314
+ * problem to two thirds of the operators who read it.
14315
+ */
14316
+ const loadFlushProfile = (args) => {
14317
+ try {
14318
+ return loadProjectProfile({
14319
+ cwd: args.cwd,
14320
+ profilePath: args.profilePath
14321
+ });
14322
+ } catch (error) {
14323
+ const message = error instanceof Error ? error.message : String(error);
14324
+ throw new Error(`${message}\n\nhq:flush reads the profile only to learn this repository's identity and its authorized HQ origin, and it will not take either from a profile it cannot load. Evidence spooled here is still drainable: rerun with --profile pointing at a checkout whose profile loads, for example \`psf hq:flush --cwd ${args.cwd} --profile <working-checkout>/software-factory.profile.json\`.`, { cause: error });
14325
+ }
14326
+ };
14327
+ /** Drains this repository's spooled HQ evidence and reports every event. */
14328
+ async function runHqFlush(args, dependencies = {}) {
14329
+ const env = dependencies.env ?? process.env;
14330
+ const { profile } = loadFlushProfile(args);
14331
+ if (!profile.hq?.enabled) return {
14332
+ orphans: [],
14333
+ reason: "the project profile does not enable HQ ingest",
14334
+ retained: 0,
14335
+ status: "skipped"
14336
+ };
14337
+ const repository = {
14338
+ owner: profile.repository.owner,
14339
+ repo: profile.repository.name
14340
+ };
14341
+ const explicitDirectories = args.dir && args.dir.length > 0 ? { explicitDirectories: args.dir } : {};
14342
+ const pending = await (dependencies.countSpool ?? countHqSpoolWork)({
14343
+ cwd: args.cwd,
14344
+ ...explicitDirectories,
14345
+ repository
14346
+ }, { env });
14347
+ const retained = pending.pending + pending.unlistable;
14348
+ const orphans = await orphanSpools(args, repository, env, dependencies.sweepOrphans ?? sweepHqSpoolOrphans);
14349
+ const userConfig = tryUserConfig({ env });
14350
+ const allowedOrigins = userConfig?.config.hqAllowedOrigins ?? [];
14351
+ const endpointOrigin = new URL(profile.hq.endpoint).origin;
14352
+ if (!allowedOrigins.includes(endpointOrigin)) return {
14353
+ orphans,
14354
+ reason: `endpoint origin ${endpointOrigin} is not authorized by hqAllowedOrigins in the operator user config`,
14355
+ retained,
14356
+ status: "skipped"
14357
+ };
14358
+ if (retained === 0) return {
14359
+ delivered: 0,
14360
+ duplicate: 0,
14361
+ endpoint: endpointOrigin,
14362
+ incomplete: false,
14363
+ orphans,
14364
+ outcomes: [],
14365
+ rejected: 0,
14366
+ remaining: 0,
14367
+ spools: pending.spools,
14368
+ status: "flushed",
14369
+ undeliverable: 0,
14370
+ unreachable: 0,
14371
+ unreachableFiles: 0
14372
+ };
14373
+ const resolution = resolveHqCredentials({
14374
+ env,
14375
+ ...userConfig?.config.hqIngestCredentials === void 0 ? {} : { references: userConfig.config.hqIngestCredentials },
14376
+ ...dependencies.resolveSecret === void 0 ? {} : { resolve: dependencies.resolveSecret }
14377
+ });
14378
+ if (resolution.status !== "resolved") return {
14379
+ orphans,
14380
+ reason: `${describeHqCredentialFailure(resolution)} Nothing was drained; ${retained} event(s) remain spooled.`,
14381
+ retained,
14382
+ status: "skipped"
14383
+ };
14384
+ return {
14385
+ ...await (dependencies.flush ?? flushHqSpool)({
14386
+ clientId: resolution.credentials.clientId,
14387
+ clientSecret: resolution.credentials.clientSecret,
14388
+ cwd: args.cwd,
14389
+ endpoint: profile.hq.endpoint,
14390
+ ...explicitDirectories,
14391
+ repository
14392
+ }, {
14393
+ env,
14394
+ ...dependencies.fetch ? { fetch: dependencies.fetch } : {}
14395
+ }),
14396
+ endpoint: endpointOrigin,
14397
+ orphans,
14398
+ status: "flushed"
14399
+ };
14400
+ }
14401
+ const outcomeLine = (outcome) => `[${outcome.status}] ${outcome.kind} ${outcome.eventId}${outcome.detail === void 0 ? "" : ` — ${outcome.detail}`}`;
14402
+ const orphanLine = (orphan) => `ORPHAN SPOOL: ${orphan.directory} — ${orphan.unlistable > 0 && orphan.pending === 0 ? "could not be inspected" : `${orphan.pending} event(s)`}${orphan.oldestQueuedAt === void 0 ? "" : `, oldest ${orphan.oldestQueuedAt}`}`;
14403
+ /**
14404
+ * Orphan lines, plus the recovery instruction. This run did not drain these
14405
+ * locations and does not claim to have: the operator decides, with `--dir`.
14406
+ */
14407
+ const orphanLines = (orphans) => orphans.length === 0 ? [] : [...orphans.map(orphanLine), `${orphans.length} spool location(s) hold this repository's evidence under an earlier key it no longer resolves to; drain each with \`psf hq:flush --dir <path>\`.`];
14408
+ /** Human rendering. Nothing here can contain a credential value. */
14409
+ function renderHqFlush(result) {
14410
+ if (result.status === "skipped") return `${[`hq:flush skipped: ${result.reason}`, ...orphanLines(result.orphans)].join("\n")}\n`;
14411
+ return `${[
14412
+ `hq:flush ${result.endpoint}`,
14413
+ ...result.spools.length === 0 ? ["no spool directory found"] : result.spools.map((spool) => `spool: ${spool}`),
14414
+ ...result.outcomes.map(outcomeLine),
14415
+ `delivered ${result.delivered}, duplicate ${result.duplicate}, rejected ${result.rejected}, undeliverable ${result.undeliverable}, unreachable ${result.unreachable}, migrated ${result.outcomes.filter((outcome) => outcome.status === "migrated").length}`,
14416
+ ...result.undeliverable > 0 ? [`${result.undeliverable} event(s) can never be delivered and were dispositioned in place, renamed with \`.undeliverable\` and left readable; they no longer count as work waiting.`] : [],
14417
+ result.incomplete ? `INCOMPLETE: ${result.remaining} event(s) still spooled; the drain did not finish. Run hq:flush again.` : `spool drained: ${result.remaining} event(s) remain (rejections stay until HQ accepts them)`,
14418
+ ...orphanLines(result.orphans)
14419
+ ].join("\n")}\n`;
14420
+ }
14421
+ const hqFlushExitCode = (result) => {
14422
+ if (result.status === "skipped") return result.retained > 0 ? 2 : 0;
14423
+ if (result.unreachableFiles > 0) return 1;
14424
+ return result.incomplete ? 2 : 0;
14425
+ };
14426
+ //#endregion
14427
+ //#region src/commands/hq-flush.ts
14428
+ function createHqFlushCommand(output, action = runHqFlush) {
14429
+ return new Command("hq:flush").description("Drain this repository's spooled HQ evidence and report every event; exits 1 when transport failed and 2 when the drain did not finish").option("--cwd <path>", "working directory to evaluate", ".").option("--dir <path>", "drain an explicit spool directory instead of the default locations; repeatable", (value, previous = []) => [...previous, value]).option("--json", "print the flush result as JSON").option("--profile <path>", "path to the project profile JSON file").action(async (options) => {
14430
+ const result = await action({
14431
+ cwd: resolveCwdOption(options.cwd),
14432
+ ...options.dir ? { dir: options.dir } : {},
14433
+ json: Boolean(options.json),
14434
+ ...options.profile ? { profilePath: options.profile } : {}
14435
+ });
14436
+ output.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : renderHqFlush(result));
14437
+ const exitCode = hqFlushExitCode(result);
14438
+ if (exitCode !== 0) process.exitCode = exitCode;
14439
+ });
14440
+ }
14441
+ //#endregion
12793
14442
  //#region src/follow-up.ts
12794
14443
  const FACTORY_CLI_EXECUTABLE = "patronage-factory";
12795
14444
  const FactoryCliInvocationSchema = z.object({
@@ -13323,13 +14972,18 @@ function defaultPrReadyGit() {
13323
14972
  stablePatchId
13324
14973
  };
13325
14974
  }
14975
+ /**
14976
+ * The base is the live PR's own base SHA, read per attempt (#365). An explicit
14977
+ * `--base` is an assertion to check, never the source of truth: it is refused
14978
+ * when it disagrees, so a stale value cannot silently redirect the diff.
14979
+ */
13326
14980
  function authoritativePrBase({ args, cwd, git, pr }) {
13327
14981
  if (args.base.length === 0) {
13328
- if (!git.canResolveCommit(cwd, pr.baseRefOid)) throw new Error(`The authoritative PR base ${pr.baseRefOid} is not available locally; fetch the PR base and rerun pr:ready.`);
14982
+ if (!git.canResolveCommit(cwd, pr.baseRefOid)) throw new Error(`The authoritative PR base ${pr.baseRefOid} is not available locally; fetch the PR base and rerun.`);
13329
14983
  return pr.baseRefOid;
13330
14984
  }
13331
14985
  const requestedBaseSha = git.resolveCommitSha(cwd, args.base);
13332
- if (!requestedBaseSha || !sameHeadSha(requestedBaseSha, pr.baseRefOid)) throw new Error(`pr:ready --base ${args.base} does not resolve to the authoritative PR base ${pr.baseRefOid}; fetch the base and rerun with --base ${pr.baseRefOid}.`);
14986
+ if (!requestedBaseSha || !sameHeadSha(requestedBaseSha, pr.baseRefOid)) throw new Error(`--base ${args.base} does not resolve to the authoritative PR base ${pr.baseRefOid}; omit --base to derive it, or fetch the base and rerun.`);
13333
14987
  return pr.baseRefOid;
13334
14988
  }
13335
14989
  const MERGEABLE_UNKNOWN_POLL_MS = 2e3;
@@ -13362,6 +15016,7 @@ function defaultPrReadyGithub() {
13362
15016
  };
13363
15017
  }
13364
15018
  const prReadyProofSchema = z.object({
15019
+ blockedReasons: blockedReasonsSchema.optional(),
13365
15020
  blockingReasons: z.array(z.string()),
13366
15021
  command: z.literal("patronage-factory pr:ready"),
13367
15022
  followUp: FollowUpActionSchema.optional(),
@@ -13371,12 +15026,17 @@ const prReadyProofSchema = z.object({
13371
15026
  profilePath: z.string().min(1).optional(),
13372
15027
  repairs: z.array(readinessRepairSchema).default([]),
13373
15028
  repository: z.string().regex(/^[^/\s]+\/[^/\s]+$/u).optional(),
13374
- schemaVersion: z.literal(1),
15029
+ schemaVersion: z.literal(2),
13375
15030
  status: z.enum([
13376
15031
  "ready",
13377
15032
  "blocked",
13378
15033
  "slice-ready/not-final"
13379
15034
  ])
15035
+ }).superRefine((proof, context) => {
15036
+ for (const issue of blockedReasonIssues({
15037
+ ...proof,
15038
+ finalReviewPoint: proof.ledger.finalReviewPoint
15039
+ })) context.addIssue(issue);
13380
15040
  });
13381
15041
  function validatePrReadyProof(value) {
13382
15042
  return prReadyProofSchema.parse(value);
@@ -13388,7 +15048,7 @@ const PR_READY_PROOF_DESCRIPTOR = {
13388
15048
  defaultPath: DEFAULT_PR_READY_PROOF_PATH,
13389
15049
  label: "pr:ready proof",
13390
15050
  parse: validatePrReadyProof,
13391
- schemaVersion: 1
15051
+ schemaVersion: 2
13392
15052
  };
13393
15053
  const PR_VIEW_JSON_FIELDS = [
13394
15054
  "baseRefName",
@@ -13491,7 +15151,7 @@ function readOptionalReviewProof(cwd, proofPath) {
13491
15151
  return readOptionalProofFor(PR_REVIEW_PROOF_DESCRIPTOR, cwd, proofPath);
13492
15152
  }
13493
15153
  function writeProof(proof, output) {
13494
- writeProofJson(proof, output);
15154
+ writeProofJson(validatePrReadyProof(proof), output);
13495
15155
  }
13496
15156
  const reportProof = (proof, output, json, report) => {
13497
15157
  if (!report) return;
@@ -13647,7 +15307,7 @@ async function runPrReady(args, dependencies = {}) {
13647
15307
  owner: repository.owner,
13648
15308
  pr: args.pr,
13649
15309
  proof: {
13650
- schemaVersion: 1,
15310
+ schemaVersion: 2,
13651
15311
  status: "evaluating"
13652
15312
  },
13653
15313
  repo: repository.name,
@@ -13801,6 +15461,7 @@ async function runPrReady(args, dependencies = {}) {
13801
15461
  ]);
13802
15462
  else if (!pendingExternalChecksOnly) followUp = prVerifyFollowUp(authoringSession);
13803
15463
  const proof = {
15464
+ ...evaluation.blockedReasons.length > 0 ? { blockedReasons: evaluation.blockedReasons } : {},
13804
15465
  blockingReasons: evaluation.blockingReasons,
13805
15466
  command: "patronage-factory pr:ready",
13806
15467
  ...followUp ? { followUp } : {},
@@ -13810,7 +15471,7 @@ async function runPrReady(args, dependencies = {}) {
13810
15471
  profilePath: committedProfilePath,
13811
15472
  repairs: evaluation.repairs,
13812
15473
  repository: checkoutRepositorySlug,
13813
- schemaVersion: 1,
15474
+ schemaVersion: 2,
13814
15475
  status: evaluation.status
13815
15476
  };
13816
15477
  writeProof(proof, output);
@@ -13848,7 +15509,7 @@ async function runPrReady(args, dependencies = {}) {
13848
15509
  pr: args.pr,
13849
15510
  proof: {
13850
15511
  blockingReasons: [message],
13851
- schemaVersion: 1,
15512
+ schemaVersion: 2,
13852
15513
  status: "blocked"
13853
15514
  },
13854
15515
  repo: repository.name,
@@ -14005,7 +15666,7 @@ function requiredCheckDemandGroups({ committedProfile, liveLabels, readyProof })
14005
15666
  if (!committedProfile) return [{ reasons: ["Could not read the project profile from the committed PR candidate; merge-time requiredChecks policy must come from committed refs. Fetch the PR head, then re-run pr:merge-check."] }];
14006
15667
  const recordedChecks = ledger?.externalChecks ?? [];
14007
15668
  return (committedProfile.requiredChecks ?? []).map((requiredCheck) => ({
14008
- demand: `required-check:${requiredCheck.name}`,
15669
+ demand: requiredCheckDemand(requiredCheck.name),
14009
15670
  reasons: requiredCheckDriftReasons({
14010
15671
  classification: ledger?.classification,
14011
15672
  liveLabels,
@@ -14131,7 +15792,7 @@ function runPrMergeCheck(args, dependencies = {}) {
14131
15792
  epic: args.epic
14132
15793
  };
14133
15794
  rungDemandGroups.push({
14134
- demand: `review-rung:${boundary.review}`,
15795
+ demand: reviewRungDemand(boundary.review),
14135
15796
  reasons: demandedRungSatisfactionReasons({
14136
15797
  demandedRung: boundary.review,
14137
15798
  liveHumanReviews: boundary.review === "human" ? fetchReviews({
@@ -14158,7 +15819,7 @@ function runPrMergeCheck(args, dependencies = {}) {
14158
15819
  repository
14159
15820
  }) },
14160
15821
  {
14161
- demand: "merge-freeze",
15822
+ demand: DEMAND_KEYS.mergeFreeze,
14162
15823
  reasons: freezeReasons
14163
15824
  },
14164
15825
  ...requiredCheckDemandGroups({
@@ -15127,11 +16788,25 @@ async function runPrPublish(args, dependencies = {}) {
15127
16788
  ...defaultPrReadyGithub(),
15128
16789
  editPullRequestBody
15129
16790
  };
16791
+ const fetchPr = () => github.fetchPullRequest({
16792
+ owner: profile.repository.owner,
16793
+ pr: prNumber,
16794
+ repo: profile.repository.name
16795
+ });
16796
+ const publishArgs = {
16797
+ ...args,
16798
+ base: authoritativePrBase({
16799
+ args,
16800
+ cwd,
16801
+ git: dependencies.git ?? defaultPrReadyGit(),
16802
+ pr: fetchPr()
16803
+ })
16804
+ };
15130
16805
  const verifyProofPath = path.resolve(cwd, args.verifyProof ?? ".factory-memory/pr-verify.json");
15131
16806
  const reviewProofPath = path.resolve(cwd, args.reviewProof ?? ".factory-memory/pr-review.json");
15132
16807
  const headSha = (dependencies.git?.currentHeadSha ?? currentHeadSha)(cwd);
15133
16808
  const verified = await ensureVerifyProof({
15134
- args,
16809
+ args: publishArgs,
15135
16810
  cwd,
15136
16811
  dependencies,
15137
16812
  headSha,
@@ -15146,9 +16821,9 @@ async function runPrPublish(args, dependencies = {}) {
15146
16821
  verified
15147
16822
  });
15148
16823
  const reviewProof = await ensureReviewProof({
15149
- args,
16824
+ args: publishArgs,
15150
16825
  currentReviewIdentity: currentReviewIdentityFor({
15151
- args,
16826
+ args: publishArgs,
15152
16827
  cwd,
15153
16828
  dependencies,
15154
16829
  headSha,
@@ -15159,11 +16834,7 @@ async function runPrPublish(args, dependencies = {}) {
15159
16834
  profile,
15160
16835
  reviewProofPath
15161
16836
  });
15162
- const pr = github.fetchPullRequest({
15163
- owner: profile.repository.owner,
15164
- pr: prNumber,
15165
- repo: profile.repository.name
15166
- });
16837
+ const pr = fetchPr();
15167
16838
  const parts = renderPrBodySectionParts({
15168
16839
  reviewProof,
15169
16840
  verifyProof: verifyProof && verifyProofPassed(verifyProof) ? verifyProof : void 0
@@ -15234,15 +16905,43 @@ async function runPrPublish(args, dependencies = {}) {
15234
16905
  };
15235
16906
  if (args.json) console.log(JSON.stringify(result, null, 2));
15236
16907
  else console.log(renderPublishSummary(result));
16908
+ await deliverSpooledEvidence({
16909
+ awaitPending: dependencies.awaitPendingIngest,
16910
+ cwd,
16911
+ flush: dependencies.flushHqSpool,
16912
+ profilePath: args.profilePath
16913
+ });
15237
16914
  return result;
15238
16915
  }
16916
+ /**
16917
+ * Deliver whatever HQ evidence this lane spooled, at the moment the operator
16918
+ * is handing the work off (#390). Publish resolves credentials the same
16919
+ * explicit way `hq:flush` does, so a lane with no credentials in its
16920
+ * environment still gets its proofs to HQ.
16921
+ *
16922
+ * Bounded, reported, never gating: every failure mode here is a stderr line.
16923
+ * HQ is a view, so no delivery outcome may reach publish's verdict or exit
16924
+ * status.
16925
+ */
16926
+ async function deliverSpooledEvidence({ awaitPending, cwd, flush, profilePath }) {
16927
+ try {
16928
+ await (awaitPending ?? awaitPendingHqIngest)();
16929
+ const result = await (flush ?? ((args) => runHqFlush({
16930
+ cwd: args.cwd,
16931
+ ...profilePath === void 0 ? {} : { profilePath }
16932
+ })))({ cwd });
16933
+ if (result.status === "skipped" || result.outcomes.length > 0 || result.orphans.length > 0 || hqFlushExitCode(result) !== 0) process.stderr.write(renderHqFlush(result));
16934
+ } catch (error) {
16935
+ process.stderr.write(`hq:flush advisory drain failed: ${error instanceof Error ? error.message : String(error)}\n`);
16936
+ }
16937
+ }
15239
16938
  //#endregion
15240
16939
  //#region src/commands/pr-publish.ts
15241
16940
  function createPrPublishCommand(_output, action = runPrPublish) {
15242
- return new Command("pr:publish").description("Compose supplied proof into a ready-for-human handoff; never launches review").option("--pr <number>", "pull request number", positiveInteger("--pr")).option("--base <ref>", "base branch or ref", "origin/main").option("--authoring-session <id>", "known authoring session identity for verification retries").option("--cwd <path>", "working directory to evaluate", ".").option("--epic <number>", "epic issue containing the factory-boundary manifest; the composed readiness evaluation then applies the wave-demanded review rung (#351)", positiveInteger("--epic")).option("--findings <path>", "clean-session findings to validate when no current review proof is available").option("--json", "print the publish result as JSON").option("--output <path>", "write readiness proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--review-proof <path>", "pr:review proof JSON path").option("--verify-proof <path>", "pr:verify proof JSON path").action(async (options) => {
16941
+ return new Command("pr:publish").description("Compose supplied proof into a ready-for-human handoff; never launches review").option("--pr <number>", "pull request number", positiveInteger("--pr")).option("--base <ref>", "explicit base branch or ref override").option("--authoring-session <id>", "known authoring session identity for verification retries").option("--cwd <path>", "working directory to evaluate", ".").option("--epic <number>", "epic issue containing the factory-boundary manifest; the composed readiness evaluation then applies the wave-demanded review rung (#351)", positiveInteger("--epic")).option("--findings <path>", "clean-session findings to validate when no current review proof is available").option("--json", "print the publish result as JSON").option("--output <path>", "write readiness proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--review-proof <path>", "pr:review proof JSON path").option("--verify-proof <path>", "pr:verify proof JSON path").action(async (options) => {
15243
16942
  await action({
15244
16943
  authoringSessionIds: options.authoringSession ? [options.authoringSession] : void 0,
15245
- base: options.base,
16944
+ base: options.base ?? "",
15246
16945
  cwd: resolveCwdOption(options.cwd),
15247
16946
  epic: options.epic,
15248
16947
  findings: options.findings,
@@ -15619,6 +17318,7 @@ function createProgram(options = {}) {
15619
17318
  program.addCommand(createConfigCommand(output));
15620
17319
  program.addCommand(createGuardCommand(output));
15621
17320
  program.addCommand(createEvidenceEmitCommand(output));
17321
+ program.addCommand(createHqFlushCommand(output));
15622
17322
  program.addCommand(createBoundaryCheckCommand(output, options.actions?.boundaryCheck));
15623
17323
  program.addCommand(createPrVerifyCommand(output));
15624
17324
  program.addCommand(createCloseoutCommand(output));