@patronage/software-factory 1.0.0-alpha.27 → 1.0.0-alpha.29

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
@@ -18,7 +18,7 @@ import { FACTORY_APP_BROKER_ORIGIN, FACTORY_APP_BROKER_PATH, FACTORY_CURSOR_OIDC
18
18
  import http from "node:http";
19
19
  import { pathToFileURL } from "node:url";
20
20
  //#region package.json
21
- var version = "1.0.0-alpha.27";
21
+ var version = "1.0.0-alpha.29";
22
22
  //#endregion
23
23
  //#region src/cli-entry.ts
24
24
  /**
@@ -6576,10 +6576,48 @@ function verifyOutput(proof) {
6576
6576
  title: `pr:verify ${value.outcome ?? "unknown"}`
6577
6577
  };
6578
6578
  }
6579
+ /**
6580
+ * The refusals to name, preferring the attributed projection (#391) so each
6581
+ * line carries its demand code. `blockingReasons` is the fallback because the
6582
+ * throwing path in `pr:ready` publishes a proof that carries only that field.
6583
+ * The two projections hold the same refusals in the same order, so reading
6584
+ * either one tells the same story.
6585
+ */
6586
+ const readyReasonLines = (value) => {
6587
+ const named = value.blockedReasons ?? [];
6588
+ if (named.length > 0) return named.map((reason) => ({
6589
+ ...reason.code === void 0 ? {} : { code: reason.code },
6590
+ detail: reason.detail ?? "unrecorded"
6591
+ }));
6592
+ return (value.blockingReasons ?? []).map((reason) => ({ detail: typeof reason === "string" ? reason : "unrecorded" }));
6593
+ };
6594
+ const readyReasonBlock = (reasons) => {
6595
+ return `\n\n${reasons.length === 1 ? "1 blocking reason:" : `${reasons.length} blocking reasons:`}\n\n${reasons.map((reason) => `- ${reason.code === void 0 ? "" : `**${reason.code}** — `}${reason.detail}`).join("\n")}`;
6596
+ };
6597
+ const readyIdentityField = (value) => value === void 0 || value.trim().length === 0 ? "unrecorded" : `\`${value}\``;
6598
+ /**
6599
+ * #1037: the review identity the proof already records, printed where the
6600
+ * merge decision is made. `pr:ready` recorded the review session, model, and
6601
+ * producer, and the authoring session it was checked against, but only the PR
6602
+ * body prose showed them — so a human merging from the checks page could not
6603
+ * see which model produced the review that admitted the candidate.
6604
+ *
6605
+ * This is recording made visible, not a control. The attended merge is the
6606
+ * control.
6607
+ */
6608
+ const readyIdentityBlock = (value) => {
6609
+ const runs = value.ledger?.reviewRuns ?? [];
6610
+ if (runs.length === 0 && value.authoringSession === void 0) return "";
6611
+ return `\n\nRecorded review identity:\n\n${[...runs.map((run) => {
6612
+ const rung = run.rung === void 0 ? "" : ` at rung \`${run.rung}\``;
6613
+ return `- ${run.kind ?? "review"}${rung}: session ${readyIdentityField(run.sessionId)} · model ${readyIdentityField(run.model)} · producer ${readyIdentityField(run.producer)}`;
6614
+ }), `- authoring session: ${readyIdentityField(value.authoringSession)}`].join("\n")}`;
6615
+ };
6579
6616
  function readyOutput(proof) {
6580
6617
  const value = proof;
6618
+ const reasons = readyReasonLines(value);
6581
6619
  return {
6582
- summary: `Schema v${value.schemaVersion}; readiness verdict **${value.status ?? "unknown"}**; ${value.blockingReasons?.length ?? 0} blocking reason(s); ledger head \`${value.ledger?.headSha ?? "unknown"}\`.`,
6620
+ summary: `${`Schema v${value.schemaVersion}; readiness verdict **${value.status ?? "unknown"}**;${reasons.length === 0 ? " no blocking reasons;" : ""} ledger head \`${value.ledger?.headSha ?? "unknown"}\`.`}${reasons.length === 0 ? "" : readyReasonBlock(reasons)}${readyIdentityBlock(value)}`,
6583
6621
  title: `pr:ready ${value.status ?? "unknown"}`
6584
6622
  };
6585
6623
  }
@@ -7508,6 +7546,15 @@ const assertPrVerifyProofRules = (proof, context) => {
7508
7546
  * factory must adopt this release before HQ ingests its pr:verify proofs.
7509
7547
  */
7510
7548
  const SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS = [4];
7549
+ const prVerifyTestFailureSchema = z.object({
7550
+ file: z.string().min(1),
7551
+ kind: z.enum([
7552
+ "assertion",
7553
+ "error",
7554
+ "timeout"
7555
+ ]),
7556
+ title: z.string().min(1)
7557
+ });
7511
7558
  const executedCommandSchema = z.object({
7512
7559
  command: z.string().min(1),
7513
7560
  counts: z.object({
@@ -7516,6 +7563,7 @@ const executedCommandSchema = z.object({
7516
7563
  }).optional(),
7517
7564
  durationMs: z.number().nonnegative(),
7518
7565
  exitCode: z.number(),
7566
+ failures: z.array(prVerifyTestFailureSchema).min(1).optional(),
7519
7567
  name: z.string().min(1),
7520
7568
  scope: z.enum([
7521
7569
  "docs-only",
@@ -7797,6 +7845,13 @@ const verificationProofForReadiness = (state) => {
7797
7845
  const failedExecutedCommand = (proof) => {
7798
7846
  return proof.executedCommands?.find((command) => command.exitCode !== 0) ?? proof.executedCommands?.at(-1);
7799
7847
  };
7848
+ const MAX_NAMED_TEST_FAILURES = 3;
7849
+ const formatFailedTests = (failures) => {
7850
+ if (!failures || failures.length === 0) return "";
7851
+ const named = failures.slice(0, MAX_NAMED_TEST_FAILURES).map((failure) => `${failure.file} > ${failure.title} (${failure.kind})`).join("; ");
7852
+ const remaining = failures.length - MAX_NAMED_TEST_FAILURES;
7853
+ return remaining > 0 ? ` on ${named}; and ${remaining} more failing test${remaining === 1 ? "" : "s"}` : ` on ${named}`;
7854
+ };
7800
7855
  const verificationProofBlockingReason = (state) => {
7801
7856
  if (state.kind === "typed-missing") {
7802
7857
  if (state.reason === "enoent") return `Typed pr:verify proof at ${state.path} does not exist; pr:verify has not run for this PR head. Run patronage-factory pr:verify.`;
@@ -7808,7 +7863,7 @@ const verificationProofBlockingReason = (state) => {
7808
7863
  }
7809
7864
  if (state.kind === "typed-aborted") {
7810
7865
  const failed = failedExecutedCommand(state.proof);
7811
- if (failed) return `Typed pr:verify proof shows verification aborted at ${failed.name} (exit code ${failed.exitCode}); verification did not pass. Re-run patronage-factory pr:verify.`;
7866
+ if (failed) return `Typed pr:verify proof shows verification aborted at ${failed.name} (exit code ${failed.exitCode})${formatFailedTests(failed.failures)}; verification did not pass. Re-run patronage-factory pr:verify.`;
7812
7867
  return "Typed pr:verify proof shows verification aborted; verification did not pass. Re-run patronage-factory pr:verify.";
7813
7868
  }
7814
7869
  };
@@ -8036,6 +8091,111 @@ function parseVitestCounts(stdout) {
8036
8091
  if (testsMatch) counts.tests = Number(testsMatch.groups?.total ?? testsMatch.groups?.passed);
8037
8092
  return counts;
8038
8093
  }
8094
+ const MAX_RECORDED_TEST_FAILURES = 50;
8095
+ const ANSI_ESCAPE_PATTERN = /\u001B\[[0-9;]*m/gu;
8096
+ /**
8097
+ * Vitest's per-failure header: `FAIL <file> > <suite> > <test title>`. This
8098
+ * pattern claims only the `FAIL ` prefix; `splitVitestFailHeader` below reads
8099
+ * the rest, because a multi-project run puts a project label between `FAIL`
8100
+ * and the file and its shape depends on color support.
8101
+ */
8102
+ const VITEST_FAIL_HEADER_PATTERN = /^\s*FAIL\s+(?<rest>\S.*)$/u;
8103
+ /** Vitest separates the file, each suite, and the test title with ` > `. */
8104
+ const VITEST_TITLE_SEPARATOR = " > ";
8105
+ /**
8106
+ * Splits a failure header into the failing file and the full test title.
8107
+ *
8108
+ * The file is the LAST whitespace-separated token before the first ` > `,
8109
+ * which is what makes this survive a multi-project run. Vitest's
8110
+ * `formatProjectName` writes the project label as `|name|` when color is
8111
+ * unsupported and as a color-boxed ` name ` when it is; after this module
8112
+ * strips the SGR codes both leave the label as its own token ahead of the
8113
+ * file, and both drop out here. The title keeps every suite name, so the
8114
+ * split is on the FIRST separator and never a later one.
8115
+ *
8116
+ * Answers `undefined` for a header that names no test.
8117
+ */
8118
+ const splitVitestFailHeader = (rest) => {
8119
+ const separatorIndex = rest.indexOf(VITEST_TITLE_SEPARATOR);
8120
+ if (separatorIndex === -1) return;
8121
+ const file = rest.slice(0, separatorIndex).trim().split(/\s+/u).at(-1);
8122
+ const title = rest.slice(separatorIndex + 3).trim();
8123
+ if (!file || title.length === 0) return;
8124
+ return {
8125
+ file,
8126
+ title
8127
+ };
8128
+ };
8129
+ const VITEST_TIMEOUT_PATTERN = /^\s*\S*Error:\s+Test timed out in /u;
8130
+ const VITEST_ASSERTION_PATTERN = /^\s*\S*AssertionError\b/u;
8131
+ /**
8132
+ * Reads the failure class off the error line under a failure header. Anything
8133
+ * that is neither the test-timeout error nor an assertion error — including a
8134
+ * line that is not error-shaped at all — classifies as `error` rather than
8135
+ * guessing at a narrower class.
8136
+ */
8137
+ const classifyVitestFailure = (errorLine) => {
8138
+ if (errorLine === void 0) return "error";
8139
+ if (VITEST_TIMEOUT_PATTERN.test(errorLine)) return "timeout";
8140
+ if (VITEST_ASSERTION_PATTERN.test(errorLine)) return "assertion";
8141
+ return "error";
8142
+ };
8143
+ /**
8144
+ * Scrapes the failing tests out of a vitest run's console output (#1035).
8145
+ *
8146
+ * Callers pass BOTH captured streams: vitest writes its `Failed Tests`
8147
+ * section to stderr, so stdout alone carries no failure header.
8148
+ *
8149
+ * Deliberately line-shaped, the same class of scan as `parseVitestCounts`
8150
+ * above: it reads the `FAIL <file> > <title>` header and the error line that
8151
+ * follows it, and nothing else. It must never grow toward a parser of vitest's
8152
+ * report. Output it does not recognize yields an empty list, never a throw —
8153
+ * an unreadable report is a proof that records no failures, not a verification
8154
+ * run that crashes while writing why it failed.
8155
+ */
8156
+ function parseVitestFailures(stdout) {
8157
+ const lines = stdout.replace(ANSI_ESCAPE_PATTERN, "").split(/\r?\n/u);
8158
+ const failures = [];
8159
+ const seen = /* @__PURE__ */ new Set();
8160
+ for (const [index, line] of lines.entries()) {
8161
+ const header = line.match(VITEST_FAIL_HEADER_PATTERN);
8162
+ if (!header?.groups?.rest) continue;
8163
+ const split = splitVitestFailHeader(header.groups.rest);
8164
+ if (!split) continue;
8165
+ const { file, title } = split;
8166
+ const key = `${file}\u0000${title}`;
8167
+ if (seen.has(key)) continue;
8168
+ seen.add(key);
8169
+ const errorLine = lines.slice(index + 1, index + 3).find((candidate) => candidate.trim().length > 0);
8170
+ failures.push({
8171
+ file,
8172
+ kind: classifyVitestFailure(errorLine),
8173
+ title
8174
+ });
8175
+ if (failures.length >= MAX_RECORDED_TEST_FAILURES) break;
8176
+ }
8177
+ return failures;
8178
+ }
8179
+ /**
8180
+ * Reduces one captured verification run to the outcome the proof records.
8181
+ *
8182
+ * Which stream each scan reads is the load-bearing decision here. Vitest's
8183
+ * default reporter prints the run tree and the `Test Files` summary that
8184
+ * `parseVitestCounts` reads to stdout, but prints the whole `Failed Tests`
8185
+ * section — every `FAIL` header and every error line — through
8186
+ * `logger.error`, which is stderr. Scanning stdout alone for failures finds
8187
+ * none at all, so the failure scan reads both streams.
8188
+ */
8189
+ function verificationOutcomeOf(result) {
8190
+ const counts = parseVitestCounts(result.stdout);
8191
+ const failures = result.exitCode === 0 ? [] : parseVitestFailures(`${result.stdout}\n${result.stderr}`);
8192
+ return {
8193
+ ...counts ? { counts } : {},
8194
+ durationMs: result.durationMs,
8195
+ exitCode: result.exitCode,
8196
+ ...failures.length > 0 ? { failures } : {}
8197
+ };
8198
+ }
8039
8199
  function defaultPrVerifyGit() {
8040
8200
  return {
8041
8201
  changedFiles,
@@ -8044,13 +8204,7 @@ function defaultPrVerifyGit() {
8044
8204
  mergeBaseSha,
8045
8205
  recognizeCatchUp: recognizeCatchUpCandidate,
8046
8206
  runVerificationCommand(command, cwd, env) {
8047
- const result = runInheritedTee("bash", ["-lc", command], cwd, env);
8048
- const counts = parseVitestCounts(result.stdout);
8049
- return {
8050
- ...counts ? { counts } : {},
8051
- durationMs: result.durationMs,
8052
- exitCode: result.exitCode
8053
- };
8207
+ return verificationOutcomeOf(runInheritedTee("bash", ["-lc", command], cwd, env));
8054
8208
  },
8055
8209
  showFileAtRef,
8056
8210
  stablePatchId: runStablePatchId,
@@ -8136,6 +8290,19 @@ function formatVerifyTimingSummary(proof) {
8136
8290
  }
8137
8291
  return lines.join("\n");
8138
8292
  }
8293
+ /**
8294
+ * The line `pr:verify` prints last when it aborts (#1035): where the record
8295
+ * is, which command failed, and — when the command reported them — which
8296
+ * tests failed and in what class. A timeout under machine load and a real
8297
+ * assertion look identical without this, so every red verify used to cost a
8298
+ * full visible rerun just to read a test title.
8299
+ */
8300
+ function formatVerifyFailurePointer(proof, proofPath) {
8301
+ const failed = proof.executedCommands?.find((command) => command.exitCode !== 0);
8302
+ const lines = [failed ? `pr:verify aborted at ${failed.name} (exit code ${failed.exitCode}); record: ${proofPath}` : `pr:verify aborted; record: ${proofPath}`];
8303
+ for (const failure of failed?.failures ?? []) lines.push(` ${failure.kind}: ${failure.file} > ${failure.title}`);
8304
+ return lines.join("\n");
8305
+ }
8139
8306
  function resolveMode({ classification, mode }) {
8140
8307
  if (mode === "auto") {
8141
8308
  if (classification === "docs/process-only") return "docs-only";
@@ -8299,6 +8466,7 @@ function runPrVerify(args, dependencies = {}) {
8299
8466
  writeProofCopies(proof);
8300
8467
  failureProofWritten = true;
8301
8468
  if (executedCommands.length > 0) console.log(`\n${formatVerifyTimingSummary(proof)}`);
8469
+ console.log(`\n${formatVerifyFailurePointer(proof, output)}`);
8302
8470
  publishFactoryCheckSafely(publishCheckRun, {
8303
8471
  conclusion: "failure",
8304
8472
  cwd,
@@ -8321,7 +8489,8 @@ function runPrVerify(args, dependencies = {}) {
8321
8489
  exitCode: result.exitCode,
8322
8490
  name: command.name,
8323
8491
  scope: command.scope,
8324
- ...result.counts ? { counts: result.counts } : {}
8492
+ ...result.counts ? { counts: result.counts } : {},
8493
+ ...result.failures && result.failures.length > 0 ? { failures: result.failures } : {}
8325
8494
  };
8326
8495
  executedCommands.push(executedCommand);
8327
8496
  if (result.exitCode !== 0) {
@@ -9706,6 +9875,7 @@ z.object({
9706
9875
  "not-armed"
9707
9876
  ])
9708
9877
  }).optional(),
9878
+ authoringSession: z.string().min(1).optional(),
9709
9879
  blockedReasons: blockedReasonsSchema.optional(),
9710
9880
  blockingReasons: z.array(z.string()),
9711
9881
  command: z.literal("patronage-factory pr:ready"),
@@ -14638,9 +14808,13 @@ const renderPrBodySections = ({ reviewProof, verifyProof }) => `${renderPrBodySe
14638
14808
  //#region src/pr-readiness/status-check-rollup.ts
14639
14809
  var status_check_rollup_exports = /* @__PURE__ */ __exportAll({
14640
14810
  HOSTED_VERIFY_CHECK_NAME: () => HOSTED_VERIFY_CHECK_NAME$1,
14811
+ HOSTED_VERIFY_DRAFT_CHECK_NAME: () => HOSTED_VERIFY_DRAFT_CHECK_NAME,
14641
14812
  hostedVerifyCheckState: () => hostedVerifyCheckState,
14813
+ isDraftHostedVerifyCheck: () => isDraftHostedVerifyCheck,
14814
+ isExcludedFromAdmissionChecks: () => isExcludedFromAdmissionChecks,
14642
14815
  isFactoryReadyCheck: () => isFactoryReadyCheck,
14643
14816
  isHostedVerifyCheck: () => isHostedVerifyCheck,
14817
+ isSupersededHostedVerifyCheck: () => isSupersededHostedVerifyCheck,
14644
14818
  pendingCheckNames: () => pendingCheckNames,
14645
14819
  statusCheckState: () => statusCheckState
14646
14820
  });
@@ -14683,6 +14857,39 @@ const HOSTED_VERIFY_CHECK_NAME$1 = "verify";
14683
14857
  */
14684
14858
  const isHostedVerifyCheck = (check) => check.name === "verify" && Boolean(check.workflowName);
14685
14859
  /**
14860
+ * The name the hosted summary job takes on a DRAFT head (#1045).
14861
+ *
14862
+ * The workflow renames that job so the pinned `verify` context does not exist
14863
+ * while the candidate is a draft. The generator that emits the workflow owns
14864
+ * the literal; this is the reader's copy of it, and
14865
+ * `software-factory-hq/alchemy/verify-workflow.test.ts` pins the two equal.
14866
+ */
14867
+ const HOSTED_VERIFY_DRAFT_CHECK_NAME = `${HOSTED_VERIFY_CHECK_NAME$1} (draft)`;
14868
+ /**
14869
+ * Is this the draft-head summary run's residue?
14870
+ *
14871
+ * The draft run and the `ready_for_review` run land on the SAME head SHA, and
14872
+ * they post two different contexts — `verify (draft)` and `verify` — which
14873
+ * `effectiveChecks` keys separately and therefore never collapses. The Verify
14874
+ * workflow cancels an in-flight pull-request run when the next one starts, and
14875
+ * `ready_for_review` IS the next one, so undrafting typically cancels the
14876
+ * draft run mid-flight. That leaves a CANCELLED `verify (draft)` row sitting
14877
+ * next to a green `verify` on the admitted head.
14878
+ *
14879
+ * GitHub does not care: the branch ruleset pins `verify` alone. The factory's
14880
+ * own rollup read would: `CANCELLED` is a failed state, so the candidate's
14881
+ * `requiredChecks` would read `failed` forever and no push-free repair could
14882
+ * clear it. The draft run is a superseded generation of the summary job the
14883
+ * `verify` row already answers for, so admission does not judge it.
14884
+ */
14885
+ const isDraftHostedVerifyCheck = (check) => check.name === HOSTED_VERIFY_DRAFT_CHECK_NAME && Boolean(check.workflowName);
14886
+ /**
14887
+ * The rollup rows an admission read must not judge: the factory's own branded
14888
+ * checks (it would be judging itself) and the draft-head summary residue
14889
+ * above.
14890
+ */
14891
+ const isExcludedFromAdmissionChecks = (check) => isFactoryReadyCheck(check) || isDraftHostedVerifyCheck(check);
14892
+ /**
14686
14893
  * The hosted `verify` gate's state on this head, read by presence (#477).
14687
14894
  *
14688
14895
  * This is the one rollup question the rollup can answer honestly. Wave 2
@@ -14769,6 +14976,31 @@ const statusCheckState = (rollup, exclude) => {
14769
14976
  if (currentChecks.some((check) => PENDING_STATES.has(progressState(check)))) return "pending";
14770
14977
  return "unknown";
14771
14978
  };
14979
+ /**
14980
+ * Is this a hosted `verify` check run that GitHub started before `cutoffIso`?
14981
+ * (#1045)
14982
+ *
14983
+ * A caller that waits for `verify` on a head it just changed the state of —
14984
+ * `pr:publish` undrafting a candidate — must not accept the run that was
14985
+ * already there. #1034's history is the shape: a draft head carried a `verify`
14986
+ * that had run nothing, the wait saw it as terminal on its first attempt, and
14987
+ * readiness read `passed` about two minutes before the `ready_for_review` run
14988
+ * created its own check runs. The same shape is reachable from any superseded
14989
+ * `verify` generation still on the head.
14990
+ *
14991
+ * Fail CLOSED on an unreadable timestamp: a `verify` row that cannot be shown
14992
+ * to be newer than the cutoff is treated as superseded. The wait is bounded,
14993
+ * so the cost of that refusal is one timeout that falls through to the
14994
+ * caller's existing behaviour; the cost of failing open is the false green
14995
+ * this exists to remove.
14996
+ */
14997
+ const isSupersededHostedVerifyCheck = (check, cutoffIso) => {
14998
+ if (!isHostedVerifyCheck(check)) return false;
14999
+ const startedMs = checkTimestampMs(check);
15000
+ const cutoffMs = Date.parse(cutoffIso);
15001
+ if (startedMs === void 0 || Number.isNaN(cutoffMs)) return true;
15002
+ return startedMs < cutoffMs;
15003
+ };
14772
15004
  //#endregion
14773
15005
  //#region src/arm-auto-merge.ts
14774
15006
  /**
@@ -15605,6 +15837,43 @@ const requiredChecksEvaluation = (input) => {
15605
15837
  outcomes
15606
15838
  };
15607
15839
  };
15840
+ /**
15841
+ * REPAIR SITES 2 and 3 of 3 (#1045). #1048 makes the permitted set a type:
15842
+ * only a `RepairableDemand` blocker may carry a repair, so a fourth site on a
15843
+ * proof refusal fails `tsc`. The type keys on the demand, not on the state, so
15844
+ * it still permits a repair on a FAILED required check — the evaluator test
15845
+ * that turns every non-route-owned refusal on at once pins that half.
15846
+ *
15847
+ * Both hosted-check demands in `collectBlockers` are route-owned in exactly
15848
+ * two states, and in both the repair is the same one — wait for the NEXT
15849
+ * hosted run:
15850
+ *
15851
+ * 1. `requiredChecks === "pending"`: a run is in flight on this head.
15852
+ * 2. `draft === true`: the candidate is a draft, so the hosted verification
15853
+ * workflow deliberately posts no `verify` context at all (#1045 — `core`
15854
+ * and `docs` skip on a draft, and the summary job is renamed so the pinned
15855
+ * context is ABSENT rather than a green or gray check that ran nothing).
15856
+ * Absence is what `hostedVerifyCheck: "none"` reports, so without this
15857
+ * repair a draft would raise a human blocker no human can clear, and
15858
+ * `pr:ready` would publish a red check on every draft — the notification
15859
+ * noise #1037 removed.
15860
+ *
15861
+ * Draft is route-owned by construction: `pr:publish` undrafts the candidate
15862
+ * itself, and the repair is literally named `await-post-undraft-checks`
15863
+ * because it is about the run the promotion triggers.
15864
+ *
15865
+ * A draft whose checks came back FAILED is deliberately excluded: a real red
15866
+ * is not a not-yet, and waiting for another run would not clear it. That stays
15867
+ * a human blocker, exactly as it is today.
15868
+ */
15869
+ const awaitPostUndraftChecksRepair = (input) => {
15870
+ if (!(input.requiredChecks === "pending" || input.draft && input.requiredChecks !== "failed")) return {};
15871
+ return { repair: {
15872
+ action: "Poll and retry until post-undraft GitHub checks settle or fail; a draft head and UNSTABLE/pending are transient route-owned states, not human blockers.",
15873
+ code: "await-post-undraft-checks",
15874
+ command: `gh pr checks ${input.pr} --watch`
15875
+ } };
15876
+ };
15608
15877
  const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUntyped, input, prVerifyStatus, verificationProof, securityRequired, securityStatus, securityUntyped }) => {
15609
15878
  const blockers = [];
15610
15879
  if (prVerifyStatus === "missing") blockers.push({
@@ -15652,22 +15921,21 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
15652
15921
  demand: DEMAND_KEYS.mergeState,
15653
15922
  reason: `GitHub merge state is ${input.mergeStateStatus}/${input.mergeable}; expected CLEAN or review-only BLOCKED with MERGEABLE.`
15654
15923
  });
15924
+ const awaitPostUndraftChecks = awaitPostUndraftChecksRepair(input);
15655
15925
  if ((input.hostedVerifyCheck ?? "none") === "none") blockers.push({
15656
15926
  demand: DEMAND_KEYS.githubChecks,
15657
- reason: `GitHub has no ${HOSTED_VERIFY_CHECK_NAME$1} check run from its workflow producer on this head; the branch ruleset pins it as a required check and the status rollup does not report the shortfall.`
15927
+ reason: `GitHub has no ${HOSTED_VERIFY_CHECK_NAME$1} check run from its workflow producer on this head; the branch ruleset pins it as a required check and the status rollup does not report the shortfall.`,
15928
+ ...awaitPostUndraftChecks
15658
15929
  });
15659
15930
  if (input.requiredChecks === "none" && input.classification !== "docs/process-only") blockers.push({
15660
15931
  demand: DEMAND_KEYS.githubChecks,
15661
- reason: "GitHub has no current checks for a non-docs PR."
15932
+ reason: "GitHub has no current checks for a non-docs PR.",
15933
+ ...awaitPostUndraftChecks
15662
15934
  });
15663
15935
  else if (input.requiredChecks !== "passed" && input.requiredChecks !== "none") blockers.push({
15664
15936
  demand: DEMAND_KEYS.githubChecks,
15665
15937
  reason: `${PENDING_CHECKS_BLOCKER_REASON_PREFIX} ${input.requiredChecks}; expected passed.`,
15666
- ...input.requiredChecks === "pending" ? { repair: {
15667
- action: "Poll and retry until post-undraft GitHub checks settle or fail; UNSTABLE/pending is a transient route-owned state, not a human blocker.",
15668
- code: "await-post-undraft-checks",
15669
- command: `gh pr checks ${input.pr} --watch`
15670
- } } : {}
15938
+ ...awaitPostUndraftChecks
15671
15939
  });
15672
15940
  if (input.draft) {
15673
15941
  const promotion = prUndraftFollowUp(input.pr);
@@ -15683,7 +15951,7 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
15683
15951
  }
15684
15952
  return blockers;
15685
15953
  };
15686
- const orderRepairs = (repairs) => repairs.toSorted((a, b) => READINESS_REPAIR_CODES.indexOf(a.code) - READINESS_REPAIR_CODES.indexOf(b.code));
15954
+ const orderRepairs = (repairs) => [...new Map(repairs.map((repair) => [repair.code, repair])).values()].toSorted((a, b) => READINESS_REPAIR_CODES.indexOf(a.code) - READINESS_REPAIR_CODES.indexOf(b.code));
15687
15955
  const verificationProofForInput = ({ input }) => {
15688
15956
  if (input.verificationProof) return {
15689
15957
  fullVerifiedHeadShas: input.docsOnlyVerifyBaselineHeadShas,
@@ -16668,6 +16936,7 @@ const prReadyProofSchema = z.object({
16668
16936
  "not-armed"
16669
16937
  ])
16670
16938
  }).optional(),
16939
+ authoringSession: z.string().min(1).optional(),
16671
16940
  blockedReasons: blockedReasonsSchema.optional(),
16672
16941
  blockingReasons: z.array(z.string()),
16673
16942
  command: z.literal("patronage-factory pr:ready"),
@@ -16994,7 +17263,7 @@ async function runPrReady(args, dependencies = {}) {
16994
17263
  requiredChecks
16995
17264
  });
16996
17265
  const output = path.resolve(cwd, args.output ?? ".factory-memory/pr-ready.json");
16997
- const externalCheckState = statusCheckState(pr.statusCheckRollup, isFactoryReadyCheck);
17266
+ const externalCheckState = statusCheckState(pr.statusCheckRollup, isExcludedFromAdmissionChecks);
16998
17267
  const hostedVerifyCheck = hostedVerifyCheckState(pr.statusCheckRollup);
16999
17268
  const effectiveMergeStateStatus = pr.mergeStateStatus === "UNSTABLE" && (externalCheckState === "passed" || externalCheckState === "pending" || externalCheckState === "none") ? "CLEAN" : pr.mergeStateStatus;
17000
17269
  const mergeFreeze = armingMergeFreezeOutcome({
@@ -17056,6 +17325,7 @@ async function runPrReady(args, dependencies = {}) {
17056
17325
  });
17057
17326
  const undraftRepair = evaluation.repairs.find((repair) => repair.code === "undraft-pr");
17058
17327
  const pendingExternalChecksOnly = externalCheckState === "pending" && evaluation.blockingReasons.length === 1;
17328
+ const routeOwnedNotYetOnly = evaluation.status === "blocked" && evaluation.humanBlockingReasons.length === 0;
17059
17329
  const boundary = waveDemand?.kind === "demanded" ? waveDemand.demand : void 0;
17060
17330
  const arming = evaluation.status === "ready" && boundary?.autoMerge === true ? (dependencies.armAutoMerge ?? armAutoMerge)({
17061
17331
  cwd,
@@ -17088,10 +17358,12 @@ async function runPrReady(args, dependencies = {}) {
17088
17358
  ...arming ? [armingNotice(arming)] : [],
17089
17359
  ...waveDemand?.notices ?? [],
17090
17360
  ...evaluation.status === "ready" && !boundary ? ["No boundary wave authorized this candidate, so nothing was armed: pr:ready admits it but schedules no merge. Re-run with --epic <issue> to resolve the wave demand and arm. That issue can be the work issue itself, carrying its own fence; do not file a second boundary issue for one candidate."] : [],
17091
- ...evaluation.status === "ready" && boundary?.autoMerge === false ? [`This candidate is admitted but unscheduled: boundary wave ${boundary.wave} does not authorize machine merge. GitHub auto-merge remains disabled; use the attended merge operation authorized for this wave.`] : []
17361
+ ...evaluation.status === "ready" && boundary?.autoMerge === false ? [`This candidate is admitted but unscheduled: boundary wave ${boundary.wave} does not authorize machine merge. GitHub auto-merge remains disabled; use the attended merge operation authorized for this wave.`] : [],
17362
+ ...routeOwnedNotYetOnly ? [`No ${FACTORY_CHECK_NAMES["pr-ready"]} check run was published for ${pr.headRefOid}: every refusal on this run is route-owned and not yet cleared (${evaluation.repairs.map((repair) => repair.code).join(", ")}). An absent required check blocks the merge, so the silence admits nothing; a proof failure still publishes a red check. Clear the repair and re-run pr:ready to green the head.`] : []
17092
17363
  ];
17093
17364
  const proof = {
17094
17365
  ...arming ? { arming } : {},
17366
+ ...authoringSession ? { authoringSession } : {},
17095
17367
  ...evaluation.blockedReasons.length > 0 ? { blockedReasons: evaluation.blockedReasons } : {},
17096
17368
  blockingReasons: evaluation.blockingReasons,
17097
17369
  command: "patronage-factory pr:ready",
@@ -17119,7 +17391,7 @@ async function runPrReady(args, dependencies = {}) {
17119
17391
  sha: pr.headRefOid,
17120
17392
  conclusion: CHECK_CONCLUSION_BY_READINESS_STATUS[proof.status]
17121
17393
  };
17122
- const publishedFinalCheck = await (dependencies.publishFinalCheckRun ? dependencies.publishFinalCheckRun(finalCheckInput) : ensureFactoryCheckRunPublished(finalCheckInput, { onDiagnostic: (message) => process.stderr.write(message) }));
17394
+ const publishedFinalCheck = routeOwnedNotYetOnly || await (dependencies.publishFinalCheckRun ? dependencies.publishFinalCheckRun(finalCheckInput) : ensureFactoryCheckRunPublished(finalCheckInput, { onDiagnostic: (message) => process.stderr.write(message) }));
17123
17395
  finalCheckPublished = true;
17124
17396
  if (!publishedFinalCheck) {
17125
17397
  notices.push(`The ${FACTORY_CHECK_NAMES["pr-ready"]} check run was NOT confirmed for ${pr.headRefOid}, so GitHub is not serving this run's verdict as the settled state of the required check. Either the publication never landed there, or another run of that name is still unfinished on this head (#524) — an unfinished run blocks the merge whatever this run published, and publishing a newer one does not clear it. GitHub reports no rollup reason for either. Re-run pr:ready once the factory GitHub App can post; if it stays unconfirmed, look for a non-terminal ${FACTORY_CHECK_NAMES["pr-ready"]} run on ${pr.headRefOid}.`);
@@ -17504,10 +17776,20 @@ function emitLadderStageTrace({ cwd, evaluation, headSha, issue, patchId, profil
17504
17776
  }
17505
17777
  //#endregion
17506
17778
  //#region src/review-contract.ts
17507
- 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):
17779
+ 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.`;
17780
+ 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".`;
17781
+ /**
17782
+ * The findings-file instructions for one candidate.
17783
+ *
17784
+ * `waveRung` is the review rung the candidate's boundary wave demands, when it
17785
+ * resolves one. It selects the findings schema version the gate will accept.
17786
+ */
17787
+ const reviewFooterInstructions = (waveRung) => {
17788
+ const schemaVersion = waveRung === void 0 ? 1 : 2;
17789
+ return `Do not print a machine-readable footer in your chat response. Write your findings to the typed findings file at the path given in your dispatch/task instructions (the file pr:review reads via --findings <path>); that file is the verdict. The file must be valid JSON matching review-findings-file.ts's schema (schemaVersion ${schemaVersion}):
17508
17790
  {
17509
- "schemaVersion": 1,
17510
- "model": "<your model identifier>",
17791
+ "schemaVersion": ${schemaVersion},
17792
+ ${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>",
17511
17793
  "sessionId": "<this session's id>",
17512
17794
  "patchId": "<the stable patch-id from Review scope above>",
17513
17795
  "reviews": [
@@ -17520,10 +17802,13 @@ const REVIEW_FOOTER_INSTRUCTIONS = `Do not print a machine-readable footer in yo
17520
17802
  }
17521
17803
  ]
17522
17804
  }
17805
+ ${authorityRule(waveRung)}
17806
+ ${SESSION_ID_RULE}
17523
17807
  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.`;
17808
+ };
17524
17809
  //#endregion
17525
17810
  //#region src/review-prompt.ts
17526
- const reviewScope = ({ base, changedFiles: files, headSha, patchId, profile }) => `Review scope:
17811
+ const reviewScope = ({ base, changedFiles: files, headSha, patchId, profile, waveRung }) => `Review scope:
17527
17812
  - Repository: ${profile.repository.owner}/${profile.repository.name}
17528
17813
  - Base ref: ${base}
17529
17814
  - Head SHA: ${headSha}
@@ -17539,7 +17824,7 @@ Required constraints:
17539
17824
  - Do not request elevated or unsandboxed approvals.
17540
17825
  - 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.
17541
17826
 
17542
- ${REVIEW_FOOTER_INSTRUCTIONS}`;
17827
+ ${reviewFooterInstructions(waveRung)}`;
17543
17828
  const MODE_FLOOR_FILES = {
17544
17829
  correctness: "correctness.md",
17545
17830
  security: "security/SKILL.md"
@@ -17613,7 +17898,7 @@ ${entries.map(priorLedgerEntryLine).join("\n")}
17613
17898
 
17614
17899
  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.`
17615
17900
  });
17616
- const assembleReviewPrompt = ({ base, changedFiles, cwd = process.cwd(), headSha, issueFocus, kind, patchId, priorLedgerEntries, profile }, dependencies = {}) => {
17901
+ const assembleReviewPrompt = ({ base, changedFiles, cwd = process.cwd(), headSha, issueFocus, kind, patchId, priorLedgerEntries, profile, waveRung }, dependencies = {}) => {
17617
17902
  const priorLedger = priorLedgerEntries ? nonOpenLadderFindings(priorLedgerEntries) : [];
17618
17903
  const optionalSegments = {
17619
17904
  "coding-standards": codingStandardsSegment({
@@ -17633,7 +17918,8 @@ const assembleReviewPrompt = ({ base, changedFiles, cwd = process.cwd(), headSha
17633
17918
  changedFiles,
17634
17919
  headSha,
17635
17920
  patchId,
17636
- profile
17921
+ profile,
17922
+ waveRung
17637
17923
  }) },
17638
17924
  { text: modeFloorInstructions(kind, changedFiles) },
17639
17925
  ...REVIEW_PROMPT_SECTION_PROVENANCES.flatMap((provenance) => {
@@ -17658,7 +17944,8 @@ const ASSEMBLED_REVIEW_PROMPT_SCHEMA_VERSION = 1;
17658
17944
  const assembledReviewPromptKindSchema = z.object({
17659
17945
  kind: z.enum(["correctness", "security"]),
17660
17946
  prompt: z.string().min(1),
17661
- sections: z.array(reviewPromptSectionSchema)
17947
+ sections: z.array(reviewPromptSectionSchema),
17948
+ waveRung: z.enum(EVIDENCE_REVIEW_RUNGS).optional()
17662
17949
  });
17663
17950
  const assembledReviewPromptArtifactSchema = z.object({
17664
17951
  headSha: z.string().regex(/^[0-9a-f]{40}$/u),
@@ -17687,6 +17974,51 @@ const defaultFetchIssueBody = ({ issue, owner, repo }) => runGhJson([
17687
17974
  "--json",
17688
17975
  "body"
17689
17976
  ]).body;
17977
+ const candidateWavePullRequestSchema = z.object({
17978
+ baseRefName: z.string().min(1),
17979
+ body: z.string(),
17980
+ headRefName: z.string().min(1),
17981
+ number: z.number().int().positive()
17982
+ });
17983
+ const defaultFetchWavePullRequest = (cwd) => runGhJsonAt([
17984
+ "pr",
17985
+ "view",
17986
+ "--json",
17987
+ "baseRefName,body,headRefName,number"
17988
+ ], cwd);
17989
+ /**
17990
+ * The review rung this candidate's boundary wave demands (#1001).
17991
+ *
17992
+ * The assembled prompt tells the reviewer which findings schema the gate will
17993
+ * accept. A wave that demands a rung needs schemaVersion 2 with `producer` and
17994
+ * `rung`; the v1 shape is recorded without authority, and `pr:publish` then
17995
+ * refuses it with "no passing review at that authority".
17996
+ *
17997
+ * This is prompt enrichment and fails open, like the coding-standards read:
17998
+ * every unreadable step returns undefined and the prompt keeps the v1 shape.
17999
+ * It decides no authority — `pr:ready` resolves the same wave demand again and
18000
+ * is the stage that refuses on it.
18001
+ */
18002
+ const assembleWaveRung = ({ args, cwd, dependencies, fetchIssueBody, fetchWavePullRequest, profile }) => {
18003
+ if (args.epic === void 0) return;
18004
+ try {
18005
+ const pullRequest = candidateWavePullRequestSchema.parse(fetchWavePullRequest(cwd));
18006
+ const demand = resolveWaveDemand({
18007
+ baseRefName: pullRequest.baseRefName,
18008
+ epic: args.epic,
18009
+ ...dependencies.fetchClosingPullRequests === void 0 ? {} : { fetchClosingPullRequests: dependencies.fetchClosingPullRequests },
18010
+ fetchIssueBody,
18011
+ headRefName: pullRequest.headRefName,
18012
+ owner: profile.repository.owner,
18013
+ pr: pullRequest.number,
18014
+ prBody: pullRequest.body,
18015
+ repo: profile.repository.name
18016
+ });
18017
+ return demand.kind === "demanded" ? demand.demand.review : void 0;
18018
+ } catch {
18019
+ return;
18020
+ }
18021
+ };
17690
18022
  const assertReviewCycleInWindow = (cycle, maxCycles) => {
17691
18023
  if (cycle < 1 || cycle > maxCycles) throw new Error(`--cycle must be between 1 and ${maxCycles}.`);
17692
18024
  };
@@ -17806,7 +18138,7 @@ const assembleGitDependencies = (git) => git.repositoryRoot !== void 0 && git.sh
17806
18138
  repositoryRoot: git.repositoryRoot,
17807
18139
  showFileAtRef: git.showFileAtRef
17808
18140
  } : void 0;
17809
- const writeAssembledReviewPrompt = ({ args, cwd, fetchIssueBody, files, git, headSha, kinds, output, patchId, profile }) => {
18141
+ const writeAssembledReviewPrompt = ({ args, cwd, fetchIssueBody, files, git, headSha, kinds, output, patchId, profile, waveRung }) => {
17810
18142
  const priorLedger = priorLedgerFor(priorLadderStateFor({
17811
18143
  base: args.base,
17812
18144
  cwd,
@@ -17826,12 +18158,14 @@ const writeAssembledReviewPrompt = ({ args, cwd, fetchIssueBody, files, git, hea
17826
18158
  kind,
17827
18159
  patchId,
17828
18160
  priorLedgerEntries: priorLedger,
17829
- profile
18161
+ profile,
18162
+ ...waveRung === void 0 ? {} : { waveRung }
17830
18163
  }, assembleGit === void 0 ? {} : { git: assembleGit });
17831
18164
  return {
17832
18165
  kind,
17833
18166
  prompt: assembled.prompt,
17834
- sections: assembled.sections
18167
+ sections: assembled.sections,
18168
+ ...waveRung === void 0 ? {} : { waveRung }
17835
18169
  };
17836
18170
  });
17837
18171
  const artifactPath = path.resolve(cwd, DEFAULT_ASSEMBLED_REVIEW_PROMPT_PATH);
@@ -17839,7 +18173,7 @@ const writeAssembledReviewPrompt = ({ args, cwd, fetchIssueBody, files, git, hea
17839
18173
  writeProofJson({
17840
18174
  headSha,
17841
18175
  patchId,
17842
- 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,
18176
+ 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,
17843
18177
  schemaVersion: ASSEMBLED_REVIEW_PROMPT_SCHEMA_VERSION
17844
18178
  }, artifactPath);
17845
18179
  console.log(`pr:review assembled prompt written to ${artifactPath}`);
@@ -18068,17 +18402,26 @@ async function runPrReview(args, dependencies = {}) {
18068
18402
  console.log(ASSEMBLE_FAITHFUL_MERGE_NOTICE);
18069
18403
  return assembleProof();
18070
18404
  }
18405
+ const fetchIssueBody = dependencies.fetchIssueBody ?? defaultFetchIssueBody;
18071
18406
  writeAssembledReviewPrompt({
18072
18407
  args,
18073
18408
  cwd,
18074
- fetchIssueBody: dependencies.fetchIssueBody ?? defaultFetchIssueBody,
18409
+ fetchIssueBody,
18075
18410
  files,
18076
18411
  git,
18077
18412
  headSha,
18078
18413
  kinds,
18079
18414
  output,
18080
18415
  patchId,
18081
- profile
18416
+ profile,
18417
+ waveRung: assembleWaveRung({
18418
+ args,
18419
+ cwd,
18420
+ dependencies,
18421
+ fetchIssueBody,
18422
+ fetchWavePullRequest: dependencies.fetchWavePullRequest ?? defaultFetchWavePullRequest,
18423
+ profile
18424
+ })
18082
18425
  });
18083
18426
  return assembleProof();
18084
18427
  }
@@ -18212,6 +18555,7 @@ async function runPrReview(args, dependencies = {}) {
18212
18555
  }
18213
18556
  //#endregion
18214
18557
  //#region src/pr-publish.ts
18558
+ const defaultNow = () => /* @__PURE__ */ new Date();
18215
18559
  var PrPublishFollowUpError = class extends Error {
18216
18560
  result;
18217
18561
  constructor(result, cause) {
@@ -18543,7 +18887,8 @@ const PUBLISH_HOSTED_CHECKS_WAIT_MS = 15e3;
18543
18887
  * PR #983 measured the second half: `verify` finished first, readiness ran and
18544
18888
  * published red, and the head's status-HUD job was still in progress.
18545
18889
  *
18546
- * The `patronage-factory/*` family is excluded through `isFactoryReadyCheck`,
18890
+ * The `patronage-factory/*` family and the draft-head summary residue are
18891
+ * excluded through `isExcludedFromAdmissionChecks` (#1045),
18547
18892
  * the predicate readiness itself uses to exclude its own writes from the
18548
18893
  * rollup gate. Waiting on it would deadlock: readiness is what completes those
18549
18894
  * runs, and a stranded non-terminal generation of one (#524) never finishes.
@@ -18555,7 +18900,7 @@ const PUBLISH_HOSTED_CHECKS_WAIT_MS = 15e3;
18555
18900
  * proceeds exactly as it does today. A moved head is reported, never absorbed:
18556
18901
  * the caller aborts the transaction on it.
18557
18902
  */
18558
- async function awaitHostedChecksSettled({ expectedHeadSha, fetchPr, report = (message) => process.stderr.write(message), sleep = setTimeout$1 }) {
18903
+ async function awaitHostedChecksSettled({ expectedHeadSha, fetchPr, report = (message) => process.stderr.write(message), sleep = setTimeout$1, verifyNotBefore }) {
18559
18904
  for (let attempts = 0;; attempts += 1) {
18560
18905
  const pr = fetchPr();
18561
18906
  if (!sameHeadSha(pr.headRefOid, expectedHeadSha)) return {
@@ -18563,9 +18908,10 @@ async function awaitHostedChecksSettled({ expectedHeadSha, fetchPr, report = (me
18563
18908
  attempts,
18564
18909
  status: "head-moved"
18565
18910
  };
18566
- const verifyState = hostedVerifyCheckState(pr.statusCheckRollup);
18911
+ const rollup = verifyNotBefore === void 0 ? pr.statusCheckRollup : pr.statusCheckRollup?.filter((check) => !isSupersededHostedVerifyCheck(check, verifyNotBefore));
18912
+ const verifyState = hostedVerifyCheckState(rollup);
18567
18913
  const verifySettled = verifyState !== "none" && verifyState !== "pending";
18568
- const pending = pendingCheckNames(pr.statusCheckRollup, isFactoryReadyCheck);
18914
+ const pending = pendingCheckNames(rollup, isExcludedFromAdmissionChecks);
18569
18915
  if (verifySettled && pending.length === 0) return {
18570
18916
  attempts,
18571
18917
  status: "settled",
@@ -18600,15 +18946,28 @@ const reevaluateReadiness = (args, dependencies) => (dependencies.runPrReady ??
18600
18946
  * invocation's verify and review proof describe the head it waited for. That
18601
18947
  * is the same reason the #348 await aborts on head movement.
18602
18948
  */
18603
- const awaitHostedChecksBeforeReadiness = async ({ dependencies, expectedHeadSha, fetchPr }) => {
18949
+ const awaitHostedChecksBeforeReadiness = async ({ dependencies, expectedHeadSha, fetchPr, verifyNotBefore }) => {
18604
18950
  const outcome = await awaitHostedChecksSettled({
18605
18951
  expectedHeadSha,
18606
18952
  fetchPr,
18607
- ...dependencies.sleep === void 0 ? {} : { sleep: dependencies.sleep }
18953
+ ...dependencies.sleep === void 0 ? {} : { sleep: dependencies.sleep },
18954
+ ...verifyNotBefore === void 0 ? {} : { verifyNotBefore }
18608
18955
  });
18609
18956
  if (outcome.status === "head-moved") throw new PrPublishTransactionAbortedError(`awaited the head's hosted check runs for ${expectedHeadSha}, but the head moved to ${outcome.actualHeadSha} while waiting. Re-run pr:publish against the current head.`);
18610
18957
  return outcome;
18611
18958
  };
18959
+ /**
18960
+ * The instant `pr:publish` measures `verify` staleness from (#1045). Read
18961
+ * before the promotion, so every check run that already existed on the head
18962
+ * sorts strictly before it.
18963
+ */
18964
+ const promotionInstant = (dependencies) => (dependencies.now ?? defaultNow)().toISOString();
18965
+ /**
18966
+ * The staleness cutoff to hand the hosted-run await — present only when this
18967
+ * invocation actually promoted the candidate. Without a promotion there is no
18968
+ * draft-era `verify` to distrust, and the wait keeps today's behaviour.
18969
+ */
18970
+ const promotionVerifyCutoff = (promotion, promotionStartedAt) => promotion.status === "succeeded" ? { verifyNotBefore: promotionStartedAt } : {};
18612
18971
  const requirePromotionHead = ({ actualHeadSha, expectedHeadSha, phase }) => {
18613
18972
  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.`);
18614
18973
  };
@@ -18816,6 +19175,7 @@ async function runPrPublish(args, dependencies = {}) {
18816
19175
  json: false,
18817
19176
  report: false
18818
19177
  };
19178
+ const promotionStartedAt = promotionInstant(dependencies);
18819
19179
  const promotion = await promoteDraftBeforeDemandEvaluation({
18820
19180
  cwd,
18821
19181
  expectedHeadSha: verifyProof.headSha,
@@ -18828,7 +19188,8 @@ async function runPrPublish(args, dependencies = {}) {
18828
19188
  await awaitHostedChecksBeforeReadiness({
18829
19189
  dependencies,
18830
19190
  expectedHeadSha: verifyProof.headSha,
18831
- fetchPr
19191
+ fetchPr,
19192
+ ...promotionVerifyCutoff(promotion, promotionStartedAt)
18832
19193
  });
18833
19194
  const initialProof = await reevaluateReadiness(readyEvalArgs, publishDependencies);
18834
19195
  requirePromotedProofHead({
@@ -18980,7 +19341,7 @@ function assertForegroundGate({ gate, env = process.env }) {
18980
19341
  //#endregion
18981
19342
  //#region src/commands/pr-review.ts
18982
19343
  function createPrReviewCommand(output, action) {
18983
- 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({
19344
+ 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({
18984
19345
  gate: "pr:review",
18985
19346
  resolveCycle: (options) => options.cycle,
18986
19347
  resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
@@ -18998,6 +19359,7 @@ function createPrReviewCommand(output, action) {
18998
19359
  cwd: resolveCwdOption(options.cwd),
18999
19360
  cycle: options.cycle,
19000
19361
  dispositions: options.dispositions,
19362
+ epic: options.epic,
19001
19363
  findings: options.findings,
19002
19364
  ...options.historical === true ? { historical: true } : {},
19003
19365
  issue: options.issue,