@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/CONTEXT.md +3 -3
- package/README.md +15 -7
- package/dist/index.d.ts +1160 -686
- package/dist/index.js +6343 -4458
- package/dist/schemas.d.ts +275 -21
- package/dist/schemas.js +490 -4
- package/package.json +1 -1
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.
|
|
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 =
|
|
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:
|
|
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 };
|