@patronage/software-factory 1.0.0-alpha.28 → 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.d.ts CHANGED
@@ -1360,8 +1360,8 @@ declare const mergeFreezeStateSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
1360
1360
  generationId: z.ZodNumber;
1361
1361
  headSha: z.ZodString;
1362
1362
  outcome: z.ZodEnum<{
1363
- stale: "stale";
1364
1363
  active: "active";
1364
+ stale: "stale";
1365
1365
  }>;
1366
1366
  reason: z.ZodString;
1367
1367
  recordedAt: z.ZodISODateTime;
@@ -1609,9 +1609,9 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
1609
1609
  reviewedPatchId: z.ZodOptional<z.ZodString>;
1610
1610
  status: z.ZodEnum<{
1611
1611
  "not-required": "not-required";
1612
+ stale: "stale";
1612
1613
  blocked: "blocked";
1613
1614
  current: "current";
1614
- stale: "stale";
1615
1615
  missing: "missing";
1616
1616
  }>;
1617
1617
  }, z.core.$strip>;
@@ -1621,9 +1621,9 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
1621
1621
  reviewedPatchId: z.ZodOptional<z.ZodString>;
1622
1622
  status: z.ZodEnum<{
1623
1623
  "not-required": "not-required";
1624
+ stale: "stale";
1624
1625
  blocked: "blocked";
1625
1626
  current: "current";
1626
- stale: "stale";
1627
1627
  missing: "missing";
1628
1628
  }>;
1629
1629
  }, z.core.$strip>>;
@@ -1657,7 +1657,7 @@ type ReadinessStatus = "ready" | "blocked";
1657
1657
  type ManagedReadinessLedger = z.infer<typeof managedReadinessLedgerSchema>;
1658
1658
  declare function validateManagedReadinessLedger(value: unknown): ManagedReadinessLedger;
1659
1659
  declare namespace status_check_rollup_d_exports {
1660
- export { CheckState, HOSTED_VERIFY_CHECK_NAME, StatusCheckRollup, hostedVerifyCheckState, isFactoryReadyCheck, isHostedVerifyCheck, pendingCheckNames, statusCheckState };
1660
+ export { CheckState, HOSTED_VERIFY_CHECK_NAME, HOSTED_VERIFY_DRAFT_CHECK_NAME, StatusCheckRollup, hostedVerifyCheckState, isDraftHostedVerifyCheck, isExcludedFromAdmissionChecks, isFactoryReadyCheck, isHostedVerifyCheck, isSupersededHostedVerifyCheck, pendingCheckNames, statusCheckState };
1661
1661
  }
1662
1662
  type CheckState = "passed" | "failed" | "pending" | "none" | "unknown";
1663
1663
  interface StatusCheckRollup {
@@ -1691,6 +1691,39 @@ declare const HOSTED_VERIFY_CHECK_NAME = "verify";
1691
1691
  * would accept exactly the thing the pin rejects.
1692
1692
  */
1693
1693
  declare const isHostedVerifyCheck: (check: StatusCheckRollup) => boolean;
1694
+ /**
1695
+ * The name the hosted summary job takes on a DRAFT head (#1045).
1696
+ *
1697
+ * The workflow renames that job so the pinned `verify` context does not exist
1698
+ * while the candidate is a draft. The generator that emits the workflow owns
1699
+ * the literal; this is the reader's copy of it, and
1700
+ * `software-factory-hq/alchemy/verify-workflow.test.ts` pins the two equal.
1701
+ */
1702
+ declare const HOSTED_VERIFY_DRAFT_CHECK_NAME = "verify (draft)";
1703
+ /**
1704
+ * Is this the draft-head summary run's residue?
1705
+ *
1706
+ * The draft run and the `ready_for_review` run land on the SAME head SHA, and
1707
+ * they post two different contexts — `verify (draft)` and `verify` — which
1708
+ * `effectiveChecks` keys separately and therefore never collapses. The Verify
1709
+ * workflow cancels an in-flight pull-request run when the next one starts, and
1710
+ * `ready_for_review` IS the next one, so undrafting typically cancels the
1711
+ * draft run mid-flight. That leaves a CANCELLED `verify (draft)` row sitting
1712
+ * next to a green `verify` on the admitted head.
1713
+ *
1714
+ * GitHub does not care: the branch ruleset pins `verify` alone. The factory's
1715
+ * own rollup read would: `CANCELLED` is a failed state, so the candidate's
1716
+ * `requiredChecks` would read `failed` forever and no push-free repair could
1717
+ * clear it. The draft run is a superseded generation of the summary job the
1718
+ * `verify` row already answers for, so admission does not judge it.
1719
+ */
1720
+ declare const isDraftHostedVerifyCheck: (check: StatusCheckRollup) => boolean;
1721
+ /**
1722
+ * The rollup rows an admission read must not judge: the factory's own branded
1723
+ * checks (it would be judging itself) and the draft-head summary residue
1724
+ * above.
1725
+ */
1726
+ declare const isExcludedFromAdmissionChecks: (check: StatusCheckRollup) => boolean;
1694
1727
  /**
1695
1728
  * The hosted `verify` gate's state on this head, read by presence (#477).
1696
1729
  *
@@ -1716,6 +1749,25 @@ declare const hostedVerifyCheckState: (rollup: StatusCheckRollup[] | undefined)
1716
1749
  */
1717
1750
  declare const pendingCheckNames: (rollup: StatusCheckRollup[] | undefined, exclude?: (check: StatusCheckRollup) => boolean) => string[];
1718
1751
  declare const statusCheckState: (rollup: StatusCheckRollup[] | undefined, exclude?: (check: StatusCheckRollup) => boolean) => CheckState;
1752
+ /**
1753
+ * Is this a hosted `verify` check run that GitHub started before `cutoffIso`?
1754
+ * (#1045)
1755
+ *
1756
+ * A caller that waits for `verify` on a head it just changed the state of —
1757
+ * `pr:publish` undrafting a candidate — must not accept the run that was
1758
+ * already there. #1034's history is the shape: a draft head carried a `verify`
1759
+ * that had run nothing, the wait saw it as terminal on its first attempt, and
1760
+ * readiness read `passed` about two minutes before the `ready_for_review` run
1761
+ * created its own check runs. The same shape is reachable from any superseded
1762
+ * `verify` generation still on the head.
1763
+ *
1764
+ * Fail CLOSED on an unreadable timestamp: a `verify` row that cannot be shown
1765
+ * to be newer than the cutoff is treated as superseded. The wait is bounded,
1766
+ * so the cost of that refusal is one timeout that falls through to the
1767
+ * caller's existing behaviour; the cost of failing open is the false green
1768
+ * this exists to remove.
1769
+ */
1770
+ declare const isSupersededHostedVerifyCheck: (check: StatusCheckRollup, cutoffIso: string) => boolean;
1719
1771
  //#endregion
1720
1772
  //#region src/github-client.d.ts
1721
1773
  /** Read a branch tip from GitHub rather than a checkout's cached remote ref. */
@@ -1782,6 +1834,33 @@ interface PrVerifyCommandResult {
1782
1834
  name: string;
1783
1835
  scope: "docs-only" | "trivial" | "full";
1784
1836
  }
1837
+ /**
1838
+ * One failing test a verification command reported (#1035).
1839
+ *
1840
+ * The proof keeps the failing test's IDENTITY and its failure CLASS, and
1841
+ * nothing else. That is enough to decide retry-or-fix — a `timeout` under
1842
+ * machine load is a flake, an `assertion` is a real break — without rerunning
1843
+ * the aggregate to read a title off the console.
1844
+ *
1845
+ * It deliberately keeps no raw output tail and no error message body. This
1846
+ * payload is transmitted to HQ ingest, and arbitrary command output can carry
1847
+ * secrets (an echoed environment, a connection string inside a stack frame). A
1848
+ * file path, a test title, and a closed `kind` enum cannot. The cost is the
1849
+ * assertion diff and the stack frames: they help you FIX a failure, and the
1850
+ * operator still reads them from the run's own console output.
1851
+ */
1852
+ interface PrVerifyTestFailure {
1853
+ /** The test file the reporter named, as the reporter wrote it. */
1854
+ file: string;
1855
+ /**
1856
+ * The failure class. `timeout` is the test-timeout error vitest raises at
1857
+ * its own ceiling; `assertion` is a failed expectation; `error` is every
1858
+ * other thrown failure.
1859
+ */
1860
+ kind: "assertion" | "error" | "timeout";
1861
+ /** The full test title, suite names included, as the reporter wrote it. */
1862
+ title: string;
1863
+ }
1785
1864
  interface PrVerifyExecutedCommand {
1786
1865
  command: string;
1787
1866
  counts?: {
@@ -1790,6 +1869,12 @@ interface PrVerifyExecutedCommand {
1790
1869
  };
1791
1870
  durationMs: number;
1792
1871
  exitCode: number;
1872
+ /**
1873
+ * The failing tests this command reported (#1035). Present only when the
1874
+ * command produced parseable failures, so a passing command's record is
1875
+ * byte-identical to the one it wrote before this field existed.
1876
+ */
1877
+ failures?: PrVerifyTestFailure[];
1793
1878
  name: string;
1794
1879
  scope: "docs-only" | "trivial" | "full";
1795
1880
  }
@@ -1864,7 +1949,7 @@ interface PrVerifyProof {
1864
1949
  type PrVerifyBaselineFullProof = NonNullable<PrVerifyProof["baselineFullProofs"]>[number];
1865
1950
  declare function validatePrVerifyProof(value: unknown): PrVerifyProof;
1866
1951
  declare namespace verification_proof_d_exports {
1867
- export { PrVerifyBaselineFullProof, PrVerifyCommandResult, PrVerifyExecutedCommand, PrVerifyNotDemandedRecord, PrVerifyNotRequiredCommand, PrVerifyProof, PrVerifySchemaVersion, ResolvedPrVerifyMode, SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS, VerificationHeadShas, VerificationProofState, VerifiedHeadShas, prVerifyFullHeadShasForDiff, prVerifyProofHeadShas, readPrVerifyProof, resolveVerifyProofApplicability, trustedCatchUpRecognition, trustedImpactStamp, validatePrVerifyProof, verificationProofApplicabilityFor, verificationProofBlockingReason, verificationProofForReadiness, verificationProofStateFor, verifyProofIdentityMatches, verifyProofPassed };
1952
+ export { PrVerifyBaselineFullProof, PrVerifyCommandResult, PrVerifyExecutedCommand, PrVerifyNotDemandedRecord, PrVerifyNotRequiredCommand, PrVerifyProof, PrVerifySchemaVersion, PrVerifyTestFailure, ResolvedPrVerifyMode, SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS, VerificationHeadShas, VerificationProofState, VerifiedHeadShas, prVerifyFullHeadShasForDiff, prVerifyProofHeadShas, readPrVerifyProof, resolveVerifyProofApplicability, trustedCatchUpRecognition, trustedImpactStamp, validatePrVerifyProof, verificationProofApplicabilityFor, verificationProofBlockingReason, verificationProofForReadiness, verificationProofStateFor, verifyProofIdentityMatches, verifyProofPassed };
1868
1953
  }
1869
1954
  interface VerifiedHeadShas {
1870
1955
  docsOnlyVerifiedHeadShas: string[];
@@ -2334,6 +2419,14 @@ interface PrReadyProof {
2334
2419
  headSha: string;
2335
2420
  outcome: "armed" | "merged" | "not-armed";
2336
2421
  };
2422
+ /**
2423
+ * The authoring session readiness resolved for the review-independence
2424
+ * cross-check (#1037). Recorded so the branded check can print it beside the
2425
+ * review session, model, and producer the ledger already carries — the four
2426
+ * identities a human weighs at merge time. Additive and optional: proofs
2427
+ * written before it carry no field, so no schema version moves.
2428
+ */
2429
+ authoringSession?: string;
2337
2430
  /**
2338
2431
  * Why this run blocked, one entry per refusing demand (#391): `code` is the
2339
2432
  * demand key from the resolver's vocabulary, `detail` the one-sentence
@@ -2639,9 +2732,9 @@ declare const loadEvidenceEnvelopes: (cwd: string) => LoadedEvidenceEnvelope[];
2639
2732
  declare const REVIEW_STATUS_VALUES: readonly ["not-required", "current", "stale", "missing", "blocked"];
2640
2733
  declare const reviewStatusSchema: z.ZodEnum<{
2641
2734
  "not-required": "not-required";
2735
+ stale: "stale";
2642
2736
  blocked: "blocked";
2643
2737
  current: "current";
2644
- stale: "stale";
2645
2738
  missing: "missing";
2646
2739
  }>;
2647
2740
  type ReviewStatus = z.infer<typeof reviewStatusSchema>;
@@ -3014,6 +3107,8 @@ interface VerificationCommandOutcome {
3014
3107
  };
3015
3108
  durationMs: number;
3016
3109
  exitCode: number;
3110
+ /** The failing tests scraped from this command's output (#1035). */
3111
+ failures?: PrVerifyTestFailure[];
3017
3112
  }
3018
3113
  interface PrVerifyArgs extends LoadProjectProfileInput {
3019
3114
  authoringSession?: string;
@@ -3150,6 +3245,12 @@ interface PrPublishDependencies extends PrReadyDependencies {
3150
3245
  cwd: string;
3151
3246
  }) => Promise<HqFlushResult>;
3152
3247
  runPrVerify?: (args: PrVerifyArgs) => Promise<PrVerifyProof>;
3248
+ /**
3249
+ * Injectable clock for the promotion instant the hosted-run await measures
3250
+ * `verify` staleness against (#1045; tests only — production reads the real
3251
+ * clock).
3252
+ */
3253
+ now?: () => Date;
3153
3254
  /**
3154
3255
  * Injectable delay for the bounded hosted-run await (#348; tests only —
3155
3256
  * production always waits the real interval).
@@ -3885,8 +3986,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
3885
3986
  interiorCycle: z.ZodOptional<z.ZodNumber>;
3886
3987
  observedAt: z.ZodString;
3887
3988
  purpose: z.ZodEnum<{
3888
- review: "review";
3889
3989
  code: "code";
3990
+ review: "review";
3890
3991
  }>;
3891
3992
  slotResolution: z.ZodObject<{
3892
3993
  effort: z.ZodEnum<{
@@ -3964,8 +4065,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
3964
4065
  interiorCycle: z.ZodOptional<z.ZodNumber>;
3965
4066
  observedAt: z.ZodString;
3966
4067
  purpose: z.ZodEnum<{
3967
- review: "review";
3968
4068
  code: "code";
4069
+ review: "review";
3969
4070
  }>;
3970
4071
  slotResolution: z.ZodObject<{
3971
4072
  effort: z.ZodEnum<{
@@ -4034,8 +4135,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
4034
4135
  interiorCycle: z.ZodOptional<z.ZodNumber>;
4035
4136
  observedAt: z.ZodString;
4036
4137
  purpose: z.ZodEnum<{
4037
- review: "review";
4038
4138
  code: "code";
4139
+ review: "review";
4039
4140
  }>;
4040
4141
  slotResolution: z.ZodObject<{
4041
4142
  effort: z.ZodEnum<{
@@ -4107,7 +4208,7 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
4107
4208
  exitCode: number | null;
4108
4209
  harness: string;
4109
4210
  observedAt: string;
4110
- purpose: "review" | "code";
4211
+ purpose: "code" | "review";
4111
4212
  slotResolution: {
4112
4213
  effort: "high" | "low" | "medium" | "xhigh";
4113
4214
  engine: string;
@@ -4461,13 +4562,13 @@ declare const evaluateReadiness: (input: EvaluationInput) => {
4461
4562
  reviews: {
4462
4563
  correctness: {
4463
4564
  required: boolean;
4464
- status: "not-required" | "blocked" | "current" | "stale" | "missing";
4565
+ status: "not-required" | "stale" | "blocked" | "current" | "missing";
4465
4566
  reviewedHeadSha?: string | undefined;
4466
4567
  reviewedPatchId?: string | undefined;
4467
4568
  };
4468
4569
  security?: {
4469
4570
  required: boolean;
4470
- status: "not-required" | "blocked" | "current" | "stale" | "missing";
4571
+ status: "not-required" | "stale" | "blocked" | "current" | "missing";
4471
4572
  reviewedHeadSha?: string | undefined;
4472
4573
  reviewedPatchId?: string | undefined;
4473
4574
  } | undefined;
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.28";
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}.`);
@@ -17514,7 +17786,7 @@ const authorityRule = (waveRung) => waveRung === void 0 ? `Emit schemaVersion 1
17514
17786
  */
17515
17787
  const reviewFooterInstructions = (waveRung) => {
17516
17788
  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}):
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}):
17518
17790
  {
17519
17791
  "schemaVersion": ${schemaVersion},
17520
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>",
@@ -18283,6 +18555,7 @@ async function runPrReview(args, dependencies = {}) {
18283
18555
  }
18284
18556
  //#endregion
18285
18557
  //#region src/pr-publish.ts
18558
+ const defaultNow = () => /* @__PURE__ */ new Date();
18286
18559
  var PrPublishFollowUpError = class extends Error {
18287
18560
  result;
18288
18561
  constructor(result, cause) {
@@ -18614,7 +18887,8 @@ const PUBLISH_HOSTED_CHECKS_WAIT_MS = 15e3;
18614
18887
  * PR #983 measured the second half: `verify` finished first, readiness ran and
18615
18888
  * published red, and the head's status-HUD job was still in progress.
18616
18889
  *
18617
- * 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),
18618
18892
  * the predicate readiness itself uses to exclude its own writes from the
18619
18893
  * rollup gate. Waiting on it would deadlock: readiness is what completes those
18620
18894
  * runs, and a stranded non-terminal generation of one (#524) never finishes.
@@ -18626,7 +18900,7 @@ const PUBLISH_HOSTED_CHECKS_WAIT_MS = 15e3;
18626
18900
  * proceeds exactly as it does today. A moved head is reported, never absorbed:
18627
18901
  * the caller aborts the transaction on it.
18628
18902
  */
18629
- 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 }) {
18630
18904
  for (let attempts = 0;; attempts += 1) {
18631
18905
  const pr = fetchPr();
18632
18906
  if (!sameHeadSha(pr.headRefOid, expectedHeadSha)) return {
@@ -18634,9 +18908,10 @@ async function awaitHostedChecksSettled({ expectedHeadSha, fetchPr, report = (me
18634
18908
  attempts,
18635
18909
  status: "head-moved"
18636
18910
  };
18637
- 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);
18638
18913
  const verifySettled = verifyState !== "none" && verifyState !== "pending";
18639
- const pending = pendingCheckNames(pr.statusCheckRollup, isFactoryReadyCheck);
18914
+ const pending = pendingCheckNames(rollup, isExcludedFromAdmissionChecks);
18640
18915
  if (verifySettled && pending.length === 0) return {
18641
18916
  attempts,
18642
18917
  status: "settled",
@@ -18671,15 +18946,28 @@ const reevaluateReadiness = (args, dependencies) => (dependencies.runPrReady ??
18671
18946
  * invocation's verify and review proof describe the head it waited for. That
18672
18947
  * is the same reason the #348 await aborts on head movement.
18673
18948
  */
18674
- const awaitHostedChecksBeforeReadiness = async ({ dependencies, expectedHeadSha, fetchPr }) => {
18949
+ const awaitHostedChecksBeforeReadiness = async ({ dependencies, expectedHeadSha, fetchPr, verifyNotBefore }) => {
18675
18950
  const outcome = await awaitHostedChecksSettled({
18676
18951
  expectedHeadSha,
18677
18952
  fetchPr,
18678
- ...dependencies.sleep === void 0 ? {} : { sleep: dependencies.sleep }
18953
+ ...dependencies.sleep === void 0 ? {} : { sleep: dependencies.sleep },
18954
+ ...verifyNotBefore === void 0 ? {} : { verifyNotBefore }
18679
18955
  });
18680
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.`);
18681
18957
  return outcome;
18682
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 } : {};
18683
18971
  const requirePromotionHead = ({ actualHeadSha, expectedHeadSha, phase }) => {
18684
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.`);
18685
18973
  };
@@ -18887,6 +19175,7 @@ async function runPrPublish(args, dependencies = {}) {
18887
19175
  json: false,
18888
19176
  report: false
18889
19177
  };
19178
+ const promotionStartedAt = promotionInstant(dependencies);
18890
19179
  const promotion = await promoteDraftBeforeDemandEvaluation({
18891
19180
  cwd,
18892
19181
  expectedHeadSha: verifyProof.headSha,
@@ -18899,7 +19188,8 @@ async function runPrPublish(args, dependencies = {}) {
18899
19188
  await awaitHostedChecksBeforeReadiness({
18900
19189
  dependencies,
18901
19190
  expectedHeadSha: verifyProof.headSha,
18902
- fetchPr
19191
+ fetchPr,
19192
+ ...promotionVerifyCutoff(promotion, promotionStartedAt)
18903
19193
  });
18904
19194
  const initialProof = await reevaluateReadiness(readyEvalArgs, publishDependencies);
18905
19195
  requirePromotedProofHead({
package/dist/schemas.d.ts CHANGED
@@ -531,6 +531,33 @@ interface PrVerifyCommandResult {
531
531
  name: string;
532
532
  scope: "docs-only" | "trivial" | "full";
533
533
  }
534
+ /**
535
+ * One failing test a verification command reported (#1035).
536
+ *
537
+ * The proof keeps the failing test's IDENTITY and its failure CLASS, and
538
+ * nothing else. That is enough to decide retry-or-fix — a `timeout` under
539
+ * machine load is a flake, an `assertion` is a real break — without rerunning
540
+ * the aggregate to read a title off the console.
541
+ *
542
+ * It deliberately keeps no raw output tail and no error message body. This
543
+ * payload is transmitted to HQ ingest, and arbitrary command output can carry
544
+ * secrets (an echoed environment, a connection string inside a stack frame). A
545
+ * file path, a test title, and a closed `kind` enum cannot. The cost is the
546
+ * assertion diff and the stack frames: they help you FIX a failure, and the
547
+ * operator still reads them from the run's own console output.
548
+ */
549
+ interface PrVerifyTestFailure {
550
+ /** The test file the reporter named, as the reporter wrote it. */
551
+ file: string;
552
+ /**
553
+ * The failure class. `timeout` is the test-timeout error vitest raises at
554
+ * its own ceiling; `assertion` is a failed expectation; `error` is every
555
+ * other thrown failure.
556
+ */
557
+ kind: "assertion" | "error" | "timeout";
558
+ /** The full test title, suite names included, as the reporter wrote it. */
559
+ title: string;
560
+ }
534
561
  interface PrVerifyExecutedCommand {
535
562
  command: string;
536
563
  counts?: {
@@ -539,6 +566,12 @@ interface PrVerifyExecutedCommand {
539
566
  };
540
567
  durationMs: number;
541
568
  exitCode: number;
569
+ /**
570
+ * The failing tests this command reported (#1035). Present only when the
571
+ * command produced parseable failures, so a passing command's record is
572
+ * byte-identical to the one it wrote before this field existed.
573
+ */
574
+ failures?: PrVerifyTestFailure[];
542
575
  name: string;
543
576
  scope: "docs-only" | "trivial" | "full";
544
577
  }
@@ -640,6 +673,15 @@ declare const prVerifyProofSchema: z.ZodObject<{
640
673
  }, z.core.$strip>>;
641
674
  durationMs: z.ZodNumber;
642
675
  exitCode: z.ZodNumber;
676
+ failures: z.ZodOptional<z.ZodArray<z.ZodObject<{
677
+ file: z.ZodString;
678
+ kind: z.ZodEnum<{
679
+ error: "error";
680
+ assertion: "assertion";
681
+ timeout: "timeout";
682
+ }>;
683
+ title: z.ZodString;
684
+ }, z.core.$strip>>>;
643
685
  name: z.ZodString;
644
686
  scope: z.ZodEnum<{
645
687
  trivial: "trivial";
@@ -734,6 +776,14 @@ interface PrReadyProof {
734
776
  headSha: string;
735
777
  outcome: "armed" | "merged" | "not-armed";
736
778
  };
779
+ /**
780
+ * The authoring session readiness resolved for the review-independence
781
+ * cross-check (#1037). Recorded so the branded check can print it beside the
782
+ * review session, model, and producer the ledger already carries — the four
783
+ * identities a human weighs at merge time. Additive and optional: proofs
784
+ * written before it carry no field, so no schema version moves.
785
+ */
786
+ authoringSession?: string;
737
787
  /**
738
788
  * Why this run blocked, one entry per refusing demand (#391): `code` is the
739
789
  * demand key from the resolver's vocabulary, `detail` the one-sentence
@@ -1014,6 +1064,7 @@ declare const prReadyProofSchema: z.ZodObject<{
1014
1064
  "not-armed": "not-armed";
1015
1065
  }>;
1016
1066
  }, z.core.$strip>>;
1067
+ authoringSession: z.ZodOptional<z.ZodString>;
1017
1068
  blockedReasons: z.ZodOptional<z.ZodArray<z.ZodObject<{
1018
1069
  code: z.ZodString;
1019
1070
  detail: z.ZodString;
package/dist/schemas.js CHANGED
@@ -1171,6 +1171,15 @@ const assertPrVerifyProofRules = (proof, context) => {
1171
1171
  * factory must adopt this release before HQ ingests its pr:verify proofs.
1172
1172
  */
1173
1173
  const SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS = [4];
1174
+ const prVerifyTestFailureSchema = z.object({
1175
+ file: z.string().min(1),
1176
+ kind: z.enum([
1177
+ "assertion",
1178
+ "error",
1179
+ "timeout"
1180
+ ]),
1181
+ title: z.string().min(1)
1182
+ });
1174
1183
  const executedCommandSchema = z.object({
1175
1184
  command: z.string().min(1),
1176
1185
  counts: z.object({
@@ -1179,6 +1188,7 @@ const executedCommandSchema = z.object({
1179
1188
  }).optional(),
1180
1189
  durationMs: z.number().nonnegative(),
1181
1190
  exitCode: z.number(),
1191
+ failures: z.array(prVerifyTestFailureSchema).min(1).optional(),
1182
1192
  name: z.string().min(1),
1183
1193
  scope: z.enum([
1184
1194
  "docs-only",
@@ -1614,6 +1624,7 @@ const prReadyProofSchema = z.object({
1614
1624
  "not-armed"
1615
1625
  ])
1616
1626
  }).optional(),
1627
+ authoringSession: z.string().min(1).optional(),
1617
1628
  blockedReasons: blockedReasonsSchema.optional(),
1618
1629
  blockingReasons: z.array(z.string()),
1619
1630
  command: z.literal("patronage-factory pr:ready"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patronage/software-factory",
3
- "version": "1.0.0-alpha.28",
3
+ "version": "1.0.0-alpha.29",
4
4
  "description": "Shared Patronage software factory CLI and project-profile validation tools",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,7 +37,7 @@
37
37
  "picomatch": "^4.0.5",
38
38
  "yaml": "^2.9.0",
39
39
  "zod": "4.4.3",
40
- "@patronage/factory-ci": "1.0.0-alpha.28"
40
+ "@patronage/factory-ci": "1.0.0-alpha.29"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "24.13.3",