@patronage/software-factory 0.20.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/schemas.js CHANGED
@@ -1,4 +1,235 @@
1
1
  import { z } from "zod";
2
+ //#region src/review-rungs.ts
3
+ const EVIDENCE_REVIEW_RUNGS$1 = [
4
+ "independent-model",
5
+ "oracle",
6
+ "human"
7
+ ];
8
+ Object.fromEntries(EVIDENCE_REVIEW_RUNGS$1.map((rung, index) => [rung, index]));
9
+ //#endregion
10
+ //#region src/demand-keys.ts
11
+ /** The demands that exist at most once per candidate. */
12
+ const DEMAND_KEYS = {
13
+ /** The PR is still a draft. */
14
+ draft: "draft",
15
+ /** The candidate is not the intended final human review point. */
16
+ finalReviewPoint: "final-review-point",
17
+ /** GitHub's own check rollup for the candidate head. */
18
+ githubChecks: "github-checks",
19
+ /** Local HEAD and the GitHub PR head must be the same commit. */
20
+ headIdentity: "head-identity",
21
+ /** An unhandled post-readiness human comment or review submission. */
22
+ humanBlocker: "human-blocker",
23
+ /** The repository-wide merge freeze. */
24
+ mergeFreeze: "merge-freeze",
25
+ /** GitHub's mergeability / merge-state rollup. */
26
+ mergeState: "merge-state",
27
+ /** The PR body's required rendered sections. */
28
+ prBodySections: "pr-body-sections",
29
+ /** A current, head-bound, passing typed `pr:verify` proof. */
30
+ prVerify: "pr-verify",
31
+ /** The profile-resolved review ladder policy. */
32
+ reviewLadder: "review-ladder",
33
+ /** Unresolved GitHub review threads. */
34
+ reviewThreads: "review-threads",
35
+ /** An explicit trivial waiver that the diff does not support. */
36
+ trivialWaiver: "trivial-waiver"
37
+ };
38
+ /** The families whose instances are named by a resolved value. */
39
+ const QUALIFIED_DEMAND_FAMILIES = [
40
+ "required-check",
41
+ "review-mode",
42
+ "review-rung"
43
+ ];
44
+ const QUALIFIER_PATTERN = /^[A-Za-z0-9._%/-]{1,64}$/u;
45
+ const percentEncode = (character) => {
46
+ const code = character.codePointAt(0) ?? 0;
47
+ if (code >= 55296 && code <= 57343) return `%u${code.toString(16).toUpperCase().padStart(4, "0")}`;
48
+ return [...new TextEncoder().encode(character)].map((byte) => `%${byte.toString(16).toUpperCase().padStart(2, "0")}`).join("");
49
+ };
50
+ /**
51
+ * Express a resolved value as a qualifier. Percent-encoding, not scrubbing:
52
+ * the mapping is injective, so two differently named required checks can never
53
+ * collapse into one key — which would let one operator waiver silently cover a
54
+ * demand nobody waived, and make HQ count two causes as one.
55
+ *
56
+ * Deterministic in both directions of use: the key a `pr:ready` proof records
57
+ * is the key a `pr:merge-check` waiver matches.
58
+ */
59
+ const demandQualifier = (value) => value.replaceAll(/[^A-Za-z0-9._/-]/gu, percentEncode);
60
+ const FIXED_DEMAND_KEYS = Object.values(DEMAND_KEYS);
61
+ /** The review modes a profile can resolve, and therefore demand. */
62
+ const REVIEW_MODE_DEMAND_VALUES = ["correctness", "security"];
63
+ const escapePattern = /%(?:u[0-9A-F]{4}|[0-9A-F]{2})/gu;
64
+ /**
65
+ * Reverse `demandQualifier`. A qualifier is canonical exactly when encoding
66
+ * its decoded form reproduces it — which rejects both malformed escapes and
67
+ * noncanonical aliases like `%41` for `A`. Returns undefined when the
68
+ * qualifier cannot have been minted here.
69
+ */
70
+ const decodeQualifier = (qualifier) => {
71
+ const bytes = [];
72
+ let decoded = "";
73
+ let index = 0;
74
+ const flush = () => {
75
+ if (bytes.length === 0) return true;
76
+ try {
77
+ decoded += new TextDecoder("utf-8", {
78
+ fatal: true,
79
+ ignoreBOM: true
80
+ }).decode(Uint8Array.from(bytes));
81
+ } catch {
82
+ return false;
83
+ }
84
+ bytes.length = 0;
85
+ return true;
86
+ };
87
+ while (index < qualifier.length) {
88
+ const character = qualifier[index];
89
+ if (character !== "%") {
90
+ if (!flush()) return;
91
+ decoded += character;
92
+ index += 1;
93
+ continue;
94
+ }
95
+ escapePattern.lastIndex = index;
96
+ const escape = escapePattern.exec(qualifier);
97
+ if (!escape || escape.index !== index) return;
98
+ if (escape[0][1] === "u") {
99
+ if (!flush()) return;
100
+ decoded += String.fromCodePoint(Number.parseInt(escape[0].slice(2), 16));
101
+ } else bytes.push(Number.parseInt(escape[0].slice(1), 16));
102
+ index += escape[0].length;
103
+ }
104
+ return flush() ? decoded : void 0;
105
+ };
106
+ const qualifierIsMintable = (family, qualifier) => {
107
+ if (!QUALIFIER_PATTERN.test(qualifier)) return false;
108
+ const decoded = decodeQualifier(qualifier);
109
+ if (decoded === void 0 || demandQualifier(decoded) !== qualifier) return false;
110
+ if (family === "review-rung") return EVIDENCE_REVIEW_RUNGS$1.includes(decoded);
111
+ if (family === "review-mode") return REVIEW_MODE_DEMAND_VALUES.includes(decoded);
112
+ return true;
113
+ };
114
+ /**
115
+ * A demand key from the closed vocabulary: a known unqualified family, or a
116
+ * known qualified family with a qualifier this module could actually have
117
+ * minted. Closed on purpose — an invented family, an unresolvable rung, or a
118
+ * noncanonical encoding would be indistinguishable from a typo to anyone
119
+ * counting causes, which is the whole point of recording codes.
120
+ */
121
+ const demandKeySchema = z.string().refine((value) => {
122
+ if (FIXED_DEMAND_KEYS.includes(value)) return true;
123
+ const separator = value.indexOf(":");
124
+ if (separator === -1) return false;
125
+ const family = value.slice(0, separator);
126
+ return QUALIFIED_DEMAND_FAMILIES.includes(family) && qualifierIsMintable(family, value.slice(separator + 1));
127
+ }, { message: `a demand key is one of ${FIXED_DEMAND_KEYS.join(", ")} or a resolvable ${QUALIFIED_DEMAND_FAMILIES.join(" / ")} key` });
128
+ /**
129
+ * The key shape an operator may *name* in a waiver: syntactic, not closed.
130
+ *
131
+ * Deliberately distinct from `demandKeySchema` above, which is what a proof's
132
+ * reason codes must come from. A waiver store is durable operator evidence
133
+ * written before today's vocabulary existed, so tightening its validation
134
+ * would make an existing record parse as no waiver and let the next write drop
135
+ * it. A waiver naming a key nobody resolves is already inert — `pr:merge-check`
136
+ * reports it as an unapplied waiver — so nothing is admitted by accepting it.
137
+ */
138
+ const waiverDemandKeySchema = z.string().regex(/^[a-z][a-z0-9-]*(?::[A-Za-z0-9._%/-]+)?$/u, "A demand key is a lowercase family, optionally qualified by its resolved value (e.g. review-rung:human).");
139
+ //#endregion
140
+ //#region src/blocked-reasons.ts
141
+ /**
142
+ * The wire bound on one `detail`. A blocked proof carries one short sentence
143
+ * per demand, never a transcript: HQ's ingest cap is 256 KB and this field
144
+ * must stay far under it even when a candidate blocks on every gate at once.
145
+ */
146
+ const BLOCKED_REASON_DETAIL_MAX = 280;
147
+ /**
148
+ * The wire bound on how many demands one proof may name. Well past any real
149
+ * candidate (there are a dozen fixed demands plus the profile's checks and the
150
+ * live human blockers) and, with the detail bound, keeps the whole field two
151
+ * orders of magnitude under the ingest cap. Bounded here rather than trimmed
152
+ * downstream: HQ rejects an over-long list loudly instead of storing a quietly
153
+ * truncated one.
154
+ */
155
+ const BLOCKED_REASONS_MAX = 100;
156
+ /** One refusal sentence: single line, trimmed, bounded. */
157
+ const blockedReasonDetailSchema = z.string().min(1).max(280).refine((value) => value.trim().length > 0, { message: "must not be blank" }).refine((value) => !/[\r\n]/u.test(value), { message: "must be one line, not a multi-line payload" });
158
+ const blockedReasonSchema = z.object({
159
+ code: demandKeySchema,
160
+ detail: blockedReasonDetailSchema
161
+ });
162
+ const blockedReasonsSchema = z.array(blockedReasonSchema).max(100);
163
+ /** Fit one refusal sentence to the wire bound without losing its head. */
164
+ const blockedReasonDetail = (reason) => {
165
+ const line = reason.replaceAll(/\s+/gu, " ").trim();
166
+ return line.length <= 280 ? line : `${line.slice(0, 279)}…`;
167
+ };
168
+ const blockedReasonIssue = (message) => [{
169
+ code: "custom",
170
+ message,
171
+ path: ["blockedReasons"]
172
+ }];
173
+ /**
174
+ * The invariant every reader enforces, shared by the emitting schema and the
175
+ * wire schema so it is one rule rather than two that can drift. It is the
176
+ * producer's state machine, written down:
177
+ *
178
+ * - `ready` refused nothing, so it carries neither projection, and it is the
179
+ * final review point;
180
+ * - `slice-ready/not-final` was held back by exactly one demand — being a
181
+ * slice — and names it;
182
+ * - `blocked` refused something other than being a slice, and says so;
183
+ * - whichever it is, `blockedReasons` names every refusal listed in
184
+ * `blockingReasons`, in the same order, as the bounded form of that sentence.
185
+ *
186
+ * Naming the slice demand and the ledger's `finalReviewPoint` are the same
187
+ * fact, so a proof that says one and not the other is refused.
188
+ */
189
+ const blockedReasonIssues = (proof) => {
190
+ const named = proof.blockedReasons ?? [];
191
+ const reasons = proof.blockingReasons ?? [];
192
+ const slice = DEMAND_KEYS.finalReviewPoint;
193
+ const namesSlice = named.some((reason) => reason.code === slice);
194
+ if (proof.status === "ready") {
195
+ if (named.length > 0 || reasons.length > 0) return blockedReasonIssue("a ready pr:ready proof must carry no blocking reasons");
196
+ } else if (proof.status === "slice-ready/not-final") {
197
+ if (named.length !== 1 || !namesSlice) return blockedReasonIssue(`a slice-ready/not-final pr:ready proof is held back by exactly one demand, ${slice}`);
198
+ } else if (reasons.length === 0) return blockedReasonIssue("a blocked pr:ready proof must record what blocked it");
199
+ else if (named.length > 0 && !named.some((r) => r.code !== slice)) return blockedReasonIssue(`a blocked pr:ready proof must name a demand other than ${slice}`);
200
+ if (proof.finalReviewPoint !== void 0 && namesSlice === proof.finalReviewPoint) return blockedReasonIssue(`naming ${slice} and the ledger's finalReviewPoint are the same fact; this proof says both`);
201
+ if (named.length !== reasons.length) return blockedReasonIssue(`blockedReasons must name every blocking reason: ${reasons.length} reason(s), ${named.length} named`);
202
+ const drifted = named.findIndex((reason, index) => reason.detail !== blockedReasonDetail(reasons[index]));
203
+ return drifted === -1 ? [] : blockedReasonIssue(`blockedReasons[${drifted}] does not carry blocking reason ${drifted}; the two projections must tell one story`);
204
+ };
205
+ //#endregion
206
+ //#region src/demand-waiver.ts
207
+ const DEMAND_WAIVER_SCHEMA_VERSION = 1;
208
+ const shaSchema = z.string().regex(/^[0-9a-f]{40}$/u);
209
+ const demandWaiverSchema = z.object({
210
+ candidate: z.object({
211
+ headSha: shaSchema,
212
+ pr: z.number().int().positive()
213
+ }),
214
+ demand: waiverDemandKeySchema,
215
+ operator: z.string().trim().min(1),
216
+ rationale: z.string().trim().min(1),
217
+ recordedAt: z.iso.datetime(),
218
+ session: z.string().trim().min(1)
219
+ });
220
+ z.object({
221
+ command: z.literal("patronage-factory demand:waive"),
222
+ schemaVersion: z.literal(DEMAND_WAIVER_SCHEMA_VERSION),
223
+ waivers: z.array(demandWaiverSchema)
224
+ });
225
+ const waivedDemandSchema = z.object({
226
+ demand: waiverDemandKeySchema,
227
+ operator: z.string().trim().min(1),
228
+ rationale: z.string().trim().min(1),
229
+ recordedAt: z.iso.datetime(),
230
+ session: z.string().trim().min(1),
231
+ unmetReasons: z.array(z.string().min(1)).min(1)
232
+ });
2
233
  const MAX_CLOSEOUT_ROWS = 1e3;
3
234
  const IdentifierSchema = z.string().min(1).max(500);
4
235
  const NarrativeSchema = z.string().min(1).max(1e4);
@@ -103,6 +334,239 @@ const closeoutArtifactSchema = z.object({
103
334
  schemaVersion: z.literal(3)
104
335
  }).strict();
105
336
  //#endregion
337
+ //#region src/retro-envelope.ts
338
+ /**
339
+ * Versioned retro envelope schema (epic #27 wave 2, issue #34).
340
+ *
341
+ * One envelope per lane, built at `factory:closeout` and delivered through the
342
+ * typed gate-sink as the `retro-envelope` ingest kind. Re-derived in TypeScript
343
+ * from the `spike/telemetry-layer2` S5 scratch schema (reference-only, never
344
+ * merged). This module is the single source of truth for the v1 wire shape
345
+ * and its bounds ({@link RETRO_ENVELOPE_WIRE_BOUNDS}) — producer and consumer
346
+ * alike. HQ imports these exports directly from the Worker-safe
347
+ * `@patronage/software-factory/schemas` subpath
348
+ * (`software-factory-hq/src/contracts/retro-schemas.ts`) instead of
349
+ * maintaining a parallel hand-written copy, so there is exactly one wire
350
+ * contract and no drift-detection machinery is needed (issue #350; formerly
351
+ * a hand-written twin plus a 767-line parity test, #46).
352
+ *
353
+ * DESIGN INVARIANT: cross-family token sums must be UNREPRESENTABLE.
354
+ *
355
+ * The two model families use different tokenizers, prices, and accounting
356
+ * conventions, so any token total that spans Claude and GPT is a lie:
357
+ *
358
+ * 1. There is no combined/total token field anywhere in the envelope.
359
+ * 2. `tokenFamilies` is strict — its only keys are `claude` and `gpt`; data
360
+ * cannot smuggle in a third "all"/"combined" slot.
361
+ * 3. The family BLOCKS are structurally different shapes with DIFFERENT keys
362
+ * (claude is a single flat block keyed on freshInput/cacheReadInput/
363
+ * cacheCreationInput; gpt is `{ roles: [...] }`). The exclusive input-tier
364
+ * COUNTS share no key name across families. The residual names shared
365
+ * between the claude block and a gpt ROLE entry are pinned to exactly
366
+ * {costUsd, model, output} (PR #32 advisory): `costUsd` is deliberate —
367
+ * USD is the one cross-family summable unit (rule 4); `model` is an
368
+ * unsummable label; `output` is the same name at DIFFERENT depths (lane
369
+ * block vs per-role entry), frozen by a tripwire test in
370
+ * `retro-envelope.test.ts` so the overlap cannot grow. Renaming `output`
371
+ * is a schemaVersion-2 wire change, deliberately not spent in v1.
372
+ * 4. Cost is per-family USD and nullable. Combined totals are allowed in USD
373
+ * only, and only as a projection-time sum of per-family USD.
374
+ *
375
+ * Field names also encode the S2/S3 reader lessons: Claude `freshInput` alone
376
+ * is not prompt size (true input context = freshInput + cacheReadInput +
377
+ * cacheCreationInput, requestId-deduped), and codex `inputInclusiveOfCache`
378
+ * already includes `cachedInput`, so `freshInputDerived` (inclusive − cached)
379
+ * is the only value safe to feed a per-token pricer.
380
+ *
381
+ * COMPLETENESS POSTURE: harvest may have no usable native log for a lane, so
382
+ * `tokenFamilies` may legitimately be absent. The closeout build gate demands
383
+ * a valid envelope, not available telemetry. A families-absent envelope keeps
384
+ * its operator-visible data gaps and is a replayable advisory HQ event, so it
385
+ * never substitutes unavailable usage with zero. The wire shape (field names,
386
+ * types, structure) stays byte-parity with HQ v1.
387
+ */
388
+ const RETRO_ENVELOPE_SCHEMA_VERSION = 1;
389
+ /**
390
+ * v1 wire bounds — the single source the schemas below are built from and the
391
+ * builder's sanitization seam clamps to (`retro-envelope-builder.ts` imports
392
+ * these; it keeps no bound constants of its own). HQ imports this module
393
+ * directly (issue #350), so there is one set of bounds, not a second copy to
394
+ * keep in sync.
395
+ */
396
+ const RETRO_ENVELOPE_WIRE_BOUNDS = {
397
+ /** archiveRef pointer (key/URL) max characters. */
398
+ archiveRefMaxChars: 2048,
399
+ /** refs.branch max characters (git ref length ceiling). */
400
+ branchMaxChars: 255,
401
+ /** gates[] wire cap — the builder keeps the most recent records. */
402
+ gatesMax: 500,
403
+ /** gpt roles[] cap (delegated roles per lane). */
404
+ gptRolesMax: 20,
405
+ /** Cap for list fields: joinKeys id arrays, phases, dataGaps. */
406
+ listMax: 100,
407
+ /** Bounded name fields: agentRunId, gate, phase name, refs, join-key ids. */
408
+ nameMaxChars: 200,
409
+ /** Short identifier fields: repo name/owner, model, role. */
410
+ shortMaxChars: 100,
411
+ /** Free-text fields: dataGaps entries, outcome.verdict. */
412
+ textMaxChars: 500
413
+ };
414
+ const nonNegInt = z.number().int().nonnegative();
415
+ const isoTimestamp = z.iso.datetime();
416
+ const boundedName = z.string().min(1).max(RETRO_ENVELOPE_WIRE_BOUNDS.nameMaxChars);
417
+ const boundedShortName = z.string().min(1).max(RETRO_ENVELOPE_WIRE_BOUNDS.shortMaxChars);
418
+ const boundedText = z.string().min(1).max(RETRO_ENVELOPE_WIRE_BOUNDS.textMaxChars);
419
+ const RetroRepoSchema = z.object({
420
+ name: boundedShortName,
421
+ owner: boundedShortName
422
+ }).strict();
423
+ const RetroRefsSchema = z.object({
424
+ branch: z.string().min(1).max(RETRO_ENVELOPE_WIRE_BOUNDS.branchMaxChars).optional(),
425
+ epic: boundedName.optional(),
426
+ headSha: z.string().regex(/^[0-9a-f]{7,40}$/u).optional(),
427
+ issue: boundedName.optional(),
428
+ prNumber: z.number().int().positive().optional()
429
+ }).strict();
430
+ /**
431
+ * Per-lane join keys: how the envelope re-joins raw per-session sources.
432
+ * `agentRunId` lives at the envelope root; these carry the per-family session
433
+ * identities (Claude `session.id`, codex `threadId`).
434
+ */
435
+ const RetroJoinKeysSchema = z.object({
436
+ claudeSessionIds: z.array(boundedName).max(RETRO_ENVELOPE_WIRE_BOUNDS.listMax).default([]),
437
+ codexThreadIds: z.array(boundedName).max(RETRO_ENVELOPE_WIRE_BOUNDS.listMax).default([])
438
+ }).strict();
439
+ const RetroWallClockSchema = z.object({
440
+ endTs: isoTimestamp,
441
+ startTs: isoTimestamp,
442
+ totalSec: z.number().nonnegative()
443
+ }).strict();
444
+ const RetroPhaseMarkSchema = z.object({
445
+ at: isoTimestamp,
446
+ deltaSec: z.number().nonnegative().optional(),
447
+ name: boundedName
448
+ }).strict();
449
+ /** Gate-timing ledger entry: `{gate, startedAt, duration, outcome, cycle}`. */
450
+ const RetroGateLedgerEntrySchema = z.object({
451
+ cycle: nonNegInt,
452
+ duration: z.number().nonnegative(),
453
+ gate: boundedName,
454
+ outcome: z.enum([
455
+ "pass",
456
+ "fail",
457
+ "skip"
458
+ ]),
459
+ startedAt: isoTimestamp
460
+ }).strict();
461
+ /**
462
+ * Claude family: Anthropic-style EXCLUSIVE input tiers. True input context is
463
+ * freshInput + cacheReadInput + cacheCreationInput; values are
464
+ * requestId-deduped (S2: naive row sums overcount input ~1.94x).
465
+ */
466
+ const claudeTokenBlockSchema = z.object({
467
+ cacheCreationInput: nonNegInt,
468
+ cacheReadInput: nonNegInt,
469
+ costUsd: z.number().nonnegative().nullable(),
470
+ family: z.literal("claude"),
471
+ freshInput: nonNegInt,
472
+ model: boundedShortName,
473
+ output: nonNegInt,
474
+ requests: nonNegInt
475
+ }).strict();
476
+ /**
477
+ * GPT/codex family, per delegated role. codex reports `input_tokens`
478
+ * INCLUSIVE of cached tokens; `freshInputDerived` = inclusive − cached is the
479
+ * only value safe for a per-token pricer (S3: skipping this overprices ~3.9x).
480
+ */
481
+ const gptRoleUsageSchema = z.object({
482
+ cachedInput: nonNegInt,
483
+ costUsd: z.number().nonnegative().nullable(),
484
+ freshInputDerived: nonNegInt,
485
+ inputInclusiveOfCache: nonNegInt,
486
+ model: boundedShortName,
487
+ output: nonNegInt,
488
+ reasoningOutput: nonNegInt,
489
+ role: boundedShortName,
490
+ threadId: boundedName.optional()
491
+ }).strict().refine((usage) => usage.freshInputDerived === usage.inputInclusiveOfCache - usage.cachedInput, { message: "freshInputDerived must equal inputInclusiveOfCache - cachedInput (S3 accounting rule)" });
492
+ const gptTokenBlockSchema = z.object({
493
+ family: z.literal("gpt"),
494
+ roles: z.array(gptRoleUsageSchema).min(1).max(RETRO_ENVELOPE_WIRE_BOUNDS.gptRolesMax)
495
+ }).strict();
496
+ /**
497
+ * Strict: only `claude` and `gpt`, both optional, no third slot.
498
+ *
499
+ * A lane may have no usable native session data. The build gate accepts that
500
+ * state and the advisory sink journals the valid envelope with its recorded
501
+ * gaps, without inventing usage.
502
+ */
503
+ const retroTokenFamiliesSchema = z.object({
504
+ claude: claudeTokenBlockSchema.optional(),
505
+ gpt: gptTokenBlockSchema.optional()
506
+ }).strict();
507
+ const RetroCycleCountersSchema = z.object({
508
+ gateRunsToFirstGreen: nonNegInt,
509
+ reviewerFixRounds: nonNegInt,
510
+ thermoFixRounds: nonNegInt
511
+ }).strict();
512
+ const RetroOutcomeSchema = z.object({
513
+ mergeCheck: z.enum([
514
+ "pass",
515
+ "fail",
516
+ "not-run"
517
+ ]).optional(),
518
+ status: z.enum([
519
+ "success",
520
+ "blocked",
521
+ "fail",
522
+ "ship-with-followups"
523
+ ]),
524
+ verdict: boundedText.optional()
525
+ }).strict();
526
+ const retroEnvelopeV1Schema = z.object({
527
+ agentRunId: boundedName,
528
+ /** Pointer (key/URL) to #8's durable proof archive — never the payload. */
529
+ archiveRef: z.string().min(1).max(RETRO_ENVELOPE_WIRE_BOUNDS.archiveRefMaxChars).optional(),
530
+ cycles: RetroCycleCountersSchema,
531
+ dataGaps: z.array(boundedText).max(RETRO_ENVELOPE_WIRE_BOUNDS.listMax).default([]),
532
+ gates: z.array(RetroGateLedgerEntrySchema).max(RETRO_ENVELOPE_WIRE_BOUNDS.gatesMax),
533
+ generatedAt: isoTimestamp,
534
+ interventions: z.object({ count: nonNegInt }).strict(),
535
+ joinKeys: RetroJoinKeysSchema,
536
+ kind: z.literal("retro-envelope"),
537
+ outcome: RetroOutcomeSchema.optional(),
538
+ phases: z.array(RetroPhaseMarkSchema).max(RETRO_ENVELOPE_WIRE_BOUNDS.listMax),
539
+ refs: RetroRefsSchema,
540
+ repo: RetroRepoSchema,
541
+ schemaVersion: z.literal(1),
542
+ tokenFamilies: retroTokenFamiliesSchema,
543
+ wallClock: RetroWallClockSchema
544
+ }).strict().refine((envelope) => envelope.refs.epic !== void 0 || envelope.refs.issue !== void 0 || envelope.refs.prNumber !== void 0, { message: "refs must anchor to an epic, issue, or PR" });
545
+ /**
546
+ * Version 1 envelopes written before #170 carried an inert `harness` label.
547
+ * Accept that exact historical field and normalize it away. The current strict
548
+ * schema above neither emits nor exposes it, and all other unknown fields still
549
+ * fail validation.
550
+ */
551
+ const retroEnvelopeV1ReaderSchema = z.preprocess((candidate) => {
552
+ if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate) || !("harness" in candidate)) return candidate;
553
+ const record = candidate;
554
+ if (!boundedShortName.safeParse(record.harness).success) return candidate;
555
+ const { harness: _historicalHarness, ...current } = record;
556
+ return current;
557
+ }, retroEnvelopeV1Schema);
558
+ /**
559
+ * Versioned payload validators, keyed by schema major. Unknown majors never
560
+ * reach these — {@link parseRetroEnvelope} returns them raw and marked
561
+ * degraded, mirroring HQ's ingest skew posture (stored raw, never dropped).
562
+ */
563
+ const RETRO_ENVELOPE_VALIDATORS = { [1]: retroEnvelopeV1ReaderSchema };
564
+ const SUPPORTED_RETRO_ENVELOPE_SCHEMA_VERSIONS = Object.keys(RETRO_ENVELOPE_VALIDATORS).map(Number);
565
+ /** Epic anchor for an envelope: refs.epic, else refs.issue, else the PR. */
566
+ const retroEpicReference = (refs) => {
567
+ return (refs.epic ?? refs.issue ?? (refs.prNumber === void 0 ? void 0 : `pr#${refs.prNumber}`))?.replace(/^#/u, "");
568
+ };
569
+ //#endregion
106
570
  //#region src/schemas.ts
107
571
  const EVIDENCE_CHECK_TYPES = ["review", "verify"];
108
572
  const EVIDENCE_REVIEW_RUNGS = [
@@ -411,7 +875,7 @@ const prReviewProofSchema = z.object({
411
875
  patchId: z.string().regex(/^[0-9a-f]{40,64}$/u),
412
876
  reviewCycle: z.number().int().positive().optional(),
413
877
  reviewRequirement: z.object({
414
- reason: z.literal("docs-only-profile-bypass"),
878
+ reason: z.enum(["no-applicable-mode"]),
415
879
  status: z.literal("not-required")
416
880
  }).strict().optional(),
417
881
  reviews: z.array(prReviewResultSchema),
@@ -561,6 +1025,7 @@ const prMergeCheckProofSchema = z.object({
561
1025
  pr: z.number().int().positive(),
562
1026
  schemaVersion: z.literal(1),
563
1027
  status: z.enum(["pass", "fail"]),
1028
+ waivedDemands: z.array(waivedDemandSchema).optional(),
564
1029
  worktreeHeldBranch: z.object({
565
1030
  branch: z.string().min(1),
566
1031
  worktreePath: z.string().min(1)
@@ -749,8 +1214,16 @@ const managedReadinessLedgerSchema = z.object({
749
1214
  verifiedHeadSha: z.string().optional()
750
1215
  })
751
1216
  });
752
- const PR_READY_SCHEMA_VERSION = 1;
1217
+ const PR_READY_SCHEMA_VERSION = 2;
1218
+ /**
1219
+ * The pr:ready proof versions a reader still accepts. `pr:ready` emits v2 only
1220
+ * (#391) — one current contract — but v1 events were spooled before the bump
1221
+ * and HQ must ingest them without degrading, so the wire schema parses both.
1222
+ */
1223
+ const SUPPORTED_PR_READY_SCHEMA_VERSIONS = [1, 2];
1224
+ const prReadySchemaVersionSchema = z.number().refine((value) => SUPPORTED_PR_READY_SCHEMA_VERSIONS.includes(value), { message: `schemaVersion must be one of: ${SUPPORTED_PR_READY_SCHEMA_VERSIONS.join(", ")}` });
753
1225
  const prReadyProofSchema = z.object({
1226
+ blockedReasons: blockedReasonsSchema.optional(),
754
1227
  blockingReasons: z.array(z.string()),
755
1228
  command: z.literal("patronage-factory pr:ready"),
756
1229
  followUp: followUpActionSchema.optional(),
@@ -760,12 +1233,25 @@ const prReadyProofSchema = z.object({
760
1233
  profilePath: z.string().min(1).optional(),
761
1234
  repairs: z.array(readinessRepairSchema).default([]),
762
1235
  repository: z.string().regex(/^[^/\s]+\/[^/\s]+$/u).optional(),
763
- schemaVersion: z.literal(1),
1236
+ schemaVersion: prReadySchemaVersionSchema,
764
1237
  status: z.enum([
765
1238
  "ready",
766
1239
  "blocked",
767
1240
  "slice-ready/not-final"
768
1241
  ])
1242
+ }).superRefine((proof, context) => {
1243
+ if (proof.schemaVersion < 2) {
1244
+ if (proof.blockedReasons !== void 0) context.addIssue({
1245
+ code: "custom",
1246
+ message: "blockedReasons is a schemaVersion 2 field; a v1 pr:ready proof must not carry it.",
1247
+ path: ["blockedReasons"]
1248
+ });
1249
+ return;
1250
+ }
1251
+ for (const issue of blockedReasonIssues({
1252
+ ...proof,
1253
+ finalReviewPoint: proof.ledger.finalReviewPoint
1254
+ })) context.addIssue(issue);
769
1255
  });
770
1256
  //#endregion
771
- export { BOUNDARY_CHECK_SCHEMA_VERSION, BOUNDARY_REVIEW_PROOF_KIND, EVIDENCE_ENVELOPE_SCHEMA_VERSION, PR_MERGE_CHECK_SCHEMA_VERSION, PR_READY_SCHEMA_VERSION, PR_REVIEW_SCHEMA_VERSION, SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS, boundaryCheckProofSchema, boundaryReviewProofSchema, closeoutArtifactSchema, evidenceEnvelopeSchema, prMergeCheckProofSchema, prReadyProofSchema, prReviewProofSchema, prVerifyProofSchema };
1257
+ export { BLOCKED_REASONS_MAX, BLOCKED_REASON_DETAIL_MAX, BOUNDARY_CHECK_SCHEMA_VERSION, BOUNDARY_REVIEW_PROOF_KIND, EVIDENCE_ENVELOPE_SCHEMA_VERSION, PR_MERGE_CHECK_SCHEMA_VERSION, PR_READY_SCHEMA_VERSION, PR_REVIEW_SCHEMA_VERSION, RETRO_ENVELOPE_SCHEMA_VERSION, RETRO_ENVELOPE_VALIDATORS, RETRO_ENVELOPE_WIRE_BOUNDS, SUPPORTED_PR_READY_SCHEMA_VERSIONS, SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS, SUPPORTED_RETRO_ENVELOPE_SCHEMA_VERSIONS, blockedReasonSchema, blockedReasonsSchema, boundaryCheckProofSchema, boundaryReviewProofSchema, closeoutArtifactSchema, evidenceEnvelopeSchema, prMergeCheckProofSchema, prReadyProofSchema, prReviewProofSchema, prVerifyProofSchema, retroEnvelopeV1Schema, retroEpicReference, waivedDemandSchema };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patronage/software-factory",
3
- "version": "0.20.0",
3
+ "version": "0.25.0",
4
4
  "description": "Shared Patronage software factory CLI and project-profile validation tools",
5
5
  "license": "MIT",
6
6
  "repository": {