@dev-loops/core 1.0.0-rc.4 → 1.0.0-rc.6

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.
@@ -0,0 +1,595 @@
1
+ /**
2
+ * review-dispatch-plan.mjs — cache-aware review dispatch plan + request-prefix
3
+ * fingerprinting + stable/volatile request separation (issue #1468 slices 1-2).
4
+ *
5
+ * This module is the mechanically-checkable foundation for the cache-efficient
6
+ * review dispatch design (Section A/B/C/D of the #1468 spec). It owns:
7
+ *
8
+ * 1. Harness capability model — explicit representation of what a harness can
9
+ * observe/control about provider prompt caching
10
+ * (breakpointControl / barrierSignal / cacheTtlControl / usageTelemetry).
11
+ * Opaque capabilities are represented as such and are NEVER described as
12
+ * verified cache hits.
13
+ * 2. Request-prefix fingerprinting — a deterministic sha256 over every
14
+ * cache-relevant value the dev-loops layer observes or controls (concrete
15
+ * model, tool definitions/order, system/project/agent instructions,
16
+ * thinking/tool-choice settings, content-block boundaries, shared artifact
17
+ * bytes, and breakpoint/TTL intent). Values owned opaquely by a harness are
18
+ * represented as a placeholder, never assumed identical.
19
+ * 3. Stable/volatile request separation — physically separates stable handoff
20
+ * content from volatile `gateState` at the request/artifact boundary, so a
21
+ * provider-visible cache boundary sits after the stable materialized
22
+ * briefing block and before the late volatile tail + angle suffix.
23
+ * 4. Dispatch-plan builder — one deterministic per-gate-round artifact that
24
+ * records the complete cache-relevant request shape without duplicating
25
+ * briefing content (Section A).
26
+ * 5. Primer-form default — deterministic default by harness capability
27
+ * (Section C/D): first-output-observable harnesses may let a lead reviewer
28
+ * prime; completion-only harnesses default to a short dedicated primer
29
+ * unless an adequate TTL is explicit; multiple concrete models partition
30
+ * into one primer group per model/request-prefix.
31
+ *
32
+ * This module is pure and offline: no GitHub, no harness, no clock. All runtime
33
+ * execution adapters consume it; it never executes a reviewer itself.
34
+ */
35
+ import { createHash } from "node:crypto";
36
+
37
+ /* ------------------------------------------------------------------ *
38
+ * 1. Harness capability model
39
+ * ------------------------------------------------------------------ */
40
+
41
+ /** Allowed values for each capability dimension (Section D). */
42
+ export const BREAKPOINT_CONTROL_VALUES = Object.freeze(["explicit", "automatic", "opaque"]);
43
+ export const BARRIER_SIGNAL_VALUES = Object.freeze(["first_output", "completion_only"]);
44
+ export const CACHE_TTL_CONTROL_VALUES = Object.freeze(["5m_1h", "fixed", "opaque"]);
45
+ export const USAGE_TELEMETRY_VALUES = Object.freeze(["available", "unavailable"]);
46
+
47
+ /** Allowed breakpoint/TTL intents a consumer may declare for a request group. */
48
+ export const TTL_INTENT_VALUES = Object.freeze(["5m", "1h", "harness_managed"]);
49
+
50
+ /** Cache boundary markers; the only current boundary is after the shared prefix. */
51
+ export const CACHE_BOUNDARY_AFTER_SHARED_PREFIX = "after_shared_prefix";
52
+ export const CACHE_BOUNDARY_VALUES = Object.freeze([CACHE_BOUNDARY_AFTER_SHARED_PREFIX]);
53
+
54
+ /**
55
+ * Default capability posture per harness name. These are conservative defaults:
56
+ * any harness whose provider-cache controls we cannot assert explicitly is
57
+ * represented with `opaque` / `unavailable` capabilities, which fail closed
58
+ * rather than over-claim. Consumers may pass explicit capabilities to
59
+ * {normalizeHarnessCapabilities}.
60
+ */
61
+ export const HARNESS_DEFAULT_CAPABILITIES = Object.freeze({
62
+ claude: Object.freeze({
63
+ breakpointControl: "automatic",
64
+ barrierSignal: "completion_only",
65
+ cacheTtlControl: "fixed",
66
+ usageTelemetry: "available",
67
+ }),
68
+ pi: Object.freeze({
69
+ breakpointControl: "opaque",
70
+ barrierSignal: "completion_only",
71
+ cacheTtlControl: "opaque",
72
+ usageTelemetry: "unavailable",
73
+ }),
74
+ });
75
+
76
+ const CAPABILITY_DIMENSIONS = [
77
+ ["breakpointControl", BREAKPOINT_CONTROL_VALUES],
78
+ ["barrierSignal", BARRIER_SIGNAL_VALUES],
79
+ ["cacheTtlControl", CACHE_TTL_CONTROL_VALUES],
80
+ ["usageTelemetry", USAGE_TELEMETRY_VALUES],
81
+ ];
82
+
83
+ /**
84
+ * Normalize + validate a harness capability object. Fails closed on unknown
85
+ * dimension names, unknown values, or a non-object. Returns a frozen canonical
86
+ * object.
87
+ *
88
+ * @param {object} input
89
+ * @param {string} [input.harness] - harness name; used only for the default merge
90
+ * (matches HARNESS_DEFAULT_CAPABILITIES keys, else fails closed on unknown).
91
+ * @param {object} [input.capabilities] - explicit per-dimension overrides.
92
+ * @returns {Readonly<Record<string,string>>} frozen capability object.
93
+ */
94
+ export function normalizeHarnessCapabilities({ harness, capabilities } = {}) {
95
+ let base = {};
96
+ if (harness != null) {
97
+ const key = String(harness).trim().toLowerCase();
98
+ if (!Object.prototype.hasOwnProperty.call(HARNESS_DEFAULT_CAPABILITIES, key)) {
99
+ throw new Error(
100
+ `Unknown harness ${JSON.stringify(harness)} — must be one of ${Object.keys(HARNESS_DEFAULT_CAPABILITIES).join(", ")} or supply explicit capabilities`,
101
+ );
102
+ }
103
+ base = HARNESS_DEFAULT_CAPABILITIES[key];
104
+ }
105
+ const merged = { ...base };
106
+ if (capabilities != null) {
107
+ if (typeof capabilities !== "object" || Array.isArray(capabilities)) {
108
+ throw new Error("capabilities must be an object of dimension -> value pairs");
109
+ }
110
+ for (const [dim, value] of Object.entries(capabilities)) {
111
+ const known = CAPABILITY_DIMENSIONS.find(([name]) => name === dim);
112
+ if (!known) {
113
+ throw new Error(`Unknown capability dimension ${JSON.stringify(dim)}`);
114
+ }
115
+ const [, allowed] = known;
116
+ if (!allowed.includes(value)) {
117
+ throw new Error(
118
+ `Invalid ${dim} ${JSON.stringify(value)} — must be one of ${allowed.join(", ")}`,
119
+ );
120
+ }
121
+ merged[dim] = value;
122
+ }
123
+ }
124
+ // Fail closed if any dimension was never resolved to a real value.
125
+ for (const [dim] of CAPABILITY_DIMENSIONS) {
126
+ if (merged[dim] == null) {
127
+ throw new Error(`Capability dimension ${dim} was not resolved (must be explicit or from a known harness)`);
128
+ }
129
+ }
130
+ return Object.freeze({
131
+ breakpointControl: merged.breakpointControl,
132
+ barrierSignal: merged.barrierSignal,
133
+ cacheTtlControl: merged.cacheTtlControl,
134
+ usageTelemetry: merged.usageTelemetry,
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Is this capability set honest about provider cache reuse? A harness whose
140
+ * usage telemetry is `unavailable` cannot claim verified `1 write + N reads`.
141
+ * This is the fail-closed honesty gate for telemetry evidence (Section D).
142
+ *
143
+ * @param {Readonly<Record<string,string>>} caps - normalized capabilities.
144
+ * @returns {{ verified: boolean, reason: string|null }}
145
+ */
146
+ export function cacheReuseVeracity(caps) {
147
+ if (!caps) return { verified: false, reason: "missing capability record" };
148
+ if (caps.usageTelemetry !== "available") {
149
+ return {
150
+ verified: false,
151
+ reason: `usageTelemetry=${caps.usageTelemetry} — provider reuse cannot be verified, only ordering + fingerprint invariants may be claimed`,
152
+ };
153
+ }
154
+ return { verified: true, reason: null };
155
+ }
156
+
157
+ /* ------------------------------------------------------------------ *
158
+ * 2. Request-prefix fingerprinting
159
+ * ------------------------------------------------------------------ */
160
+
161
+ const HEX = /^[0-9a-f]{64}$/;
162
+
163
+ /** @param {string} value @returns {boolean} */
164
+ function isSha256Hex(value) {
165
+ return typeof value === "string" && HEX.test(value.trim().toLowerCase());
166
+ }
167
+
168
+ /**
169
+ * Deterministic sha256 hex of a canonical-JSON serialization. Content may be a
170
+ * string, Buffer, or the live object; buffers are canonicalized by hex so a
171
+ * push/pull through memory vs disk yields identical bytes.
172
+ *
173
+ * @param {unknown} content
174
+ * @returns {string} `sha256:<64-hex>`
175
+ */
176
+ export function sha256Hex(content) {
177
+ const h = createHash("sha256");
178
+ if (Buffer.isBuffer(content)) {
179
+ h.update(content);
180
+ } else if (typeof content === "string") {
181
+ h.update(content);
182
+ } else {
183
+ // Deterministic canonical key ordering via stable stringify.
184
+ const canonical = stableStringify(content);
185
+ h.update(JSON.stringify(canonical));
186
+ }
187
+ return `sha256:${h.digest("hex")}`;
188
+ }
189
+
190
+ const isPlainObject = (v) =>
191
+ v != null && typeof v === "object" && !Array.isArray(v) && !Buffer.isBuffer(v);
192
+
193
+ /** Recursively sort object keys for a byte-deterministic serialization. */
194
+ function stableStringify(value) {
195
+ if (Array.isArray(value)) return value.map(stableStringify);
196
+ // Canonicalize nested Buffers to hex (the `__buffer:` prefix keeps a Buffer
197
+ // distinct from a string that happens to equal its hex, so bytes and text can
198
+ // never collide). Mirrors sha256Hex's top-level Buffer handling so a push/pull
199
+ // through memory vs disk yields identical bytes even for nested buffers.
200
+ if (Buffer.isBuffer(value)) return `__buffer:${value.toString("hex")}`;
201
+ if (isPlainObject(value)) {
202
+ const out = {};
203
+ for (const key of Object.keys(value).sort()) out[key] = stableStringify(value[key]);
204
+ return out;
205
+ }
206
+ return value;
207
+ }
208
+
209
+ /**
210
+ * Fingerprint the complete observable request prefix (Section A). Every
211
+ * cache-relevant value the dev-loops layer observes or controls is folded into
212
+ * the hash: concrete model, tool definitions/order, system/project/agent
213
+ * instructions, thinking/tool-choice settings, content-block boundaries, shared
214
+ * artifact bytes, and breakpoint/TTL intent. Values owned opaquely by a harness
215
+ * should be passed as `null`-free opaque markers (see `opaqueMarker`).
216
+ *
217
+ * @param {object} input
218
+ * @param {string} input.model - concrete model id for this request group.
219
+ * @param {string[]|object[]} [input.tools] - tool definitions/order.
220
+ * @param {string|string[]} [input.systemInstructions] - system/project/agent instructions.
221
+ * @param {object|string} [input.settings] - thinking/tool-choice settings.
222
+ * @param {object[]} [input.contentBlocks] - content-block boundaries + shared bytes.
223
+ * @param {string} [input.sharedArtifact] - shared artifact reference (path) or bytes.
224
+ * @param {string} [input.cacheBoundary] - e.g. `after_shared_prefix`.
225
+ * @param {string} [input.ttlIntent] - one of TTL_INTENT_VALUES.
226
+ * @param {string[]} [input.angleSuffix] - angle-specific suffix (excluded from the
227
+ * STABLE prefix fingerprint; included here only when invasive).
228
+ * @returns {{ fingerprint: string, canonical: object }}
229
+ */
230
+ export function fingerprintRequestPrefix(input) {
231
+ if (!input || typeof input !== "object") {
232
+ throw new Error("fingerprintRequestPrefix requires an object input");
233
+ }
234
+ if (typeof input.model !== "string" || input.model.trim().length === 0) {
235
+ throw new Error("fingerprintRequestPrefix requires a non-empty concrete model");
236
+ }
237
+ // Fail closed on invalid shapes (AC-2: the fingerprint covers the COMPLETE
238
+ // observable prefix). A silently-coerced non-array tools/contentBlocks would
239
+ // collapse two genuinely different request prefixes into one fingerprint, so
240
+ // reject rather than quietly omit.
241
+ if (input.tools != null && !Array.isArray(input.tools)) {
242
+ throw new Error("fingerprintRequestPrefix tools must be an array of tool definitions");
243
+ }
244
+ if (input.contentBlocks != null && !Array.isArray(input.contentBlocks)) {
245
+ throw new Error("fingerprintRequestPrefix contentBlocks must be an array");
246
+ }
247
+ // Fail closed on out-of-enum cacheBoundary/ttlIntent: a caller typo would
248
+ // fold an undefinable value into the fingerprint that no other caller could
249
+ // ever reproduce/validate, silently undermining the mechanically-checkable
250
+ // contract (AC-2 parity with validateRequestGroups).
251
+ const boundary = input.cacheBoundary ?? CACHE_BOUNDARY_AFTER_SHARED_PREFIX;
252
+ const ttl = input.ttlIntent ?? "harness_managed";
253
+ if (!CACHE_BOUNDARY_VALUES.includes(boundary)) {
254
+ throw new Error(`fingerprintRequestPrefix invalid cacheBoundary ${JSON.stringify(boundary)}`);
255
+ }
256
+ if (!TTL_INTENT_VALUES.includes(ttl)) {
257
+ throw new Error(`fingerprintRequestPrefix invalid ttlIntent ${JSON.stringify(ttl)}`);
258
+ }
259
+ const canonical = {
260
+ model: input.model.trim(),
261
+ tools: input.tools ?? [],
262
+ systemInstructions: input.systemInstructions ?? null,
263
+ settings: input.settings ?? null,
264
+ contentBlocks: input.contentBlocks ?? null,
265
+ sharedArtifact: input.sharedArtifact ?? null,
266
+ cacheBoundary: boundary,
267
+ ttlIntent: ttl,
268
+ // angleSuffix is excluded from the STABLE prefix fingerprint but is always
269
+ // folded into this full request-prefix fingerprint when present, so the
270
+ // claimed volatile difference stays assertable.
271
+ ...(input.angleSuffix != null ? { angleSuffix: input.angleSuffix } : {}),
272
+ };
273
+ return { fingerprint: sha256Hex(canonical), canonical };
274
+ }
275
+
276
+ /**
277
+ * Opaque placeholder for a value owned opaquely by the harness. Using this in a
278
+ * fingerprint input records that the value existed but was not byte-observable,
279
+ * so two runs that differ only in an unobservable harness value STILL collapse
280
+ * to the same fingerprint (they cannot be proven different) — and, symmetrically,
281
+ * a claimed difference under an opaque value is not assertable.
282
+ *
283
+ * @param {string} label
284
+ * @returns {string}
285
+ */
286
+ export function opaqueMarker(label) {
287
+ return `__opaque:${String(label)}`;
288
+ }
289
+
290
+ /* ------------------------------------------------------------------ *
291
+ * 3. Stable/volatile request separation
292
+ * ------------------------------------------------------------------ */
293
+
294
+ /**
295
+ * The stable-prefix fingerprint is computed ONLY over the stable prefix +
296
+ * materialized briefing block — never the volatile tail or angle suffix. This
297
+ * is the mechanical proof for AC-1: changing only `gateState` (or the angle
298
+ * suffix) MUST NOT change the shared request prefix block.
299
+ *
300
+ * @param {object} input
301
+ * @param {string|Buffer} input.stablePrefix - stable review-agent/system/tool prefix.
302
+ * @param {string|Buffer} input.briefingBlock - materialized shared briefing block bytes.
303
+ * @param {string} [input.cacheBoundary]
304
+ * @param {string} [input.ttlIntent]
305
+ * @returns {{ stableFingerprint: string, briefedBytes: string }}
306
+ */
307
+ export function fingerprintStablePrefix({ stablePrefix, briefingBlock, cacheBoundary, ttlIntent } = {}) {
308
+ const parts = [stablePrefix ?? "", briefingBlock ?? ""];
309
+ const stableFingerprint = sha256Hex({ stablePrefix: parts[0], briefingBlock: parts[1] });
310
+ return {
311
+ stableFingerprint,
312
+ // Canonicalize through stableStringify so a Buffer stablePrefix/briefingBlock
313
+ // becomes `__buffer:<hex>` here too — raw JSON.stringify would expand Buffers
314
+ // into large {type:"Buffer",data:[…]} decimal arrays (byte-unstable + costly),
315
+ // reintroducing exactly what sha256Hex avoids.
316
+ briefedBytes: JSON.stringify(stableStringify({
317
+ cacheBoundary: cacheBoundary ?? CACHE_BOUNDARY_AFTER_SHARED_PREFIX,
318
+ ttlIntent: ttlIntent ?? "harness_managed",
319
+ stableBytes: parts,
320
+ })),
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Compose a cache-aware request as ordered segments with a declared cache
326
+ * boundary after the stable briefing block (Section B):
327
+ *
328
+ * [stable review-agent/system/tool prefix]
329
+ * [materialized shared briefing block]
330
+ * <cache boundary>
331
+ * [late volatile gate state, when needed]
332
+ * [angle-specific suffix]
333
+ *
334
+ * Returns the ordered segment list plus the boundary index and the stable
335
+ * fingerprints, so a consumer can render the request and a verifier can assert
336
+ * stable-prefix equality byte-for-byte regardless of volatile/angle changes.
337
+ *
338
+ * @param {object} input
339
+ * @param {string|Buffer} input.stablePrefix
340
+ * @param {string|Buffer} input.briefingBlock
341
+ * @param {object} [input.volatileState] - late volatile gate state (serialized AFTER the boundary).
342
+ * @param {string|Buffer} [input.angleSuffix]
343
+ * @param {string} [input.cacheBoundary]
344
+ * @param {string} [input.ttlIntent]
345
+ * @returns {object} ordered segments + boundary + fingerprints.
346
+ */
347
+ export function composeCacheAwareRequest({ stablePrefix, briefingBlock, volatileState, angleSuffix, cacheBoundary, ttlIntent } = {}) {
348
+ const boundary = cacheBoundary ?? CACHE_BOUNDARY_AFTER_SHARED_PREFIX;
349
+ const ttl = ttlIntent ?? "harness_managed";
350
+ // Fail closed on out-of-enum cacheBoundary/ttlIntent (parity with
351
+ // fingerprintRequestPrefix / validateRequestGroups) so a caller typo cannot
352
+ // silently flow into the returned structure and break later parity checks.
353
+ if (!CACHE_BOUNDARY_VALUES.includes(boundary)) {
354
+ throw new Error(`composeCacheAwareRequest invalid cacheBoundary ${JSON.stringify(boundary)}`);
355
+ }
356
+ if (!TTL_INTENT_VALUES.includes(ttl)) {
357
+ throw new Error(`composeCacheAwareRequest invalid ttlIntent ${JSON.stringify(ttl)}`);
358
+ }
359
+ const { stableFingerprint, briefedBytes } = fingerprintStablePrefix({
360
+ stablePrefix,
361
+ briefingBlock,
362
+ cacheBoundary: boundary,
363
+ ttlIntent: ttl,
364
+ });
365
+ const late = (typeof volatileState === "object" && volatileState !== null && !Buffer.isBuffer(volatileState))
366
+ ? JSON.stringify(volatileState)
367
+ : (volatileState ?? "");
368
+ const segments = [
369
+ { slot: "stablePrefix", bytes: stablePrefix ?? "" },
370
+ { slot: "briefingBlock", bytes: briefingBlock ?? "" },
371
+ ];
372
+ // The cache boundary sits AFTER the stable prefix + briefing block. The marker
373
+ // segment is a structural pointer, NOT request bytes: it is byte-empty so a
374
+ // consumer concatenating segment bytes never injects the boundary label into
375
+ // the provider-visible prompt (the label lives in the separate cacheBoundary
376
+ // field).
377
+ const boundaryIndex = segments.length;
378
+ segments.push({ slot: "<cache boundary>", bytes: "" });
379
+ if (late.length > 0) segments.push({ slot: "volatileState", bytes: late });
380
+ if (angleSuffix != null && String(angleSuffix).length > 0) {
381
+ segments.push({ slot: "angleSuffix", bytes: String(angleSuffix) });
382
+ }
383
+ return {
384
+ cacheBoundary: boundary,
385
+ boundaryIndex,
386
+ stableFingerprint,
387
+ briefedBytes,
388
+ segments,
389
+ };
390
+ }
391
+
392
+ /* ------------------------------------------------------------------ *
393
+ * 4. Dispatch-plan builder (Section A)
394
+ * ------------------------------------------------------------------ */
395
+
396
+ /**
397
+ * Build a deterministic per-gate-round dispatch plan. The plan records the
398
+ * complete cache-relevant request shape WITHOUT duplicating briefing content
399
+ * (it stores the shared-prefix path + hash, not the bytes).
400
+ *
401
+ * @param {object} input
402
+ * @param {string} input.gate - draft_gate | pre_approval_gate | ...
403
+ * @param {string} input.headSha - full reviewed head SHA.
404
+ * @param {string} [input.sharedPrefixPath] - path to the materialized briefing-prefix file.
405
+ * @param {string} [input.sharedPrefixHash] - `sha256:<hex>` of those bytes.
406
+ * @param {Array<object>} [input.requestGroups] - each { model, requestPrefixFingerprint, cacheBoundary, ttlIntent, angles[] }.
407
+ * @param {object} [input.capabilities] - normalized harness capabilities.
408
+ * @param {object} [input.extra] - opaque consumer fields folded into the canonical hash but ignored by validation.
409
+ * @returns {object} validated dispatch-plan object.
410
+ */
411
+ export function buildReviewDispatchPlan({ gate, headSha, sharedPrefixPath, sharedPrefixHash, requestGroups = [], capabilities, extra } = {}) {
412
+ if (typeof gate !== "string" || gate.length === 0) {
413
+ throw new Error("buildReviewDispatchPlan requires a non-empty gate");
414
+ }
415
+ if (typeof headSha !== "string" || !/^[0-9a-f]{7,64}$/i.test(headSha.trim())) {
416
+ throw new Error("buildReviewDispatchPlan requires a hex headSha");
417
+ }
418
+ if (sharedPrefixHash != null && !isSha256Hex(String(sharedPrefixHash).replace(/^sha256:/, ""))) {
419
+ throw new Error(`sharedPrefixHash must be sha256:<64 hex> or absent, got ${JSON.stringify(sharedPrefixHash)}`);
420
+ }
421
+ // Normalize the stored artifact to a single canonical `sha256:<hex>` form so
422
+ // the plan (and its planHash) never depends on whether the caller passed a
423
+ // raw 64-hex string or an already-prefixed one for identical bytes (mixed-
424
+ // format artifacts are byte-non-deterministic across callers).
425
+ const normalizedSharedPrefixHash = sharedPrefixHash != null
426
+ ? `sha256:${String(sharedPrefixHash).replace(/^sha256:/, "").trim().toLowerCase()}`
427
+ : null;
428
+ const groups = validateRequestGroups(requestGroups);
429
+ let caps = capabilities;
430
+ if (caps != null) {
431
+ // A capability spec may carry a `harness` key plus dimension overrides.
432
+ const hasHarness = typeof caps === "object" && !Array.isArray(caps) && typeof caps.harness === "string";
433
+ if (hasHarness) {
434
+ const { harness: harnessName, ...dims } = caps;
435
+ caps = normalizeHarnessCapabilities({ harness: harnessName, capabilities: dims });
436
+ } else {
437
+ caps = normalizeHarnessCapabilities({ capabilities: caps });
438
+ }
439
+ }
440
+ const plan = {
441
+ gate,
442
+ headSha: headSha.trim().toLowerCase(),
443
+ ...(sharedPrefixPath != null ? { sharedPrefixPath } : {}),
444
+ ...(normalizedSharedPrefixHash != null ? { sharedPrefixHash: normalizedSharedPrefixHash } : {}),
445
+ requestGroups: groups,
446
+ ...(caps != null ? { capabilities: caps } : {}),
447
+ };
448
+ // Deterministic plan hash over the canonical plan, with opaque `extra` folded
449
+ // under its own namespace so an `extra` key can never shadow a real plan
450
+ // field (the fingerprint must always pin the plan's actual values).
451
+ const planHash = sha256Hex({ ...plan, extra: extra ?? {} }).replace(/^sha256:/, "");
452
+ return Object.freeze({ ...plan, planHash: `sha256:${planHash}` });
453
+ }
454
+
455
+ function validateRequestGroups(requestGroups) {
456
+ if (!Array.isArray(requestGroups)) {
457
+ throw new Error("requestGroups must be an array");
458
+ }
459
+ return requestGroups.map((g, i) => {
460
+ if (typeof g.model !== "string" || g.model.trim().length === 0) {
461
+ throw new Error(`requestGroups[${i}].model must be a non-empty concrete model`);
462
+ }
463
+ const fpRaw = typeof g.requestPrefixFingerprint === "string" ? g.requestPrefixFingerprint.trim() : null;
464
+ if (fpRaw != null && !isSha256Hex(fpRaw.replace(/^sha256:/, ""))) {
465
+ throw new Error(`requestGroups[${i}].requestPrefixFingerprint must be sha256:<hex> or absent`);
466
+ }
467
+ // Normalize to a single canonical `sha256:<hex>` form (same rationale as
468
+ // sharedPrefixHash) so the grouping/partition logic and the plan artifact
469
+ // see byte-identical fingerprints regardless of caller format.
470
+ const fp = fpRaw != null
471
+ ? `sha256:${fpRaw.replace(/^sha256:/, "").toLowerCase()}`
472
+ : null;
473
+ const cacheBoundary = g.cacheBoundary ?? CACHE_BOUNDARY_AFTER_SHARED_PREFIX;
474
+ if (!CACHE_BOUNDARY_VALUES.includes(cacheBoundary)) {
475
+ throw new Error(`requestGroups[${i}] invalid cacheBoundary ${JSON.stringify(cacheBoundary)}`);
476
+ }
477
+ const ttlIntent = g.ttlIntent ?? "harness_managed";
478
+ if (!TTL_INTENT_VALUES.includes(ttlIntent)) {
479
+ throw new Error(`requestGroups[${i}] invalid ttlIntent ${JSON.stringify(ttlIntent)}`);
480
+ }
481
+ if (!Array.isArray(g.angles) || g.angles.length === 0) {
482
+ throw new Error(`requestGroups[${i}].angles must be a non-empty array of angle names`);
483
+ }
484
+ return Object.freeze({
485
+ model: g.model.trim(),
486
+ ...(fpRaw != null ? { requestPrefixFingerprint: fp } : {}),
487
+ cacheBoundary,
488
+ ttlIntent,
489
+ angles: [...new Set(g.angles.map(String))],
490
+ });
491
+ });
492
+ }
493
+
494
+ /* ------------------------------------------------------------------ *
495
+ * 5. Primer-form default (Section C/D)
496
+ * ------------------------------------------------------------------ */
497
+
498
+ export const PRIMER_FORM_LEAD_REVIEWER = "lead_reviewer";
499
+ export const PRIMER_FORM_DEDICATED = "dedicated_primer";
500
+
501
+ /**
502
+ * Resolve the primer form for a request group by harness capability (Section
503
+ * C/D default-by-harness-capability):
504
+ * - first_output-observable harness + an adequate TTL (5m/1h) → may prime with
505
+ * a lead reviewer (`lead_reviewer`).
506
+ * - completion_only harness without an adequate explicit TTL → short dedicated
507
+ * primer (`dedicated_primer`) so a full review wait cannot silently outrun
508
+ * the cache TTL.
509
+ * - usageTelemetry unavailable + opaque controls → conservative dedicated
510
+ * primer (cannot observe barrier evidence), failing toward the safe option.
511
+ *
512
+ * @param {object} input
513
+ * @param {Readonly<Record<string,string>>} input.capabilities - normalized capabilities.
514
+ * @param {string} [input.ttlIntent] - declared TTL intent for the group.
515
+ * @returns {{ primerForm: "lead_reviewer"|"dedicated_primer", reason: string }}
516
+ */
517
+ export function resolvePrimerForm({ capabilities, ttlIntent } = {}) {
518
+ const caps = capabilities ?? {};
519
+ const adequateTtl = ttlIntent === "5m" || ttlIntent === "1h";
520
+ if (caps.barrierSignal === "first_output" && (adequateTtl || caps.cacheTtlControl === "5m_1h")) {
521
+ const reason = adequateTtl
522
+ ? "barrierSignal=first_output and an adequate TTL is available — a real lead reviewer may prime the group"
523
+ : "barrierSignal=first_output with cacheTtlControl=5m_1h capability — a real lead reviewer fits the cache control window";
524
+ return {
525
+ primerForm: PRIMER_FORM_LEAD_REVIEWER,
526
+ reason,
527
+ };
528
+ }
529
+ if (caps.barrierSignal === "completion_only" && caps.cacheTtlControl === "5m_1h" && ttlIntent === "1h") {
530
+ return {
531
+ primerForm: PRIMER_FORM_LEAD_REVIEWER,
532
+ reason: "completion_only harness with an explicit one-hour TTL — a long lead review fits the cache window",
533
+ };
534
+ }
535
+ return {
536
+ primerForm: PRIMER_FORM_DEDICATED,
537
+ reason: "completion_only or opaque controls without an adequate explicit TTL — a short dedicated primer avoids a full review outrunning the cache TTL",
538
+ };
539
+ }
540
+
541
+ /**
542
+ * Partition request groups into primer groups — one per distinct concrete
543
+ * model/request-prefix (Section C: heterogeneous per-angle model routing cannot
544
+ * silently reuse one model's primer to warm another).
545
+ *
546
+ * @param {object[]} requestGroups - validated request groups.
547
+ * @param {Readonly<Record<string,string>>} [capabilities]
548
+ * @returns {Array<{ model: string, requestPrefixFingerprint: string|null, groups: object[] }>}
549
+ */
550
+ export function partitionPrimerGroups(requestGroups, capabilities = {}) {
551
+ if (!Array.isArray(requestGroups)) throw new Error("partitionPrimerGroups requires an array");
552
+ // One primer group per distinct (model, request-prefix) — a primer warms ONE
553
+ // concrete model's provider cache under a specific request prefix. Groups that
554
+ // share a model AND a real (`sha256:<hex>`) fingerprint collapse into one
555
+ // bucket; groups without a proven fingerprint never collapse (see below). Use
556
+ // a nested Map keyed by model then by fingerprint (never a single delimiter-joined
557
+ // string), so a model id that itself contains "::" stays intact and two distinct
558
+ // (model, fp) pairs can never collide onto one bucket (dedup/identity safety).
559
+ const byModel = new Map();
560
+ for (let i = 0; i < requestGroups.length; i++) {
561
+ const g = requestGroups[i];
562
+ if (!byModel.has(g.model)) byModel.set(g.model, new Map());
563
+ const fpMap = byModel.get(g.model);
564
+ // Fail closed: only groups carrying a REAL (`sha256:<hex>`) fingerprint may
565
+ // share a primer bucket (that proves a common cache-relevant prefix). A
566
+ // fingerprint-less group is keyed by its own index so it NEVER collapses
567
+ // with another — without a fingerprint you cannot prove two groups share the
568
+ // same prefix, so merging them would let one primer silently cover multiple
569
+ // unknown prefixes (at best wasted priming, at worst misleading evidence).
570
+ const hasRealFp = typeof g.requestPrefixFingerprint === "string"
571
+ && g.requestPrefixFingerprint.startsWith("sha256:");
572
+ const key = hasRealFp ? g.requestPrefixFingerprint : `__unkeyed:${i}`;
573
+ if (!fpMap.has(key)) fpMap.set(key, []);
574
+ fpMap.get(key).push(g);
575
+ }
576
+ const out = [];
577
+ for (const [model, fpMap] of byModel.entries()) {
578
+ for (const [fp, groups] of fpMap.entries()) {
579
+ // Resolve the primer form CONSERVATIVELY across every collapsed group's TTL
580
+ // intent: a partition primes with a lead reviewer only when ALL its groups
581
+ // would; any group that needs a dedicated primer forces the whole partition
582
+ // down, so a mixed-TTL collapse is never decided by the first group alone.
583
+ const allLead = groups.every(
584
+ (g) => resolvePrimerForm({ capabilities, ttlIntent: g.ttlIntent }).primerForm === PRIMER_FORM_LEAD_REVIEWER,
585
+ );
586
+ out.push({
587
+ model,
588
+ requestPrefixFingerprint: fp.startsWith("sha256:") ? fp : null,
589
+ primerForm: allLead ? PRIMER_FORM_LEAD_REVIEWER : PRIMER_FORM_DEDICATED,
590
+ groups: groups.map((g) => ({ model: g.model, angles: g.angles, ttlIntent: g.ttlIntent })),
591
+ });
592
+ }
593
+ }
594
+ return out;
595
+ }