@patronage/software-factory 1.0.0-alpha.26 → 1.0.0-alpha.28

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,12 +1,10 @@
1
- #!/usr/bin/env node
2
1
  import { t as __exportAll } from "./chunk-pbuEa-1d.js";
3
- import { appendFileSync, constants, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
- import { pathToFileURL } from "node:url";
5
2
  import { Command, InvalidArgumentError } from "commander";
6
3
  import path from "node:path";
7
4
  import crypto, { createHash, randomBytes, randomUUID } from "node:crypto";
8
5
  import { z } from "zod";
9
6
  import { execFileSync, spawnSync } from "node:child_process";
7
+ import { appendFileSync, constants, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
10
8
  import { Buffer as Buffer$1 } from "node:buffer";
11
9
  import picomatch from "picomatch";
12
10
  import { link, lstat, mkdir, open, readFile, readdir, realpath, rename, stat, unlink } from "node:fs/promises";
@@ -16,9 +14,27 @@ import { setImmediate as setImmediate$1, setTimeout as setTimeout$1 } from "node
16
14
  import { Worker } from "node:worker_threads";
17
15
  import { parse } from "yaml";
18
16
  import { promisify } from "node:util";
19
- import { FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_STEP_NAME, GitHubApiError, mintInstallationToken, previewProofInventory, productionImpactTargetOutput, readVitestProfileDocument } from "@patronage/factory-ci";
17
+ import { FACTORY_APP_BROKER_ORIGIN, FACTORY_APP_BROKER_PATH, FACTORY_CURSOR_OIDC_AUDIENCE, FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_STEP_NAME, GitHubApiError, mintInstallationToken, previewProofInventory, productionImpactTargetOutput, readVitestProfileDocument } from "@patronage/factory-ci";
18
+ import http from "node:http";
19
+ import { pathToFileURL } from "node:url";
20
20
  //#region package.json
21
- var version = "1.0.0-alpha.26";
21
+ var version = "1.0.0-alpha.28";
22
+ //#endregion
23
+ //#region src/cli-entry.ts
24
+ /**
25
+ * The path of the module that runs a factory command.
26
+ *
27
+ * The package publishes two dist entries (#386). `index.js` is the library
28
+ * export: consumers import it, so it runs nothing. `cli.js` is the bin, and it
29
+ * is the only entry that runs a command. A spawned follow-up that names
30
+ * `index.js` therefore exits 0 having done nothing.
31
+ *
32
+ * Every caller that needs the runnable entry resolves it here, so there is one
33
+ * place to get it wrong rather than one per call site. The entry is this
34
+ * module's sibling and carries this module's extension — `cli.ts` under tsx in
35
+ * development, `cli.js` in the built package.
36
+ */
37
+ const factoryCliEntry = () => path.join(import.meta.dirname, `cli${path.extname(import.meta.filename)}`);
22
38
  //#endregion
23
39
  //#region src/review-rungs.ts
24
40
  const EVIDENCE_REVIEW_RUNGS = [
@@ -1261,11 +1277,52 @@ function currentHeadSha(cwd) {
1261
1277
  function repositoryRoot(cwd) {
1262
1278
  return runCapture("git", ["rev-parse", "--show-toplevel"], cwd).stdout.trim();
1263
1279
  }
1264
- function showFileAtRef(cwd, ref, absolutePath) {
1265
- const rel = path.relative(cwd, absolutePath);
1266
- const spec = rel.startsWith(".") ? rel : `./${rel}`;
1280
+ /** A path handed to a git object read that is not repository-relative. */
1281
+ var RepositoryRelativePathError = class extends Error {
1282
+ name = "RepositoryRelativePathError";
1283
+ };
1284
+ /**
1285
+ * The repository-relative contract, enforced before a path reaches git.
1286
+ *
1287
+ * git resolves `<ref>:<path>` from the repository root ONLY for a bare name.
1288
+ * Measured against real git from a package subdirectory, `HEAD:./package.json`
1289
+ * and `HEAD:../software-factory/package.json` both resolve — the first against
1290
+ * the working directory, the second by traversal. So a caller that passes
1291
+ * either form silently addresses a different file depending on where the
1292
+ * command ran, which is the whole defect class this contract exists to close
1293
+ * (#922, #1004 review cycles 2 to 4).
1294
+ *
1295
+ * Every current call site already passes a bare name; this makes the promise
1296
+ * enforced rather than documented. It throws instead of returning a value,
1297
+ * because a control that cannot answer must fail loudly, and callers that
1298
+ * tolerate a missing file must not silently tolerate a mis-addressed one.
1299
+ */
1300
+ function assertRepositoryRelativePath(repositoryRelativePath, caller) {
1301
+ const refuse = (reason) => {
1302
+ throw new RepositoryRelativePathError(`${caller} requires a repository-relative path, but received ${JSON.stringify(repositoryRelativePath)}: ${reason}. git resolves "<ref>:<path>" from the repository root only for a bare name, so a "./", "../", or absolute form addresses a different file depending on the working directory.`);
1303
+ };
1304
+ if (repositoryRelativePath.length === 0) return refuse("it is empty");
1305
+ if (repositoryRelativePath.startsWith("/") || path.isAbsolute(repositoryRelativePath)) return refuse("it is absolute");
1306
+ if (repositoryRelativePath.split("/").some((segment) => segment === "." || segment === "..")) return refuse("it contains a \".\" or \"..\" segment");
1307
+ return repositoryRelativePath;
1308
+ }
1309
+ /**
1310
+ * A file's content at a ref, addressed the way git addresses it (#922).
1311
+ *
1312
+ * `repositoryRelativePath` is repository-relative, NOT relative to `cwd`.
1313
+ * git resolves `<ref>:<path>` from the repository ROOT unless the path starts
1314
+ * with `./` or `../`, so a repository-relative name is correct from every
1315
+ * working directory inside the checkout, including one reached through a
1316
+ * symlink. `cwd` is still passed so git discovers the same repository.
1317
+ *
1318
+ * This used to relativize an absolute path against `cwd` and prefix `./`,
1319
+ * which made the address cwd-relative and therefore wrong from any
1320
+ * subdirectory (#1004 review cycles 2 and 3).
1321
+ */
1322
+ function showFileAtRef(cwd, ref, repositoryRelativePath) {
1323
+ assertRepositoryRelativePath(repositoryRelativePath, "showFileAtRef");
1267
1324
  try {
1268
- return runCapture("git", ["show", `${ref}:${spec}`], cwd).stdout;
1325
+ return runCapture("git", ["show", `${ref}:${repositoryRelativePath}`], cwd).stdout;
1269
1326
  } catch {
1270
1327
  return;
1271
1328
  }
@@ -5483,7 +5540,7 @@ const analyzeSourcePaths = (sourceFiles, targets, profilePath, inertGlobs) => {
5483
5540
  };
5484
5541
  if (file === profilePath) return {
5485
5542
  kind: "conservative",
5486
- reason: `changed source path "${file}" is the impact profile being evaluated; fail closed to full impact`
5543
+ reason: `changed source path "${file}" is the impact profile being evaluated; fail closed to full impact. A profile migration waives each retired target's required-check demand (see the admission skill).`
5487
5544
  };
5488
5545
  }
5489
5546
  return {
@@ -6009,6 +6066,75 @@ function generationOrderMs(value) {
6009
6066
  return Number.isFinite(parsed) ? parsed : Number.NEGATIVE_INFINITY;
6010
6067
  }
6011
6068
  //#endregion
6069
+ //#region src/cursor-oidc-mint.ts
6070
+ const DEFAULT_CURSOR_AGENT_SOCKET = "/run/cursor/api.sock";
6071
+ const DEFAULT_TIMEOUT_MS = 5e3;
6072
+ const cursorAgentSocketPath = (env = process.env) => {
6073
+ const fromEnv = env.CURSOR_AGENT_SOCKET;
6074
+ return fromEnv !== void 0 && fromEnv.length > 0 ? fromEnv : DEFAULT_CURSOR_AGENT_SOCKET;
6075
+ };
6076
+ const isCursorManagedVm = (env = process.env, exists = existsSync) => {
6077
+ if (env.CURSOR_AGENT_SOCKET !== void 0 && env.CURSOR_AGENT_SOCKET.length > 0) return true;
6078
+ return exists(DEFAULT_CURSOR_AGENT_SOCKET);
6079
+ };
6080
+ var CursorOidcMintError = class extends Error {
6081
+ constructor(message) {
6082
+ super(message);
6083
+ this.name = "CursorOidcMintError";
6084
+ }
6085
+ };
6086
+ /**
6087
+ * Mint a Cursor OIDC JWT from the local agent socket. No nonce: the broker
6088
+ * does not run a nonce protocol (#949).
6089
+ */
6090
+ const mintCursorOidcToken = (options = {}) => new Promise((resolve, reject) => {
6091
+ const socketPath = options.socketPath ?? cursorAgentSocketPath();
6092
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
6093
+ const body = JSON.stringify({ aud: options.audience ?? FACTORY_CURSOR_OIDC_AUDIENCE });
6094
+ const req = http.request({
6095
+ headers: {
6096
+ "Content-Length": String(Buffer.byteLength(body)),
6097
+ "Content-Type": "application/json"
6098
+ },
6099
+ method: "POST",
6100
+ path: "/v1/tokens/oidc",
6101
+ socketPath
6102
+ }, (response) => {
6103
+ const chunks = [];
6104
+ response.on("data", (chunk) => {
6105
+ chunks.push(chunk);
6106
+ });
6107
+ response.on("end", () => {
6108
+ const text = Buffer.concat(chunks).toString("utf-8");
6109
+ if (response.statusCode !== 200) {
6110
+ reject(new CursorOidcMintError(`Cursor OIDC mint failed: ${String(response.statusCode)}`));
6111
+ return;
6112
+ }
6113
+ let parsed;
6114
+ try {
6115
+ parsed = JSON.parse(text);
6116
+ } catch {
6117
+ reject(new CursorOidcMintError("Cursor OIDC mint returned invalid JSON"));
6118
+ return;
6119
+ }
6120
+ if (parsed === null || typeof parsed !== "object" || typeof parsed.token !== "string") {
6121
+ reject(new CursorOidcMintError("Cursor OIDC mint omitted token"));
6122
+ return;
6123
+ }
6124
+ resolve(parsed.token);
6125
+ });
6126
+ });
6127
+ req.on("error", (error) => {
6128
+ reject(new CursorOidcMintError(`Cursor OIDC mint failed: ${error instanceof Error ? error.message : String(error)}`));
6129
+ });
6130
+ req.setTimeout(timeoutMs, () => {
6131
+ req.destroy();
6132
+ reject(new CursorOidcMintError("Cursor OIDC mint timed out"));
6133
+ });
6134
+ req.write(body);
6135
+ req.end();
6136
+ });
6137
+ //#endregion
6012
6138
  //#region src/pr-verify-status.ts
6013
6139
  const PR_VERIFY_STATUS_CONTEXT = "patronage-factory/pr-verify";
6014
6140
  const GITHUB_PUBLISH_TIMEOUT_MS = 5e3;
@@ -6067,6 +6193,143 @@ function createGhCommitStatusPoster(output, run = runGhStatus) {
6067
6193
  };
6068
6194
  }
6069
6195
  //#endregion
6196
+ //#region src/factory-app-broker-client.ts
6197
+ const FACTORY_APP_BROKER_URL = `${FACTORY_APP_BROKER_ORIGIN}${FACTORY_APP_BROKER_PATH}`;
6198
+ var FactoryAppBrokerError = class extends Error {
6199
+ status;
6200
+ constructor(message, status) {
6201
+ super(message);
6202
+ this.name = "FactoryAppBrokerError";
6203
+ if (status !== void 0) this.status = status;
6204
+ }
6205
+ };
6206
+ const factoryAppBrokerUrl = (env = process.env) => {
6207
+ const override = env.FACTORY_APP_BROKER_URL;
6208
+ return override !== void 0 && override.length > 0 ? override : FACTORY_APP_BROKER_URL;
6209
+ };
6210
+ /**
6211
+ * Exchange a Cursor OIDC JWT for a repo-scoped Factory App installation token.
6212
+ * Missing or refused exchange fails closed — no token is invented.
6213
+ */
6214
+ const exchangeFactoryAppInstallationToken = async (input) => {
6215
+ const request = input.fetch ?? fetch;
6216
+ const url = input.url ?? FACTORY_APP_BROKER_URL;
6217
+ let response;
6218
+ try {
6219
+ response = await request(url, {
6220
+ body: JSON.stringify({
6221
+ owner: input.owner,
6222
+ repo: input.repo
6223
+ }),
6224
+ headers: {
6225
+ Authorization: `Bearer ${input.oidc}`,
6226
+ "Content-Type": "application/json"
6227
+ },
6228
+ method: "POST",
6229
+ signal: AbortSignal.timeout(input.timeoutMs ?? 5e3)
6230
+ });
6231
+ } catch (error) {
6232
+ throw new FactoryAppBrokerError(`Factory App broker exchange failed: ${error instanceof Error ? error.message : String(error)}`);
6233
+ }
6234
+ if (!response.ok) throw new FactoryAppBrokerError(`Factory App broker exchange failed: ${String(response.status)}`, response.status);
6235
+ const body = await response.json();
6236
+ if (body === null || typeof body !== "object" || typeof body.token !== "string" || body.token.length === 0) throw new FactoryAppBrokerError("Factory App broker exchange omitted token");
6237
+ return body.token;
6238
+ };
6239
+ //#endregion
6240
+ //#region src/github-app-access.ts
6241
+ const NO_APP_SKIP_MESSAGE = "no factory GitHub App configured; skipping durable check-run republish.\n";
6242
+ const exchangeFromCursorVm = async (repository, dependencies, env, request, timeoutMs) => {
6243
+ const mint = dependencies.mintCursorOidc ?? mintCursorOidcToken;
6244
+ const exchange = dependencies.exchangeBroker ?? exchangeFactoryAppInstallationToken;
6245
+ let oidc;
6246
+ try {
6247
+ oidc = await mint({
6248
+ socketPath: cursorAgentSocketPath(env),
6249
+ timeoutMs
6250
+ });
6251
+ } catch (error) {
6252
+ return {
6253
+ failClosed: true,
6254
+ kind: "unavailable",
6255
+ message: `Cursor OIDC mint failed: ${error instanceof Error ? error.message : String(error)}\n`
6256
+ };
6257
+ }
6258
+ try {
6259
+ return {
6260
+ appId: FACTORY_PROOF_GATE_APP_ID,
6261
+ kind: "ok",
6262
+ token: await exchange({
6263
+ fetch: request,
6264
+ oidc,
6265
+ owner: repository.owner,
6266
+ repo: repository.repo,
6267
+ timeoutMs,
6268
+ url: factoryAppBrokerUrl(env)
6269
+ })
6270
+ };
6271
+ } catch (error) {
6272
+ return {
6273
+ failClosed: true,
6274
+ kind: "unavailable",
6275
+ message: `Factory App broker exchange failed: ${error instanceof Error ? error.message : String(error)}\n`
6276
+ };
6277
+ }
6278
+ };
6279
+ /**
6280
+ * Resolve a Factory App installation token for App-owned GitHub writes.
6281
+ *
6282
+ * Order: caller token, `FACTORY_GITHUB_APP_TOKEN` (Actions), local PEM mint,
6283
+ * Cursor OIDC broker exchange. A Cursor-managed VM with no PEM must succeed
6284
+ * the exchange; missing mint or publication fails closed (#949).
6285
+ */
6286
+ const resolveFactoryAppAccess = async (repository, dependencies = {}) => {
6287
+ const env = dependencies.env ?? process.env;
6288
+ if (dependencies.token) return {
6289
+ appId: FACTORY_PROOF_GATE_APP_ID,
6290
+ kind: "ok",
6291
+ token: dependencies.token
6292
+ };
6293
+ const workflowToken = env.FACTORY_GITHUB_APP_TOKEN;
6294
+ if (workflowToken !== void 0 && workflowToken.length > 0) return {
6295
+ appId: FACTORY_PROOF_GATE_APP_ID,
6296
+ kind: "ok",
6297
+ token: workflowToken
6298
+ };
6299
+ const config = dependencies.githubApp ?? tryUserConfig({ env })?.config.githubApp;
6300
+ const request = dependencies.fetch ?? fetch;
6301
+ const timeoutMs = dependencies.timeoutMs ?? 5e3;
6302
+ if (config) try {
6303
+ const token = await mintInstallationToken({
6304
+ credentials: config,
6305
+ owner: repository.owner,
6306
+ repo: repository.repo
6307
+ }, {
6308
+ fetch: request,
6309
+ now: dependencies.now ?? Date.now,
6310
+ timeoutMs,
6311
+ ...dependencies.transportAttempts === void 0 ? {} : { transportAttempts: dependencies.transportAttempts }
6312
+ });
6313
+ return {
6314
+ appId: String(config.appId),
6315
+ kind: "ok",
6316
+ token
6317
+ };
6318
+ } catch (error) {
6319
+ return {
6320
+ failClosed: true,
6321
+ kind: "unavailable",
6322
+ message: `unable to mint Factory App token: ${error instanceof Error ? error.message : String(error)}\n`
6323
+ };
6324
+ }
6325
+ if (isCursorManagedVm(env, dependencies.existsSync)) return exchangeFromCursorVm(repository, dependencies, env, request, timeoutMs);
6326
+ return {
6327
+ failClosed: false,
6328
+ kind: "unavailable",
6329
+ message: NO_APP_SKIP_MESSAGE
6330
+ };
6331
+ };
6332
+ //#endregion
6070
6333
  //#region src/github-app-client.ts
6071
6334
  const readExchange = async (request, url, init, timeoutMs) => {
6072
6335
  const response = await request(url, {
@@ -6159,14 +6422,10 @@ const OUTCOMES = ["aborted", "passed"];
6159
6422
  const isOutcome = (value) => OUTCOMES.includes(value);
6160
6423
  /**
6161
6424
  * The payload's outcome must agree with the check run's own conclusion, which
6162
- * comes from `verifyProofPassed`: a schemaVersion 1 proof predates the
6163
- * `outcome` field and counts as passing for the compatibility window. Any other
6164
- * proof with no recorded outcome is unfinished (fail closed).
6425
+ * comes from `verifyProofPassed`: a proof with no recorded outcome is
6426
+ * unfinished (fail closed).
6165
6427
  */
6166
- const outcomeFor = (value) => {
6167
- if (isOutcome(value.outcome)) return value.outcome;
6168
- return value.schemaVersion === 1 ? "passed" : "aborted";
6169
- };
6428
+ const outcomeFor = (value) => isOutcome(value.outcome) ? value.outcome : "aborted";
6170
6429
  const asClassification = (value) => DIFF_CLASSIFICATIONS.includes(value) ? value : "unknown";
6171
6430
  /**
6172
6431
  * Project a local pr:verify proof onto the durable check-run payload. Accepts
@@ -6394,7 +6653,7 @@ async function postFactoryCheckRun({ config, deadlineMs, dependencies, detailsUr
6394
6653
  const request = dependencies.fetch ?? fetch;
6395
6654
  const timeoutMs = dependencies.timeoutMs ?? 5e3;
6396
6655
  try {
6397
- const token = await installationToken(input, config, request, (dependencies.now ?? Date.now)(), {
6656
+ const token = dependencies.token ?? await installationToken(input, config, request, (dependencies.now ?? Date.now)(), {
6398
6657
  timeoutMs,
6399
6658
  transportAttempts: 1
6400
6659
  });
@@ -6488,7 +6747,7 @@ async function resolveDetailsUrlSafely(input, dependencies, onDiagnostic) {
6488
6747
  async function servedFactoryCheckRun(input, config, dependencies) {
6489
6748
  const request = dependencies.fetch ?? fetch;
6490
6749
  const timeoutMs = dependencies.timeoutMs ?? 5e3;
6491
- const token = await installationToken(input, config, request, (dependencies.now ?? Date.now)(), { timeoutMs });
6750
+ const token = dependencies.token ?? await installationToken(input, config, request, (dependencies.now ?? Date.now)(), { timeoutMs });
6492
6751
  const query = new URLSearchParams({
6493
6752
  app_id: String(config.appId),
6494
6753
  check_name: FACTORY_CHECK_NAMES[input.gate],
@@ -6621,10 +6880,12 @@ async function confirmPublishedCheckRun({ config, createdId, dependencies, input
6621
6880
  * the head SHA visible on GitHub (pushed the branch, created the PR) can make
6622
6881
  * the proof reliably present for that SHA before it returns (#247).
6623
6882
  *
6624
- * Never throws and never posts a commit-status fallback: the commit status is a
6625
- * human-readable mirror, not a proof surface, so a caller that needs an
6626
- * App-verified check run must be told plainly whether it got one. Returns
6627
- * `true` only when the App-owned check run landed.
6883
+ * Never posts a commit-status fallback: the commit status is a human-readable
6884
+ * mirror, not a proof surface, so a caller that needs an App-verified check
6885
+ * run must be told plainly whether it got one. Returns `true` only when the
6886
+ * App-owned check run landed. Throws when Factory App access is fail-closed
6887
+ * (missing mint or Cursor OIDC exchange, #949): `pr:ready` must not notice
6888
+ * that miss and exit 0. An optional-App skip still returns `false`.
6628
6889
  *
6629
6890
  * "Landed" means GitHub serves it, not that the POST was accepted (#520). On
6630
6891
  * PR #519 the POST was accepted, `pr:ready` reported ready and armed, and the
@@ -6650,18 +6911,28 @@ async function confirmPublishedCheckRun({ config, createdId, dependencies, input
6650
6911
  */
6651
6912
  async function ensureFactoryCheckRunPublished(input, dependencies = {}) {
6652
6913
  const { onDiagnostic } = dependencies;
6914
+ const checkName = FACTORY_CHECK_NAMES[input.gate];
6915
+ const access = await resolveFactoryAppAccess(input, dependencies);
6916
+ if (access.kind !== "ok") {
6917
+ onDiagnostic?.(`${checkName}: ${access.message}`);
6918
+ if (access.failClosed) throw new Error(`${checkName}: ${access.message.trim()}`);
6919
+ return false;
6920
+ }
6653
6921
  try {
6654
- const config = dependencies.githubApp ?? tryUserConfig()?.config.githubApp;
6655
- if (!config) {
6656
- onDiagnostic?.(`${FACTORY_CHECK_NAMES[input.gate]}: no factory GitHub App configured; skipping durable check-run republish.\n`);
6657
- return false;
6658
- }
6922
+ const config = dependencies.githubApp ?? tryUserConfig()?.config.githubApp ?? {
6923
+ appId: access.appId,
6924
+ privateKeyPath: "factory-app-broker"
6925
+ };
6926
+ const tokenDependencies = {
6927
+ ...dependencies,
6928
+ token: access.token
6929
+ };
6659
6930
  const detailsUrl = await resolveDetailsUrlSafely(input, dependencies, onDiagnostic);
6660
6931
  const created = await postFactoryCheckRun({
6661
6932
  config,
6662
6933
  dependencies: {
6663
6934
  retry: DURABLE_CHECK_RUN_RETRY,
6664
- ...dependencies
6935
+ ...tokenDependencies
6665
6936
  },
6666
6937
  detailsUrl,
6667
6938
  input
@@ -6669,7 +6940,7 @@ async function ensureFactoryCheckRunPublished(input, dependencies = {}) {
6669
6940
  return await confirmPublishedCheckRun({
6670
6941
  config,
6671
6942
  createdId: typeof created.id === "number" ? created.id : void 0,
6672
- dependencies,
6943
+ dependencies: tokenDependencies,
6673
6944
  input,
6674
6945
  onDiagnostic
6675
6946
  });
@@ -6708,15 +6979,26 @@ function createFactoryCheckPublisher(output, dependencies = {}) {
6708
6979
  output.stderr.write(`${checkName}: unable to post fallback status: ${message}\n`);
6709
6980
  }
6710
6981
  };
6711
- const config = dependencies.githubApp ?? tryUserConfig()?.config.githubApp;
6712
- if (!config) {
6713
- postFallback();
6982
+ const access = await resolveFactoryAppAccess(input, {
6983
+ ...dependencies,
6984
+ transportAttempts: 1
6985
+ });
6986
+ if (access.kind !== "ok") {
6987
+ output.stderr.write(`${checkName}: ${access.message}`);
6988
+ if (!access.failClosed) postFallback();
6714
6989
  return;
6715
6990
  }
6991
+ const config = dependencies.githubApp ?? tryUserConfig()?.config.githubApp ?? {
6992
+ appId: access.appId,
6993
+ privateKeyPath: "factory-app-broker"
6994
+ };
6716
6995
  try {
6717
6996
  await postFactoryCheckRun({
6718
6997
  config,
6719
- dependencies,
6998
+ dependencies: {
6999
+ ...dependencies,
7000
+ token: access.token
7001
+ },
6720
7002
  detailsUrl,
6721
7003
  input
6722
7004
  });
@@ -6733,6 +7015,77 @@ function createFactoryCheckPublisher(output, dependencies = {}) {
6733
7015
  };
6734
7016
  }
6735
7017
  //#endregion
7018
+ //#region src/repository-profile-path.ts
7019
+ /**
7020
+ * `git rev-parse --show-toplevel` answers with a real path, while a caller's
7021
+ * `cwd` may reach the same directory through a symlink (every macOS temporary
7022
+ * directory does). Both sides are resolved before they are compared, so a
7023
+ * symlinked checkout is not mistaken for a profile outside the repository.
7024
+ */
7025
+ const realPath = (value) => {
7026
+ try {
7027
+ return realpathSync(value);
7028
+ } catch {
7029
+ return value;
7030
+ }
7031
+ };
7032
+ /**
7033
+ * The repository top level for `cwd`, or `cwd` itself when it is not a git
7034
+ * checkout. Never throws: a caller rooting a path has a usable answer either
7035
+ * way, and the checkout-less case keeps the previous cwd-rooted behavior.
7036
+ */
7037
+ const repositoryRootOrCwd = ({ cwd, readRepositoryRoot = repositoryRoot }) => {
7038
+ try {
7039
+ return readRepositoryRoot(cwd);
7040
+ } catch {
7041
+ return cwd;
7042
+ }
7043
+ };
7044
+ /**
7045
+ * The repository-relative, POSIX-separated path of `profilePath`.
7046
+ *
7047
+ * A cwd that is not a git checkout, and a profile the repository top level
7048
+ * does not contain, both fall back to the cwd-relative path: this function
7049
+ * makes a path more portable, and it must never invent one. Callers that
7050
+ * require the profile to be inside the trusted checkout keep enforcing that
7051
+ * themselves.
7052
+ */
7053
+ const repositoryRelativeProfilePath = ({ cwd, profilePath, readRepositoryRoot = repositoryRoot }) => {
7054
+ const cwdRelative = path.relative(cwd, profilePath).split(path.sep).join("/");
7055
+ const root = repositoryRootOrCwd({
7056
+ cwd,
7057
+ readRepositoryRoot
7058
+ });
7059
+ const rootRelative = path.relative(realPath(root), realPath(profilePath)).split(path.sep).join("/");
7060
+ return rootRelative.length > 0 && !rootRelative.startsWith("../") ? rootRelative : cwdRelative;
7061
+ };
7062
+ /** A path that names no file inside the root it was measured from. */
7063
+ const escapesRoot = (relativePath) => relativePath.length === 0 || relativePath === ".." || relativePath.startsWith("../") || path.isAbsolute(relativePath);
7064
+ /**
7065
+ * The same path, for a caller that must REFUSE a profile the repository does
7066
+ * not contain.
7067
+ *
7068
+ * Containment is measured from the repository top level, not from `cwd`.
7069
+ * Measuring it from `cwd` refuses the repository-root profile whenever the
7070
+ * command runs in a package subdirectory — a profile that is plainly inside
7071
+ * the trusted checkout — and it refused before the conversion above could run
7072
+ * (cycle-1 review finding on PR #1004).
7073
+ *
7074
+ * Fail-closed is unchanged. A profile genuinely outside the repository root
7075
+ * falls back to the cwd-relative path, which still escapes, and throws here.
7076
+ * A cwd that is not a git checkout has no root to measure from and keeps the
7077
+ * previous cwd-relative rule exactly.
7078
+ */
7079
+ const repositoryContainedProfilePath = ({ cwd, profilePath, readRepositoryRoot = repositoryRoot }) => {
7080
+ const relativePath = repositoryRelativeProfilePath({
7081
+ cwd,
7082
+ profilePath,
7083
+ readRepositoryRoot
7084
+ });
7085
+ if (escapesRoot(relativePath)) throw new Error("The readiness profile must be inside the trusted checkout.");
7086
+ return relativePath;
7087
+ };
7088
+ //#endregion
6736
7089
  //#region src/pr-classification.ts
6737
7090
  const MISSING_STAMP_REASON = "impact stamp is missing; fail closed to non-trivial";
6738
7091
  function ownershipFromStamp(stamp, file) {
@@ -6758,7 +7111,10 @@ function reasonForOwnership(file, ownership) {
6758
7111
  }
6759
7112
  }
6760
7113
  function relativeProfilePath(options) {
6761
- return path.relative(options.cwd, options.profilePath).split(path.sep).join("/");
7114
+ return repositoryRelativeProfilePath({
7115
+ cwd: options.cwd,
7116
+ profilePath: options.profilePath
7117
+ });
6762
7118
  }
6763
7119
  function applyProfileEditGuard(files, relativeProfile, profilePath, base) {
6764
7120
  if (!files.some((file) => file === relativeProfile || file === profilePath)) return base;
@@ -6990,9 +7346,7 @@ const planVerificationBattery = ({ commands, stamp, vetoedTargets }) => {
6990
7346
  * skip this epic forbids.
6991
7347
  *
6992
7348
  * Rule 4 is scoped to `outcome: "passed"` because an ABORTED proof legitimately
6993
- * records partial execution — the run stopped at the failing command. It is
6994
- * also scoped to schemaVersion >= 4: `executedCommands` postdates v1–v3, so a
6995
- * legacy proof that never recorded it is not making a false completeness claim.
7349
+ * records partial execution — the run stopped at the failing command.
6996
7350
  *
6997
7351
  * The check is one-directional on purpose. Every SELECTED command must be
6998
7352
  * accounted for, but `executedCommands` may legitimately carry MORE than the
@@ -7035,7 +7389,7 @@ const assertBatteryCompleteness = (proof, context) => {
7035
7389
  });
7036
7390
  seen.add(entry.name);
7037
7391
  }
7038
- if (proof.outcome !== "passed" || proof.schemaVersion < 4) return;
7392
+ if (proof.outcome !== "passed") return;
7039
7393
  const disposed = new Set([...executed, ...seen]);
7040
7394
  const undisposed = selected.filter((name) => !disposed.has(name));
7041
7395
  if (undisposed.length > 0) context.addIssue({
@@ -7045,7 +7399,7 @@ const assertBatteryCompleteness = (proof, context) => {
7045
7399
  });
7046
7400
  };
7047
7401
  /**
7048
- * The stamp-authorization control: a passed v4 proof may only claim a command
7402
+ * The stamp-authorization control: a passed proof may only claim a command
7049
7403
  * was NOT REQUIRED if its own recorded stamp says so.
7050
7404
  *
7051
7405
  * `assertBatteryCompleteness` proves the two lists partition the selected
@@ -7068,8 +7422,8 @@ const assertBatteryCompleteness = (proof, context) => {
7068
7422
  * provably released.
7069
7423
  *
7070
7424
  * The proof is one artifact and a determined forger controls all of it; this
7071
- * is internal-coherence belt-and-braces in the same trust domain as the
7072
- * version guards, and write-time truth stays `pr:verify`'s job.
7425
+ * is internal-coherence belt-and-braces in the same trust domain as the rest
7426
+ * of the proof, and write-time truth stays `pr:verify`'s job.
7073
7427
  *
7074
7428
  * The authorizing evidence is the proof's OWN `impactStamp` — the same stamp
7075
7429
  * `pr:verify` computed for this candidate's identity triple and recorded here,
@@ -7082,14 +7436,13 @@ const assertBatteryCompleteness = (proof, context) => {
7082
7436
  * self-contradictory stamp, an unclassified name, or an `affected` verdict all
7083
7437
  * fail the same way scoping itself would.
7084
7438
  *
7085
- * Scoped to `outcome: "passed"` and schemaVersion >= 4 for the same reasons as
7086
- * the completeness rule: an aborted run records partial execution, and a
7087
- * legacy proof predates both fields, so neither is making a false completeness
7439
+ * Scoped to `outcome: "passed"` for the same reason as the completeness rule:
7440
+ * an aborted run records partial execution, so it makes no false completeness
7088
7441
  * claim.
7089
7442
  */
7090
7443
  const assertNotRequiredStampAuthorization = (proof, context) => {
7091
7444
  const notRequired = proof.notRequiredCommands ?? [];
7092
- if (proof.outcome !== "passed" || proof.schemaVersion < 4 || notRequired.length === 0) return;
7445
+ if (proof.outcome !== "passed" || notRequired.length === 0) return;
7093
7446
  const { impactStamp } = proof;
7094
7447
  if (impactStamp === void 0) {
7095
7448
  context.addIssue({
@@ -7138,36 +7491,6 @@ const batteryScopeSummaryLines = (plan) => {
7138
7491
  //#endregion
7139
7492
  //#region src/pr-verify-proof-rules.ts
7140
7493
  const assertPrVerifyProofRules = (proof, context) => {
7141
- if (proof.schemaVersion >= 4 && proof.authoringSession === void 0) context.addIssue({
7142
- code: "custom",
7143
- message: "schemaVersion 4 pr:verify proof requires authoringSession (the recorded authoring identity, or the `unknown` sentinel).",
7144
- path: ["authoringSession"]
7145
- });
7146
- if (proof.schemaVersion < 4 && proof.authoringSession !== void 0) context.addIssue({
7147
- code: "custom",
7148
- message: "authoringSession is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
7149
- path: ["authoringSession"]
7150
- });
7151
- if (proof.schemaVersion < 4 && proof.impactStamp !== void 0) context.addIssue({
7152
- code: "custom",
7153
- message: "impactStamp is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
7154
- path: ["impactStamp"]
7155
- });
7156
- if (proof.schemaVersion < 4 && proof.notRequiredCommands !== void 0) context.addIssue({
7157
- code: "custom",
7158
- message: "notRequiredCommands is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
7159
- path: ["notRequiredCommands"]
7160
- });
7161
- if (proof.schemaVersion < 4 && proof.catchUpRecognition !== void 0) context.addIssue({
7162
- code: "custom",
7163
- message: "catchUpRecognition is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
7164
- path: ["catchUpRecognition"]
7165
- });
7166
- if (proof.schemaVersion < 4 && proof.notDemandedRecords !== void 0) context.addIssue({
7167
- code: "custom",
7168
- message: "notDemandedRecords is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
7169
- path: ["notDemandedRecords"]
7170
- });
7171
7494
  if (proof.notDemandedRecords !== void 0 && proof.impactStamp === void 0) context.addIssue({
7172
7495
  code: "custom",
7173
7496
  message: "notDemandedRecords requires impactStamp; package-level not-demanded records are bound to the stamp identity.",
@@ -7176,14 +7499,15 @@ const assertPrVerifyProofRules = (proof, context) => {
7176
7499
  assertBatteryCompleteness(proof, context);
7177
7500
  assertNotRequiredStampAuthorization(proof, context);
7178
7501
  };
7179
- //#endregion
7180
- //#region src/pr-verify-proof-contract.ts
7181
- const SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS = [
7182
- 1,
7183
- 2,
7184
- 3,
7185
- 4
7186
- ];
7502
+ /**
7503
+ * The pr:verify proof versions a reader accepts: the current one only (#920
7504
+ * item 1). #917 narrowed the pr:ready reader the same way. A pr:verify proof
7505
+ * is per-candidate and short-lived — it is rewritten on every head — so no
7506
+ * stored v1–v3 proof can outlive the release that drops it. Pre-1.0 posture
7507
+ * applies: one current contract, no compat reader. A consumer on an older
7508
+ * factory must adopt this release before HQ ingests its pr:verify proofs.
7509
+ */
7510
+ const SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS = [4];
7187
7511
  const executedCommandSchema = z.object({
7188
7512
  command: z.string().min(1),
7189
7513
  counts: z.object({
@@ -7194,7 +7518,6 @@ const executedCommandSchema = z.object({
7194
7518
  exitCode: z.number(),
7195
7519
  name: z.string().min(1),
7196
7520
  scope: z.enum([
7197
- "always",
7198
7521
  "docs-only",
7199
7522
  "trivial",
7200
7523
  "full"
@@ -7209,9 +7532,9 @@ const notDemandedRecordSchema = z.object({
7209
7532
  basis: z.string().min(1),
7210
7533
  name: z.string().min(1)
7211
7534
  });
7212
- const prVerifySchemaVersionSchema = z.number().refine((value) => SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS.includes(value), { message: `schemaVersion must be one of: ${SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS.join(", ")}` });
7535
+ const prVerifySchemaVersionSchema = z.literal(4);
7213
7536
  const prVerifyProofSchema = z.object({
7214
- authoringSession: z.string().trim().min(1).optional(),
7537
+ authoringSession: z.string().trim().min(1),
7215
7538
  base: z.string().min(1),
7216
7539
  baselineFullProofs: z.array(z.object({
7217
7540
  base: z.string().min(1),
@@ -7257,7 +7580,6 @@ const prVerifyProofSchema = z.object({
7257
7580
  impactTarget: z.string().min(1).optional(),
7258
7581
  name: z.string().min(1),
7259
7582
  scope: z.enum([
7260
- "always",
7261
7583
  "docs-only",
7262
7584
  "trivial",
7263
7585
  "full"
@@ -7288,7 +7610,7 @@ var verification_proof_exports = /* @__PURE__ */ __exportAll({
7288
7610
  function readPrVerifyProof(filePath) {
7289
7611
  return readProofFromPath(validatePrVerifyProof, filePath);
7290
7612
  }
7291
- const verifyProofPassed = (proof) => proof.outcome === "passed" || proof.outcome === void 0 && proof.schemaVersion === 1;
7613
+ const verifyProofPassed = (proof) => proof.outcome === "passed";
7292
7614
  const prVerifyProofHeadShas = (proof, creditedHeadSha = proof?.headSha) => {
7293
7615
  if (!proof || !verifyProofPassed(proof)) return {
7294
7616
  docsOnlyVerifiedHeadShas: [],
@@ -7317,7 +7639,7 @@ const verifyProofHeadMatches = ({ headSha, proof }) => Boolean(proof && evidence
7317
7639
  recorded: { headSha: proof.headSha },
7318
7640
  rule: "patch-id-or-head-sha"
7319
7641
  }));
7320
- const verifyProofIdentityMatches = ({ currentIdentity, proof }) => Boolean(proof && verifyProofPassed(proof) && proof.schemaVersion >= 3 && evidenceFresh({
7642
+ const verifyProofIdentityMatches = ({ currentIdentity, proof }) => Boolean(proof && verifyProofPassed(proof) && evidenceFresh({
7321
7643
  candidate: { patchId: currentIdentity?.patchId },
7322
7644
  recorded: { patchId: proof.patchId },
7323
7645
  rule: "patch-id"
@@ -7336,7 +7658,6 @@ const verifyProofIdentityMatches = ({ currentIdentity, proof }) => Boolean(proof
7336
7658
  */
7337
7659
  const trustedImpactStamp = ({ candidate, proof }) => {
7338
7660
  if (!proof || !verifyProofPassed(proof)) return;
7339
- if (proof.schemaVersion < 4) return;
7340
7661
  const headBound = sameHeadSha(candidate.headSha, proof.headSha);
7341
7662
  const patchBound = evidenceFresh({
7342
7663
  candidate: { patchId: candidate.patchId },
@@ -7348,13 +7669,13 @@ const trustedImpactStamp = ({ candidate, proof }) => {
7348
7669
  /**
7349
7670
  * #833: the recorded catch-up recognition, consumed ONLY when it is
7350
7671
  * identity-bound to the current candidate under exactly the rules
7351
- * `trustedImpactStamp` applies — the proof passed, is v4+, and its recorded
7672
+ * `trustedImpactStamp` applies — the proof passed, and its recorded
7352
7673
  * headSha AND patchId both name this candidate. The recognition withdraws
7353
7674
  * review and preview demands, so it earns the same strict double binding and
7354
7675
  * the same fail-closed answer (`undefined`) on every other state.
7355
7676
  */
7356
7677
  const trustedCatchUpRecognition = ({ candidate, proof }) => {
7357
- if (!proof || !verifyProofPassed(proof) || proof.schemaVersion < 4) return;
7678
+ if (!proof || !verifyProofPassed(proof)) return;
7358
7679
  const headBound = sameHeadSha(candidate.headSha, proof.headSha);
7359
7680
  const patchBound = evidenceFresh({
7360
7681
  candidate: { patchId: candidate.patchId },
@@ -7693,13 +8014,12 @@ const assertCleanInstallResolution = (repoRoot, dependencies = {}) => {
7693
8014
  };
7694
8015
  //#endregion
7695
8016
  //#region src/pr-verify.ts
7696
- const PR_VERIFY_SCHEMA_VERSION = 4;
7697
8017
  const DEFAULT_PR_VERIFY_PROOF_PATH = ".factory-memory/pr-verify.json";
7698
8018
  const PR_VERIFY_PROOF_DESCRIPTOR$1 = {
7699
8019
  defaultPath: DEFAULT_PR_VERIFY_PROOF_PATH,
7700
8020
  label: "pr:verify proof",
7701
8021
  parse: validatePrVerifyProof,
7702
- schemaVersion: PR_VERIFY_SCHEMA_VERSION
8022
+ schemaVersion: 4
7703
8023
  };
7704
8024
  const VERIFY_SCOPE_RANK = {
7705
8025
  "docs-only": 0,
@@ -7786,7 +8106,7 @@ function buildPrVerifyProof({ authoringSession, base, baselineFullProofs, catchU
7786
8106
  profilePath,
7787
8107
  projectKey,
7788
8108
  repository,
7789
- schemaVersion: PR_VERIFY_SCHEMA_VERSION,
8109
+ schemaVersion: 4,
7790
8110
  startedAt: startedAt.toISOString(),
7791
8111
  verificationCommands: commands
7792
8112
  };
@@ -7845,7 +8165,10 @@ function runPrVerify(args, dependencies = {}) {
7845
8165
  profilePath: args.profilePath
7846
8166
  });
7847
8167
  const files = git.changedFiles(cwd, args.base);
7848
- const relativeProfile = path.relative(cwd, profilePath).split(path.sep).join("/");
8168
+ const relativeProfile = repositoryRelativeProfilePath({
8169
+ cwd,
8170
+ profilePath
8171
+ });
7849
8172
  const { headSha, mergeBaseSha: proofMergeBaseSha, patchId } = resolveCandidateIdentity({
7850
8173
  base: args.base,
7851
8174
  cwd,
@@ -7863,7 +8186,7 @@ function runPrVerify(args, dependencies = {}) {
7863
8186
  changedFiles: files,
7864
8187
  profile,
7865
8188
  profilePath: relativeProfile,
7866
- readLockfile: (side) => readLockfileAtRef(cwd, side === "base" ? proofMergeBaseSha : headSha, path.resolve(cwd, LOCKFILE_PATH))
8189
+ readLockfile: (side) => readLockfileAtRef(cwd, side === "base" ? proofMergeBaseSha : headSha, LOCKFILE_PATH)
7867
8190
  });
7868
8191
  } catch (error) {
7869
8192
  if (error instanceof ImpactStampConfigError) throw error;
@@ -10415,35 +10738,38 @@ const writeGithubOutputs$1 = (outputPath, decision) => {
10415
10738
  appendFileSync(outputPath, `${lines.join("\n")}\n`, "utf-8");
10416
10739
  };
10417
10740
  /**
10418
- * Classify candidate impact from one exact base/head identity. The delta is
10419
- * the merge-base-to-head diff, and the lockfile sides are read at exactly the
10420
- * merge base and head — the same inputs `pr:verify` gives the impact stamp
10421
- * for the same candidate commits. The result may withdraw hosted routing
10422
- * work only; every doubt path returns a refused decision whose known targets
10423
- * remain demanded.
10741
+ * The candidate impact classifier: one exact base/head identity in, one impact
10742
+ * stamp out. The delta is the merge-base-to-head diff, and the lockfile sides
10743
+ * are read at exactly the merge base and head — the same inputs `pr:verify`
10744
+ * gives the impact stamp for the same candidate commits.
10745
+ *
10746
+ * It is the authority on which required checks a candidate owes (#963), so
10747
+ * every surface that states that demand calls this rather than matching paths
10748
+ * itself. Every doubt path refuses with its reason; a refusal never withdraws
10749
+ * anything.
10424
10750
  */
10425
- const runCandidateImpact = (args) => {
10751
+ const classifyCandidateImpact = (args) => {
10426
10752
  const cwd = path.resolve(args.cwd ?? process.cwd());
10427
10753
  let profile;
10428
- let profilePath;
10429
10754
  let relativeProfile;
10430
- let decision;
10431
10755
  try {
10432
- const { path: loadedPath, profile: loadedProfile } = loadProjectProfile({
10756
+ const { path: profilePath, profile: loadedProfile } = loadProjectProfile({
10433
10757
  cwd,
10434
10758
  profilePath: args.profilePath
10435
10759
  });
10436
10760
  profile = loadedProfile;
10437
- profilePath = loadedPath;
10438
- relativeProfile = path.relative(cwd, profilePath).split(path.sep).join("/");
10761
+ relativeProfile = repositoryRelativeProfilePath({
10762
+ cwd,
10763
+ profilePath
10764
+ });
10439
10765
  const base = assertCommitIdentity$1(args.base, "base");
10440
10766
  const head = assertCommitIdentity$1(args.head, "head");
10441
10767
  assertReachableCommit$1(cwd, base, "base");
10442
10768
  assertReachableCommit$1(cwd, head, "head");
10443
10769
  const checkedOutHead = runCapture("git", ["rev-parse", "HEAD"], cwd).stdout.trim().toLowerCase();
10444
10770
  if (checkedOutHead !== head) throw new Error(`head commit ${head} is not the checked-out HEAD ${checkedOutHead}; refusing to mix commit identities with declarations from another tree`);
10445
- const relativeProfilePath = path.relative(cwd, profilePath);
10446
- const committedProfile = showFileAtRef(cwd, head, profilePath);
10771
+ const relativeProfilePath = relativeProfile;
10772
+ const committedProfile = showFileAtRef(cwd, head, relativeProfile);
10447
10773
  if (committedProfile === void 0) throw new Error(`profile ${relativeProfilePath} is not readable at head commit ${head}; refusing to classify from declarations the head does not carry`);
10448
10774
  if (canonicalJson(committedProfile) === void 0 || canonicalJson(committedProfile) !== canonicalJson(readFileSync(profilePath, "utf-8"))) throw new Error(`profile ${relativeProfilePath} on disk differs from head commit ${head}; refusing to classify from declarations the head does not carry`);
10449
10775
  const mergeBase = runCapture("git", [
@@ -10462,12 +10788,43 @@ const runCandidateImpact = (args) => {
10462
10788
  const stamp = computeImpactStamp({
10463
10789
  changedFiles,
10464
10790
  profile,
10465
- profilePath: path.relative(cwd, profilePath),
10466
- readLockfile: (side) => showFileAtRef(cwd, side === "base" ? mergeBase : head, path.join(cwd, LOCKFILE_PATH))
10791
+ profilePath: relativeProfile,
10792
+ readLockfile: (side) => showFileAtRef(cwd, side === "base" ? mergeBase : head, LOCKFILE_PATH)
10467
10793
  });
10468
- const core = {
10794
+ return {
10469
10795
  baseSha: base,
10796
+ changedFiles,
10470
10797
  headSha: head,
10798
+ kind: "classified",
10799
+ mergeBaseSha: mergeBase,
10800
+ profile,
10801
+ relativeProfilePath: relativeProfile,
10802
+ stamp
10803
+ };
10804
+ } catch (error) {
10805
+ return {
10806
+ kind: "refused",
10807
+ ...profile ? { profile } : {},
10808
+ reason: `candidate impact classification refused: ${error instanceof Error ? error.message : String(error)}`,
10809
+ ...relativeProfile ? { relativeProfilePath: relativeProfile } : {}
10810
+ };
10811
+ }
10812
+ };
10813
+ /**
10814
+ * Classify candidate impact from one exact base/head identity, as the routing
10815
+ * decision the hosted plane consumes. The result may withdraw hosted routing
10816
+ * work only; every doubt path returns a refused decision whose known targets
10817
+ * remain demanded.
10818
+ */
10819
+ const runCandidateImpact = (args) => {
10820
+ const cwd = path.resolve(args.cwd ?? process.cwd());
10821
+ const classification = classifyCandidateImpact(args);
10822
+ let decision;
10823
+ if (classification.kind === "classified") {
10824
+ const { baseSha, changedFiles, headSha, mergeBaseSha: mergeBase, stamp } = classification;
10825
+ const core = {
10826
+ baseSha,
10827
+ headSha,
10471
10828
  inertPaths: stamp.inertPaths,
10472
10829
  mergeBaseSha: mergeBase,
10473
10830
  schemaVersion: CANDIDATE_IMPACT_DECISION_SCHEMA_VERSION,
@@ -10500,16 +10857,15 @@ const runCandidateImpact = (args) => {
10500
10857
  state: "demanded"
10501
10858
  }))
10502
10859
  };
10503
- } catch (error) {
10504
- decision = refusal$1({
10505
- base: args.base,
10506
- head: args.head,
10507
- profile,
10508
- reason: `candidate impact classification refused: ${error instanceof Error ? error.message : String(error)}`
10509
- });
10510
- }
10860
+ } else decision = refusal$1({
10861
+ base: args.base,
10862
+ head: args.head,
10863
+ ...classification.profile ? { profile: classification.profile } : {},
10864
+ reason: classification.reason
10865
+ });
10511
10866
  if (args.emitRunContext) {
10512
10867
  const fullSuiteBaseRef = emptyTreeHash(cwd);
10868
+ const relativeProfile = classification.relativeProfilePath;
10513
10869
  const { env } = createVerificationRunContext({
10514
10870
  baseRef: decision.mergeBaseSha ?? fullSuiteBaseRef,
10515
10871
  changedFiles: decision.basis.changedFiles,
@@ -13067,6 +13423,60 @@ const reviewProofCheck = ({ candidate, candidateError, proof, reviewModes }) =>
13067
13423
  status: accepted ? "ok" : "warning"
13068
13424
  };
13069
13425
  };
13426
+ /**
13427
+ * Resolve the demand this candidate actually owes.
13428
+ *
13429
+ * The `pr:verify` stamp wins when one binds this candidate: readiness admits
13430
+ * on that stamp and nothing else, so the checklist must not tell a different
13431
+ * story about an already-verified candidate. Before `pr:verify` has stamped
13432
+ * anything there is no trusted stamp to read, and that is exactly the state
13433
+ * #963 reports from — so preflight runs the classifier itself. Preflight
13434
+ * never gates, so a stamp it computed can only inform; it never becomes
13435
+ * evidence, and readiness still requires the verified one.
13436
+ */
13437
+ const resolveRequiredCheckDemand = ({ candidate, cwd, profile, profilePath, verifyProof }) => {
13438
+ const stamped = trustedImpactStamp({
13439
+ candidate: {
13440
+ headSha: candidate.headSha,
13441
+ patchId: candidate.patchId
13442
+ },
13443
+ proof: verifyProof
13444
+ });
13445
+ if (stamped) return {
13446
+ basis: `the pr:verify proof's impact stamp for this candidate — ${impactStampSummaryLines(stamped)[0]}`,
13447
+ kind: "resolved",
13448
+ stamp: stamped
13449
+ };
13450
+ if ((profile.impact?.targets ?? []).length === 0) return {
13451
+ basis: "the profile declares no impact targets, so every required check is unconditional",
13452
+ kind: "resolved"
13453
+ };
13454
+ const classification = classifyCandidateImpact({
13455
+ base: candidate.mergeBaseSha,
13456
+ cwd,
13457
+ head: candidate.headSha,
13458
+ profilePath
13459
+ });
13460
+ if (classification.kind === "refused") return {
13461
+ kind: "unresolved",
13462
+ reason: classification.reason
13463
+ };
13464
+ return {
13465
+ basis: `candidate:impact over ${candidate.mergeBaseSha}..${candidate.headSha} — ${impactStampSummaryLines(classification.stamp)[0]}`,
13466
+ kind: "resolved",
13467
+ stamp: classification.stamp
13468
+ };
13469
+ };
13470
+ /** The row that names the authority on what this candidate owes (#963). */
13471
+ const requiredCheckDemandCheck = (resolution) => resolution.kind === "resolved" ? {
13472
+ message: `Required-check demand is resolved against this candidate by ${resolution.basis}. Rows below state their own resolution; a check reported as not demanded is not owed on this candidate.`,
13473
+ name: "admission:required-check-demand",
13474
+ status: "ok"
13475
+ } : {
13476
+ message: `Required-check demand is NOT resolvable for this candidate (${resolution.reason}), so every declared required check below is shown in force (fail closed). Resolve the refusal, or read candidate:impact directly, before treating the rows below as the demand set.`,
13477
+ name: "admission:required-check-demand",
13478
+ status: "warning"
13479
+ };
13070
13480
  const requiredCheckMessage = (outcome) => {
13071
13481
  if (outcome.status === "out-of-scope") return `Not demanded for this candidate: ${outcome.scopeReason}.`;
13072
13482
  if (outcome.status === "satisfied") return `Satisfied by a ${outcome.checkType}-type evidence envelope${outcome.matchedPath ? ` (${outcome.matchedPath})` : ""}.`;
@@ -13078,7 +13488,7 @@ const requiredCheckMessage = (outcome) => {
13078
13488
  * readiness's own `evaluateRequiredChecks`, so scoping, freshness and
13079
13489
  * independence stay exactly one implementation.
13080
13490
  */
13081
- const requiredCheckChecks = ({ candidate, candidateError, cwd, profile, verifyProof }) => {
13491
+ const requiredCheckChecks = ({ candidate, candidateError, cwd, profile, profilePath, verifyProof }) => {
13082
13492
  const requiredChecks = resolveProfileRequiredChecks(profile);
13083
13493
  if (requiredChecks.length === 0) return [{
13084
13494
  message: "Profile declares no requiredChecks; no external evidence envelope (including a preview-deploy proof) is demanded.",
@@ -13090,13 +13500,14 @@ const requiredCheckChecks = ({ candidate, candidateError, cwd, profile, verifyPr
13090
13500
  name: "admission:required-checks",
13091
13501
  status: "warning"
13092
13502
  }];
13093
- const impactStamp = trustedImpactStamp({
13094
- candidate: {
13095
- headSha: candidate.headSha,
13096
- patchId: candidate.patchId
13097
- },
13098
- proof: verifyProof
13503
+ const demand = resolveRequiredCheckDemand({
13504
+ candidate,
13505
+ cwd,
13506
+ profile,
13507
+ profilePath,
13508
+ verifyProof
13099
13509
  });
13510
+ const impactStamp = demand.kind === "resolved" ? demand.stamp : void 0;
13100
13511
  const { outcomes } = evaluateRequiredChecks({
13101
13512
  authoringSessionIds: resolveAuthoringSessionIds({ recorded: verifyProof?.authoringSession }),
13102
13513
  candidate: {
@@ -13111,11 +13522,11 @@ const requiredCheckChecks = ({ candidate, candidateError, cwd, profile, verifyPr
13111
13522
  ...impactStamp ? { impactStamp } : {}
13112
13523
  }
13113
13524
  });
13114
- return outcomes.map((outcome) => ({
13525
+ return [requiredCheckDemandCheck(demand), ...outcomes.map((outcome) => ({
13115
13526
  message: requiredCheckMessage(outcome),
13116
13527
  name: `admission:required-check:${outcome.name}`,
13117
13528
  status: outcome.status === "unmet" ? "warning" : "ok"
13118
- }));
13529
+ }))];
13119
13530
  };
13120
13531
  /**
13121
13532
  * The full admission checklist for the current repository and candidate, in the
@@ -13124,7 +13535,7 @@ const requiredCheckChecks = ({ candidate, candidateError, cwd, profile, verifyPr
13124
13535
  * requirement is reported by the doctor `github-app` check, which states the
13125
13536
  * consequence of its absence.
13126
13537
  */
13127
- const admissionPreflightChecks = ({ base = "origin/main", cwd, env = process.env, hqIngestCredentials, profile, reviewProofPath = DEFAULT_PR_REVIEW_PROOF_PATH, verifyProofPath = DEFAULT_PR_VERIFY_PROOF_PATH }) => {
13538
+ const admissionPreflightChecks = ({ base = "origin/main", cwd, env = process.env, hqIngestCredentials, profile, profilePath, reviewProofPath = DEFAULT_PR_REVIEW_PROOF_PATH, verifyProofPath = DEFAULT_PR_VERIFY_PROOF_PATH }) => {
13128
13539
  const resolvedVerifyProofPath = path.resolve(cwd, verifyProofPath);
13129
13540
  const preread = tryReadProof(readPrVerifyProof, resolvedVerifyProofPath);
13130
13541
  const resolved = resolveCandidate({
@@ -13154,6 +13565,7 @@ const admissionPreflightChecks = ({ base = "origin/main", cwd, env = process.env
13154
13565
  candidateError,
13155
13566
  cwd,
13156
13567
  profile,
13568
+ profilePath,
13157
13569
  verifyProof: preread.proof
13158
13570
  }),
13159
13571
  hqIngestCredentialCheck({
@@ -13239,6 +13651,7 @@ async function doctorProjectProfile(input = {}) {
13239
13651
  }, input.hqSpoolDependencies);
13240
13652
  const checks = [
13241
13653
  ...buildProfileChecks(profile, cwd, {
13654
+ env,
13242
13655
  userConfig: userConfig.loaded?.config,
13243
13656
  userConfigCheck: userConfig.check
13244
13657
  }),
@@ -13317,17 +13730,24 @@ function buildProfileChecks(profile, cwd, userConfigInput) {
13317
13730
  },
13318
13731
  reviewLadderCheck(),
13319
13732
  userConfigInput.userConfigCheck,
13320
- githubAppCheck(userConfigInput.userConfig),
13733
+ githubAppCheck(userConfigInput.userConfig, userConfigInput.env),
13321
13734
  gitRemoteCheck(profile, cwd)
13322
13735
  ];
13323
13736
  }
13324
- function githubAppCheck(userConfig) {
13737
+ function githubAppCheck(userConfig, env) {
13325
13738
  const app = userConfig?.githubApp;
13326
- if (!app) return {
13327
- message: "Patronage Factory GitHub App credentials are not configured; gate publishing will use user-token commit statuses with Details links. Proof bindings cannot publish, so hosted required checks cannot be satisfied by local proof and must go green by their own means.",
13328
- name: "github-app",
13329
- status: "warning"
13330
- };
13739
+ if (!app) {
13740
+ if (env.CURSOR_AGENT_SOCKET !== void 0 && env.CURSOR_AGENT_SOCKET.length > 0) return {
13741
+ message: "Patronage Factory GitHub App PEM is not configured; this Cursor-managed VM publishes patronage-factory/pr-ready through the Factory App broker (Cursor OIDC, audience Factory). Missing exchange fails closed.",
13742
+ name: "github-app",
13743
+ status: "warning"
13744
+ };
13745
+ return {
13746
+ message: "Patronage Factory GitHub App credentials are not configured; gate publishing will use user-token commit statuses with Details links. Proof bindings cannot publish, so hosted required checks cannot be satisfied by local proof and must go green by their own means.",
13747
+ name: "github-app",
13748
+ status: "warning"
13749
+ };
13750
+ }
13331
13751
  if (!existsSync(app.privateKeyPath)) return {
13332
13752
  message: `Patronage Factory GitHub App id ${app.appId} is configured, but private key ${app.privateKeyPath} was not found.`,
13333
13753
  name: "github-app",
@@ -13391,7 +13811,7 @@ function repositoryUrlMatches(origin, profile) {
13391
13811
  //#endregion
13392
13812
  //#region src/commands/doctor.ts
13393
13813
  function createDoctorCommand(output) {
13394
- 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) => {
13814
+ return markCwdOptionDefault(new Command("doctor").description("Validate a project profile without mutating GitHub or local files").option("--base <ref>", "base branch or ref for --preflight", "origin/main").option("--cwd <path>", "working directory to validate", collectCwdOption).option("--json", "print the doctor report as JSON").option("--preflight", "also list the pre-checkable admission requirements for the current candidate (read-only; never blocks); each required check states whether this candidate is demanded to satisfy it, resolved by candidate:impact; findings-file shape and wave membership have no read-only pre-check").option("--profile <path>", "path to the project profile JSON file").action(async (options) => {
13395
13815
  const report = await doctorProjectProfile({
13396
13816
  base: options.base,
13397
13817
  cwd: resolveCwdOption(options.cwd),
@@ -16187,10 +16607,10 @@ function defaultPrReadyGit() {
16187
16607
  ], cwd);
16188
16608
  },
16189
16609
  mergeBaseSha,
16190
- objectIdAtRef: (cwd, ref, absolutePath) => {
16191
- const relativePath = path.relative(cwd, absolutePath);
16610
+ objectIdAtRef: (cwd, ref, repositoryRelativePath) => {
16611
+ assertRepositoryRelativePath(repositoryRelativePath, "objectIdAtRef");
16192
16612
  try {
16193
- return runCapture("git", ["rev-parse", `${ref}:${relativePath}`], cwd).stdout.trim();
16613
+ return runCapture("git", ["rev-parse", `${ref}:${repositoryRelativePath}`], cwd).stdout.trim();
16194
16614
  } catch {}
16195
16615
  },
16196
16616
  resolveCommitSha: (cwd, ref) => {
@@ -16425,9 +16845,9 @@ const resolveExternalEvidenceInputs = ({ args, cwd, recordedAuthoringSession, re
16425
16845
  externalRequiredChecks: requiredChecks
16426
16846
  };
16427
16847
  };
16428
- function policyProfileAtCommittedHead({ cwd, git, headSha, profilePath }) {
16429
- const content = git.showFileAtRef(cwd, headSha, profilePath);
16430
- const blobSha = git.objectIdAtRef(cwd, headSha, profilePath);
16848
+ function policyProfileAtCommittedHead({ cwd, git, headSha, repositoryRelativeProfile }) {
16849
+ const content = git.showFileAtRef(cwd, headSha, repositoryRelativeProfile);
16850
+ const blobSha = git.objectIdAtRef(cwd, headSha, repositoryRelativeProfile);
16431
16851
  const committedProfile = content ? tryParseProfileContent(content) : void 0;
16432
16852
  if (!(committedProfile && blobSha && /^[0-9a-f]{40}$/u.test(blobSha))) throw new Error("Could not read, validate, and identify readiness policy from the committed PR head; fetch the head and re-run pr:ready.");
16433
16853
  return {
@@ -16435,11 +16855,6 @@ function policyProfileAtCommittedHead({ cwd, git, headSha, profilePath }) {
16435
16855
  profile: committedProfile
16436
16856
  };
16437
16857
  }
16438
- function checkoutRelativeProfilePath(cwd, profilePath) {
16439
- const relativePath = path.relative(cwd, profilePath);
16440
- if (relativePath.length === 0 || relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) throw new Error("The readiness profile must be inside the trusted checkout.");
16441
- return relativePath.split(path.sep).join("/");
16442
- }
16443
16858
  function readinessProfilePath(cwd, requestedPath) {
16444
16859
  return path.resolve(cwd, requestedPath ?? "software-factory.profile.json");
16445
16860
  }
@@ -16499,15 +16914,18 @@ async function runPrReady(args, dependencies = {}) {
16499
16914
  hudMergeBaseSha = resolvedMergeBaseSha;
16500
16915
  hudPatchId = patchId;
16501
16916
  const files = git.changedFiles(cwd, authoritativeBaseSha, pr.headRefOid);
16917
+ const committedProfilePath = repositoryContainedProfilePath({
16918
+ cwd,
16919
+ profilePath
16920
+ });
16502
16921
  const committedPolicy = policyProfileAtCommittedHead({
16503
16922
  cwd,
16504
16923
  git,
16505
16924
  headSha: pr.headRefOid,
16506
- profilePath
16925
+ repositoryRelativeProfile: committedProfilePath
16507
16926
  });
16508
16927
  const policyProfile = committedPolicy.profile;
16509
16928
  if (repositorySlug(policyProfile.repository) !== checkoutRepositorySlug) throw new Error(`Committed readiness profile repository ${repositorySlug(policyProfile.repository)} does not match trusted checkout repository ${checkoutRepositorySlug}.`);
16510
- const committedProfilePath = checkoutRelativeProfilePath(cwd, profilePath);
16511
16929
  const policy = resolveReviewDemand({
16512
16930
  authority: dependencies.basePolicy ?? defaultBaseReviewPolicyAuthority(),
16513
16931
  callerBaseRef: pr.baseRefName,
@@ -16740,7 +17158,12 @@ async function runPrReady(args, dependencies = {}) {
16740
17158
  repo: repository.name,
16741
17159
  sha: presentedHeadSha
16742
17160
  };
16743
- if (!await (dependencies.publishFinalCheckRun ? dependencies.publishFinalCheckRun(failureCheckInput) : ensureFactoryCheckRunPublished(failureCheckInput, { onDiagnostic: (diagnostic) => process.stderr.write(diagnostic) }))) console.warn(`pr:ready could not publish the failing ${FACTORY_CHECK_NAMES["pr-ready"]} check run for ${presentedHeadSha}; the required check is absent rather than red.`);
17161
+ try {
17162
+ if (!await (dependencies.publishFinalCheckRun ? dependencies.publishFinalCheckRun(failureCheckInput) : ensureFactoryCheckRunPublished(failureCheckInput, { onDiagnostic: (diagnostic) => process.stderr.write(diagnostic) }))) console.warn(`pr:ready could not publish the failing ${FACTORY_CHECK_NAMES["pr-ready"]} check run for ${presentedHeadSha}; the required check is absent rather than red.`);
17163
+ } catch (publishError) {
17164
+ const publishMessage = publishError instanceof Error ? publishError.message : String(publishError);
17165
+ console.warn(`pr:ready could not publish the failing ${FACTORY_CHECK_NAMES["pr-ready"]} check run for ${presentedHeadSha}; the required check is absent rather than red. ${publishMessage}`);
17166
+ }
16744
17167
  await presentHud();
16745
17168
  }
16746
17169
  throw error;
@@ -17081,10 +17504,20 @@ function emitLadderStageTrace({ cwd, evaluation, headSha, issue, patchId, profil
17081
17504
  }
17082
17505
  //#endregion
17083
17506
  //#region src/review-contract.ts
17084
- const REVIEW_FOOTER_INSTRUCTIONS = `Do not print a machine-readable footer in your chat response. Instead, write your findings to the typed findings file at the path given in your dispatch/task instructions (the file pr:review reads via --findings <path>). The file must be valid JSON matching review-findings-file.ts's schema (schemaVersion 1):
17507
+ const SESSION_ID_RULE = `"sessionId" is this review session's own id. It must differ from the authoring session id the candidate was verified under (FACTORY_SESSION_ID): pr:review --findings refuses a findings file whose sessionId equals the authoring session.`;
17508
+ const authorityRule = (waveRung) => waveRung === void 0 ? `Emit schemaVersion 1 and omit "producer" and "rung": this candidate resolves no boundary-wave review rung. If your dispatch instructions do name a rung, emit schemaVersion 2 with that rung and with "producer" instead.` : `This candidate belongs to a boundary wave that demands the ${waveRung} review rung, so schemaVersion 2 is required. Record "rung": "${waveRung}" exactly as shown, and set "producer" to the engine and model that ran this review (for example "codex-cli/gpt-5.6-sol"). Findings without both fields are recorded without authority, and pr:publish then refuses with "no passing review at that authority".`;
17509
+ /**
17510
+ * The findings-file instructions for one candidate.
17511
+ *
17512
+ * `waveRung` is the review rung the candidate's boundary wave demands, when it
17513
+ * resolves one. It selects the findings schema version the gate will accept.
17514
+ */
17515
+ const reviewFooterInstructions = (waveRung) => {
17516
+ const schemaVersion = waveRung === void 0 ? 1 : 2;
17517
+ return `Do not print a machine-readable footer in your chat response. Instead, write your findings to the typed findings file at the path given in your dispatch/task instructions (the file pr:review reads via --findings <path>). The file must be valid JSON matching review-findings-file.ts's schema (schemaVersion ${schemaVersion}):
17085
17518
  {
17086
- "schemaVersion": 1,
17087
- "model": "<your model identifier>",
17519
+ "schemaVersion": ${schemaVersion},
17520
+ ${waveRung === void 0 ? "" : ` "producer": "<the engine and model that ran this review, for example codex-cli/gpt-5.6-sol>",\n "rung": "${waveRung}",\n`} "model": "<your model identifier>",
17088
17521
  "sessionId": "<this session's id>",
17089
17522
  "patchId": "<the stable patch-id from Review scope above>",
17090
17523
  "reviews": [
@@ -17097,10 +17530,13 @@ const REVIEW_FOOTER_INSTRUCTIONS = `Do not print a machine-readable footer in yo
17097
17530
  }
17098
17531
  ]
17099
17532
  }
17533
+ ${authorityRule(waveRung)}
17534
+ ${SESSION_ID_RULE}
17100
17535
  Use category "correctness", "safety", "coordination", or "maintainability"; set blockingAfterCap true for correctness, safety, or coordination findings. citedSpan must be the exact code text the finding is about, quoted verbatim from the diff (identity is derived from this, not from the title, so quote precisely). prescribedAction must be a concise, standalone statement of the required fix (identity is derived from this text too). If this finding is a restatement of a prior finding shown in the prior review ledger below, set supersedes to {"title": "<exact prior title>", "file": "<its file>"} instead of minting a new title for the same theme.`;
17536
+ };
17101
17537
  //#endregion
17102
17538
  //#region src/review-prompt.ts
17103
- const reviewScope = ({ base, changedFiles: files, headSha, patchId, profile }) => `Review scope:
17539
+ const reviewScope = ({ base, changedFiles: files, headSha, patchId, profile, waveRung }) => `Review scope:
17104
17540
  - Repository: ${profile.repository.owner}/${profile.repository.name}
17105
17541
  - Base ref: ${base}
17106
17542
  - Head SHA: ${headSha}
@@ -17116,7 +17552,7 @@ Required constraints:
17116
17552
  - Do not request elevated or unsandboxed approvals.
17117
17553
  - When you flag a finding, report every other instance of the same class in this diff in the same response, and include a one-line coverage note.
17118
17554
 
17119
- ${REVIEW_FOOTER_INSTRUCTIONS}`;
17555
+ ${reviewFooterInstructions(waveRung)}`;
17120
17556
  const MODE_FLOOR_FILES = {
17121
17557
  correctness: "correctness.md",
17122
17558
  security: "security/SKILL.md"
@@ -17149,7 +17585,7 @@ const readCodingStandardsAtHead = ({ cwd, git, headSha }) => {
17149
17585
  } catch {
17150
17586
  return;
17151
17587
  }
17152
- return (git?.showFileAtRef ?? showFileAtRef)(root, headSha, path.join(root, CODING_STANDARDS_FILE));
17588
+ return (git?.showFileAtRef ?? showFileAtRef)(root, headSha, CODING_STANDARDS_FILE);
17153
17589
  };
17154
17590
  const codingStandardsSegment = ({ contents, headSha }) => {
17155
17591
  const text = contents?.replace(/\n+$/u, "");
@@ -17190,7 +17626,7 @@ ${entries.map(priorLedgerEntryLine).join("\n")}
17190
17626
 
17191
17627
  Do not re-raise any of the findings above under a new title: they are already adjudicated (waived, filed as a follow-up, or fixed in this thread). If new evidence genuinely supersedes one of them, say so explicitly and name the exact prior finding (its title and file) that it supersedes, rather than minting an unrelated title for the same underlying theme.`
17192
17628
  });
17193
- const assembleReviewPrompt = ({ base, changedFiles, cwd = process.cwd(), headSha, issueFocus, kind, patchId, priorLedgerEntries, profile }, dependencies = {}) => {
17629
+ const assembleReviewPrompt = ({ base, changedFiles, cwd = process.cwd(), headSha, issueFocus, kind, patchId, priorLedgerEntries, profile, waveRung }, dependencies = {}) => {
17194
17630
  const priorLedger = priorLedgerEntries ? nonOpenLadderFindings(priorLedgerEntries) : [];
17195
17631
  const optionalSegments = {
17196
17632
  "coding-standards": codingStandardsSegment({
@@ -17210,7 +17646,8 @@ const assembleReviewPrompt = ({ base, changedFiles, cwd = process.cwd(), headSha
17210
17646
  changedFiles,
17211
17647
  headSha,
17212
17648
  patchId,
17213
- profile
17649
+ profile,
17650
+ waveRung
17214
17651
  }) },
17215
17652
  { text: modeFloorInstructions(kind, changedFiles) },
17216
17653
  ...REVIEW_PROMPT_SECTION_PROVENANCES.flatMap((provenance) => {
@@ -17235,7 +17672,8 @@ const ASSEMBLED_REVIEW_PROMPT_SCHEMA_VERSION = 1;
17235
17672
  const assembledReviewPromptKindSchema = z.object({
17236
17673
  kind: z.enum(["correctness", "security"]),
17237
17674
  prompt: z.string().min(1),
17238
- sections: z.array(reviewPromptSectionSchema)
17675
+ sections: z.array(reviewPromptSectionSchema),
17676
+ waveRung: z.enum(EVIDENCE_REVIEW_RUNGS).optional()
17239
17677
  });
17240
17678
  const assembledReviewPromptArtifactSchema = z.object({
17241
17679
  headSha: z.string().regex(/^[0-9a-f]{40}$/u),
@@ -17264,6 +17702,51 @@ const defaultFetchIssueBody = ({ issue, owner, repo }) => runGhJson([
17264
17702
  "--json",
17265
17703
  "body"
17266
17704
  ]).body;
17705
+ const candidateWavePullRequestSchema = z.object({
17706
+ baseRefName: z.string().min(1),
17707
+ body: z.string(),
17708
+ headRefName: z.string().min(1),
17709
+ number: z.number().int().positive()
17710
+ });
17711
+ const defaultFetchWavePullRequest = (cwd) => runGhJsonAt([
17712
+ "pr",
17713
+ "view",
17714
+ "--json",
17715
+ "baseRefName,body,headRefName,number"
17716
+ ], cwd);
17717
+ /**
17718
+ * The review rung this candidate's boundary wave demands (#1001).
17719
+ *
17720
+ * The assembled prompt tells the reviewer which findings schema the gate will
17721
+ * accept. A wave that demands a rung needs schemaVersion 2 with `producer` and
17722
+ * `rung`; the v1 shape is recorded without authority, and `pr:publish` then
17723
+ * refuses it with "no passing review at that authority".
17724
+ *
17725
+ * This is prompt enrichment and fails open, like the coding-standards read:
17726
+ * every unreadable step returns undefined and the prompt keeps the v1 shape.
17727
+ * It decides no authority — `pr:ready` resolves the same wave demand again and
17728
+ * is the stage that refuses on it.
17729
+ */
17730
+ const assembleWaveRung = ({ args, cwd, dependencies, fetchIssueBody, fetchWavePullRequest, profile }) => {
17731
+ if (args.epic === void 0) return;
17732
+ try {
17733
+ const pullRequest = candidateWavePullRequestSchema.parse(fetchWavePullRequest(cwd));
17734
+ const demand = resolveWaveDemand({
17735
+ baseRefName: pullRequest.baseRefName,
17736
+ epic: args.epic,
17737
+ ...dependencies.fetchClosingPullRequests === void 0 ? {} : { fetchClosingPullRequests: dependencies.fetchClosingPullRequests },
17738
+ fetchIssueBody,
17739
+ headRefName: pullRequest.headRefName,
17740
+ owner: profile.repository.owner,
17741
+ pr: pullRequest.number,
17742
+ prBody: pullRequest.body,
17743
+ repo: profile.repository.name
17744
+ });
17745
+ return demand.kind === "demanded" ? demand.demand.review : void 0;
17746
+ } catch {
17747
+ return;
17748
+ }
17749
+ };
17267
17750
  const assertReviewCycleInWindow = (cycle, maxCycles) => {
17268
17751
  if (cycle < 1 || cycle > maxCycles) throw new Error(`--cycle must be between 1 and ${maxCycles}.`);
17269
17752
  };
@@ -17383,7 +17866,7 @@ const assembleGitDependencies = (git) => git.repositoryRoot !== void 0 && git.sh
17383
17866
  repositoryRoot: git.repositoryRoot,
17384
17867
  showFileAtRef: git.showFileAtRef
17385
17868
  } : void 0;
17386
- const writeAssembledReviewPrompt = ({ args, cwd, fetchIssueBody, files, git, headSha, kinds, output, patchId, profile }) => {
17869
+ const writeAssembledReviewPrompt = ({ args, cwd, fetchIssueBody, files, git, headSha, kinds, output, patchId, profile, waveRung }) => {
17387
17870
  const priorLedger = priorLedgerFor(priorLadderStateFor({
17388
17871
  base: args.base,
17389
17872
  cwd,
@@ -17403,12 +17886,14 @@ const writeAssembledReviewPrompt = ({ args, cwd, fetchIssueBody, files, git, hea
17403
17886
  kind,
17404
17887
  patchId,
17405
17888
  priorLedgerEntries: priorLedger,
17406
- profile
17889
+ profile,
17890
+ ...waveRung === void 0 ? {} : { waveRung }
17407
17891
  }, assembleGit === void 0 ? {} : { git: assembleGit });
17408
17892
  return {
17409
17893
  kind,
17410
17894
  prompt: assembled.prompt,
17411
- sections: assembled.sections
17895
+ sections: assembled.sections,
17896
+ ...waveRung === void 0 ? {} : { waveRung }
17412
17897
  };
17413
17898
  });
17414
17899
  const artifactPath = path.resolve(cwd, DEFAULT_ASSEMBLED_REVIEW_PROMPT_PATH);
@@ -17416,7 +17901,7 @@ const writeAssembledReviewPrompt = ({ args, cwd, fetchIssueBody, files, git, hea
17416
17901
  writeProofJson({
17417
17902
  headSha,
17418
17903
  patchId,
17419
- reviews: existing.status === "ok" && existing.artifact.patchId === patchId && existing.artifact.headSha === headSha ? [...new Map([...existing.artifact.reviews, ...assembledReviews].map((review) => [review.kind, review])).values()] : assembledReviews,
17904
+ reviews: existing.status === "ok" && existing.artifact.patchId === patchId && existing.artifact.headSha === headSha ? [...new Map([...existing.artifact.reviews.filter((review) => review.waveRung === waveRung), ...assembledReviews].map((review) => [review.kind, review])).values()] : assembledReviews,
17420
17905
  schemaVersion: ASSEMBLED_REVIEW_PROMPT_SCHEMA_VERSION
17421
17906
  }, artifactPath);
17422
17907
  console.log(`pr:review assembled prompt written to ${artifactPath}`);
@@ -17474,7 +17959,10 @@ const reviewPolicyIdentity = ({ args, changedFiles: files, cwd, dependencies, gi
17474
17959
  changedFiles: files,
17475
17960
  cwd,
17476
17961
  lookupPullRequest: dependencies.lookupPullRequest ?? lookupCandidatePullRequest,
17477
- profilePath: path.relative(cwd, profilePath).split(path.sep).join("/"),
17962
+ profilePath: repositoryRelativeProfilePath({
17963
+ cwd,
17964
+ profilePath
17965
+ }),
17478
17966
  readCheckoutRepository: git.checkoutRepository ?? checkoutRepository
17479
17967
  });
17480
17968
  /**
@@ -17642,17 +18130,26 @@ async function runPrReview(args, dependencies = {}) {
17642
18130
  console.log(ASSEMBLE_FAITHFUL_MERGE_NOTICE);
17643
18131
  return assembleProof();
17644
18132
  }
18133
+ const fetchIssueBody = dependencies.fetchIssueBody ?? defaultFetchIssueBody;
17645
18134
  writeAssembledReviewPrompt({
17646
18135
  args,
17647
18136
  cwd,
17648
- fetchIssueBody: dependencies.fetchIssueBody ?? defaultFetchIssueBody,
18137
+ fetchIssueBody,
17649
18138
  files,
17650
18139
  git,
17651
18140
  headSha,
17652
18141
  kinds,
17653
18142
  output,
17654
18143
  patchId,
17655
- profile
18144
+ profile,
18145
+ waveRung: assembleWaveRung({
18146
+ args,
18147
+ cwd,
18148
+ dependencies,
18149
+ fetchIssueBody,
18150
+ fetchWavePullRequest: dependencies.fetchWavePullRequest ?? defaultFetchWavePullRequest,
18151
+ profile
18152
+ })
17656
18153
  });
17657
18154
  return assembleProof();
17658
18155
  }
@@ -17839,11 +18336,7 @@ const defaultFollowUpRunner = (factoryCliInvocation) => (followUp, cwd) => {
17839
18336
  const [command, ...args] = resolveFollowUpArgv(followUp, factoryCliInvocation);
17840
18337
  runCapture(command, args, cwd);
17841
18338
  };
17842
- const defaultPrPublishCliInvocation = () => {
17843
- const moduleExtension = path.extname(import.meta.filename);
17844
- return defaultFactoryCliInvocation(path.join(import.meta.dirname, `index${moduleExtension}`));
17845
- };
17846
- const prPublishCliInvocation = (explicit) => explicit ?? defaultPrPublishCliInvocation();
18339
+ const prPublishCliInvocation = (explicit) => explicit ?? defaultFactoryCliInvocation(factoryCliEntry());
17847
18340
  const errorMessage = (error) => error instanceof Error ? error.message : String(error);
17848
18341
  /**
17849
18342
  * GitHub creates a pull request against a branch name, while the rest of the
@@ -18558,7 +19051,7 @@ function assertForegroundGate({ gate, env = process.env }) {
18558
19051
  //#endregion
18559
19052
  //#region src/commands/pr-review.ts
18560
19053
  function createPrReviewCommand(output, action) {
18561
- return markCwdOptionDefault(new Command("pr:review").description("Assemble a factory-owned review prompt, or validate clean-session findings and write typed review proof").option("--base <ref>", "base branch or ref", "origin/main").option("--cwd <path>", "working directory to review", collectCwdOption).option("--cycle <number>", "review cycle number", positiveInteger$1("--cycle"), 1).option("--mode <mode>", "correctness, security, or all", "all").option("--assemble", "write the factory-owned review prompt for a clean session; do not validate findings").option("--findings <path>", "typed clean-session findings JSON file").option("--historical", "record findings that read an earlier commit on this branch: the cycle joins the ladder as prior history and renders no verdict of its own, so it never admits. It leaves the candidate's recorded verdict untouched. Use --dispositions for the findings its fixes answered").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; --assemble also loads Review focus from this issue", positiveInteger$1("--issue")).option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--verify-proof <path>", "pr:verify proof JSON path").action(withGateTiming({
19054
+ return markCwdOptionDefault(new Command("pr:review").description("Assemble a factory-owned review prompt, or validate clean-session findings and write typed review proof").option("--base <ref>", "base branch or ref", "origin/main").option("--cwd <path>", "working directory to review", collectCwdOption).option("--cycle <number>", "review cycle number", positiveInteger$1("--cycle"), 1).option("--mode <mode>", "correctness, security, or all", "all").option("--assemble", "write the factory-owned review prompt for a clean session; do not validate findings").option("--epic <number>", "issue carrying the factory-boundary manifest, which may be the work issue itself; --assemble then asks the reviewer for the findings schema the wave-demanded review rung accepts (#1001)", positiveInteger$1("--epic")).option("--findings <path>", "typed clean-session findings JSON file").option("--historical", "record findings that read an earlier commit on this branch: the cycle joins the ladder as prior history and renders no verdict of its own, so it never admits. It leaves the candidate's recorded verdict untouched. Use --dispositions for the findings its fixes answered").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; --assemble also loads Review focus from this issue", positiveInteger$1("--issue")).option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--verify-proof <path>", "pr:verify proof JSON path").action(withGateTiming({
18562
19055
  gate: "pr:review",
18563
19056
  resolveCycle: (options) => options.cycle,
18564
19057
  resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
@@ -18576,6 +19069,7 @@ function createPrReviewCommand(output, action) {
18576
19069
  cwd: resolveCwdOption(options.cwd),
18577
19070
  cycle: options.cycle,
18578
19071
  dispositions: options.dispositions,
19072
+ epic: options.epic,
18579
19073
  findings: options.findings,
18580
19074
  ...options.historical === true ? { historical: true } : {},
18581
19075
  issue: options.issue,
@@ -18704,8 +19198,11 @@ const runProductionImpact = (args) => {
18704
19198
  const stamp = computeImpactStamp({
18705
19199
  changedFiles,
18706
19200
  profile,
18707
- profilePath: path.relative(cwd, profilePath),
18708
- readLockfile: (side) => showFileAtRef(cwd, side === "base" ? before : after, path.join(cwd, LOCKFILE_PATH))
19201
+ profilePath: repositoryRelativeProfilePath({
19202
+ cwd,
19203
+ profilePath
19204
+ }),
19205
+ readLockfile: (side) => showFileAtRef(cwd, side === "base" ? before : after, LOCKFILE_PATH)
18709
19206
  });
18710
19207
  const core = {
18711
19208
  afterSha: after,
@@ -19496,7 +19993,7 @@ function formatScratchFileCheckSummary(check) {
19496
19993
  //#endregion
19497
19994
  //#region src/index.ts
19498
19995
  function createProgram(options = {}) {
19499
- const factoryCliInvocation = options.factoryCliInvocation ? FactoryCliInvocationSchema.parse(options.factoryCliInvocation) : defaultFactoryCliInvocation(import.meta.filename);
19996
+ const factoryCliInvocation = options.factoryCliInvocation ? FactoryCliInvocationSchema.parse(options.factoryCliInvocation) : defaultFactoryCliInvocation(factoryCliEntry());
19500
19997
  const output = options.output ?? {
19501
19998
  stderr: process.stderr,
19502
19999
  stdout: process.stdout
@@ -19526,24 +20023,18 @@ function createProgram(options = {}) {
19526
20023
  program.addCommand(createDemandWaiveCommand(output, options.actions?.demandWaive));
19527
20024
  return program;
19528
20025
  }
19529
- async function run(argv = process.argv) {
20026
+ /**
20027
+ * Parse `argv` and run the matching command. `cliEntry` is the path of the
20028
+ * runnable bin module, which follow-up commands spawn; the bin entry passes its
20029
+ * own path. This function never runs itself — `./cli.js` is the only caller
20030
+ * that does (#386).
20031
+ */
20032
+ async function run(argv = process.argv, cliEntry = factoryCliEntry()) {
19530
20033
  try {
19531
- await createProgram({ factoryCliInvocation: defaultFactoryCliInvocation(import.meta.filename) }).parseAsync(argv);
20034
+ await createProgram({ factoryCliInvocation: defaultFactoryCliInvocation(cliEntry) }).parseAsync(argv);
19532
20035
  } finally {
19533
20036
  await awaitPendingHqIngest();
19534
20037
  }
19535
20038
  }
19536
- function isDirectCliExecution() {
19537
- const [, entryPath] = process.argv;
19538
- if (!entryPath) return false;
19539
- return import.meta.url === pathToFileURL(realpathSync(entryPath)).href;
19540
- }
19541
- if (isDirectCliExecution()) try {
19542
- await run();
19543
- } catch (error) {
19544
- if (error instanceof PrPublishFollowUpError) console.error(JSON.stringify(error.result, null, 2));
19545
- else console.error("\nError:", error instanceof Error ? error.message : String(error));
19546
- process.exit(1);
19547
- }
19548
20039
  //#endregion
19549
20040
  export { DEFAULT_DEMAND_WAIVER_PATH, DEFAULT_FACTORY_REPOSITORY, DEFAULT_ROOT_SHARED_GLOBS, DemandWaiveRefusalError, EMPTY_TREE_OBJECT_HASH, EPIC_STRUCTURE_NODE_STATUSES, EPIC_STRUCTURE_SCHEMA_VERSION, EpicStructureValidationError, FACTORY_BASE_REF_ENV, FACTORY_CHANGED_FILES_ENV, FACTORY_CHANGED_FILES_FILE_ENV, FactoryCliInvocationSchema, FollowUpActionSchema, MERGE_FREEZE_APP_SLUG, MERGE_FREEZE_CHECK_NAME, PrPublishFollowUpError, REVIEW_FOCUS_SECTION, WorkerCheckoutGuardError, appendReviewLadderStageTraceEvent, appendWithTraceSinks, applyDemandWaiver, assembleReviewPrompt, assertFactoryPrStatusIdentity, assertWorkerCheckoutAllowed, authorizeDemandWaiver, batteryScopeSummaryLines, blockingLadderFindings, boundary_manifest_exports as boundaryManifest, boundary_review_proof_exports as boundaryReviewProof, buildEpicStructureEvent, buildEpicStructurePayload, buildEvidenceEnvelope, buildReviewGateNotRequiredTraceEvent, buildReviewLadderStageTraceEvent, comment_provenance_exports as commentProvenance, createGitHubCheckRunMergeFreezeStore, createLocalJsonlTraceSink, createProgram, createVerificationRunContext, doctorProjectProfile, epicStructureEventId, evaluateReviewLadder, evidenceEnvelopeFilename, findingKey, followUpFromArgv, impactStampScopeDecision, inferFixedInThreadDispositions, isProductionHqUrl, loadProjectProfile, normalizeIssueComments, openLadderFindings, planFactoryPrStatusHud, planPreviewLifecycle, planPublishFollowUp, planVerificationBattery, readiness_evaluation_exports as prReadinessEvaluation, external_evidence_exports as prReadinessExternalEvidence, pr_body_renderer_exports as prReadinessPrBodyRenderer, proof_identity_exports as prReadinessProofIdentity, review_proof_exports as prReadinessReviewProof, status_check_rollup_exports as prReadinessStatusChecks, verification_proof_exports as prReadinessVerificationProof, presentFactoryPrStatusHud, previewLifecycleTargets, publishEpicStructure, readDemandWaivers, readFactoryPrStatusSources, readPrReadyProof, readPrReviewProof, refreshFactoryPrStatusHud, refreshFactoryPrStatusHudSafely, renderFactoryPrStatusHud, resolveFactoryRepository, resolveFindingBlocking, resolveReviewFindingCategory, resolveReviewFindingSeverity, resolveReviewLadderPolicy, resolveTraceWriteSinks, reviewCycleStateFor, reviewFocusFromIssueBody, reviewPromptSectionSchema, reviewPromptSectionsSchema, run, runBoundaryCheck, runDemandWaive, runEvidenceEmit, runPrPublish, runPrReady, runPrReview, runPrVerify, scanFactoryTraceDiagnostics, scanFactoryTraceEvents, selectWaiversForCandidate, staleRepeatLadderFindings, toFactoryTraceEnvelope, tryAppendReviewGateNotRequiredTraceEvent, tryAppendReviewLadderStageTraceEvent, validateBoundaryCheckProof, validateDagDocument, validateDemandWaiverStore, validateFactoryTraceEvent, validateMergeFreezeState, validatePrReadyProof, validatePrReviewProof, validatePrVerifyProof, validatePreviewLifecyclePlan, waivedDemandNotice, waivedDemandSchema, worktree_scratch_files_exports as worktreeScratchFiles };