@patronage/software-factory 1.0.0-alpha.1 → 1.0.0-alpha.10

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
@@ -1,23 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  import { t as __exportAll } from "./chunk-pbuEa-1d.js";
3
- import { appendFileSync, constants, cpSync, createReadStream, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { appendFileSync, constants, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { Command, InvalidArgumentError } from "commander";
6
6
  import path from "node:path";
7
- import crypto, { createHash, createSign, randomUUID } from "node:crypto";
7
+ import crypto, { createHash, randomUUID } from "node:crypto";
8
8
  import { z } from "zod";
9
9
  import { execFileSync, spawnSync } from "node:child_process";
10
10
  import { link, lstat, mkdir, open, readFile, readdir, realpath, rename, stat, unlink } from "node:fs/promises";
11
- import os, { homedir } from "node:os";
12
- import { createInterface } from "node:readline";
11
+ import os, { homedir, tmpdir } from "node:os";
13
12
  import { setImmediate } from "node:timers";
14
13
  import { setImmediate as setImmediate$1, setTimeout as setTimeout$1 } from "node:timers/promises";
15
14
  import { Worker } from "node:worker_threads";
16
15
  import picomatch from "picomatch";
17
16
  import { parse } from "yaml";
18
17
  import { promisify } from "node:util";
18
+ import { FACTORY_PROOF_GATE_STEP_NAME, GitHubApiError, mintInstallationToken, productionImpactTargetOutput, readVitestProfileDocument } from "@patronage/factory-ci";
19
19
  //#region package.json
20
- var version = "1.0.0-alpha.1";
20
+ var version = "1.0.0-alpha.10";
21
21
  //#endregion
22
22
  //#region src/review-rungs.ts
23
23
  const EVIDENCE_REVIEW_RUNGS$1 = [
@@ -44,6 +44,21 @@ const FACTORY_BOUNDARY_FENCE_LANG = "factory-boundary";
44
44
  const BOUNDARY_CLOSEOUT_DEFAULT_RUNG = "oracle";
45
45
  const rungMeetsMinimum = (rung, minimum) => RUNG_ORDER[rung] >= RUNG_ORDER[minimum];
46
46
  const rungSchema = z.enum(EVIDENCE_REVIEW_RUNGS$1);
47
+ const BOUNDARY_TOPOLOGIES = [
48
+ "each-to-main",
49
+ "flagged",
50
+ "stacked",
51
+ "each-to-epic"
52
+ ];
53
+ const isValidEpicIntegrationBranch = (branch) => {
54
+ if (!branch.startsWith("epic/") || branch.length === 5) return false;
55
+ if (branch.includes("//") || branch.includes("..") || branch.includes("@{") || branch.endsWith("/")) return false;
56
+ for (const character of branch) {
57
+ const codePoint = character.codePointAt(0);
58
+ if (codePoint === void 0 || codePoint <= 32 || codePoint === 127 || "~^:?*[\\".includes(character)) return false;
59
+ }
60
+ return branch.split("/").every((component) => component.length > 0 && !component.startsWith(".") && !component.endsWith(".") && !component.endsWith(".lock"));
61
+ };
47
62
  const GITHUB_LOGIN_PATTERN = /^[A-Za-z\d](?:[A-Za-z\d]|-(?=[A-Za-z\d])){0,38}(?:\[[bB][oO][tT]\])?$/u;
48
63
  const waveSchema = z.object({
49
64
  autoMerge: z.boolean().optional(),
@@ -55,15 +70,17 @@ const boundaryManifestSchema = z.object({
55
70
  boundary: z.string().min(1),
56
71
  closeout: z.object({ review: rungSchema }).passthrough().optional(),
57
72
  declaredBy: z.string().regex(GITHUB_LOGIN_PATTERN, { message: "must be a GitHub login (letters, digits, single hyphens, max 39 chars), optionally suffixed with \"[bot]\" for a GitHub App identity — not an email address or display name" }),
73
+ integrationBranch: z.string().refine(isValidEpicIntegrationBranch, { message: "must name a valid non-empty Git branch under \"epic/**\" (git check-ref-format --branch rules)" }).optional(),
58
74
  prs: z.record(z.string().regex(/^[0-9]+$/u), z.string().min(1)).optional(),
59
75
  schemaVersion: z.literal(1),
60
- topology: z.enum([
61
- "each-to-main",
62
- "flagged",
63
- "stacked"
64
- ]),
76
+ topology: z.enum(BOUNDARY_TOPOLOGIES),
65
77
  waves: z.array(waveSchema).min(1)
66
78
  }).passthrough().superRefine((manifest, context) => {
79
+ if (manifest.topology === "each-to-epic" && manifest.integrationBranch === void 0) context.addIssue({
80
+ code: "custom",
81
+ message: "topology \"each-to-epic\" requires the shared epic/** branch name",
82
+ path: ["integrationBranch"]
83
+ });
67
84
  const names = manifest.waves.map((wave) => wave.name);
68
85
  const seen = /* @__PURE__ */ new Set();
69
86
  for (const name of names) {
@@ -233,6 +250,69 @@ const boundaryReviewProofAuthorsSeen = (comments, boundary) => {
233
250
  };
234
251
  const undispositionedBlockingFindings = (proof) => proof.findings.filter((finding) => (finding.category !== "maintainability" || finding.blockingAfterCap === true) && finding.disposition === void 0).map((finding) => `boundary finding "${finding.title}" (category ${finding.category ?? "unknown"}) has no disposition; fix it or record waived / follow-up-filed`);
235
252
  //#endregion
253
+ //#region src/boundary-topology.ts
254
+ const eachToEpicBranch = (manifest) => manifest.topology === "each-to-epic" ? manifest.integrationBranch : void 0;
255
+ /**
256
+ * Validate only the current member during admission. This deliberately does
257
+ * not inspect sibling membership or require the terminal PR: an epic branch
258
+ * is assembled one admitted interior candidate at a time.
259
+ */
260
+ const pullRequestTopologyReasons = ({ manifest, pullRequest }) => {
261
+ const integrationBranch = eachToEpicBranch(manifest);
262
+ if (!integrationBranch) return [];
263
+ const { baseRefName, headRefName, number } = pullRequest;
264
+ if (!headRefName) return [`PR #${number} has no live head branch name; each-to-epic admission fails closed until GitHub reports the member's headRefName.`];
265
+ if (baseRefName === integrationBranch && headRefName !== integrationBranch) return [];
266
+ if (baseRefName === "main" && headRefName === integrationBranch) return [];
267
+ return [`PR #${number} contradicts each-to-epic topology: live head/base ${headRefName ?? "(unknown)"} -> ${baseRefName ?? "(unknown)"}; expected an interior member targeting ${integrationBranch}, or the terminal ${integrationBranch} -> main PR.`];
268
+ };
269
+ /**
270
+ * Validate the complete declared member set at boundary closeout. Callers
271
+ * supply only issue-linked and explicit-override PRs; branch names classify
272
+ * those authoritative members' roles but never discover membership.
273
+ */
274
+ const boundaryTopologyReasons = ({ manifest, pullRequests }) => {
275
+ const integrationBranch = eachToEpicBranch(manifest);
276
+ if (!integrationBranch) return [];
277
+ const reasons = [];
278
+ const byNumber = /* @__PURE__ */ new Map();
279
+ for (const pullRequest of pullRequests) {
280
+ const previous = byNumber.get(pullRequest.number);
281
+ if (previous && (previous.baseRefName !== pullRequest.baseRefName || previous.headRefName !== pullRequest.headRefName)) {
282
+ reasons.push(`PR #${pullRequest.number} has contradictory live branch data across its declared memberships.`);
283
+ continue;
284
+ }
285
+ byNumber.set(pullRequest.number, pullRequest);
286
+ }
287
+ const terminalPullRequests = [];
288
+ for (const pullRequest of byNumber.values()) {
289
+ if (!pullRequest.headRefName) {
290
+ reasons.push(`PR #${pullRequest.number} has no live head branch name; each-to-epic closeout fails closed until GitHub reports headRefName.`);
291
+ continue;
292
+ }
293
+ if (pullRequest.state === "CLOSED") {
294
+ reasons.push(`PR #${pullRequest.number} is closed without merge and cannot satisfy the each-to-epic branch graph.`);
295
+ continue;
296
+ }
297
+ if (pullRequest.baseRefName === integrationBranch) {
298
+ if (pullRequest.state === "OPEN") reasons.push(`PR #${pullRequest.number} is an open interior member targeting ${integrationBranch}; each-to-epic closeout requires every interior member to be merged before the integration branch is complete.`);
299
+ if (pullRequest.headRefName === integrationBranch) reasons.push(`PR #${pullRequest.number} cannot use ${integrationBranch} as both head and base.`);
300
+ continue;
301
+ }
302
+ if (pullRequest.baseRefName === "main") {
303
+ if (pullRequest.headRefName !== integrationBranch) {
304
+ reasons.push(`PR #${pullRequest.number} targets main from ${pullRequest.headRefName ?? "(unknown)"}; the each-to-epic terminal must originate from declared integrationBranch ${integrationBranch}.`);
305
+ continue;
306
+ }
307
+ terminalPullRequests.push(pullRequest.number);
308
+ continue;
309
+ }
310
+ reasons.push(`PR #${pullRequest.number} targets ${pullRequest.baseRefName ?? "(unknown)"}; every each-to-epic interior member must target declared integrationBranch ${integrationBranch}.`);
311
+ }
312
+ if (terminalPullRequests.length !== 1) reasons.push(`each-to-epic requires exactly one terminal member PR from ${integrationBranch} to main; found ${terminalPullRequests.length}.`);
313
+ return reasons;
314
+ };
315
+ //#endregion
236
316
  //#region src/comment-provenance.ts
237
317
  var comment_provenance_exports = /* @__PURE__ */ __exportAll({
238
318
  SHA_MATCH_MIN_LENGTH: () => 7,
@@ -981,7 +1061,7 @@ const requiredCheckSchema = z.object({
981
1061
  const impactTargetSchema = z.object({
982
1062
  importers: z.array(z.string().min(1)).min(1),
983
1063
  name: z.string().min(1),
984
- paths: z.array(repoRelativeGlobSchema).min(1).optional()
1064
+ paths: z.array(repoRelativeGlobSchema).min(1).optional().describe("Opt-in repo-relative path subscriptions. A target is affected only when a valid changed path matches one of its globs; valid unmatched paths affect no target and are recorded as unsubscribed. Shared files must be listed by every subscribing target.")
985
1065
  }).strict();
986
1066
  const impactConfigSchema = z.object({ targets: z.array(impactTargetSchema).min(1).superRefine((targets, context) => {
987
1067
  const names = targets.map((target) => target.name);
@@ -1082,7 +1162,7 @@ const factoryProjectProfileSchema = z.object({
1082
1162
  mappedChecks.add(command.requiredCheck);
1083
1163
  }
1084
1164
  }).meta({ description: PROFILE_JSON_SCHEMA_DESCRIPTION });
1085
- const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1165
+ const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1086
1166
  const stringList = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
1087
1167
  const quoteList = (values) => JSON.stringify(values);
1088
1168
  const namesExistingFile = (root, entry) => {
@@ -1131,13 +1211,13 @@ const migratedPolicyLines = (policy, root) => {
1131
1211
  */
1132
1212
  function v2MigrationLines(input, root) {
1133
1213
  const lines = [];
1134
- const proof = isRecord$1(input.proof) ? input.proof : void 0;
1135
- if (proof && isRecord$1(proof.classificationPolicy)) lines.push(...migratedPolicyLines(proof.classificationPolicy, root));
1136
- const commands = isRecord$1(input.verification) ? input.verification.commands : void 0;
1214
+ const proof = isRecord$2(input.proof) ? input.proof : void 0;
1215
+ if (proof && isRecord$2(proof.classificationPolicy)) lines.push(...migratedPolicyLines(proof.classificationPolicy, root));
1216
+ const commands = isRecord$2(input.verification) ? input.verification.commands : void 0;
1137
1217
  if (Array.isArray(commands)) {
1138
- for (const [index, command] of commands.entries()) if (isRecord$1(command) && command.scope === "always") lines.push(`verification.commands[${index}].scope "always" -> "docs-only"`);
1218
+ for (const [index, command] of commands.entries()) if (isRecord$2(command) && command.scope === "always") lines.push(`verification.commands[${index}].scope "always" -> "docs-only"`);
1139
1219
  }
1140
- if (isRecord$1(input.hq) && typeof input.hq.endpoint === "string") try {
1220
+ if (isRecord$2(input.hq) && typeof input.hq.endpoint === "string") try {
1141
1221
  const url = new URL(input.hq.endpoint);
1142
1222
  if (url.href !== `${url.origin}/` && url.href !== url.origin) lines.push(`hq.endpoint ${JSON.stringify(input.hq.endpoint)} -> ${JSON.stringify(url.origin)}`);
1143
1223
  } catch {}
@@ -1149,25 +1229,25 @@ function hasCorrectnessOnlyReview(review) {
1149
1229
  }
1150
1230
  const v4MigrationLines = (input) => {
1151
1231
  const lines = [];
1152
- if (isRecord$1(input.project) && "key" in input.project) lines.push("project.key -> removed; current proof writers derive projectKey from repository.name");
1153
- if (isRecord$1(input.repository) && "defaultBranch" in input.repository) lines.push("repository.defaultBranch -> removed; commands resolve the live base from Git/GitHub or an explicit --base");
1154
- if (isRecord$1(input.env) && "required" in input.env) lines.push("env.required -> removed; the command that needs an environment value owns its fail-closed diagnostic");
1155
- if (isRecord$1(input.review)) {
1232
+ if (isRecord$2(input.project) && "key" in input.project) lines.push("project.key -> removed; current proof writers derive projectKey from repository.name");
1233
+ if (isRecord$2(input.repository) && "defaultBranch" in input.repository) lines.push("repository.defaultBranch -> removed; commands resolve the live base from Git/GitHub or an explicit --base");
1234
+ if (isRecord$2(input.env) && "required" in input.env) lines.push("env.required -> removed; the command that needs an environment value owns its fail-closed diagnostic");
1235
+ if (isRecord$2(input.review)) {
1156
1236
  const isCorrectnessOnlyReview = hasCorrectnessOnlyReview(input.review);
1157
1237
  if ("defaultMaxCycles" in input.review) lines.push("review.defaultMaxCycles -> removed; the factory review cap is 5");
1158
1238
  if ("modes" in input.review) lines.push("review.modes -> removed; correctness is always required");
1159
1239
  if (isCorrectnessOnlyReview) lines.push("review -> removed; a correctness-only review has no v5 configuration, so delete the object");
1160
1240
  if (Array.isArray(input.review.conditional)) {
1161
- for (const [index, conditional] of input.review.conditional.entries()) if (isRecord$1(conditional) && "modes" in conditional) lines.push(`review.conditional[${index}].modes -> removed; each retained conditional path list demands security review`);
1241
+ for (const [index, conditional] of input.review.conditional.entries()) if (isRecord$2(conditional) && "modes" in conditional) lines.push(`review.conditional[${index}].modes -> removed; each retained conditional path list demands security review`);
1162
1242
  }
1163
1243
  }
1164
1244
  if (Array.isArray(input.requiredChecks)) {
1165
- for (const [index, check] of input.requiredChecks.entries()) if (isRecord$1(check) && "checkType" in check) lines.push(`requiredChecks[${index}].checkType -> removed; external required checks are verify-type`);
1245
+ for (const [index, check] of input.requiredChecks.entries()) if (isRecord$2(check) && "checkType" in check) lines.push(`requiredChecks[${index}].checkType -> removed; external required checks are verify-type`);
1166
1246
  }
1167
1247
  return lines;
1168
1248
  };
1169
1249
  function legacyProfileMigration(input, root) {
1170
- if (!(isRecord$1(input) && typeof input.schemaVersion === "number" && LEGACY_PROFILE_SCHEMA_VERSIONS.includes(input.schemaVersion))) return;
1250
+ if (!(isRecord$2(input) && typeof input.schemaVersion === "number" && LEGACY_PROFILE_SCHEMA_VERSIONS.includes(input.schemaVersion))) return;
1171
1251
  const foundVersion = input.schemaVersion;
1172
1252
  const lines = [`schemaVersion ${foundVersion} -> 5`];
1173
1253
  if (foundVersion === 2) lines.push(...v2MigrationLines(input, root));
@@ -1347,7 +1427,6 @@ function formatUserConfigError(configPath, error) {
1347
1427
  }
1348
1428
  const DEFAULT_HQ_TRANSPORT_TIMEOUT_MS = 2500;
1349
1429
  const HQ_RETRY_SPOOL_DIRNAME = "hq-retry-spool";
1350
- const HQ_RETRY_JOURNAL_BASENAME = "hq-retry-journal.jsonl";
1351
1430
  /**
1352
1431
  * Appended to a spooled event that can never be delivered (#445).
1353
1432
  *
@@ -1388,9 +1467,6 @@ const SPOOL_REPOSITORY_MARKER = ".repository-identity";
1388
1467
  * and the extra level is slack for a key scheme that nests one deeper.
1389
1468
  */
1390
1469
  const HQ_SPOOL_SWEEP_MAX_DEPTH = 4;
1391
- `${HQ_RETRY_JOURNAL_BASENAME}`;
1392
- `${HQ_RETRY_SPOOL_DIRNAME}`;
1393
- const LEGACY_FACTORY_MEMORY_DIRNAME = ".factory-memory";
1394
1470
  const HQ_SPOOL_STATE_SEGMENTS = ["patronage-factory", "hq-spool"];
1395
1471
  const MAX_HQ_CONFIG_BYTES = 1024 * 1024;
1396
1472
  const MAX_HQ_INGEST_PAYLOAD_BYTES = 256 * 1024;
@@ -1502,12 +1578,6 @@ const repositorySpoolLayout = (repository, env = process.env) => ({
1502
1578
  HQ_RETRY_SPOOL_DIRNAME
1503
1579
  ]
1504
1580
  });
1505
- /** Read-only drain source for spools written before the relocation. */
1506
- const legacySpoolLayout = (cwd) => ({
1507
- create: false,
1508
- root: cwd,
1509
- segments: [LEGACY_FACTORY_MEMORY_DIRNAME, HQ_RETRY_SPOOL_DIRNAME]
1510
- });
1511
1581
  /**
1512
1582
  * An operator-named directory (`hq:flush --dir`). Accepts either the spool
1513
1583
  * itself or the directory holding it, so a `.factory-memory` path and a
@@ -1579,30 +1649,10 @@ const secureSpoolLayout = async (layout, deadline) => {
1579
1649
  const memoryIdentity = identities.at(-2);
1580
1650
  if (spool === void 0 || memory === void 0 || spoolIdentity === void 0 || memoryIdentity === void 0 || !await directoryIdentityMatches(memory, memoryIdentity, deadline) || !await directoryIdentityMatches(spool, spoolIdentity, deadline)) return;
1581
1651
  return {
1582
- memory,
1583
- memoryIdentity,
1584
1652
  spool,
1585
1653
  spoolIdentity
1586
1654
  };
1587
1655
  };
1588
- /**
1589
- * The legacy JSONL journal lives one level above the spool. Securing it on its
1590
- * own lets a pre-spool `.factory-memory` (journal but no spool directory)
1591
- * drain without the read path creating anything inside the worktree.
1592
- */
1593
- const secureJournalDirectory = async (layout, deadline) => {
1594
- const segments = layout.segments.slice(0, -1);
1595
- if (segments.length === 0) return;
1596
- const chain = await secureChain(layout, segments, deadline);
1597
- if (chain === void 0) return;
1598
- const directory = chain.directories.at(-1);
1599
- const identity = chain.identities.at(-1);
1600
- if (directory === void 0 || identity === void 0 || !await directoryIdentityMatches(directory, identity, deadline)) return;
1601
- return {
1602
- directory,
1603
- identity
1604
- };
1605
- };
1606
1656
  const readDirectoryIdentity = async (directory, deadline) => {
1607
1657
  const opened = await openWithin(directory, constants.O_RDONLY + constants.O_DIRECTORY + constants.O_NOFOLLOW, remainingMs(deadline));
1608
1658
  if (opened.status !== "fulfilled") return;
@@ -2332,225 +2382,6 @@ const replayCloseoutSpool = async (layout, endpoint, clientId, clientSecret, req
2332
2382
  if (!await mutateBoundDirectory(spool, spoolIdentity, performance.now() + flushBudgetMs, () => outcome.ok ? unlink(claimPath) : rename(claimPath, eventPath))) return;
2333
2383
  }
2334
2384
  };
2335
- const writeLegacyCursor = async (directory, directoryIdentity, cursorPath, offset, deadline) => {
2336
- if (path.dirname(cursorPath) !== directory || !await directoryIdentityMatches(directory, directoryIdentity, deadline)) return false;
2337
- const temporary = `${cursorPath}.${randomUUID()}.tmp`;
2338
- const opened = await openWithin(temporary, constants.O_CREAT + constants.O_EXCL + constants.O_WRONLY + constants.O_NOFOLLOW, remainingMs(deadline), 384);
2339
- if (opened.status !== "fulfilled") return false;
2340
- const wrote = await settleWithin((async () => {
2341
- await opened.value.writeFile(String(offset), "utf-8");
2342
- await opened.value.sync();
2343
- })(), remainingMs(deadline));
2344
- const closed = await settleWithin(opened.value.close(), remainingMs(deadline));
2345
- if (wrote.status !== "fulfilled" || closed.status !== "fulfilled") {
2346
- await mutateBoundDirectory(directory, directoryIdentity, deadline, async () => unlink(temporary));
2347
- return false;
2348
- }
2349
- if (!await mutateBoundDirectory(directory, directoryIdentity, deadline, async () => rename(temporary, cursorPath))) {
2350
- await mutateBoundDirectory(directory, directoryIdentity, deadline, async () => unlink(temporary));
2351
- return false;
2352
- }
2353
- return await syncBoundDirectory(directory, directoryIdentity, deadline);
2354
- };
2355
- const readLegacyCursor = async (cursorPath, deadline) => {
2356
- const file = await readBoundedTextFileNoFollow(cursorPath, deadline, 128);
2357
- if (file === void 0) return 0;
2358
- const offset = Number(file.contents);
2359
- return Number.isSafeInteger(offset) && offset >= 0 ? offset : 0;
2360
- };
2361
- const claimLegacySource = async (sourcePath, target, directory, directoryIdentity, deadline) => {
2362
- if (path.dirname(sourcePath) !== directory || !await directoryIdentityMatches(directory, directoryIdentity, deadline)) return;
2363
- const metadata = await settleWithin(lstat(sourcePath), remainingMs(deadline));
2364
- if (metadata.status !== "fulfilled" || !metadata.value.isFile() || metadata.value.isSymbolicLink()) return;
2365
- const claimPath = `${target}.legacy-claim-${randomUUID()}`;
2366
- if (!await mutateBoundDirectory(directory, directoryIdentity, deadline, async () => rename(sourcePath, claimPath))) return;
2367
- return claimPath;
2368
- };
2369
- const releaseLegacyClaim = async (claimPath, target, directory, directoryIdentity, deadline) => {
2370
- if (path.dirname(claimPath) !== directory) return false;
2371
- const readyPath = `${target}.legacy-ready-${randomUUID()}`;
2372
- return mutateBoundDirectory(directory, directoryIdentity, deadline, async () => rename(claimPath, readyPath));
2373
- };
2374
- async function replayRetryJournal(layout, writeLayout, endpoint, clientId, clientSecret, request, dependencies, transportBudgetMs, setupDeadline, replayDeadline, options = {}) {
2375
- const maxEntries = options.maxEntries ?? MAX_HQ_REPLAY_ENTRIES;
2376
- const { report } = options;
2377
- const secured = await settleWithin(secureJournalDirectory(layout, setupDeadline), remainingMs(setupDeadline));
2378
- if (secured.status !== "fulfilled" || secured.value === void 0) return;
2379
- const { directory, identity: directoryIdentity } = secured.value;
2380
- const target = path.join(directory, HQ_RETRY_JOURNAL_BASENAME);
2381
- const baseName = path.basename(target);
2382
- const listing = await settleWithin(readdir(directory), remainingMs(setupDeadline));
2383
- if (listing.status !== "fulfilled") return;
2384
- let delivered = 0;
2385
- let corruptDropped = 0;
2386
- 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();
2387
- for (const name of sources) {
2388
- if (delivered >= maxEntries || remainingMs(replayDeadline) <= 0) break;
2389
- const sourcePath = path.join(directory, name);
2390
- if (name.startsWith(`${baseName}.draining-`) || name.startsWith(`${baseName}.legacy-claim-`)) {
2391
- const metadata = await settleWithin(lstat(sourcePath), remainingMs(setupDeadline));
2392
- if (metadata.status !== "fulfilled" || !metadata.value.isFile() || metadata.value.isSymbolicLink() || Date.now() - Math.max(metadata.value.mtimeMs, metadata.value.ctimeMs) < STALE_SPOOL_ARTIFACT_MS) continue;
2393
- }
2394
- const claimPath = await claimLegacySource(sourcePath, target, directory, directoryIdentity, setupDeadline);
2395
- if (claimPath === void 0) continue;
2396
- const opened = await openWithin(claimPath, constants.O_RDONLY + constants.O_NOFOLLOW, remainingMs(setupDeadline));
2397
- if (opened.status !== "fulfilled") {
2398
- await releaseLegacyClaim(claimPath, target, directory, directoryIdentity, setupDeadline);
2399
- continue;
2400
- }
2401
- const sourceMetadata = await settleWithin(opened.value.stat(), remainingMs(setupDeadline));
2402
- if (sourceMetadata.status !== "fulfilled" || !sourceMetadata.value.isFile()) {
2403
- await settleWithin(opened.value.close(), remainingMs(setupDeadline));
2404
- await releaseLegacyClaim(claimPath, target, directory, directoryIdentity, setupDeadline);
2405
- continue;
2406
- }
2407
- const cursorPath = `${target}.legacy-cursor-${String(sourceMetadata.value.dev)}-${String(sourceMetadata.value.ino)}`;
2408
- let offset = await readLegacyCursor(cursorPath, setupDeadline);
2409
- if (offset >= sourceMetadata.value.size) {
2410
- await settleWithin(opened.value.close(), remainingMs(setupDeadline));
2411
- const current = await settleWithin(lstat(claimPath), remainingMs(setupDeadline));
2412
- if (current.status === "fulfilled" && current.value.isFile() && !current.value.isSymbolicLink() && current.value.dev === sourceMetadata.value.dev && current.value.ino === sourceMetadata.value.ino && current.value.size === sourceMetadata.value.size) {
2413
- await mutateBoundDirectory(directory, directoryIdentity, setupDeadline, async () => unlink(claimPath));
2414
- await mutateBoundDirectory(directory, directoryIdentity, setupDeadline, async () => unlink(cursorPath));
2415
- }
2416
- continue;
2417
- }
2418
- const abort = new AbortController();
2419
- const streamBudget = Math.min(remainingMs(setupDeadline), remainingMs(replayDeadline));
2420
- const abortTimer = setTimeout(() => abort.abort(), Math.max(0, streamBudget));
2421
- abortTimer.unref();
2422
- const stream = createReadStream(claimPath, {
2423
- autoClose: false,
2424
- encoding: "utf-8",
2425
- fd: opened.value.fd,
2426
- signal: abort.signal,
2427
- start: offset
2428
- });
2429
- const lines = createInterface({
2430
- crlfDelay: Infinity,
2431
- input: stream
2432
- });
2433
- let reachedEof = true;
2434
- try {
2435
- for await (const line of lines) {
2436
- if (delivered >= maxEntries || remainingMs(replayDeadline) <= 0 || remainingMs(setupDeadline) <= 0) {
2437
- reachedEof = false;
2438
- break;
2439
- }
2440
- const nextOffset = Math.min(sourceMetadata.value.size, offset + Buffer.byteLength(line, "utf-8") + 1);
2441
- if (line.trim().length === 0) {
2442
- offset = nextOffset;
2443
- if (!await writeLegacyCursor(directory, directoryIdentity, cursorPath, offset, setupDeadline)) {
2444
- reachedEof = false;
2445
- break;
2446
- }
2447
- continue;
2448
- }
2449
- let parsed;
2450
- try {
2451
- parsed = JSON.parse(line);
2452
- } catch {
2453
- corruptDropped += 1;
2454
- offset = nextOffset;
2455
- if (!await writeLegacyCursor(directory, directoryIdentity, cursorPath, offset, setupDeadline)) {
2456
- reachedEof = false;
2457
- break;
2458
- }
2459
- continue;
2460
- }
2461
- if (!isReplayableEntry(parsed)) {
2462
- offset = nextOffset;
2463
- if (!await writeLegacyCursor(directory, directoryIdentity, cursorPath, offset, setupDeadline)) {
2464
- reachedEof = false;
2465
- break;
2466
- }
2467
- continue;
2468
- }
2469
- const entryEndpoint = validatedEndpoint(parsed.endpoint);
2470
- let handled = false;
2471
- const row = {
2472
- eventId: parsed.event.eventId,
2473
- kind: parsed.event.kind
2474
- };
2475
- let rowOutcome = {
2476
- detail: `recorded endpoint ${entryEndpoint?.origin ?? "(unusable)"} is not this repository's authorized HQ origin ${endpoint.origin}`,
2477
- ...row,
2478
- spool: directory,
2479
- status: "migrated"
2480
- };
2481
- if (entryEndpoint?.origin === endpoint.origin) {
2482
- rowOutcome = {
2483
- ...rowOutcome,
2484
- detail: "event could not be serialized for delivery"
2485
- };
2486
- let body;
2487
- try {
2488
- body = JSON.stringify(parsed.event);
2489
- } catch {
2490
- body = void 0;
2491
- }
2492
- if (body !== void 0) {
2493
- const outcome = await attemptTransport(request, endpoint, clientId, clientSecret, body, Math.min(transportBudgetMs, remainingMs(replayDeadline)), report !== void 0);
2494
- handled = outcome.ok;
2495
- if (handled) delivered += 1;
2496
- rowOutcome = outcome.ok ? {
2497
- ...row,
2498
- spool: directory,
2499
- status: outcome.duplicate === true ? "duplicate" : "delivered"
2500
- } : {
2501
- ...row,
2502
- detail: outcome.detail === void 0 ? outcome.reason : `${outcome.reason}: ${outcome.detail}`,
2503
- spool: directory,
2504
- status: drainFailureStatus(outcome.reason)
2505
- };
2506
- }
2507
- }
2508
- if (!handled) {
2509
- const retained = await appendCloseoutSpool(writeLayout, parsed, performance.now() + journalFlushBudgetFor(dependencies));
2510
- if (retained === void 0) options.onMigrate?.();
2511
- if (retained !== void 0) {
2512
- report?.({
2513
- ...rowOutcome,
2514
- detail: `${rowOutcome.detail ?? rowOutcome.status}; migration into the current spool ${retained}`,
2515
- status: "unreachable"
2516
- });
2517
- reachedEof = false;
2518
- break;
2519
- }
2520
- }
2521
- report?.(rowOutcome);
2522
- offset = nextOffset;
2523
- if (!await writeLegacyCursor(directory, directoryIdentity, cursorPath, offset, setupDeadline)) {
2524
- reachedEof = false;
2525
- break;
2526
- }
2527
- }
2528
- } catch {
2529
- reachedEof = false;
2530
- } finally {
2531
- clearTimeout(abortTimer);
2532
- abort.abort();
2533
- lines.close();
2534
- stream.destroy();
2535
- await settleWithin(opened.value.close(), remainingMs(setupDeadline));
2536
- }
2537
- if (reachedEof && offset >= sourceMetadata.value.size) {
2538
- const current = await settleWithin(lstat(claimPath), remainingMs(setupDeadline));
2539
- if (current.status === "fulfilled" && current.value.isFile() && !current.value.isSymbolicLink() && current.value.dev === sourceMetadata.value.dev && current.value.ino === sourceMetadata.value.ino && current.value.size === sourceMetadata.value.size && await directoryIdentityMatches(directory, directoryIdentity, setupDeadline)) {
2540
- await mutateBoundDirectory(directory, directoryIdentity, setupDeadline, async () => unlink(claimPath));
2541
- await mutateBoundDirectory(directory, directoryIdentity, setupDeadline, async () => unlink(cursorPath));
2542
- }
2543
- } else await releaseLegacyClaim(claimPath, target, directory, directoryIdentity, setupDeadline);
2544
- }
2545
- await reportReplayOutcome(dependencies, delivered, corruptDropped);
2546
- }
2547
- async function reportReplayOutcome(dependencies, delivered, corruptDropped) {
2548
- if (delivered <= 0 && corruptDropped <= 0) return;
2549
- const parts = [];
2550
- if (delivered > 0) parts.push(`${delivered} queued event(s) delivered from the retry journal`);
2551
- if (corruptDropped > 0) parts.push(`${corruptDropped} unparseable journal line(s) dropped (partial write recovered, #120)`);
2552
- await reportDiagnostic(dependencies, performance.now() + journalFlushBudgetFor(dependencies), parts.join("; "), delivered > 0 ? "HQ ingest confirmed" : "HQ ingest recovered");
2553
- }
2554
2385
  async function deliverHqIngest(input, dependencies, setupDeadline, timeoutMs, transportBudgetMs) {
2555
2386
  let config;
2556
2387
  try {
@@ -2594,7 +2425,6 @@ async function deliverHqIngest(input, dependencies, setupDeadline, timeoutMs, tr
2594
2425
  owner: input.profile.repository.owner,
2595
2426
  repo: input.profile.repository.name
2596
2427
  }, env);
2597
- const readLayouts = [writeLayout, legacySpoolLayout(input.cwd)];
2598
2428
  const now = dependencies.now ?? (() => /* @__PURE__ */ new Date());
2599
2429
  const makeEventId = dependencies.randomUUID ?? randomUUID;
2600
2430
  let event;
@@ -2665,10 +2495,7 @@ async function deliverHqIngest(input, dependencies, setupDeadline, timeoutMs, tr
2665
2495
  throw new Error(reason);
2666
2496
  }
2667
2497
  const replayDeadline = performance.now() + timeoutMs;
2668
- for (const readLayout of readLayouts) {
2669
- await replayCloseoutSpool(readLayout, endpoint, clientId, clientSecret, request, transportBudgetMs, replayDeadline, replayDeadline, journalFlushBudgetFor(dependencies), persistedEventPath);
2670
- await replayRetryJournal(readLayout, writeLayout, endpoint, clientId, clientSecret, request, dependencies, transportBudgetMs, replayDeadline, replayDeadline);
2671
- }
2498
+ await replayCloseoutSpool(writeLayout, endpoint, clientId, clientSecret, request, transportBudgetMs, replayDeadline, replayDeadline, journalFlushBudgetFor(dependencies), persistedEventPath);
2672
2499
  const outcome = await attemptTransport(request, endpoint, clientId, clientSecret, body, transportBudgetMs);
2673
2500
  if (!outcome.ok) {
2674
2501
  ({reason, unconfirmed} = outcome);
@@ -2747,20 +2574,7 @@ async function awaitPendingHqIngest() {
2747
2574
  const DEFAULT_HQ_FLUSH_BUDGET_MS = 600 * 1e3;
2748
2575
  /** ENOENT is the only filesystem answer that means "this location is absent". */
2749
2576
  const isMissingEntryError = (error) => typeof error === "object" && error !== null && error.code === "ENOENT";
2750
- const journalRowCount = (contents) => contents.split("\n").filter((line) => line.trim() !== "").length;
2751
- const journalLineTimestamps = (contents) => {
2752
- const timestamps = [];
2753
- for (const line of contents.split("\n")) {
2754
- const trimmed = line.trim();
2755
- if (trimmed === "") continue;
2756
- try {
2757
- const parsed = JSON.parse(trimmed);
2758
- if (typeof parsed.failedAt === "string") timestamps.push(parsed.failedAt);
2759
- } catch {}
2760
- }
2761
- return timestamps;
2762
- };
2763
- const countRemainingSpoolWork = async (spools, drainLayouts, deadline) => {
2577
+ const countRemainingSpoolWork = async (spools, deadline) => {
2764
2578
  let remaining = 0;
2765
2579
  let unlistableScans = 0;
2766
2580
  let oldestMs;
@@ -2789,27 +2603,6 @@ const countRemainingSpoolWork = async (spools, drainLayouts, deadline) => {
2789
2603
  if (probed.status === "fulfilled") noteTimestamp(probed.value.mtime.toISOString());
2790
2604
  }
2791
2605
  }
2792
- for (const layout of drainLayouts) {
2793
- const journalDir = path.dirname(layoutPath(layout));
2794
- const journalListing = await settleWithin(readdir(journalDir), remainingMs(deadline));
2795
- if (journalListing.status !== "fulfilled") {
2796
- await countUnlistable(journalDir);
2797
- continue;
2798
- }
2799
- 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-`));
2800
- for (const name of journalNames) {
2801
- const journalFilePath = path.join(journalDir, name);
2802
- const read = await settleWithin(readFile(journalFilePath, "utf-8"), remainingMs(deadline));
2803
- if (read.status === "fulfilled") {
2804
- remaining += journalRowCount(read.value);
2805
- for (const timestamp of journalLineTimestamps(read.value)) noteTimestamp(timestamp);
2806
- continue;
2807
- }
2808
- remaining += 1;
2809
- const probed = await settleWithin(lstat(journalFilePath), remainingMs(deadline));
2810
- if (probed.status === "fulfilled") noteTimestamp(probed.value.mtime.toISOString());
2811
- }
2812
- }
2813
2606
  return {
2814
2607
  ...oldestMs === void 0 ? {} : { oldestQueuedAt: new Date(oldestMs).toISOString() },
2815
2608
  remaining,
@@ -2830,10 +2623,10 @@ const DEFAULT_HQ_SPOOL_INSPECT_BUDGET_MS = 5e3;
2830
2623
  */
2831
2624
  async function countHqSpoolWork(input, dependencies = {}) {
2832
2625
  const env = dependencies.env ?? process.env;
2833
- const layouts = input.explicitDirectories && input.explicitDirectories.length > 0 ? input.explicitDirectories.map(explicitSpoolLayout) : [repositorySpoolLayout(input.repository, env), legacySpoolLayout(input.cwd)];
2626
+ const layouts = input.explicitDirectories && input.explicitDirectories.length > 0 ? input.explicitDirectories.map(explicitSpoolLayout) : [repositorySpoolLayout(input.repository, env)];
2834
2627
  const deadline = performance.now() + (dependencies.budgetMs ?? DEFAULT_HQ_SPOOL_INSPECT_BUDGET_MS);
2835
2628
  const paths = layouts.map((layout) => layoutPath(layout));
2836
- const { oldestQueuedAt, remaining, unlistableScans } = await countRemainingSpoolWork(paths, layouts, deadline);
2629
+ const { oldestQueuedAt, remaining, unlistableScans } = await countRemainingSpoolWork(paths, deadline);
2837
2630
  const existing = await Promise.all(paths.map(async (spool) => {
2838
2631
  return (await settleWithin(lstat(spool), remainingMs(deadline))).status === "fulfilled" ? spool : void 0;
2839
2632
  }));
@@ -3001,7 +2794,7 @@ async function sweepHqSpoolOrphans(input, dependencies = {}) {
3001
2794
  });
3002
2795
  continue;
3003
2796
  }
3004
- const counted = await countRemainingSpoolWork([spool], [explicitSpoolLayout(spool)], deadline);
2797
+ const counted = await countRemainingSpoolWork([spool], deadline);
3005
2798
  if (counted.remaining === 0 && counted.unlistableScans === 0) continue;
3006
2799
  orphans.push({
3007
2800
  directory: spool,
@@ -3021,7 +2814,7 @@ async function sweepHqSpoolOrphans(input, dependencies = {}) {
3021
2814
  });
3022
2815
  continue;
3023
2816
  }
3024
- const counted = await countRemainingSpoolWork([spool], [explicitSpoolLayout(spool)], deadline);
2817
+ const counted = await countRemainingSpoolWork([spool], deadline);
3025
2818
  if (counted.remaining === 0 && counted.unlistableScans === 0) continue;
3026
2819
  orphans.push({
3027
2820
  directory: spool,
@@ -3041,8 +2834,8 @@ async function sweepHqSpoolOrphans(input, dependencies = {}) {
3041
2834
  * This is the deliberate counterpart to the sink's advisory emit path: the
3042
2835
  * caller has already resolved credentials explicitly, so there is no cap, no
3043
2836
  * daemon, and no background retry — one pass, bounded, over the repo-keyed
3044
- * location plus the legacy cwd location (or the operator's `--dir`). Delivery
3045
- * itself is the sink's own replay, so there is exactly one POST path.
2837
+ * location (or the operator's `--dir`). Delivery itself is the sink's own
2838
+ * replay, so there is exactly one POST path.
3046
2839
  */
3047
2840
  async function flushHqSpool(input, dependencies = {}) {
3048
2841
  const endpoint = validatedIngestEndpoint(input.endpoint);
@@ -3050,10 +2843,12 @@ async function flushHqSpool(input, dependencies = {}) {
3050
2843
  const env = dependencies.env ?? process.env;
3051
2844
  const request = dependencies.fetch ?? fetch;
3052
2845
  const transportBudgetMs = dependencies.transportTimeoutMs ?? DEFAULT_HQ_TRANSPORT_TIMEOUT_MS;
3053
- const flushBudgetMs = dependencies.journalFlushBudgetMs ?? HQ_JOURNAL_FLUSH_BUDGET_MS;
2846
+ const flushBudgetMs = journalFlushBudgetFor({
2847
+ env,
2848
+ journalFlushBudgetMs: dependencies.journalFlushBudgetMs
2849
+ });
3054
2850
  const deadline = performance.now() + (dependencies.budgetMs ?? DEFAULT_HQ_FLUSH_BUDGET_MS);
3055
- const writeLayout = repositorySpoolLayout(input.repository, env);
3056
- const layouts = input.explicitDirectories && input.explicitDirectories.length > 0 ? input.explicitDirectories.map(explicitSpoolLayout) : [writeLayout, legacySpoolLayout(input.cwd)];
2851
+ const layouts = input.explicitDirectories && input.explicitDirectories.length > 0 ? input.explicitDirectories.map(explicitSpoolLayout) : [repositorySpoolLayout(input.repository, env)];
3057
2852
  const outcomeById = /* @__PURE__ */ new Map();
3058
2853
  let rejectedFiles = 0;
3059
2854
  let unreachableFiles = 0;
@@ -3062,31 +2857,13 @@ async function flushHqSpool(input, dependencies = {}) {
3062
2857
  outcomeById.set(outcome.eventId, outcome);
3063
2858
  dependencies.report?.(outcome);
3064
2859
  };
3065
- const journalReport = (outcome) => {
3066
- if (outcome.status === "unreachable" && outcome.detail?.includes("migration into the current spool")) unreachableFiles += 1;
3067
- record(outcome);
3068
- };
3069
2860
  const spoolReport = (outcome) => {
3070
2861
  if (outcome.status === "rejected") rejectedFiles += 1;
3071
2862
  if (outcome.status === "unreachable") unreachableFiles += 1;
3072
2863
  record(outcome);
3073
2864
  };
3074
2865
  const spools = [];
3075
- let migratedIntoWriteLayout = false;
3076
- const journalDrainOptions = {
3077
- maxEntries: Number.POSITIVE_INFINITY,
3078
- onMigrate: () => {
3079
- migratedIntoWriteLayout = true;
3080
- },
3081
- report: journalReport
3082
- };
3083
- for (const layout of layouts) await replayRetryJournal(layout, writeLayout, endpoint, input.clientId, input.clientSecret, request, {
3084
- env,
3085
- fetch: request,
3086
- journalFlushBudgetMs: flushBudgetMs
3087
- }, transportBudgetMs, deadline, deadline, journalDrainOptions);
3088
- const drainLayouts = migratedIntoWriteLayout && !layouts.some((layout) => layoutPath(layout) === layoutPath(writeLayout)) ? [...layouts, writeLayout] : layouts;
3089
- for (const layout of drainLayouts) {
2866
+ for (const layout of layouts) {
3090
2867
  await replayCloseoutSpool(layout, endpoint, input.clientId, input.clientSecret, request, transportBudgetMs, deadline, deadline, flushBudgetMs, void 0, {
3091
2868
  maxEntries: Number.POSITIVE_INFINITY,
3092
2869
  report: spoolReport
@@ -3096,7 +2873,7 @@ async function flushHqSpool(input, dependencies = {}) {
3096
2873
  if ((await settleWithin(lstat(layoutPath(layout)), remainingMs(deadline))).status === "fulfilled") unsecurableSpools += 1;
3097
2874
  } else spools.push(secured.spool);
3098
2875
  }
3099
- const { remaining, unlistableScans } = await countRemainingSpoolWork(spools, drainLayouts, deadline);
2876
+ const { remaining, unlistableScans } = await countRemainingSpoolWork(spools, deadline);
3100
2877
  const outcomes = [...outcomeById.values()];
3101
2878
  const count = (status) => outcomes.filter((outcome) => outcome.status === status).length;
3102
2879
  const rejected = count("rejected");
@@ -3856,15 +3633,18 @@ const impactStampTargetSchema = z.object({
3856
3633
  const impactStampSchema = z.object({
3857
3634
  basis: z.enum(["target-scoped", "conservative"]),
3858
3635
  reasons: z.array(z.string()),
3859
- stampVersion: z.literal(2),
3636
+ stampVersion: z.literal(3),
3860
3637
  targets: z.array(impactStampTargetSchema).superRefine((targets, context) => {
3861
3638
  const names = targets.map((target) => target.name);
3862
3639
  if (new Set(names).size !== names.length) context.addIssue({
3863
3640
  code: "custom",
3864
3641
  message: "impact stamp targets must have distinct names."
3865
3642
  });
3866
- })
3643
+ }),
3644
+ unsubscribedPaths: z.array(z.string())
3867
3645
  });
3646
+ /** Human-readable lines for every impact decision, including quiet releases. */
3647
+ const impactStampSummaryLines = (stamp) => [`Impact stamp: ${stamp.basis}; ${stamp.targets.filter(({ impact }) => impact === "affected").length}/${stamp.targets.length} target(s) affected.`, stamp.unsubscribedPaths.length === 0 ? "Unsubscribed changed paths: none." : `Unsubscribed changed paths (demand no target): ${stamp.unsubscribedPaths.join(", ")}`];
3868
3648
  /**
3869
3649
  * A configuration contradiction between the profile's impact declarations and
3870
3650
  * the repository's actual lockfile — e.g. a declared importer root that
@@ -3914,8 +3694,8 @@ const impactStampScopeDecision = ({ stamp, surface, targetName }) => {
3914
3694
  reason: `no trusted identity-bound impact stamp covers this candidate; ${floor}`,
3915
3695
  scoped: false
3916
3696
  };
3917
- if (stamp.stampVersion !== 2) return {
3918
- reason: `impact stamp version ${String(stamp.stampVersion)} is not this build's version 2; ${floor}`,
3697
+ if (stamp.stampVersion !== 3) return {
3698
+ reason: `impact stamp version ${String(stamp.stampVersion)} is not this build's version 3; ${floor}`,
3919
3699
  scoped: false
3920
3700
  };
3921
3701
  if (stamp.basis !== "target-scoped") return {
@@ -3957,15 +3737,16 @@ const impactStampDemandRelease = ({ checkName, stamp }) => {
3957
3737
  * every doubt path lands on, and the stamp a producer must fall back to if
3958
3738
  * stamp computation itself fails for any reason.
3959
3739
  */
3960
- const conservativeImpactStamp = (targets, reasons) => ({
3740
+ const conservativeImpactStamp = (targets, reasons, unsubscribedPaths = []) => ({
3961
3741
  basis: "conservative",
3962
3742
  reasons,
3963
- stampVersion: 2,
3743
+ stampVersion: 3,
3964
3744
  targets: targets.map((target) => ({
3965
3745
  basis: CONSERVATIVE_TARGET_BASIS,
3966
3746
  impact: "affected",
3967
3747
  name: target.name
3968
- }))
3748
+ })),
3749
+ unsubscribedPaths
3969
3750
  });
3970
3751
  const conservativeStamp = conservativeImpactStamp;
3971
3752
  const DEPENDENCY_FIELDS = [
@@ -3993,17 +3774,17 @@ const pnpmLockfileSchema = z.looseObject({
3993
3774
  packages: z.record(z.string(), z.unknown()).optional(),
3994
3775
  snapshots: z.record(z.string(), snapshotSectionSchema).optional()
3995
3776
  });
3996
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3777
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3997
3778
  const canonical = (value) => {
3998
3779
  if (Array.isArray(value)) return value.map(canonical);
3999
- if (isRecord(value)) return Object.fromEntries(Object.keys(value).toSorted().map((key) => [key, canonical(value[key])]));
3780
+ if (isRecord$1(value)) return Object.fromEntries(Object.keys(value).toSorted().map((key) => [key, canonical(value[key])]));
4000
3781
  return value;
4001
3782
  };
4002
3783
  const stableStringify$1 = (value) => JSON.stringify(canonical(value));
4003
3784
  const parseLockfile = (content) => {
4004
3785
  try {
4005
3786
  const parsed = parse(content);
4006
- if (!isRecord(parsed)) return;
3787
+ if (!isRecord$1(parsed)) return;
4007
3788
  const validated = pnpmLockfileSchema.safeParse(parsed);
4008
3789
  return validated.success ? validated.data : void 0;
4009
3790
  } catch {
@@ -4096,9 +3877,10 @@ const targetImpactForLockfileDelta = (target, delta) => {
4096
3877
  /**
4097
3878
  * Compute the impact stamp for one candidate: every declared target recorded
4098
3879
  * as affected/not-affected with its exact basis. `not-affected` is only ever
4099
- * returned only when every changed input is owned by a declared source glob or
4100
- * is an analyzable pnpm-lock.yaml (v9) delta. Source and lockfile reachability
4101
- * are unioned per target. Any invalid, unmatched, unreadable, unsupported, or
3880
+ * returned only when every changed input is a valid source path or an
3881
+ * analyzable pnpm-lock.yaml (v9) delta. Source and lockfile reachability are
3882
+ * unioned per target. A valid source path matched by no target is recorded in
3883
+ * `unsubscribedPaths` and affects none. Invalid, unreadable, unsupported, or
4102
3884
  * otherwise doubtful input widens the whole stamp to full impact.
4103
3885
  *
4104
3886
  * Total by contract: this function never throws. Any unexpected failure in
@@ -4175,10 +3957,6 @@ const analyzeSourcePaths = (sourceFiles, targets, profilePath) => {
4175
3957
  kind: "conservative",
4176
3958
  reason: `changed source path "${file}" is the impact profile being evaluated; fail closed to full impact`
4177
3959
  };
4178
- if (!targets.some((target) => matchesAnyGlob(target.paths ?? [], file))) return {
4179
- kind: "conservative",
4180
- reason: `changed source path "${file}" has no declared impact target owner; fail closed to full impact`
4181
- };
4182
3960
  }
4183
3961
  return {
4184
3962
  kind: "ok",
@@ -4193,7 +3971,8 @@ const analyzeSourcePaths = (sourceFiles, targets, profilePath) => {
4193
3971
  impact: "affected",
4194
3972
  name: target.name
4195
3973
  };
4196
- })
3974
+ }),
3975
+ unsubscribedPaths: sourceFiles.filter((file) => !targets.some((target) => matchesAnyGlob(target.paths ?? [], file)))
4197
3976
  };
4198
3977
  };
4199
3978
  const unionTargetImpact = (source, lockfile) => {
@@ -4209,8 +3988,9 @@ const computeImpactStampOrThrow = ({ changedFiles, profilePath = "software-facto
4209
3988
  if (targets.length === 0) return {
4210
3989
  basis: "conservative",
4211
3990
  reasons: ["no impact targets declared in the profile; every surface keeps full demand"],
4212
- stampVersion: 2,
4213
- targets: []
3991
+ stampVersion: 3,
3992
+ targets: [],
3993
+ unsubscribedPaths: changedFiles.filter((file) => file !== "pnpm-lock.yaml" && isRepoRelativePath(file))
4214
3994
  };
4215
3995
  assertDeclaredPathGlobs(targets);
4216
3996
  const sides = readLockfileSides(readLockfile);
@@ -4219,18 +3999,19 @@ const computeImpactStampOrThrow = ({ changedFiles, profilePath = "software-facto
4219
3999
  const sourceFiles = changedFiles.filter((file) => file !== LOCKFILE_PATH);
4220
4000
  const source = analyzeSourcePaths(sourceFiles, targets, profilePath);
4221
4001
  if (source.kind === "conservative") return conservativeStamp(targets, [source.reason]);
4222
- if (sides.kind === "threw") throw sides.error;
4223
- if (sides.kind === "unreadable") return conservativeStamp(targets, [`pnpm-lock.yaml is unreadable at the ${sides.side} side; fail closed to full impact`]);
4224
- if (sides.kind === "unparseable") return conservativeStamp(targets, [`pnpm-lock.yaml is unparseable or structurally malformed at the ${sides.side} side; fail closed to full impact`]);
4225
- if (sides.kind === "unsupported-version") return conservativeStamp(targets, [`pnpm-lock.yaml is not lockfileVersion ${SUPPORTED_LOCKFILE_VERSION}; only pnpm v9 lockfiles are modelled; fail closed to full impact`]);
4002
+ if (sides.kind === "threw") return conservativeStamp(targets, [`impact stamp computation failed (${sides.error instanceof Error ? sides.error.message : String(sides.error)}); fail closed to full impact`], source.unsubscribedPaths);
4003
+ if (sides.kind === "unreadable") return conservativeStamp(targets, [`pnpm-lock.yaml is unreadable at the ${sides.side} side; fail closed to full impact`], source.unsubscribedPaths);
4004
+ if (sides.kind === "unparseable") return conservativeStamp(targets, [`pnpm-lock.yaml is unparseable or structurally malformed at the ${sides.side} side; fail closed to full impact`], source.unsubscribedPaths);
4005
+ if (sides.kind === "unsupported-version") return conservativeStamp(targets, [`pnpm-lock.yaml is not lockfileVersion ${SUPPORTED_LOCKFILE_VERSION}; only pnpm v9 lockfiles are modelled; fail closed to full impact`], source.unsubscribedPaths);
4226
4006
  if (!changedFiles.includes("pnpm-lock.yaml")) return {
4227
4007
  basis: "target-scoped",
4228
- reasons: ["all changed source paths have declared target ownership; impact scoped by repo-relative path globs"],
4229
- stampVersion: 2,
4230
- targets: source.targets
4008
+ reasons: [source.unsubscribedPaths.length === 0 ? "all changed source paths match at least one declared target subscription; impact scoped by repo-relative path globs" : `${source.unsubscribedPaths.length} changed source path(s) match no declared target subscription and affect no target`],
4009
+ stampVersion: 3,
4010
+ targets: source.targets,
4011
+ unsubscribedPaths: source.unsubscribedPaths
4231
4012
  };
4232
4013
  const [baseLock, headLock] = sides.parsed;
4233
- if (stableStringify$1(nonGraphSections(baseLock)) !== stableStringify$1(nonGraphSections(headLock))) return conservativeStamp(targets, ["pnpm-lock.yaml delta touches sections outside importers/packages/snapshots (e.g. settings, overrides, patchedDependencies); fail closed to full impact"]);
4014
+ if (stableStringify$1(nonGraphSections(baseLock)) !== stableStringify$1(nonGraphSections(headLock))) return conservativeStamp(targets, ["pnpm-lock.yaml delta touches sections outside importers/packages/snapshots (e.g. settings, overrides, patchedDependencies); fail closed to full impact"], source.unsubscribedPaths);
4234
4015
  const delta = {
4235
4016
  changedImporters: changedSectionKeys(baseLock.importers, headLock.importers),
4236
4017
  changedPackages: changedSectionKeys(baseLock.packages, headLock.packages),
@@ -4241,9 +4022,10 @@ const computeImpactStampOrThrow = ({ changedFiles, profilePath = "software-facto
4241
4022
  const mixed = sourceFiles.length > 0;
4242
4023
  return {
4243
4024
  basis: "target-scoped",
4244
- reasons: mixed ? ["all changed source paths have declared target ownership and pnpm-lock.yaml is analyzable; impact is the union of path ownership and importer/snapshot graph analysis"] : ["pnpm-lock.yaml is the only changed file; impact scoped by importer/snapshot graph analysis"],
4245
- stampVersion: 2,
4246
- targets: mixed ? source.targets.map((sourceTarget, index) => unionTargetImpact(sourceTarget, lockfileTargets[index])) : lockfileTargets
4025
+ reasons: mixed ? [source.unsubscribedPaths.length === 0 ? "all changed source paths match at least one declared target subscription and pnpm-lock.yaml is analyzable; impact is the union of path subscriptions and importer/snapshot graph analysis" : `${source.unsubscribedPaths.length} changed source path(s) match no declared target subscription; subscribed path impact is unioned with importer/snapshot graph analysis`] : ["pnpm-lock.yaml is the only changed file; impact scoped by importer/snapshot graph analysis"],
4026
+ stampVersion: 3,
4027
+ targets: mixed ? source.targets.map((sourceTarget, index) => unionTargetImpact(sourceTarget, lockfileTargets[index])) : lockfileTargets,
4028
+ unsubscribedPaths: source.unsubscribedPaths
4247
4029
  };
4248
4030
  };
4249
4031
  //#endregion
@@ -5541,7 +5323,7 @@ const CLOSING_PRS_QUERY = `query($owner: String!, $repo: String!, $issue: Int!)
5541
5323
  repository(owner: $owner, name: $repo) {
5542
5324
  issue(number: $issue) {
5543
5325
  closedByPullRequestsReferences(first: 50, includeClosedPrs: true) {
5544
- nodes { number state headRefOid mergeCommit { oid } }
5326
+ nodes { number state baseRefName headRefName headRefOid mergeCommit { oid } }
5545
5327
  }
5546
5328
  }
5547
5329
  }
@@ -5569,7 +5351,9 @@ const defaultBoundaryCheckGithub = () => ({
5569
5351
  "-F",
5570
5352
  `issue=${issue}`
5571
5353
  ]).data?.repository?.issue?.closedByPullRequestsReferences?.nodes ?? []).map((node) => ({
5354
+ ...node.baseRefName ? { baseRefName: node.baseRefName } : {},
5572
5355
  ...node.headRefOid ? { headRefOid: node.headRefOid } : {},
5356
+ ...node.headRefName ? { headRefName: node.headRefName } : {},
5573
5357
  ...node.mergeCommit?.oid ? { mergeCommitOid: node.mergeCommit.oid } : {},
5574
5358
  number: node.number,
5575
5359
  state: node.state
@@ -5593,10 +5377,12 @@ const defaultBoundaryCheckGithub = () => ({
5593
5377
  "--repo",
5594
5378
  repo,
5595
5379
  "--json",
5596
- "number,state,headRefOid,mergeCommit"
5380
+ "number,state,baseRefName,headRefName,headRefOid,mergeCommit"
5597
5381
  ]);
5598
5382
  return {
5383
+ ...raw.baseRefName ? { baseRefName: raw.baseRefName } : {},
5599
5384
  ...raw.headRefOid ? { headRefOid: raw.headRefOid } : {},
5385
+ ...raw.headRefName ? { headRefName: raw.headRefName } : {},
5600
5386
  ...raw.mergeCommit?.oid ? { mergeCommitOid: raw.mergeCommit.oid } : {},
5601
5387
  number: raw.number,
5602
5388
  state: raw.state
@@ -5635,10 +5421,12 @@ const coveredEntryFromRef = (ref, wave, issue, notices) => {
5635
5421
  const computeCoveredSet = (manifest, repo, github) => {
5636
5422
  const notices = [];
5637
5423
  const byPr = /* @__PURE__ */ new Map();
5424
+ const pullRequests = /* @__PURE__ */ new Map();
5638
5425
  for (const wave of manifest.waves) for (const issue of wave.issues) {
5639
5426
  const refs = github.fetchClosingPullRequests(repo, issue);
5640
5427
  if (refs.length === 0) notices.push(`issue #${issue} (wave ${wave.name}) has no linked closing PR yet`);
5641
5428
  for (const ref of refs) {
5429
+ if (!pullRequests.has(ref.number)) pullRequests.set(ref.number, ref);
5642
5430
  const entry = coveredEntryFromRef(ref, wave.name, issue, notices);
5643
5431
  if (entry && !byPr.has(entry.pr)) byPr.set(entry.pr, entry);
5644
5432
  }
@@ -5646,12 +5434,15 @@ const computeCoveredSet = (manifest, repo, github) => {
5646
5434
  for (const [prKey, waveName] of Object.entries(manifest.prs ?? {})) {
5647
5435
  const prNumber = Number(prKey);
5648
5436
  if (byPr.has(prNumber)) continue;
5649
- const entry = coveredEntryFromRef(github.fetchPullRequest(repo, prNumber), waveName, void 0, notices);
5437
+ const ref = github.fetchPullRequest(repo, prNumber);
5438
+ pullRequests.set(prNumber, ref);
5439
+ const entry = coveredEntryFromRef(ref, waveName, void 0, notices);
5650
5440
  if (entry) byPr.set(entry.pr, entry);
5651
5441
  }
5652
5442
  return {
5653
5443
  entries: [...byPr.values()].toSorted((a, b) => a.pr - b.pr),
5654
- notices
5444
+ notices,
5445
+ pullRequests: [...pullRequests.values()].toSorted((a, b) => a.number - b.number)
5655
5446
  };
5656
5447
  };
5657
5448
  const coveredSetDivergences = (computed, proof) => {
@@ -5718,6 +5509,10 @@ function runBoundaryCheck(args, dependencies = {}) {
5718
5509
  manifestHash: parsed.manifestHash,
5719
5510
  selected
5720
5511
  });
5512
+ blockingReasons.unshift(...boundaryTopologyReasons({
5513
+ manifest: parsed.manifest,
5514
+ pullRequests: computed.pullRequests
5515
+ }));
5721
5516
  record = {
5722
5517
  blockingReasons,
5723
5518
  boundary: parsed.manifest.boundary,
@@ -5846,17 +5641,21 @@ const asClassification = (value) => DIFF_CLASSIFICATIONS$1.includes(value) ? val
5846
5641
  const prVerifyCheckPayloadFor = (proof) => {
5847
5642
  const value = proof;
5848
5643
  if (!value || typeof value.headSha !== "string" || value.headSha.length === 0 || !isResolvedPrVerifyMode(value.mode)) return;
5644
+ const carriesReleases = value.notRequiredCommands !== void 0;
5849
5645
  return {
5850
5646
  classification: asClassification(value.classification),
5851
5647
  executedCommands: (value.executedCommands ?? []).map(({ name }) => name).filter((name) => typeof name === "string"),
5852
5648
  headSha: value.headSha,
5649
+ ...carriesReleases && value.impactStamp !== void 0 ? { impactStamp: value.impactStamp } : {},
5853
5650
  kind: PR_VERIFY_CHECK_PAYLOAD_KIND,
5854
5651
  mode: value.mode,
5652
+ ...carriesReleases ? { notRequiredCommands: value.notRequiredCommands } : {},
5855
5653
  outcome: outcomeFor(value),
5856
5654
  proofSchemaVersion: typeof value.schemaVersion === "number" ? value.schemaVersion : 0,
5857
5655
  schemaVersion: PR_VERIFY_CHECK_PAYLOAD_SCHEMA_VERSION,
5858
5656
  ...typeof value.patchId === "string" ? { patchId: value.patchId } : {},
5859
- ...typeof value.repository === "string" ? { repository: value.repository } : {}
5657
+ ...typeof value.repository === "string" ? { repository: value.repository } : {},
5658
+ ...carriesReleases && value.verificationCommands !== void 0 ? { verificationCommands: value.verificationCommands } : {}
5860
5659
  };
5861
5660
  };
5862
5661
  const renderPrVerifyCheckPayloadText = (payload) => `\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
@@ -7670,31 +7469,6 @@ const defaultSleep = async (ms) => {
7670
7469
  const { setTimeout: delay } = await import("node:timers/promises");
7671
7470
  await delay(ms);
7672
7471
  };
7673
- const base64url = (value) => Buffer.from(value).toString("base64url");
7674
- function appJwt(config, now) {
7675
- const issuedAt = Math.floor(now / 1e3) - 60;
7676
- const unsigned = `${base64url(JSON.stringify({
7677
- alg: "RS256",
7678
- typ: "JWT"
7679
- }))}.${base64url(JSON.stringify({
7680
- exp: issuedAt + 600,
7681
- iat: issuedAt,
7682
- iss: config.appId
7683
- }))}`;
7684
- const signer = createSign("RSA-SHA256");
7685
- signer.update(unsigned);
7686
- signer.end();
7687
- return `${unsigned}.${signer.sign(readFileSync(config.privateKeyPath), "base64url")}`;
7688
- }
7689
- /** Carries the HTTP status so a caller can tell a retryable failure apart. */
7690
- var GitHubApiError = class extends Error {
7691
- status;
7692
- constructor(status, statusText) {
7693
- super(`GitHub API ${status} ${statusText}`);
7694
- this.name = "GitHubApiError";
7695
- this.status = status;
7696
- }
7697
- };
7698
7472
  /**
7699
7473
  * Worth another attempt: 422 is what GitHub answers for a head SHA it has not
7700
7474
  * seen yet (the whole reason the retry exists), 5xx and rate limiting are
@@ -7749,30 +7523,36 @@ async function githubJson(request, url, init, timeoutMs = GITHUB_PUBLISH_TIMEOUT
7749
7523
  if (!response.ok) throw new GitHubApiError(response.status, response.statusText);
7750
7524
  return await response.json();
7751
7525
  }
7752
- async function installationToken(repository, config, request, now, timeoutMs = GITHUB_PUBLISH_TIMEOUT_MS) {
7753
- const jwt = appJwt(config, now);
7754
- let { installationId } = config;
7755
- if (installationId === void 0) {
7756
- const installation = await githubJson(request, `https://api.github.com/repos/${repository.owner}/${repository.repo}/installation`, {
7757
- headers: { Authorization: `Bearer ${jwt}` },
7758
- method: "GET"
7759
- }, timeoutMs);
7760
- if (typeof installation.id !== "number") throw new TypeError("GitHub App installation response omitted id");
7761
- installationId = installation.id;
7762
- }
7763
- const token = await githubJson(request, `https://api.github.com/app/installations/${installationId}/access_tokens`, {
7764
- headers: { Authorization: `Bearer ${jwt}` },
7765
- method: "POST"
7766
- }, timeoutMs);
7767
- if (typeof token.token !== "string") throw new TypeError("GitHub App token response omitted token");
7768
- return token.token;
7769
- }
7526
+ /**
7527
+ * Mint an installation token for the Patronage Factory App.
7528
+ *
7529
+ * The mechanism — app JWT, installation lookup, token exchange — lives in
7530
+ * `@patronage/factory-ci` (#617), because paitronage's proof-comment publisher
7531
+ * had grown a second copy of it. What stays here is what is this repository's:
7532
+ * where the credentials come from (`user-config`), and the publish timeout the
7533
+ * rest of these calls are bound by. Nothing is cached, as before.
7534
+ */
7535
+ const installationToken = (repository, config, request, now, timeoutMs = GITHUB_PUBLISH_TIMEOUT_MS) => mintInstallationToken({
7536
+ credentials: config,
7537
+ owner: repository.owner,
7538
+ repo: repository.repo
7539
+ }, {
7540
+ fetch: request,
7541
+ now: () => now,
7542
+ timeoutMs
7543
+ });
7770
7544
  function verifyOutput(proof) {
7771
7545
  const value = proof;
7772
7546
  const commands = value.executedCommands?.length ?? 0;
7547
+ const unsubscribedBasis = value.impactStamp?.unsubscribedPaths;
7548
+ let unsubscribedSummary;
7549
+ if (Array.isArray(unsubscribedBasis)) {
7550
+ const unsubscribed = unsubscribedBasis.filter((path) => typeof path === "string");
7551
+ unsubscribedSummary = unsubscribed.length === 0 ? "Unsubscribed changed paths: none." : `Unsubscribed changed paths (demand no target): ${unsubscribed.join(", ")}.`;
7552
+ } else unsubscribedSummary = "Unsubscribed changed paths: unavailable (legacy or malformed stamp).";
7773
7553
  const payload = prVerifyCheckPayloadFor(proof);
7774
7554
  return {
7775
- summary: `Schema v${value.schemaVersion}; mode **${value.mode ?? "unknown"}**; classification **${value.classification ?? "unknown"}**; outcome **${value.outcome ?? "unknown"}**; ${commands} command(s) executed.`,
7555
+ summary: `Schema v${value.schemaVersion}; mode **${value.mode ?? "unknown"}**; classification **${value.classification ?? "unknown"}**; outcome **${value.outcome ?? "unknown"}**; ${commands} command(s) executed. ${unsubscribedSummary}`,
7776
7556
  ...payload ? { text: renderPrVerifyCheckPayloadText(payload) } : {},
7777
7557
  title: `pr:verify ${value.outcome ?? "unknown"}`
7778
7558
  };
@@ -7786,8 +7566,10 @@ function reviewOutput(proof) {
7786
7566
  for (const { disposition } of value.ladder ? ladderLedgerFor(value.ladder) : []) dispositionCounts[disposition] = (dispositionCounts[disposition] ?? 0) + 1;
7787
7567
  if (!value.ladder && findings > 0) dispositionCounts.open = findings;
7788
7568
  const dispositions = Object.entries(dispositionCounts).map(([name, count]) => `${name} ${count}`).join(", ");
7569
+ const unavailable = "unavailable (legacy proof)";
7570
+ const provenance = reviews.length === 0 ? "not required" : reviews.map(({ kind, model, producer, rung }) => `${kind ?? "unknown mode"}: rung ${rung ?? unavailable}; producer ${producer ?? unavailable}; model ${model ?? unavailable}`).join(" | ");
7789
7571
  return {
7790
- summary: `Schema v${value.schemaVersion}; modes **${modes}**; ${findings} finding(s) recorded; dispositions: ${dispositions || "none"}.`,
7572
+ summary: `Schema v${value.schemaVersion}; modes **${modes}**; ${findings} finding(s) recorded; dispositions: ${dispositions || "none"}. Review provenance: ${provenance}.`,
7791
7573
  title: findings === 0 ? "pr:review clean" : "pr:review proof recorded"
7792
7574
  };
7793
7575
  }
@@ -8881,8 +8663,24 @@ function positiveInteger(name) {
8881
8663
  };
8882
8664
  }
8883
8665
  function resolveCwdOption(cwd) {
8884
- if (typeof cwd !== "string" || cwd.trim() === "") throw new Error("--cwd requires a non-empty path; relative values resolve against the current working directory");
8885
- return path.resolve(cwd);
8666
+ const value = typeof cwd === "string" ? cwd : ".";
8667
+ if (value.trim() === "") throw new Error("--cwd requires a non-empty path; relative values resolve against the current working directory");
8668
+ return path.resolve(value);
8669
+ }
8670
+ function collectCwdOption(rawValue, previous) {
8671
+ if (previous === void 0) return rawValue;
8672
+ const previousResolved = resolveCwdOption(previous);
8673
+ const currentResolved = resolveCwdOption(rawValue);
8674
+ if (previousResolved !== currentResolved) throw new Error(`--cwd was passed multiple times with conflicting values: "${previous}" (resolves to ${previousResolved}) and "${rawValue}" (resolves to ${currentResolved})`);
8675
+ return rawValue;
8676
+ }
8677
+ function markCwdOptionDefault(command) {
8678
+ const option = command.options.find((candidate) => candidate.attributeName() === "cwd");
8679
+ if (option) {
8680
+ option.defaultValue = ".";
8681
+ option.defaultValueDescription = ".";
8682
+ }
8683
+ return command;
8886
8684
  }
8887
8685
  function resolveCheckoutPath(cwd, target) {
8888
8686
  return path.resolve(cwd, target);
@@ -8905,7 +8703,7 @@ const renderReport = (record) => {
8905
8703
  return `${lines.join("\n")}\n`;
8906
8704
  };
8907
8705
  function createBoundaryCheckCommand(output, action) {
8908
- return new Command("boundary:check").description("Boundary readiness gate (T8): require a membership-fresh boundary-review proof at the declared closeout rung. Blocks closeout/enablement, never merge.").requiredOption("--epic <number>", "epic issue number carrying the factory-boundary manifest", positiveInteger("--epic")).option("--repo <owner/name>", "GitHub repository (default: resolved from --cwd)").option("--cwd <path>", "repository working directory", ".").option("--output <path>", "write an additional proof copy to this path").option("--json", "print the full proof record as JSON").action(withGateTiming({
8706
+ return markCwdOptionDefault(new Command("boundary:check").description("Boundary readiness gate (T8): require a membership-fresh boundary-review proof at the declared closeout rung. Blocks closeout/enablement, never merge.").requiredOption("--epic <number>", "epic issue number carrying the factory-boundary manifest", positiveInteger("--epic")).option("--repo <owner/name>", "GitHub repository (default: resolved from --cwd)").option("--cwd <path>", "repository working directory", collectCwdOption).option("--output <path>", "write an additional proof copy to this path").option("--profile <path>", "path to the project profile JSON file").option("--json", "print the full proof record as JSON").action(withGateTiming({
8909
8707
  gate: "boundary:check",
8910
8708
  resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
8911
8709
  stderr: output.stderr
@@ -8921,7 +8719,10 @@ function createBoundaryCheckCommand(output, action) {
8921
8719
  if (!action) {
8922
8720
  const [owner, name] = record.repo.split("/");
8923
8721
  if (owner && name) {
8924
- const hqLaneBaseUrl = hqLaneRefBaseUrlFromProfile(tryLoadProjectProfile({ cwd: args.cwd })?.profile);
8722
+ const hqLaneBaseUrl = hqLaneRefBaseUrlFromProfile(tryLoadProjectProfile({
8723
+ cwd: args.cwd,
8724
+ profilePath: options.profile
8725
+ })?.profile);
8925
8726
  try {
8926
8727
  publishFactoryCheckSafely(createFactoryCheckPublisher(output), {
8927
8728
  conclusion: record.status === "ready" ? "success" : "failure",
@@ -8941,7 +8742,710 @@ function createBoundaryCheckCommand(output, action) {
8941
8742
  }
8942
8743
  output.stdout.write(options.json ? `${JSON.stringify(record, null, 2)}\n` : renderReport(record));
8943
8744
  if (record.status !== "ready") throw new Error(`boundary:check refused: ${record.blockingReasons.join("; ")}`);
8745
+ })));
8746
+ }
8747
+ /** `tool` discriminator every emitted report carries. */
8748
+ const CI_ANALYZE_TOOL = "psf-ci-analyze";
8749
+ /** Cohort label for runs the caller did not assign to a cohort. */
8750
+ const UNGROUPED_COHORT = "ungrouped";
8751
+ const available = (value) => ({
8752
+ available: true,
8753
+ value
8754
+ });
8755
+ const unavailable = (reason) => ({
8756
+ available: false,
8757
+ reason
8758
+ });
8759
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8760
+ const stringField$1 = (source, key, where) => {
8761
+ const value = source[key];
8762
+ return typeof value === "string" && value.length > 0 ? available(value) : unavailable(`${where} has no ${key}`);
8763
+ };
8764
+ const numberField = (source, key, where) => {
8765
+ const value = source[key];
8766
+ return typeof value === "number" && Number.isFinite(value) ? available(value) : unavailable(`${where} has no ${key}`);
8767
+ };
8768
+ /**
8769
+ * An instant is only usable when it names its own timezone. `Date.parse`
8770
+ * interprets an offset-less `2026-08-06T12:00:00` in the *host's* zone, which
8771
+ * would make a duration depend on where the analyzer ran. The GitHub API
8772
+ * always sends `Z`-form, so requiring an explicit `Z` or numeric offset
8773
+ * rejects nothing genuine — anything else becomes explicitly unavailable.
8774
+ */
8775
+ const EXPLICIT_OFFSET_PATTERN = /(?:Z|[+-]\d{2}:?\d{2})$/u;
8776
+ const parseInstantMs = (value) => {
8777
+ if (!value.available) return;
8778
+ if (!EXPLICIT_OFFSET_PATTERN.test(value.value)) return;
8779
+ const parsed = Date.parse(value.value);
8780
+ return Number.isNaN(parsed) ? void 0 : parsed;
8781
+ };
8782
+ /**
8783
+ * Wall clock between two ISO instants. Unavailable — with the missing side
8784
+ * named — whenever either instant is absent or unparsable; a negative span is
8785
+ * refused rather than clamped to a fake zero.
8786
+ */
8787
+ const durationBetween = (start, end, label) => {
8788
+ const startMs = parseInstantMs(start);
8789
+ if (startMs === void 0) return unavailable(`${label}: start instant is ${start.available ? "unparsable" : `unavailable (${start.reason})`}`);
8790
+ const endMs = parseInstantMs(end);
8791
+ if (endMs === void 0) return unavailable(`${label}: end instant is ${end.available ? "unparsable" : `unavailable (${end.reason})`}`);
8792
+ if (endMs < startMs) return unavailable(`${label}: end instant precedes start instant`);
8793
+ return available(endMs - startMs);
8794
+ };
8795
+ const GITHUB_SETUP_STEP_NAME = "Set up job";
8796
+ const INSTALL_STEP_PATTERN = /install/iu;
8797
+ const analyzeStep = (step) => {
8798
+ const startedAt = stringField$1(step, "started_at", "step");
8799
+ const completedAt = stringField$1(step, "completed_at", "step");
8800
+ return {
8801
+ completedAt,
8802
+ conclusion: stringField$1(step, "conclusion", "step"),
8803
+ durationMs: durationBetween(startedAt, completedAt, "step duration"),
8804
+ name: typeof step.name === "string" ? step.name : "(unnamed step)",
8805
+ number: numberField(step, "number", "step"),
8806
+ startedAt
8807
+ };
8808
+ };
8809
+ const namedStepDuration = (steps, matches, description) => {
8810
+ const step = steps.find((candidate) => matches(candidate.name));
8811
+ return step === void 0 ? unavailable(`no ${description} step in this job`) : step.durationMs;
8812
+ };
8813
+ const analyzeJob = (job) => {
8814
+ const steps = Array.isArray(job.steps) ? job.steps.filter(isRecord).map(analyzeStep) : [];
8815
+ const createdAt = stringField$1(job, "created_at", "job");
8816
+ const startedAt = stringField$1(job, "started_at", "job");
8817
+ const completedAt = stringField$1(job, "completed_at", "job");
8818
+ const labels = Array.isArray(job.labels) ? job.labels.filter((label) => typeof label === "string") : void 0;
8819
+ return {
8820
+ completedAt,
8821
+ conclusion: stringField$1(job, "conclusion", "job"),
8822
+ createdAt,
8823
+ installMs: namedStepDuration(steps, (name) => INSTALL_STEP_PATTERN.test(name), "install"),
8824
+ jobId: numberField(job, "id", "job"),
8825
+ name: typeof job.name === "string" ? job.name : "(unnamed job)",
8826
+ queueMs: durationBetween(createdAt, startedAt, "job queue"),
8827
+ runnerLabels: labels === void 0 ? unavailable("job has no labels array") : available(labels),
8828
+ setupMs: namedStepDuration(steps, (name) => name === GITHUB_SETUP_STEP_NAME, `"${GITHUB_SETUP_STEP_NAME}"`),
8829
+ startedAt,
8830
+ steps,
8831
+ totalMs: durationBetween(startedAt, completedAt, "job duration"),
8832
+ url: stringField$1(job, "html_url", "job")
8833
+ };
8834
+ };
8835
+ /**
8836
+ * Classify proof reuse from step conclusions alone. A job whose gate step is
8837
+ * followed by at least one skipped step reused proof; a job whose post-gate
8838
+ * steps all executed ran the full fallback. No gate step anywhere means the
8839
+ * workflow does not expose the outcome, which is unavailable — not
8840
+ * full-fallback.
8841
+ */
8842
+ const classifyProofReuse = (jobs) => {
8843
+ const gateJobs = [];
8844
+ const verdicts = /* @__PURE__ */ new Set();
8845
+ for (const job of jobs) {
8846
+ const gateIndex = job.steps.findIndex((step) => step.name === FACTORY_PROOF_GATE_STEP_NAME);
8847
+ if (gateIndex === -1) continue;
8848
+ const skippedStepsAfterGate = job.steps.slice(gateIndex + 1).filter((step) => step.conclusion.available && step.conclusion.value === "skipped").length;
8849
+ verdicts.add(skippedStepsAfterGate > 0 ? "proof-reuse" : "full-fallback");
8850
+ gateJobs.push({
8851
+ gateConclusion: job.steps[gateIndex]?.conclusion ?? unavailable("gate step vanished"),
8852
+ jobName: job.name,
8853
+ skippedStepsAfterGate
8854
+ });
8855
+ }
8856
+ if (gateJobs.length === 0) return unavailable(`no "${FACTORY_PROOF_GATE_STEP_NAME}" step in any job; this workflow does not expose a proof-reuse outcome`);
8857
+ const [single] = [...verdicts];
8858
+ return available({
8859
+ classification: verdicts.size === 1 && single ? single : "mixed",
8860
+ gateJobs
8861
+ });
8862
+ };
8863
+ const parseArtifactListing = (value) => {
8864
+ if (!isRecord(value) || !Array.isArray(value.artifacts)) return {
8865
+ artifacts: [],
8866
+ totalCount: void 0
8867
+ };
8868
+ return {
8869
+ artifacts: value.artifacts.filter(isRecord).flatMap((artifact) => {
8870
+ const { id, name } = artifact;
8871
+ return typeof id === "number" && typeof name === "string" ? [{
8872
+ expired: artifact.expired === true,
8873
+ id,
8874
+ name
8875
+ }] : [];
8876
+ }),
8877
+ totalCount: typeof value.total_count === "number" ? value.total_count : void 0
8878
+ };
8879
+ };
8880
+ const profileFromDocument = (artifactName, value) => {
8881
+ const read = readVitestProfileDocument(value);
8882
+ if (read.kind === "unrecognized") return {
8883
+ artifactName,
8884
+ kind: "unreadable",
8885
+ reason: read.reason
8886
+ };
8887
+ const { profile } = read;
8888
+ const testCounts = profile.runs.flatMap((run) => run.counts === null ? [] : [run.counts.tests]);
8889
+ return {
8890
+ artifactName,
8891
+ kind: "profile",
8892
+ profile: {
8893
+ cpuCount: profile.environment.cpuCount,
8894
+ cpuModel: profile.environment.cpuModel,
8895
+ maxWorkers: profile.options.maxWorkers,
8896
+ sampleDurationsMs: profile.runs.map((run) => run.durationMs),
8897
+ scope: profile.command.join(" "),
8898
+ slowFiles: profile.summary.slowFiles.map((file) => ({
8899
+ medianMs: file.durationMs.median,
8900
+ path: file.path
8901
+ })),
8902
+ slowTests: profile.summary.slowTests.map((test) => ({
8903
+ file: test.file,
8904
+ medianMs: test.durationMs.median,
8905
+ name: test.name
8906
+ })),
8907
+ summaryDurationMs: profile.summary.durationMs,
8908
+ testCount: testCounts.length === 0 ? unavailable("no sample in this profile carries test counts") : available(Math.max(...testCounts))
8909
+ }
8910
+ };
8911
+ };
8912
+ const collectProfiles = async (client, runId) => {
8913
+ let listing;
8914
+ try {
8915
+ listing = parseArtifactListing(await client.listArtifacts(runId));
8916
+ } catch (error) {
8917
+ return unavailable(`artifact listing failed: ${error instanceof Error ? error.message : String(error)}`);
8918
+ }
8919
+ const { artifacts } = listing;
8920
+ if (listing.totalCount !== void 0 && listing.totalCount > artifacts.length) return unavailable(`artifact listing is truncated: ${artifacts.length} of ${listing.totalCount} artifacts served`);
8921
+ if (artifacts.length === 0) return unavailable("this run has no artifacts");
8922
+ const analyses = [];
8923
+ for (const artifact of artifacts) {
8924
+ if (artifact.expired) {
8925
+ analyses.push({
8926
+ artifactName: artifact.name,
8927
+ kind: "unreadable",
8928
+ reason: "the artifact is expired and can no longer be downloaded"
8929
+ });
8930
+ continue;
8931
+ }
8932
+ let files;
8933
+ try {
8934
+ files = await client.readArtifactTextFiles(artifact.id);
8935
+ } catch (error) {
8936
+ analyses.push({
8937
+ artifactName: artifact.name,
8938
+ kind: "unreadable",
8939
+ reason: `artifact download failed: ${error instanceof Error ? error.message : String(error)}`
8940
+ });
8941
+ continue;
8942
+ }
8943
+ for (const file of files) {
8944
+ let parsed;
8945
+ try {
8946
+ parsed = JSON.parse(file.text);
8947
+ } catch {
8948
+ analyses.push({
8949
+ artifactName: `${artifact.name}/${file.name}`,
8950
+ kind: "unreadable",
8951
+ reason: "the file is not valid JSON"
8952
+ });
8953
+ continue;
8954
+ }
8955
+ const analysis = profileFromDocument(`${artifact.name}/${file.name}`, parsed);
8956
+ if (analysis.kind === "profile" || isRecord(parsed) && parsed.tool === "factory-ci-vitest-profile") analyses.push(analysis);
8957
+ }
8958
+ }
8959
+ return analyses.length === 0 ? unavailable("no compatible factory-ci Vitest profile artifacts on this run") : available(analyses);
8960
+ };
8961
+ const earliest = (instants) => instants.length === 0 ? void 0 : Math.min(...instants);
8962
+ const latest = (instants) => instants.length === 0 ? void 0 : Math.max(...instants);
8963
+ const runQueueDuration = (createdAt, jobs) => {
8964
+ const createdMs = parseInstantMs(createdAt);
8965
+ if (createdMs === void 0) return unavailable("the run object has no parsable created_at");
8966
+ const firstStart = earliest(jobs.flatMap((job) => {
8967
+ const started = parseInstantMs(job.startedAt);
8968
+ return started === void 0 ? [] : [started];
8944
8969
  }));
8970
+ if (firstStart === void 0) return unavailable("no job carries a parsable started_at");
8971
+ return firstStart < createdMs ? unavailable("the earliest job start precedes the run creation instant") : available(firstStart - createdMs);
8972
+ };
8973
+ const runCompletionInstant = (jobs) => {
8974
+ const completions = jobs.flatMap((job) => {
8975
+ const completed = parseInstantMs(job.completedAt);
8976
+ return completed === void 0 ? [] : [{
8977
+ completed,
8978
+ iso: job.completedAt
8979
+ }];
8980
+ });
8981
+ const last = latest(completions.map((entry) => entry.completed));
8982
+ const match = completions.find((entry) => entry.completed === last);
8983
+ return match?.iso.available === true ? available(match.iso.value) : unavailable("no job carries a parsable completed_at");
8984
+ };
8985
+ /**
8986
+ * `cpuModel` never comes from the Actions API — only readable profile
8987
+ * artifacts carry silicon attribution, so both absence cases say so.
8988
+ */
8989
+ const cpuModelsField = (cpuModels, profiles) => {
8990
+ if (cpuModels === void 0) return unavailable(`CPU models come only from profile artifacts (${profiles.available ? "none usable" : profiles.reason}); the Actions API does not expose silicon`);
8991
+ if (cpuModels.length === 0) return unavailable("no readable profile artifact recorded a CPU model");
8992
+ return available(cpuModels);
8993
+ };
8994
+ const analyzeOneRun = async (client, request, entry) => {
8995
+ const cohort = entry.cohort ?? "ungrouped";
8996
+ let runValue;
8997
+ let jobsValue;
8998
+ try {
8999
+ runValue = await client.getRun(entry.runId);
9000
+ jobsValue = await client.listJobs(entry.runId);
9001
+ } catch (error) {
9002
+ return {
9003
+ cohort,
9004
+ kind: "unreadable",
9005
+ reason: error instanceof Error ? error.message : String(error),
9006
+ repository: request.repository,
9007
+ runId: entry.runId
9008
+ };
9009
+ }
9010
+ if (!isRecord(runValue)) return {
9011
+ cohort,
9012
+ kind: "unreadable",
9013
+ reason: "the run response is not an object",
9014
+ repository: request.repository,
9015
+ runId: entry.runId
9016
+ };
9017
+ const jobRows = isRecord(jobsValue) && Array.isArray(jobsValue.jobs) ? jobsValue.jobs.filter(isRecord) : [];
9018
+ if (isRecord(jobsValue) && typeof jobsValue.total_count === "number" && jobsValue.total_count > jobRows.length) return {
9019
+ cohort,
9020
+ kind: "unreadable",
9021
+ reason: `jobs listing is truncated: ${jobRows.length} of ${jobsValue.total_count} jobs served`,
9022
+ repository: request.repository,
9023
+ runId: entry.runId
9024
+ };
9025
+ const jobs = jobRows.map(analyzeJob);
9026
+ const createdAt = stringField$1(runValue, "created_at", "run");
9027
+ const startedAt = stringField$1(runValue, "run_started_at", "run");
9028
+ const completedAt = runCompletionInstant(jobs);
9029
+ const profiles = request.includeProfiles ? await collectProfiles(client, entry.runId) : unavailable("profile artifacts were not requested (pass --profiles)");
9030
+ const cpuModels = profiles.available ? [...new Set(profiles.value.flatMap((profile) => profile.kind === "profile" && profile.profile.cpuModel !== null ? [profile.profile.cpuModel] : []))] : void 0;
9031
+ const labels = [...new Set(jobs.flatMap((job) => job.runnerLabels.available ? job.runnerLabels.value : []))];
9032
+ return {
9033
+ cohort,
9034
+ completedAt,
9035
+ conclusion: stringField$1(runValue, "conclusion", "run"),
9036
+ cpuModels: cpuModelsField(cpuModels, profiles),
9037
+ createdAt,
9038
+ durations: {
9039
+ queueMs: runQueueDuration(createdAt, jobs),
9040
+ totalMs: durationBetween(startedAt, completedAt, "run duration")
9041
+ },
9042
+ event: stringField$1(runValue, "event", "run"),
9043
+ headSha: stringField$1(runValue, "head_sha", "run"),
9044
+ jobs,
9045
+ kind: "analyzed",
9046
+ profiles,
9047
+ proofReuse: classifyProofReuse(jobs),
9048
+ repository: request.repository,
9049
+ runId: entry.runId,
9050
+ runnerLabels: labels.length === 0 ? unavailable("no job carries runner labels") : available(labels),
9051
+ startedAt,
9052
+ url: stringField$1(runValue, "html_url", "run"),
9053
+ workflowName: stringField$1(runValue, "name", "run")
9054
+ };
9055
+ };
9056
+ /**
9057
+ * Quantile by the floor-index convention: over `sorted` (ascending), the
9058
+ * q-quantile is the element at index `floor(q * (n - 1))`. No interpolation —
9059
+ * every reported quantile is a duration that actually happened, and the
9060
+ * convention is stable for the small sample counts (3–10 runs) this command
9061
+ * exists for. median = q(0.5), p25 = q(0.25), p75 = q(0.75).
9062
+ */
9063
+ const quantileFloorIndex = (sorted, q) => sorted[Math.floor(q * (sorted.length - 1))] ?? NaN;
9064
+ const quantileSummary = (values) => {
9065
+ const sorted = values.toSorted((left, right) => left - right);
9066
+ return {
9067
+ count: sorted.length,
9068
+ maximum: sorted.at(-1) ?? NaN,
9069
+ median: quantileFloorIndex(sorted, .5),
9070
+ minimum: sorted[0] ?? NaN,
9071
+ p25: quantileFloorIndex(sorted, .25),
9072
+ p75: quantileFloorIndex(sorted, .75)
9073
+ };
9074
+ };
9075
+ const classificationOf = (run) => run.proofReuse.available ? run.proofReuse.value.classification : "unclassified";
9076
+ const aggregateCohorts = (runs) => {
9077
+ const cells = /* @__PURE__ */ new Map();
9078
+ for (const run of runs) {
9079
+ if (run.kind !== "analyzed") continue;
9080
+ const key = `${run.cohort}\0${classificationOf(run)}`;
9081
+ const cell = cells.get(key) ?? [];
9082
+ cell.push(run);
9083
+ cells.set(key, cell);
9084
+ }
9085
+ return [...cells.entries()].map(([key, cellRuns]) => {
9086
+ const [cohort = UNGROUPED_COHORT] = key.split("\0");
9087
+ const totals = cellRuns.flatMap((run) => run.durations.totalMs.available ? [run.durations.totalMs.value] : []);
9088
+ const excludedRunIds = cellRuns.filter((run) => !run.durations.totalMs.available).map((run) => run.runId);
9089
+ return {
9090
+ classification: classificationOf(cellRuns[0]),
9091
+ cohort,
9092
+ count: cellRuns.length,
9093
+ excludedRunIds,
9094
+ runIds: cellRuns.map((run) => run.runId),
9095
+ totalDurationMs: totals.length === 0 ? unavailable("no run in this cell has an available total duration") : available(quantileSummary(totals))
9096
+ };
9097
+ }).toSorted((left, right) => `${left.cohort}\0${left.classification}`.localeCompare(`${right.cohort}\0${right.classification}`));
9098
+ };
9099
+ const cohortWarnings = (runs) => {
9100
+ const warnings = [];
9101
+ const byCohort = /* @__PURE__ */ new Map();
9102
+ for (const run of runs) {
9103
+ if (run.kind !== "analyzed") continue;
9104
+ const cohort = byCohort.get(run.cohort) ?? [];
9105
+ cohort.push(run);
9106
+ byCohort.set(run.cohort, cohort);
9107
+ }
9108
+ for (const [cohort, cohortRuns] of [...byCohort.entries()].toSorted(([left], [right]) => left.localeCompare(right))) {
9109
+ const workflows = [...new Set(cohortRuns.flatMap((run) => run.workflowName.available ? [run.workflowName.value] : []))].toSorted();
9110
+ if (workflows.length > 1) warnings.push(`cohort "${cohort}" spans workflow shapes: ${workflows.join(", ")} — durations across different workflows are not comparable`);
9111
+ const models = [...new Set(cohortRuns.flatMap((run) => run.cpuModels.available ? run.cpuModels.value : []))].toSorted();
9112
+ if (models.length > 1) warnings.push(`cohort "${cohort}" spans CPU models: ${models.join(", ")} — stratify by cpuModel before comparing (see the CI runner analysis docs)`);
9113
+ }
9114
+ return warnings;
9115
+ };
9116
+ /**
9117
+ * Analyze the requested runs. Read-only: the client is only ever asked to GET.
9118
+ * One unreadable run never aborts the report — it is carried as an explicit
9119
+ * `unreadable` entry so a partially-served API answer stays auditable.
9120
+ */
9121
+ const analyzeCiRuns = async (request, client, dependencies = {}) => {
9122
+ if (request.runs.length === 0) throw new Error("ci:analyze requires at least one run id (--run, --runs-file, or --cohort).");
9123
+ const runs = [];
9124
+ for (const entry of request.runs) runs.push(await analyzeOneRun(client, request, entry));
9125
+ return {
9126
+ cohorts: aggregateCohorts(runs),
9127
+ generatedAt: (dependencies.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
9128
+ repository: request.repository,
9129
+ runs,
9130
+ schemaVersion: 1,
9131
+ tool: CI_ANALYZE_TOOL,
9132
+ warnings: cohortWarnings(runs)
9133
+ };
9134
+ };
9135
+ const formatSeconds = (ms) => `${(ms / 1e3).toFixed(1)}s`;
9136
+ const formatMaybeDuration = (value) => value.available ? formatSeconds(value.value) : `unavailable (${value.reason})`;
9137
+ const CLASSIFICATION_HEADINGS = {
9138
+ "full-fallback": "full hosted fallback",
9139
+ mixed: "mixed proof outcomes",
9140
+ "proof-reuse": "proof reuse",
9141
+ unclassified: "no proof-gate signal"
9142
+ };
9143
+ const renderRunLine = (run) => {
9144
+ const lines = [
9145
+ ` run ${run.runId} — ${run.workflowName.available ? run.workflowName.value : "unknown workflow"} (${run.conclusion.available ? run.conclusion.value : "no conclusion"})`,
9146
+ ` total ${formatMaybeDuration(run.durations.totalMs)}, queue ${formatMaybeDuration(run.durations.queueMs)}`,
9147
+ ` ${run.url.available ? run.url.value : "run URL unavailable"}`
9148
+ ];
9149
+ if (run.cpuModels.available) lines.push(` cpu: ${run.cpuModels.value.join(", ")}`);
9150
+ if (run.profiles.available) for (const profile of run.profiles.value) {
9151
+ if (profile.kind === "unreadable") {
9152
+ lines.push(` profile ${profile.artifactName}: unreadable (${profile.reason})`);
9153
+ continue;
9154
+ }
9155
+ const p = profile.profile;
9156
+ lines.push(` profile ${profile.artifactName}: median ${formatSeconds(p.summaryDurationMs.median)} over ${p.sampleDurationsMs.length} sample(s), ${p.testCount.available ? `${p.testCount.value} tests` : "test count unavailable"}, cpu ${p.cpuModel ?? "unrecorded"}`);
9157
+ const [slowFile] = p.slowFiles;
9158
+ if (slowFile) lines.push(` slowest file ${slowFile.path} (${formatSeconds(slowFile.medianMs)} median)`);
9159
+ const [slowTest] = p.slowTests;
9160
+ if (slowTest) lines.push(` slowest test ${slowTest.name} (${formatSeconds(slowTest.medianMs)} median)`);
9161
+ }
9162
+ return lines;
9163
+ };
9164
+ /**
9165
+ * Human rendering. Structure mirrors the aggregation rule: one section per
9166
+ * cohort × classification cell, so full fallback and proof reuse are never
9167
+ * visually blended, and before/after cohorts stay separate.
9168
+ */
9169
+ const renderCiAnalyzeReport = (report) => {
9170
+ const lines = [`ci:analyze — ${report.repository} (schema v${report.schemaVersion})`];
9171
+ const analyzed = report.runs.filter((run) => run.kind === "analyzed");
9172
+ for (const cohort of report.cohorts) {
9173
+ lines.push("", `cohort "${cohort.cohort}" — ${CLASSIFICATION_HEADINGS[cohort.classification]} (${cohort.count} run(s))`);
9174
+ if (cohort.totalDurationMs.available) {
9175
+ const stats = cohort.totalDurationMs.value;
9176
+ lines.push(` total duration over ${stats.count} run(s): median ${formatSeconds(stats.median)}, p25 ${formatSeconds(stats.p25)}, p75 ${formatSeconds(stats.p75)}, min ${formatSeconds(stats.minimum)}, max ${formatSeconds(stats.maximum)}`);
9177
+ } else lines.push(` total duration: unavailable (${cohort.totalDurationMs.reason})`);
9178
+ if (cohort.excludedRunIds.length > 0) lines.push(` excluded from aggregates (total unavailable): ${cohort.excludedRunIds.join(", ")}`);
9179
+ for (const run of analyzed.filter((candidate) => candidate.cohort === cohort.cohort && classificationOf(candidate) === cohort.classification)) lines.push(...renderRunLine(run));
9180
+ }
9181
+ const unreadable = report.runs.filter((run) => run.kind === "unreadable");
9182
+ const unreadableCohorts = [...new Set(unreadable.map((run) => run.cohort))].toSorted((left, right) => left.localeCompare(right));
9183
+ for (const cohort of unreadableCohorts) {
9184
+ lines.push("", `cohort "${cohort}" — unreadable runs:`);
9185
+ for (const run of unreadable.filter((candidate) => candidate.cohort === cohort)) lines.push(` run ${run.runId}: ${run.reason}`);
9186
+ }
9187
+ if (report.warnings.length > 0) {
9188
+ lines.push("", "warnings:");
9189
+ for (const warning of report.warnings) lines.push(` - ${warning}`);
9190
+ }
9191
+ return `${lines.join("\n")}\n`;
9192
+ };
9193
+ //#endregion
9194
+ //#region src/commands/ci-analyze.ts
9195
+ /**
9196
+ * `ci:analyze` command wiring (#647).
9197
+ *
9198
+ * The analysis itself lives in `../ci-analyze.js`; this module owns the flag
9199
+ * grammar, the run-id assembly (`--run`, `--runs-file`, `--cohort`), the
9200
+ * report output paths, and the default `gh`-backed {@link CiAnalyzeClient}.
9201
+ * Authentication is entirely the caller's: every request goes through the
9202
+ * `gh` CLI, which resolves its own token (GH_TOKEN / GITHUB_TOKEN / login) —
9203
+ * no credential is stored or minted here.
9204
+ */
9205
+ const REPOSITORY_PATTERN = /^[\w.-]+\/[\w.-]+$/u;
9206
+ const parseRepository = (value) => {
9207
+ if (!REPOSITORY_PATTERN.test(value)) throw new Error(`--repo must be owner/name, got "${value}"`);
9208
+ return value;
9209
+ };
9210
+ const parseRunId = (value) => {
9211
+ const parsed = Number(value);
9212
+ if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`run id must be a positive integer, got "${value}"`);
9213
+ return parsed;
9214
+ };
9215
+ const collectRunOption = (value, previous = []) => [...previous, parseRunId(value)];
9216
+ /**
9217
+ * `--cohort label=id,id` — the same `name=value` grammar the existing psf
9218
+ * flags use for structured values, repeatable once per label.
9219
+ *
9220
+ * The label `ungrouped` is reserved: it is what the report calls runs the
9221
+ * caller left unlabeled, so accepting it as a caller cohort would silently
9222
+ * blend a named population with the genuinely unlabeled one.
9223
+ */
9224
+ const parseCohortOption = (value, previous = []) => {
9225
+ const separator = value.indexOf("=");
9226
+ const label = separator === -1 ? "" : value.slice(0, separator).trim();
9227
+ const ids = separator === -1 ? "" : value.slice(separator + 1);
9228
+ if (label === "" || ids.trim() === "") throw new Error(`--cohort must be <label>=<runId>[,<runId>...], got "${value}"`);
9229
+ if (label === "ungrouped") throw new Error(`--cohort label "${UNGROUPED_COHORT}" is reserved for runs passed without a cohort; pick another label`);
9230
+ return [...previous, {
9231
+ label,
9232
+ runIds: ids.split(",").map((id) => parseRunId(id.trim()))
9233
+ }];
9234
+ };
9235
+ /** One run id per line; blank lines and `#` comments are ignored. */
9236
+ const parseRunsFile = (content) => content.split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#")).map(parseRunId);
9237
+ /**
9238
+ * Assemble the requested run set. A run id may appear in exactly one cohort;
9239
+ * uncohorted ids (from `--run` / `--runs-file`) stay unlabeled. Conflicting
9240
+ * cohort labels for one id are a hard error rather than a silent pick.
9241
+ */
9242
+ const assembleRunRequests = (input) => {
9243
+ const cohortByRun = /* @__PURE__ */ new Map();
9244
+ for (const cohort of input.cohorts) for (const runId of cohort.runIds) {
9245
+ const existing = cohortByRun.get(runId);
9246
+ if (existing !== void 0 && existing !== cohort.label) throw new Error(`run ${runId} is assigned to both cohort "${existing}" and cohort "${cohort.label}"`);
9247
+ cohortByRun.set(runId, cohort.label);
9248
+ }
9249
+ const ordered = [];
9250
+ const seen = /* @__PURE__ */ new Set();
9251
+ for (const cohort of input.cohorts) for (const runId of cohort.runIds) if (!seen.has(runId)) {
9252
+ seen.add(runId);
9253
+ ordered.push({
9254
+ cohort: cohort.label,
9255
+ runId
9256
+ });
9257
+ }
9258
+ for (const runId of input.runIds) if (!seen.has(runId)) {
9259
+ seen.add(runId);
9260
+ ordered.push({
9261
+ cohort: cohortByRun.get(runId) ?? null,
9262
+ runId
9263
+ });
9264
+ }
9265
+ return ordered;
9266
+ };
9267
+ const GH_ARTIFACT_MAX_BYTES = 256 * 1024 * 1024;
9268
+ const PAGE_SIZE = 100;
9269
+ const MAX_PAGES = 50;
9270
+ /**
9271
+ * Follow GitHub's pagination for one collection endpoint: request pages until
9272
+ * the served rows reach the response's `total_count` (or a page comes back
9273
+ * empty). The merged document keeps the `total_count` field, so if the page
9274
+ * budget is ever exhausted the analyzer's truncation guard reports the
9275
+ * shortfall as explicit unavailability instead of a silent partial read.
9276
+ */
9277
+ const listActionsCollection = (basePath, key) => {
9278
+ const rows = [];
9279
+ let totalCount;
9280
+ for (let page = 1; page <= MAX_PAGES; page += 1) {
9281
+ const response = runGhJson(["api", `${basePath}?per_page=${PAGE_SIZE}&page=${page}`]);
9282
+ const served = Array.isArray(response[key]) ? response[key] : [];
9283
+ rows.push(...served);
9284
+ totalCount = typeof response.total_count === "number" ? response.total_count : totalCount;
9285
+ if (served.length === 0 || rows.length >= (totalCount ?? 0)) break;
9286
+ }
9287
+ return {
9288
+ [key]: rows,
9289
+ total_count: totalCount ?? rows.length
9290
+ };
9291
+ };
9292
+ /**
9293
+ * A Unix-host entry line: permissions, zip version, host OS, decompressed
9294
+ * size, text/binary flags, method, date, time, then the member name (which
9295
+ * may itself contain spaces). This is the one form GitHub's artifact service
9296
+ * actually emits; other host forms are deliberately *not* supported — they
9297
+ * fail reconciliation below rather than being half-parsed.
9298
+ */
9299
+ const ZIP_ENTRY_PATTERN = /^(?<perms>[-dl][a-srwxt-]{9})\s+\d+\.\d+\s+\S+\s+(?<size>\d+)\s+\S\S\s+\S+\s+\S+\s+\S+\s(?<name>.+)$/u;
9300
+ /** `Zip file size: 12109 bytes, number of entries: 2` */
9301
+ const ZIP_HEADER_PATTERN = /^Zip file size: \d+ bytes, number of entries: (?<count>\d+)$/u;
9302
+ /** `2 files, 81410 bytes uncompressed, 11689 bytes compressed: 85.6%` */
9303
+ const ZIP_TRAILER_PATTERN = /^(?<count>\d+) files?, \d+ bytes? uncompressed, \d+ bytes? compressed:\s+-?[\d.]+%$/u;
9304
+ /**
9305
+ * Parse zipinfo's default listing and *reconcile* it against the archive's
9306
+ * own declared entry count — fail closed, never a partial view.
9307
+ *
9308
+ * The security property the caps and member policy rest on is that the parsed
9309
+ * set is the WHOLE central directory. A listing line this parser does not
9310
+ * recognize (a FAT-host form, a future zipinfo change) therefore refuses the
9311
+ * whole artifact rather than being skipped: a skipped line would let
9312
+ * `unzip <name>` extract members the caps never counted. Three refusals:
9313
+ * an unrecognized line, a parsed count that differs from the declared count
9314
+ * (every header and trailer declaration must agree — a listing that
9315
+ * contradicts itself has no unambiguous count and is refused), and any
9316
+ * duplicate member name across the full parsed set — a parsed safe name
9317
+ * shadowed by an unparsed or differently-shaped duplicate is exactly the
9318
+ * bypass this closes.
9319
+ */
9320
+ const parseZipInfoListing = (text) => {
9321
+ const members = [];
9322
+ const declaredCounts = [];
9323
+ for (const line of text.split("\n")) {
9324
+ if (line.trim() === "" || line.startsWith("Archive:")) continue;
9325
+ const header = ZIP_HEADER_PATTERN.exec(line);
9326
+ if (header?.groups) {
9327
+ declaredCounts.push(Number(header.groups.count));
9328
+ continue;
9329
+ }
9330
+ const trailer = ZIP_TRAILER_PATTERN.exec(line);
9331
+ if (trailer?.groups) {
9332
+ declaredCounts.push(Number(trailer.groups.count));
9333
+ continue;
9334
+ }
9335
+ const entry = ZIP_ENTRY_PATTERN.exec(line);
9336
+ if (entry?.groups) {
9337
+ members.push({
9338
+ isSymlink: entry.groups.perms?.startsWith("l") === true,
9339
+ name: entry.groups.name ?? "",
9340
+ sizeBytes: Number(entry.groups.size)
9341
+ });
9342
+ continue;
9343
+ }
9344
+ throw new Error(`zip listing reconciliation failed: unrecognized listing line "${line}"`);
9345
+ }
9346
+ const [declaredEntries] = declaredCounts;
9347
+ if (declaredEntries === void 0) throw new Error("zip listing reconciliation failed: the listing declares no entry count");
9348
+ if (declaredCounts.some((count) => count !== declaredEntries)) throw new Error(`zip listing reconciliation failed: contradictory declared entry counts (${declaredCounts.join(", ")})`);
9349
+ if (members.length !== declaredEntries) throw new Error(`zip listing reconciliation failed: parsed ${members.length} of ${declaredEntries} declared entries`);
9350
+ const seen = /* @__PURE__ */ new Set();
9351
+ for (const member of members) {
9352
+ if (seen.has(member.name)) throw new Error(`zip listing reconciliation failed: duplicate member name "${member.name}"`);
9353
+ seen.add(member.name);
9354
+ }
9355
+ return members;
9356
+ };
9357
+ const MAX_ARTIFACT_DECOMPRESSED_BYTES = 256 * 1024 * 1024;
9358
+ const UNSAFE_MEMBER_CHARACTERS = /[*?[\]]/u;
9359
+ /**
9360
+ * The member policy for artifact zips, as a pure function so the security
9361
+ * decisions are testable without `unzip`. The archive is refused outright —
9362
+ * not partially read — when any member is a symlink, an absolute path, a
9363
+ * `..` traversal, option-shaped (leading `-`), or glob-ambiguous, or when the
9364
+ * archive exceeds the count or decompressed-size caps. What survives is the
9365
+ * `*.json` member list actually handed to extraction.
9366
+ */
9367
+ const selectProfileZipMembers = (members) => {
9368
+ if (members.length > 512) throw new Error(`artifact zip refused: ${members.length} members exceeds the 512-member cap`);
9369
+ let totalBytes = 0;
9370
+ for (const member of members) {
9371
+ if (member.isSymlink) throw new Error(`artifact zip refused: member "${member.name}" is a symlink`);
9372
+ if (member.name.startsWith("/") || /^[A-Za-z]:/u.test(member.name)) throw new Error(`artifact zip refused: member "${member.name}" is an absolute path`);
9373
+ if (member.name.split("/").includes("..")) throw new Error(`artifact zip refused: member "${member.name}" traverses with ..`);
9374
+ if (member.name.startsWith("-")) throw new Error(`artifact zip refused: member "${member.name}" is option-shaped`);
9375
+ if (UNSAFE_MEMBER_CHARACTERS.test(member.name)) throw new Error(`artifact zip refused: member "${member.name}" contains glob metacharacters`);
9376
+ if (!Number.isFinite(member.sizeBytes) || member.sizeBytes < 0) throw new Error(`artifact zip refused: member "${member.name}" reports no usable size`);
9377
+ totalBytes += member.sizeBytes;
9378
+ }
9379
+ if (totalBytes > 268435456) throw new Error(`artifact zip refused: ${totalBytes} decompressed bytes exceeds the ${MAX_ARTIFACT_DECOMPRESSED_BYTES}-byte cap`);
9380
+ return members.filter((member) => !member.name.endsWith("/") && member.name.endsWith(".json")).map((member) => member.name).toSorted();
9381
+ };
9382
+ /**
9383
+ * The default client: `gh api` for JSON endpoints (paginated), `gh api` plus
9384
+ * system `unzip` for artifact zips, with every member vetted by
9385
+ * {@link selectProfileZipMembers} before extraction. The exec adapter itself
9386
+ * is deliberately thin and untested networked code — every decision made from
9387
+ * these bytes lives behind {@link CiAnalyzeClient} or the pure policy
9388
+ * functions above, where fixtures drive it.
9389
+ */
9390
+ const createGhCiAnalyzeClient = (repository) => ({
9391
+ getRun: (runId) => Promise.resolve(runGhJson(["api", `/repos/${repository}/actions/runs/${runId}`])),
9392
+ listArtifacts: (runId) => Promise.resolve(listActionsCollection(`/repos/${repository}/actions/runs/${runId}/artifacts`, "artifacts")),
9393
+ listJobs: (runId) => Promise.resolve(listActionsCollection(`/repos/${repository}/actions/runs/${runId}/jobs`, "jobs")),
9394
+ readArtifactTextFiles: (artifactId) => {
9395
+ const scratch = mkdtempSync(path.join(tmpdir(), "psf-ci-analyze-"));
9396
+ try {
9397
+ const zip = execFileSync("gh", ["api", `/repos/${repository}/actions/artifacts/${artifactId}/zip`], { maxBuffer: GH_ARTIFACT_MAX_BYTES });
9398
+ const zipPath = path.join(scratch, "artifact.zip");
9399
+ writeFileSync(zipPath, zip);
9400
+ const selected = selectProfileZipMembers(parseZipInfoListing(execFileSync("unzip", ["-Z", zipPath], {
9401
+ encoding: "utf-8",
9402
+ maxBuffer: GH_ARTIFACT_MAX_BYTES
9403
+ })));
9404
+ if (selected.length === 0) return Promise.resolve([]);
9405
+ const extracted = path.join(scratch, "extracted");
9406
+ execFileSync("unzip", [
9407
+ "-o",
9408
+ "-qq",
9409
+ zipPath,
9410
+ "-d",
9411
+ extracted,
9412
+ ...selected
9413
+ ], { stdio: [
9414
+ "ignore",
9415
+ "ignore",
9416
+ "pipe"
9417
+ ] });
9418
+ return Promise.resolve(selected.map((name) => ({
9419
+ name,
9420
+ text: readFileSync(path.join(extracted, name), "utf-8")
9421
+ })));
9422
+ } finally {
9423
+ rmSync(scratch, {
9424
+ force: true,
9425
+ recursive: true
9426
+ });
9427
+ }
9428
+ }
9429
+ });
9430
+ const defaultAction = (request) => analyzeCiRuns(request, createGhCiAnalyzeClient(request.repository));
9431
+ function createCiAnalyzeCommand(output, action = defaultAction) {
9432
+ return new Command("ci:analyze").description("Read-only GitHub Actions run lifecycle analysis: queue/setup/install/step/total durations, proof-reuse classification, cohort aggregates, and optional factory-ci Vitest profile readback").requiredOption("--repo <owner/name>", "repository the run ids belong to", parseRepository).option("--run <id>", "workflow run id to analyze (repeatable)", collectRunOption).option("--runs-file <path>", "file of run ids, one per line (# comments allowed)").option("--cohort <label=ids>", "label a comma-separated run-id list as one cohort, e.g. --cohort before=1,2 (repeatable; the label \"ungrouped\" is reserved for runs passed without a cohort)", parseCohortOption).option("--profiles", "download run artifacts and read compatible factory-ci Vitest profiles (the only source of cpuModel)").option("--output <path>", "write the versioned JSON report to a path").option("--json", "print the JSON report to stdout instead of prose").action(async (options) => {
9433
+ const fromFile = options.runsFile === void 0 ? [] : parseRunsFile(readFileSync(path.resolve(options.runsFile), "utf-8"));
9434
+ const report = await action({
9435
+ includeProfiles: options.profiles === true,
9436
+ repository: options.repo,
9437
+ runs: assembleRunRequests({
9438
+ cohorts: options.cohort ?? [],
9439
+ runIds: [...options.run ?? [], ...fromFile]
9440
+ })
9441
+ });
9442
+ if (options.output !== void 0) {
9443
+ const outputPath = path.resolve(options.output);
9444
+ writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`);
9445
+ output.stderr.write(`ci:analyze report written to ${outputPath}\n`);
9446
+ }
9447
+ output.stdout.write(options.json ? `${JSON.stringify(report, null, 2)}\n` : renderCiAnalyzeReport(report));
9448
+ });
8945
9449
  }
8946
9450
  //#endregion
8947
9451
  //#region src/checkout-repository.ts
@@ -9608,6 +10112,8 @@ const buildRetroEnvelope = (input) => {
9608
10112
  const RETRO_ENVELOPE_LEDGER_DIR = ".factory-memory/retro-envelope";
9609
10113
  const safeEnvelopeName = (agentRunId) => agentRunId.replaceAll(/[^\w.-]/gu, "-").slice(0, 120);
9610
10114
  const retroEnvelopePath = (root, agentRunId) => path.join(root, RETRO_ENVELOPE_LEDGER_DIR, `${safeEnvelopeName(agentRunId)}.json`);
10115
+ const CLAUDE_CODE_SESSION_ID_ENV = "CLAUDE_CODE_SESSION_ID";
10116
+ const resolveNativeSessionId = (env) => normalizeSessionId(env[CLAUDE_CODE_SESSION_ID_ENV]);
9611
10117
  const splitRepo = (repo) => {
9612
10118
  const [owner, name] = repo.split("/");
9613
10119
  return {
@@ -9633,11 +10139,11 @@ const otherLedgersExist = (repoRoot) => {
9633
10139
  const buildAndPersistRetroEnvelope = (input) => {
9634
10140
  const env = input.env ?? process.env;
9635
10141
  const now = input.now ?? (() => /* @__PURE__ */ new Date());
9636
- const laneIdentity = resolveGateTimingAgentRunId(env);
10142
+ const laneIdentity = resolveGateTimingAgentRunId(env) ?? resolveNativeSessionId(env);
9637
10143
  const agentRunId = laneIdentity ?? `closeout-${safeEnvelopeName(input.epic)}`;
9638
10144
  const ledgerPath = gateTimingLedgerPath(input.repoRoot, agentRunId);
9639
10145
  const ledger = laneIdentity === void 0 ? [] : readGateTimingLedger(ledgerPath);
9640
- const claudeSessionId = discoverFactorySessionId(env);
10146
+ const claudeSessionId = discoverFactorySessionId(env) ?? resolveNativeSessionId(env);
9641
10147
  const dataGaps = [];
9642
10148
  if (laneIdentity === void 0) dataGaps.push("agentRunId is synthetic (no lane run identity exported): keyed to the epic, ledger not folded.");
9643
10149
  else if (!existsSync(ledgerPath) && otherLedgersExist(input.repoRoot)) dataGaps.push(`gates[] empty: no gate-timing ledger for agentRunId "${agentRunId}" although other lane ledgers exist under ${GATE_TIMING_LEDGER_DIR} — possible identity drift between ledger writes and closeout.`);
@@ -9916,6 +10422,7 @@ function createCloseoutCommand(output, dependencies = {}) {
9916
10422
  });
9917
10423
  const retro = buildRetroGate({
9918
10424
  codexThreadIds: codexThreadIdsForCloseout(dependencies.env),
10425
+ ...dependencies.env === void 0 ? {} : { env: dependencies.env },
9919
10426
  epic: options.epic,
9920
10427
  ...options.issue === void 0 ? {} : { issue: options.issue },
9921
10428
  ...options.pr === void 0 ? {} : { pr: options.pr },
@@ -11235,6 +11742,7 @@ function runPrVerify(args, dependencies = {}) {
11235
11742
  if (error instanceof ImpactStampConfigError) throw error;
11236
11743
  impactStamp = conservativeImpactStamp(profile.impact?.targets ?? [], [`impact stamp computation failed (${error instanceof Error ? error.message : String(error)}); fail closed to full impact`]);
11237
11744
  }
11745
+ for (const line of impactStampSummaryLines(impactStamp)) console.log(line);
11238
11746
  const vetoedTargets = boundFailingCheckNames({
11239
11747
  candidate: {
11240
11748
  headSha,
@@ -11541,7 +12049,7 @@ const runDemandWaive = (args, dependencies = {}) => {
11541
12049
  * offers no way to declare a demand met, only to waive it on the record.
11542
12050
  */
11543
12051
  function createDemandWaiveCommand(_output, action = runDemandWaive) {
11544
- return new Command("demand:waive").description("Waive one resolved demand for one candidate, on the operator's identity, with the rationale recorded in the proof").requiredOption("--pr <number>", "pull request number", positiveInteger("--pr")).requiredOption("--demand <key>", "resolved demand key, e.g. review-rung:human, required-check:core, merge-freeze").requiredOption("--rationale <text>", "why this demand is being waived").option("--cwd <path>", "working directory to evaluate", ".").option("--json", "print the recorded waiver as JSON").option("--output <path>", "waiver record JSON path").action((options) => {
12052
+ return markCwdOptionDefault(new Command("demand:waive").description("Waive one resolved demand for one candidate, on the operator's identity, with the rationale recorded in the proof").requiredOption("--pr <number>", "pull request number", positiveInteger("--pr")).requiredOption("--demand <key>", "resolved demand key, e.g. review-rung:human, required-check:core, merge-freeze").requiredOption("--rationale <text>", "why this demand is being waived").option("--cwd <path>", "working directory to evaluate", collectCwdOption).option("--json", "print the recorded waiver as JSON").option("--output <path>", "waiver record JSON path").action((options) => {
11545
12053
  action({
11546
12054
  cwd: resolveCwdOption(options.cwd),
11547
12055
  demand: options.demand,
@@ -11550,7 +12058,7 @@ function createDemandWaiveCommand(_output, action = runDemandWaive) {
11550
12058
  pr: options.pr,
11551
12059
  rationale: options.rationale
11552
12060
  });
11553
- });
12061
+ }));
11554
12062
  }
11555
12063
  //#endregion
11556
12064
  //#region src/demand-resolution.ts
@@ -11654,20 +12162,32 @@ const defaultFetchIssueBody = ({ issue, owner, repo }) => runGhJson([
11654
12162
  * closed: an absent, unparseable, or unmatched manifest binds nothing and
11655
12163
  * names its refusal.
11656
12164
  */
11657
- const resolveWaveDemand = ({ epic, fetchClosingPullRequests = defaultFetchClosingPullRequests, fetchIssueBody = defaultFetchIssueBody, owner, pr, repo }) => {
12165
+ const resolveWaveDemand = ({ baseRefName, epic, fetchClosingPullRequests = defaultFetchClosingPullRequests, fetchIssueBody = defaultFetchIssueBody, headRefName, owner, pr, repo }) => {
11658
12166
  const parsed = parseBoundaryManifest(fetchIssueBody({
11659
12167
  issue: epic,
11660
12168
  owner,
11661
12169
  repo
11662
12170
  }));
11663
12171
  if (!parsed.ok) return { reasons: [`Epic #${epic} boundary manifest refused: ${parsed.error}`] };
11664
- return waveDemandForPullRequest({
12172
+ const resolution = waveDemandForPullRequest({
11665
12173
  fetchClosingPullRequests,
11666
12174
  manifest: parsed.manifest,
11667
12175
  owner,
11668
12176
  pr,
11669
12177
  repo
11670
12178
  });
12179
+ if (!resolution.demand) return resolution;
12180
+ return {
12181
+ demand: resolution.demand,
12182
+ reasons: pullRequestTopologyReasons({
12183
+ manifest: parsed.manifest,
12184
+ pullRequest: {
12185
+ ...baseRefName === void 0 ? {} : { baseRefName },
12186
+ ...headRefName === void 0 ? {} : { headRefName },
12187
+ number: pr
12188
+ }
12189
+ })
12190
+ };
11671
12191
  };
11672
12192
  /**
11673
12193
  * Whether recorded typed review evidence satisfies the demanded review rung.
@@ -12143,10 +12663,10 @@ const describeOrphans = (orphans) => {
12143
12663
  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>\`.`;
12144
12664
  };
12145
12665
  /**
12146
- * Red when HQ evidence is waiting for this repository: its own spool or legacy
12147
- * journal, or a sibling location written under an earlier key of its own that
12148
- * it no longer resolves to (#420). Another repository's spool under the shared
12149
- * root is that repository's business, never this check's (#446).
12666
+ * Warns when HQ evidence is waiting for this repository: its own spool or a
12667
+ * sibling location written under an earlier key of its own that it no longer
12668
+ * resolves to (#420). Another repository's spool under the shared root is that
12669
+ * repository's business, never this check's (#446).
12150
12670
  *
12151
12671
  * Reuses `countHqSpoolWork` (#414) — the same read-only, credential-free
12152
12672
  * inspection `hq:flush` itself consults before ever resolving a secret — so
@@ -12156,19 +12676,23 @@ const describeOrphans = (orphans) => {
12156
12676
  * the spool resolve exactly one key, so evidence written under an older one is
12157
12677
  * invisible to a drain and, before this, to doctor: the failure epic #389 was
12158
12678
  * chartered to end is evidence that is neither delivered nor visibly stranded.
12159
- * An orphan holding pending events is red for the same reason the repo-keyed
12160
- * spool is.
12679
+ * An orphan holding pending events warns for the same reason the repo-keyed
12680
+ * spool does.
12681
+ *
12682
+ * `warning`, not `error` (#659, epic #663 wave 1): a spooled event is real
12683
+ * HQ-delivery lag, but doctor is a read-only diagnostic that must never gain a
12684
+ * flush side-effect, and a preflight gate that treats doctor's exit code as
12685
+ * the bar must not block admission on delivery lag it cannot fix from here.
12686
+ * `psf hq:flush` — a trusted local session, not doctor — is still how the
12687
+ * spool actually drains.
12161
12688
  */
12162
12689
  async function hqSpoolDoctorCheck(input, dependencies = {}) {
12163
12690
  const countSpool = dependencies.countSpool ?? countHqSpoolWork;
12164
12691
  const sweepOrphans = dependencies.sweepOrphans ?? sweepHqSpoolOrphans;
12165
- const [counted, swept] = await Promise.all([countSpool({
12166
- cwd: input.cwd,
12167
- repository: input.repository
12168
- }, { env: input.env }), sweepOrphans({ repository: input.repository }, { env: input.env })]);
12692
+ const [counted, swept] = await Promise.all([countSpool({ repository: input.repository }, { env: input.env }), sweepOrphans({ repository: input.repository }, { env: input.env })]);
12169
12693
  const orphanNote = swept.orphans.length === 0 ? "" : describeOrphans(swept.orphans);
12170
12694
  if (counted.pending === 0 && counted.unlistable === 0 && swept.orphans.length === 0) return {
12171
- message: "HQ spool and journal are empty; no evidence is waiting to be drained.",
12695
+ message: "HQ spool is empty; no evidence is waiting to be drained.",
12172
12696
  name: HQ_SPOOL_CHECK_NAME,
12173
12697
  status: "ok"
12174
12698
  };
@@ -12177,17 +12701,17 @@ async function hqSpoolDoctorCheck(input, dependencies = {}) {
12177
12701
  if (counted.pending > 0) return {
12178
12702
  message: `${counted.pending} HQ event(s) are spooled locally.${oldestNote}${locationsNote} ${FLUSH_REMEDY}${orphanNote}`,
12179
12703
  name: HQ_SPOOL_CHECK_NAME,
12180
- status: "error"
12704
+ status: "warning"
12181
12705
  };
12182
12706
  if (counted.unlistable > 0) return {
12183
12707
  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}`,
12184
12708
  name: HQ_SPOOL_CHECK_NAME,
12185
- status: "error"
12709
+ status: "warning"
12186
12710
  };
12187
12711
  return {
12188
12712
  message: `This repository's HQ spool is empty, but stranded evidence is waiting under the spool root ${swept.root}.${orphanNote}`,
12189
12713
  name: HQ_SPOOL_CHECK_NAME,
12190
- status: "error"
12714
+ status: "warning"
12191
12715
  };
12192
12716
  }
12193
12717
  const readJsonFiles = async (dir, readFileImpl) => {
@@ -12372,7 +12896,6 @@ async function doctorProjectProfile(input = {}) {
12372
12896
  const cwd = input.cwd ?? process.cwd();
12373
12897
  const userConfig = resolveDoctorUserConfig(env, input.userConfig);
12374
12898
  const [hqSpoolCheck, hqRetroReadbackCheck] = await Promise.all([hqSpoolDoctorCheck({
12375
- cwd,
12376
12899
  env,
12377
12900
  repository: {
12378
12901
  owner: profile.repository.owner,
@@ -12539,7 +13062,7 @@ function repositoryUrlMatches(origin, profile) {
12539
13062
  //#endregion
12540
13063
  //#region src/commands/doctor.ts
12541
13064
  function createDoctorCommand(output) {
12542
- 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) => {
13065
+ return markCwdOptionDefault(new Command("doctor").description("Validate a project profile without mutating GitHub or local files").option("--base <ref>", "base branch or ref for --preflight", "origin/main").option("--cwd <path>", "working directory to validate", collectCwdOption).option("--json", "print the doctor report as JSON").option("--preflight", "also list the pre-checkable admission requirements for the current candidate (read-only; never blocks); 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) => {
12543
13066
  const report = await doctorProjectProfile({
12544
13067
  base: options.base,
12545
13068
  cwd: resolveCwdOption(options.cwd),
@@ -12549,7 +13072,7 @@ function createDoctorCommand(output) {
12549
13072
  if (options.json) output.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
12550
13073
  else output.stdout.write(formatHumanReport(report));
12551
13074
  if (!report.ok) process.exitCode = 1;
12552
- });
13075
+ }));
12553
13076
  }
12554
13077
  function formatHumanReport(report) {
12555
13078
  return [
@@ -12957,7 +13480,7 @@ function optionalString(key, flagValue, stdinValue) {
12957
13480
  return resolved ? { [key]: resolved } : {};
12958
13481
  }
12959
13482
  function createEvidenceEmitCommand(output, action = runEvidenceEmit, readInput = readStdin) {
12960
- return new Command("evidence:emit").description("Build, validate, and write an external-evidence envelope (ADR 0014)").option("--cwd <path>", "repository working directory", ".").option("--base <ref>", "base branch or ref for the three-way binding", "origin/main").option("--check <name>", "declared requiredChecks name this evidence satisfies").option("--outcome <pass|fail>", "the check outcome").option("--producer <id>", "the producing identity (recorded, not gated)").option("--policy-version <version>", "canonical prompt / Warden rules version").option("--session-id <id>", "producer session id (independence join key)").option("--request-id <id>", "producer request id (join key)").option("--findings-pointer <ref>", "pointer to findings (never embedded)").option("--profile <path>", "path to the project profile JSON file (accepted for a uniform consumer-wrapper flag set and loaded strictly when supplied)").option("--output <path>", "write the envelope to a specific path").option("--stdin", "merge JSON field values from stdin (flags win)").option("--json", "print the written envelope as JSON").action((options) => {
13483
+ return markCwdOptionDefault(new Command("evidence:emit").description("Build, validate, and write an external-evidence envelope (ADR 0014)").option("--cwd <path>", "repository working directory", collectCwdOption).option("--base <ref>", "base branch or ref for the three-way binding", "origin/main").option("--check <name>", "declared requiredChecks name this evidence satisfies").option("--outcome <pass|fail>", "the check outcome").option("--producer <id>", "the producing identity (recorded, not gated)").option("--policy-version <version>", "canonical prompt / Warden rules version").option("--session-id <id>", "producer session id (independence join key)").option("--request-id <id>", "producer request id (join key)").option("--findings-pointer <ref>", "pointer to findings (never embedded)").option("--profile <path>", "path to the project profile JSON file (accepted for a uniform consumer-wrapper flag set and loaded strictly when supplied)").option("--output <path>", "write the envelope to a specific path").option("--stdin", "merge JSON field values from stdin (flags win)").option("--json", "print the written envelope as JSON").action((options) => {
12961
13484
  const cwd = resolveCwdOption(options.cwd);
12962
13485
  const fromStdin = options.stdin ? readInput() : {};
12963
13486
  rejectRetiredStdinFields(fromStdin);
@@ -12984,7 +13507,7 @@ function createEvidenceEmitCommand(output, action = runEvidenceEmit, readInput =
12984
13507
  });
12985
13508
  output.stdout.write(`evidence:emit wrote envelope for "${result.envelope.check}" to ${result.path}\n`);
12986
13509
  if (options.json && "envelope" in result) output.stdout.write(`${JSON.stringify(result.envelope, null, 2)}\n`);
12987
- });
13510
+ }));
12988
13511
  }
12989
13512
  //#endregion
12990
13513
  //#region src/hq-flush.ts
@@ -13050,7 +13573,6 @@ async function runHqFlush(args, dependencies = {}) {
13050
13573
  };
13051
13574
  const explicitDirectories = args.dir && args.dir.length > 0 ? { explicitDirectories: args.dir } : {};
13052
13575
  const pending = await (dependencies.countSpool ?? countHqSpoolWork)({
13053
- cwd: args.cwd,
13054
13576
  ...explicitDirectories,
13055
13577
  repository
13056
13578
  }, { env });
@@ -13095,7 +13617,6 @@ async function runHqFlush(args, dependencies = {}) {
13095
13617
  ...await (dependencies.flush ?? flushHqSpool)({
13096
13618
  clientId: resolution.credentials.clientId,
13097
13619
  clientSecret: resolution.credentials.clientSecret,
13098
- cwd: args.cwd,
13099
13620
  endpoint: profile.hq.endpoint,
13100
13621
  ...explicitDirectories,
13101
13622
  repository
@@ -13122,7 +13643,7 @@ function renderHqFlush(result) {
13122
13643
  `hq:flush ${result.endpoint}`,
13123
13644
  ...result.spools.length === 0 ? ["no spool directory found"] : result.spools.map((spool) => `spool: ${spool}`),
13124
13645
  ...result.outcomes.map(outcomeLine),
13125
- `delivered ${result.delivered}, duplicate ${result.duplicate}, rejected ${result.rejected}, undeliverable ${result.undeliverable}, unreachable ${result.unreachable}, migrated ${result.outcomes.filter((outcome) => outcome.status === "migrated").length}`,
13646
+ `delivered ${result.delivered}, duplicate ${result.duplicate}, rejected ${result.rejected}, undeliverable ${result.undeliverable}, unreachable ${result.unreachable}`,
13126
13647
  ...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.`] : [],
13127
13648
  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)`,
13128
13649
  ...orphanLines(result.orphans)
@@ -13149,7 +13670,7 @@ const hqFlushExitCode = (result) => {
13149
13670
  //#endregion
13150
13671
  //#region src/commands/hq-flush.ts
13151
13672
  function createHqFlushCommand(output, action = runHqFlush) {
13152
- 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) => {
13673
+ return markCwdOptionDefault(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", collectCwdOption).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) => {
13153
13674
  const result = await action({
13154
13675
  cwd: resolveCwdOption(options.cwd),
13155
13676
  ...options.dir ? { dir: options.dir } : {},
@@ -13159,7 +13680,7 @@ function createHqFlushCommand(output, action = runHqFlush) {
13159
13680
  output.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : renderHqFlush(result));
13160
13681
  const exitCode = hqFlushExitCode(result);
13161
13682
  if (exitCode !== 0) process.exitCode = exitCode;
13162
- });
13683
+ }));
13163
13684
  }
13164
13685
  //#endregion
13165
13686
  //#region src/follow-up.ts
@@ -13195,6 +13716,13 @@ const prVerifyFollowUp = (authoringSession) => followUpFromArgv([
13195
13716
  "pr:verify",
13196
13717
  ...authoringSession ? ["--authoring-session", authoringSession] : []
13197
13718
  ]);
13719
+ /** The route-owned Promotion from a GitHub draft into a Candidate. */
13720
+ const prUndraftFollowUp = (pr) => followUpFromArgv([
13721
+ "gh",
13722
+ "pr",
13723
+ "ready",
13724
+ String(pr)
13725
+ ]);
13198
13726
  /** Canonical zod schema for the optional follow-up field on JSON outputs. */
13199
13727
  const FollowUpActionSchema = z.object({
13200
13728
  argv: z.array(z.string()),
@@ -14310,15 +14838,18 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
14310
14838
  command: `gh pr checks ${input.pr} --watch`
14311
14839
  } } : {}
14312
14840
  });
14313
- if (input.draft) blockers.push({
14314
- demand: DEMAND_KEYS.draft,
14315
- reason: DRAFT_BLOCKER_REASON,
14316
- repair: {
14317
- action: "Mark the PR ready for review; draft is route-owned once proof gates pass.",
14318
- code: "undraft-pr",
14319
- command: `gh pr ready ${input.pr}`
14320
- }
14321
- });
14841
+ if (input.draft) {
14842
+ const promotion = prUndraftFollowUp(input.pr);
14843
+ blockers.push({
14844
+ demand: DEMAND_KEYS.draft,
14845
+ reason: DRAFT_BLOCKER_REASON,
14846
+ repair: {
14847
+ action: "Mark the PR ready for review; draft is route-owned once proof gates pass.",
14848
+ code: "undraft-pr",
14849
+ command: promotion.command
14850
+ }
14851
+ });
14852
+ }
14322
14853
  return blockers;
14323
14854
  };
14324
14855
  const orderRepairs = (repairs) => repairs.toSorted((a, b) => READINESS_REPAIR_CODES.indexOf(a.code) - READINESS_REPAIR_CODES.indexOf(b.code));
@@ -14677,6 +15208,7 @@ const PR_VIEW_JSON_FIELDS = [
14677
15208
  "autoMergeRequest",
14678
15209
  "baseRefName",
14679
15210
  "body",
15211
+ "headRefName",
14680
15212
  "headRefOid",
14681
15213
  "isDraft",
14682
15214
  "mergeStateStatus",
@@ -14879,9 +15411,11 @@ async function runPrReady(args, dependencies = {}) {
14879
15411
  });
14880
15412
  const { ladderPolicy } = demands;
14881
15413
  const waveDemand = args.epic === void 0 ? void 0 : resolveWaveDemand({
15414
+ baseRefName: pr.baseRefName,
14882
15415
  epic: args.epic,
14883
15416
  ...github.fetchClosingPullRequests ? { fetchClosingPullRequests: github.fetchClosingPullRequests } : {},
14884
15417
  ...github.fetchIssueBody ? { fetchIssueBody: github.fetchIssueBody } : {},
15418
+ headRefName: pr.headRefName,
14885
15419
  owner: repository.owner,
14886
15420
  pr: args.pr,
14887
15421
  repo: repository.name
@@ -14982,12 +15516,7 @@ async function runPrReady(args, dependencies = {}) {
14982
15516
  ]);
14983
15517
  let followUp;
14984
15518
  if (evaluation.status === "ready") followUp = arming?.outcome === "not-armed" ? readyRedispatch() : void 0;
14985
- else if (undraftRepair && evaluation.humanBlockingReasons.length === 0) followUp = followUpFromArgv([
14986
- "gh",
14987
- "pr",
14988
- "ready",
14989
- String(args.pr)
14990
- ]);
15519
+ else if (undraftRepair && evaluation.humanBlockingReasons.length === 0) followUp = prUndraftFollowUp(args.pr);
14991
15520
  else if (!pendingExternalChecksOnly) followUp = prVerifyFollowUp(authoringSession);
14992
15521
  const notices = [
14993
15522
  ...evaluation.notices,
@@ -15530,6 +16059,15 @@ const defaultPrPublishCliInvocation = () => {
15530
16059
  };
15531
16060
  const prPublishCliInvocation = (explicit) => explicit ?? defaultPrPublishCliInvocation();
15532
16061
  const errorMessage = (error) => error instanceof Error ? error.message : String(error);
16062
+ /**
16063
+ * GitHub creates a pull request against a branch name, while the rest of the
16064
+ * publish transaction treats `--base` as an exact local Git-ref assertion.
16065
+ * Keep those representations separate: only the creation adapter drops the
16066
+ * canonical remote qualifier, and callers downstream retain the original ref.
16067
+ */
16068
+ function githubCreationBranchForBaseAssertion(base) {
16069
+ return base.startsWith("origin/") ? base.slice(7) : base;
16070
+ }
15533
16071
  function discoverOrCreatePullRequestNumber({ args, branch, cwd, gh }) {
15534
16072
  if (args.pr !== void 0) return args.pr;
15535
16073
  if (!branch) throw new Error("pr:publish cannot discover a PR from a detached HEAD; pass --pr.");
@@ -15545,12 +16083,14 @@ function discoverOrCreatePullRequestNumber({ args, branch, cwd, gh }) {
15545
16083
  ];
15546
16084
  const existing = JSON.parse(gh(lookupArgs, cwd));
15547
16085
  if (existing[0]) return existing[0].number;
15548
- gh([
16086
+ const createArgs = [
15549
16087
  "pr",
15550
16088
  "create",
15551
16089
  "--draft",
15552
16090
  "--fill"
15553
- ], cwd);
16091
+ ];
16092
+ if (args.base.length > 0) createArgs.push("--base", githubCreationBranchForBaseAssertion(args.base));
16093
+ gh(createArgs, cwd);
15554
16094
  const created = JSON.parse(gh(lookupArgs, cwd));
15555
16095
  if (!created[0]) throw new Error(`gh pr create completed but no PR was found for branch ${branch}.`);
15556
16096
  return created[0].number;
@@ -15786,6 +16326,50 @@ const reevaluateReadiness = (args, dependencies) => (dependencies.runPrReady ??
15786
16326
  ...args,
15787
16327
  throwWhenBlocked: false
15788
16328
  }, dependencies);
16329
+ const requirePromotionHead = ({ actualHeadSha, expectedHeadSha, phase }) => {
16330
+ if (!sameHeadSha(actualHeadSha, expectedHeadSha)) throw new PrPublishTransactionAbortedError(`${phase}: live PR head ${actualHeadSha} differs from the proved publish head ${expectedHeadSha}. Re-run pr:publish against the current head.`);
16331
+ };
16332
+ /**
16333
+ * Promote a proved draft before the first Candidate demand evaluation (#649).
16334
+ * The route-owned action is shared with pr:ready; this helper only orders it.
16335
+ */
16336
+ const promoteDraftBeforeDemandEvaluation = async ({ cwd, expectedHeadSha, factoryCliInvocation, fetchPr, pr, prSnapshot, runFollowUp }) => {
16337
+ if (!prSnapshot.isDraft) return { status: "not-requested" };
16338
+ const before = fetchPr();
16339
+ requirePromotionHead({
16340
+ actualHeadSha: before.headRefOid,
16341
+ expectedHeadSha,
16342
+ phase: "Promotion refused before transition"
16343
+ });
16344
+ if (!before.isDraft) return { status: "not-requested" };
16345
+ const promotion = prUndraftFollowUp(pr);
16346
+ try {
16347
+ await (runFollowUp ?? defaultFollowUpRunner(factoryCliInvocation))(promotion, cwd);
16348
+ } catch (error) {
16349
+ throw new PrPublishTransactionAbortedError(`Promotion failed (${promotion.command}): ${errorMessage(error)}`);
16350
+ }
16351
+ const after = fetchPr();
16352
+ requirePromotionHead({
16353
+ actualHeadSha: after.headRefOid,
16354
+ expectedHeadSha,
16355
+ phase: "Promotion invalidated during transition"
16356
+ });
16357
+ if (after.isDraft) throw new PrPublishTransactionAbortedError(`Promotion did not make PR #${pr} ready for review at proved head ${expectedHeadSha}. Re-run pr:publish after GitHub settles.`);
16358
+ return {
16359
+ action: promotion,
16360
+ reason: "Promotion precedes Candidate demand evaluation on the unchanged head",
16361
+ status: "succeeded"
16362
+ };
16363
+ };
16364
+ const requirePromotedProofHead = ({ expectedHeadSha, promotion, proof }) => {
16365
+ if (promotion.status !== "succeeded") return;
16366
+ requirePromotionHead({
16367
+ actualHeadSha: proof.ledger.headSha,
16368
+ expectedHeadSha,
16369
+ phase: "Promotion invalidated before Candidate demand evaluation completed"
16370
+ });
16371
+ };
16372
+ const candidateFollowUpAfterPromotion = (promotion, proof) => promotion.status === "not-requested" ? proof.followUp : void 0;
15789
16373
  /**
15790
16374
  * A proof with no follow-up that is not `ready` means `pr:ready` found the
15791
16375
  * candidate blocked solely on a pending hosted check for the family excluded
@@ -15920,6 +16504,11 @@ async function runPrPublish(args, dependencies = {}) {
15920
16504
  reviewProofPath
15921
16505
  });
15922
16506
  const pr = fetchPr();
16507
+ requirePromotionHead({
16508
+ actualHeadSha: pr.headRefOid,
16509
+ expectedHeadSha: verifyProof.headSha,
16510
+ phase: "Publish refused before managed body update"
16511
+ });
15923
16512
  const parts = renderPrBodySectionParts({
15924
16513
  reviewProof,
15925
16514
  verifyProof: verifyProof && verifyProofPassed(verifyProof) ? verifyProof : void 0
@@ -15944,13 +16533,28 @@ async function runPrPublish(args, dependencies = {}) {
15944
16533
  json: false,
15945
16534
  report: false
15946
16535
  };
16536
+ const promotion = await promoteDraftBeforeDemandEvaluation({
16537
+ cwd,
16538
+ expectedHeadSha: verifyProof.headSha,
16539
+ factoryCliInvocation,
16540
+ fetchPr,
16541
+ pr: prNumber,
16542
+ prSnapshot: pr,
16543
+ runFollowUp: dependencies.runFollowUp
16544
+ });
15947
16545
  const initialProof = await reevaluateReadiness(readyEvalArgs, publishDependencies);
16546
+ requirePromotedProofHead({
16547
+ expectedHeadSha: verifyProof.headSha,
16548
+ promotion,
16549
+ proof: initialProof
16550
+ });
15948
16551
  const initialHandoff = summarizeHumanHandoff(initialProof);
15949
- let followUp = { status: "not-requested" };
16552
+ let followUp = promotion;
15950
16553
  let currentProof = initialProof;
15951
- if (initialProof.followUp) {
16554
+ const candidateFollowUp = candidateFollowUpAfterPromotion(promotion, initialProof);
16555
+ if (candidateFollowUp) {
15952
16556
  followUp = await settleFollowUp({
15953
- action: initialProof.followUp,
16557
+ action: candidateFollowUp,
15954
16558
  cwd,
15955
16559
  factoryCliInvocation,
15956
16560
  onFailure: (failed, error) => {
@@ -15965,13 +16569,13 @@ async function runPrPublish(args, dependencies = {}) {
15965
16569
  },
15966
16570
  plan: planPublishFollowUp({
15967
16571
  binding: verifyProofBinding,
15968
- followUp: initialProof.followUp,
16572
+ followUp: candidateFollowUp,
15969
16573
  proofHeadSha: verifyProof.headSha,
15970
16574
  verifiedHeadSha: verified.verifiedHeadSha
15971
16575
  }),
15972
16576
  runFollowUp: dependencies.runFollowUp
15973
16577
  });
15974
- if (followUp.status === "succeeded" && isSelfInvalidatingFollowUp(initialProof.followUp)) currentProof = await reevaluateReadiness(readyEvalArgs, publishDependencies);
16578
+ if (followUp.status === "succeeded" && isSelfInvalidatingFollowUp(candidateFollowUp)) currentProof = await reevaluateReadiness(readyEvalArgs, publishDependencies);
15975
16579
  }
15976
16580
  const proof = await awaitCurrentReadyVerdict({
15977
16581
  args: readyEvalArgs,
@@ -16022,7 +16626,7 @@ async function deliverSpooledEvidence({ awaitPending, cwd, flush, profilePath })
16022
16626
  //#endregion
16023
16627
  //#region src/commands/pr-publish.ts
16024
16628
  function createPrPublishCommand(_output, action = runPrPublish) {
16025
- 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) => {
16629
+ return markCwdOptionDefault(new Command("pr:publish").description("Promote a proved draft, then evaluate Candidate demands; never launches review").option("--pr <number>", "pull request number", positiveInteger("--pr")).option("--base <ref>", "local base Git ref assertion; origin/<branch> creates against GitHub <branch>").option("--authoring-session <id>", "known authoring session identity for verification retries").option("--cwd <path>", "working directory to evaluate", collectCwdOption).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) => {
16026
16630
  await action({
16027
16631
  authoringSessionIds: options.authoringSession ? [options.authoringSession] : void 0,
16028
16632
  base: options.base ?? "",
@@ -16036,12 +16640,12 @@ function createPrPublishCommand(_output, action = runPrPublish) {
16036
16640
  reviewProof: options.reviewProof,
16037
16641
  verifyProof: options.verifyProof
16038
16642
  });
16039
- });
16643
+ }));
16040
16644
  }
16041
16645
  //#endregion
16042
16646
  //#region src/commands/pr-ready.ts
16043
16647
  function createPrReadyCommand(output, action) {
16044
- return new Command("pr:ready").description("Evaluate typed proof and GitHub state for final readiness").requiredOption("--pr <number>", "pull request number", positiveInteger("--pr")).option("--base <ref>", "explicit base branch or ref override").option("--authoring-session <id...>", "recorded authoring session id(s) for review-type requiredCheck independence (ADR 0014 §5)").option("--cwd <path>", "working directory to evaluate", ".").option("--epic <number>", "epic issue containing the factory-boundary manifest; readiness then evaluates the wave-demanded review rung (#351)", positiveInteger("--epic")).option("--json", "print the readiness proof as JSON").option("--output <path>", "write 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(withGateTiming({
16648
+ return markCwdOptionDefault(new Command("pr:ready").description("Evaluate typed proof and GitHub state for final readiness").requiredOption("--pr <number>", "pull request number", positiveInteger("--pr")).option("--base <ref>", "explicit base branch or ref override").option("--authoring-session <id...>", "recorded authoring session id(s) for review-type requiredCheck independence (ADR 0014 §5)").option("--cwd <path>", "working directory to evaluate", collectCwdOption).option("--epic <number>", "epic issue containing the factory-boundary manifest; readiness then evaluates the wave-demanded review rung (#351)", positiveInteger("--epic")).option("--json", "print the readiness proof as JSON").option("--output <path>", "write 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(withGateTiming({
16045
16649
  gate: "pr:ready",
16046
16650
  resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
16047
16651
  stderr: output.stderr
@@ -16059,7 +16663,7 @@ function createPrReadyCommand(output, action) {
16059
16663
  verifyProof: options.verifyProof
16060
16664
  };
16061
16665
  await (action ? action(args) : runPrReady(args));
16062
- }));
16666
+ })));
16063
16667
  }
16064
16668
  //#endregion
16065
16669
  //#region src/gate-foreground-guard.ts
@@ -16088,7 +16692,7 @@ function assertForegroundGate({ gate, env = process.env }) {
16088
16692
  //#endregion
16089
16693
  //#region src/commands/pr-review.ts
16090
16694
  function createPrReviewCommand(output, action) {
16091
- return new Command("pr:review").description("Validate clean-session findings and write typed review proof").option("--base <ref>", "base branch or ref", "origin/main").option("--cwd <path>", "working directory to review", ".").option("--cycle <number>", "review cycle number", positiveInteger("--cycle"), 1).option("--mode <mode>", "correctness, security, or all", "all").option("--findings <path>", "typed clean-session findings JSON file").option("--dispositions <path>", "JSON file of ladder disposition declarations (waived / follow-up-filed / fixed-in-thread) for previously flagged findings").option("--issue <number>", "issue number recorded on the review ladder trace", positiveInteger("--issue")).option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--verify-proof <path>", "pr:verify proof JSON path").action(withGateTiming({
16695
+ return markCwdOptionDefault(new Command("pr:review").description("Validate clean-session findings and write typed review proof").option("--base <ref>", "base branch or ref", "origin/main").option("--cwd <path>", "working directory to review", collectCwdOption).option("--cycle <number>", "review cycle number", positiveInteger("--cycle"), 1).option("--mode <mode>", "correctness, security, or all", "all").option("--findings <path>", "typed clean-session findings JSON file").option("--dispositions <path>", "JSON file of ladder disposition declarations (waived / follow-up-filed / fixed-in-thread) for previously flagged findings").option("--issue <number>", "issue number recorded on the review ladder trace", positiveInteger("--issue")).option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--verify-proof <path>", "pr:verify proof JSON path").action(withGateTiming({
16092
16696
  gate: "pr:review",
16093
16697
  resolveCycle: (options) => options.cycle,
16094
16698
  resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
@@ -16113,12 +16717,12 @@ function createPrReviewCommand(output, action) {
16113
16717
  verifyProof: options.verifyProof
16114
16718
  };
16115
16719
  await (action ? action(args) : runPrReview(args, { publishCheckRun: createFactoryCheckPublisher(output) }));
16116
- }));
16720
+ })));
16117
16721
  }
16118
16722
  //#endregion
16119
16723
  //#region src/commands/pr-verify.ts
16120
16724
  function createPrVerifyCommand(output, action = runPrVerify) {
16121
- return new Command("pr:verify").description("Run project-profile verification commands and write typed proof").option("--base <ref>", "base branch or ref", "origin/main").option("--cwd <path>", "working directory to verify", ".").option("--docs-only", "run the docs/process verification gate").option("--trivial", "run the trivial verification gate").option("--full", "run the full verification gate").option("--authoring-session <id>", "explicit authoring session identity (required for review evidence)").option("--json", "print the proof as JSON after verification").option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--no-status", "skip posting the patronage-factory/pr-verify commit status").action(withGateTiming({
16725
+ return markCwdOptionDefault(new Command("pr:verify").description("Run project-profile verification commands and write typed proof").option("--base <ref>", "base branch or ref", "origin/main").option("--cwd <path>", "working directory to verify", collectCwdOption).option("--docs-only", "run the docs/process verification gate").option("--trivial", "run the trivial verification gate").option("--full", "run the full verification gate").option("--authoring-session <id>", "explicit authoring session identity (required for review evidence)").option("--json", "print the proof as JSON after verification").option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--no-status", "skip posting the patronage-factory/pr-verify commit status").action(withGateTiming({
16122
16726
  gate: "pr:verify",
16123
16727
  resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
16124
16728
  stderr: output.stderr
@@ -16140,7 +16744,7 @@ function createPrVerifyCommand(output, action = runPrVerify) {
16140
16744
  requireKnownAuthoringSession: true
16141
16745
  }, dependencies);
16142
16746
  if (options.json) output.stdout.write(`${JSON.stringify(proof, null, 2)}\n`);
16143
- }));
16747
+ })));
16144
16748
  }
16145
16749
  function modeFor(options) {
16146
16750
  if (options.docsOnly) return "docs-only";
@@ -16148,6 +16752,146 @@ function modeFor(options) {
16148
16752
  if (options.full) return "full";
16149
16753
  return "auto";
16150
16754
  }
16755
+ const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/iu;
16756
+ const ZERO_SHA_PATTERN = /^0{40}$/u;
16757
+ const demandedTargets = (profile, reason) => (profile?.impact?.targets ?? []).map(({ name }) => ({
16758
+ basis: reason,
16759
+ name,
16760
+ state: "demanded"
16761
+ }));
16762
+ const refusal = ({ after, before, profile, reason }) => ({
16763
+ ...after ? { afterSha: after } : {},
16764
+ basis: {
16765
+ changedFiles: [],
16766
+ classification: "refused",
16767
+ reasons: [reason]
16768
+ },
16769
+ ...before ? { beforeSha: before } : {},
16770
+ decision: "refused",
16771
+ schemaVersion: 1,
16772
+ targets: demandedTargets(profile, reason),
16773
+ unsubscribedPaths: []
16774
+ });
16775
+ const assertCommitIdentity = (value, name) => {
16776
+ if (value === void 0 || !COMMIT_SHA_PATTERN.test(value) || ZERO_SHA_PATTERN.test(value)) throw new Error(`${name} must be a non-zero 40-character commit SHA`);
16777
+ return value.toLowerCase();
16778
+ };
16779
+ const assertReachableCommit = (cwd, sha, name) => {
16780
+ try {
16781
+ runCapture("git", [
16782
+ "cat-file",
16783
+ "-e",
16784
+ `${sha}^{commit}`
16785
+ ], cwd);
16786
+ } catch {
16787
+ throw new Error(`${name} commit ${sha} is not reachable in this checkout`);
16788
+ }
16789
+ };
16790
+ const writeGithubOutputs = (outputPath, decision) => {
16791
+ appendFileSync(outputPath, `${[
16792
+ `decision=${decision.decision}`,
16793
+ `basis=${JSON.stringify(decision.basis)}`,
16794
+ `unsubscribed_paths=${JSON.stringify(decision.unsubscribedPaths)}`,
16795
+ ...decision.targets.map((target) => `${productionImpactTargetOutput(target.name)}=${target.state}`)
16796
+ ].join("\n")}\n`, "utf-8");
16797
+ };
16798
+ /**
16799
+ * Recompute impact from one merge push's exact before/after commits. The
16800
+ * result may withdraw consumer work only; every doubt path returns a refused
16801
+ * decision whose known targets remain demanded.
16802
+ */
16803
+ const runProductionImpact = (args) => {
16804
+ const cwd = path.resolve(args.cwd ?? process.cwd());
16805
+ let profile;
16806
+ let profilePath;
16807
+ let decision;
16808
+ try {
16809
+ const { path: loadedPath, profile: loadedProfile } = loadProjectProfile({
16810
+ cwd,
16811
+ profilePath: args.profilePath
16812
+ });
16813
+ profile = loadedProfile;
16814
+ profilePath = loadedPath;
16815
+ const before = assertCommitIdentity(args.before, "before");
16816
+ const after = assertCommitIdentity(args.after, "after");
16817
+ assertReachableCommit(cwd, before, "before");
16818
+ assertReachableCommit(cwd, after, "after");
16819
+ const checkedOutHead = runCapture("git", ["rev-parse", "HEAD"], cwd).stdout.trim().toLowerCase();
16820
+ if (checkedOutHead !== after) throw new Error(`after commit ${after} is not the checked-out HEAD ${checkedOutHead}; refusing to mix commit identities with declarations from another tree`);
16821
+ try {
16822
+ runCapture("git", [
16823
+ "merge-base",
16824
+ "--is-ancestor",
16825
+ before,
16826
+ after
16827
+ ], cwd);
16828
+ } catch {
16829
+ throw new Error(`before commit ${before} is not an ancestor of after commit ${after}`);
16830
+ }
16831
+ const changedFiles = [...new Set(filesFromNameStatus(runCapture("git", [
16832
+ "diff",
16833
+ "--find-renames",
16834
+ "--name-status",
16835
+ before,
16836
+ after
16837
+ ], cwd).stdout))].toSorted();
16838
+ const stamp = computeImpactStamp({
16839
+ changedFiles,
16840
+ profile,
16841
+ profilePath: path.relative(cwd, profilePath),
16842
+ readLockfile: (side) => showFileAtRef(cwd, side === "base" ? before : after, path.join(cwd, LOCKFILE_PATH))
16843
+ });
16844
+ const usable = stamp.basis === "target-scoped";
16845
+ decision = {
16846
+ afterSha: after,
16847
+ basis: {
16848
+ changedFiles,
16849
+ classification: stamp.basis,
16850
+ reasons: stamp.reasons
16851
+ },
16852
+ beforeSha: before,
16853
+ decision: usable ? "usable" : "refused",
16854
+ schemaVersion: 1,
16855
+ targets: stamp.targets.map((target) => ({
16856
+ basis: target.basis,
16857
+ name: target.name,
16858
+ state: usable && target.impact === "not-affected" ? "withdrawn" : "demanded"
16859
+ })),
16860
+ unsubscribedPaths: stamp.unsubscribedPaths
16861
+ };
16862
+ } catch (error) {
16863
+ decision = refusal({
16864
+ after: args.after,
16865
+ before: args.before,
16866
+ profile,
16867
+ reason: `production impact classification refused: ${error instanceof Error ? error.message : String(error)}`
16868
+ });
16869
+ }
16870
+ if (args.githubOutput) writeGithubOutputs(path.resolve(cwd, args.githubOutput), decision);
16871
+ if (args.githubSummary) appendFileSync(path.resolve(cwd, args.githubSummary), `### Production impact\n\n${productionImpactSummaryLines(decision).join("\n")}\n`, "utf-8");
16872
+ return decision;
16873
+ };
16874
+ const productionImpactSummaryLines = (decision) => decision.basis.classification === "refused" ? [
16875
+ "Production impact: refused; all consumer work remains demanded.",
16876
+ ...decision.basis.reasons,
16877
+ "Unsubscribed changed paths: unavailable because classification was refused."
16878
+ ] : [`Production impact: ${decision.basis.classification}; ${decision.targets.filter(({ state }) => state === "demanded").length}/${decision.targets.length} target(s) demanded.`, decision.unsubscribedPaths.length === 0 ? "Unsubscribed changed paths: none." : `Unsubscribed changed paths (demand no target): ${decision.unsubscribedPaths.join(", ")}`];
16879
+ //#endregion
16880
+ //#region src/commands/production-impact.ts
16881
+ function createProductionImpactCommand(output) {
16882
+ return markCwdOptionDefault(new Command("production:impact").description("Classify production targets from an exact merge-push before/after diff").option("--before <sha>", "merge-push before commit SHA").option("--after <sha>", "merge-push after commit SHA").option("--cwd <path>", "repository working directory", collectCwdOption).option("--profile <path>", "path to the project profile JSON file").option("--github-output <path>", "append stable decision outputs to a GitHub Actions output file").option("--github-summary <path>", "append a human-readable decision to a GitHub Actions summary file").option("--json", "print the complete decision as JSON").action((options) => {
16883
+ const decision = runProductionImpact({
16884
+ after: options.after,
16885
+ before: options.before,
16886
+ cwd: resolveCwdOption(options.cwd),
16887
+ githubOutput: options.githubOutput,
16888
+ githubSummary: options.githubSummary,
16889
+ profilePath: options.profile
16890
+ });
16891
+ if (options.json) output.stdout.write(`${JSON.stringify(decision, null, 2)}\n`);
16892
+ else output.stdout.write(`${productionImpactSummaryLines(decision).join("\n")}\n`);
16893
+ }));
16894
+ }
16151
16895
  //#endregion
16152
16896
  //#region src/worktree-scratch-files.ts
16153
16897
  var worktree_scratch_files_exports = /* @__PURE__ */ __exportAll({
@@ -16435,9 +17179,11 @@ function createProgram(options = {}) {
16435
17179
  program.addCommand(createDoctorCommand(output));
16436
17180
  program.addCommand(createConfigCommand(output));
16437
17181
  program.addCommand(createEvidenceEmitCommand(output));
17182
+ program.addCommand(createCiAnalyzeCommand(output));
16438
17183
  program.addCommand(createHqFlushCommand(output));
16439
17184
  program.addCommand(createBoundaryCheckCommand(output, options.actions?.boundaryCheck));
16440
17185
  program.addCommand(createPrVerifyCommand(output));
17186
+ program.addCommand(createProductionImpactCommand(output));
16441
17187
  program.addCommand(createCloseoutCommand(output));
16442
17188
  program.addCommand(createEpicPublishStructureCommand(output));
16443
17189
  program.addCommand(createPrReviewCommand(output, options.actions?.prReview));