@dev-loops/core 1.0.0-rc.5 → 1.0.0-rc.7

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.
Files changed (47) hide show
  1. package/package.json +12 -1
  2. package/src/analysis/change-classifier.mjs +10 -0
  3. package/src/analysis/diff-analyzer.mjs +68 -1
  4. package/src/claude/hook-decisions.mjs +204 -5
  5. package/src/cli/primitives.mjs +51 -1
  6. package/src/config/config.mjs +307 -14
  7. package/src/config/extension-defaults.yaml +39 -1
  8. package/src/github/comment-id-guard.mjs +158 -0
  9. package/src/github/copilot-helpers.mjs +145 -5
  10. package/src/github/gh.mjs +94 -0
  11. package/src/github/issue-ops.mjs +13 -0
  12. package/src/loop/agent-stall.mjs +196 -0
  13. package/src/loop/bash-command-classify.mjs +277 -0
  14. package/src/loop/cache-telemetry-evidence.mjs +437 -0
  15. package/src/loop/copilot-loop-iterations.mjs +2 -1
  16. package/src/loop/default-branch-guard.mjs +35 -2
  17. package/src/loop/gate-carry-forward.mjs +19 -6
  18. package/src/loop/gate-fanin.mjs +190 -29
  19. package/src/loop/handoff-envelope.mjs +40 -20
  20. package/src/loop/issue-refinement-artifact.mjs +94 -0
  21. package/src/loop/lifecycle-state.mjs +21 -2
  22. package/src/loop/main-checkout-ff.mjs +73 -0
  23. package/src/loop/markdown-sections.mjs +40 -0
  24. package/src/loop/normalize.mjs +7 -0
  25. package/src/loop/plan-file-promote-contract.mjs +14 -1
  26. package/src/loop/plan-file-refine-contract.mjs +92 -8
  27. package/src/loop/policy-constants.mjs +9 -0
  28. package/src/loop/pr-gate-coordination.mjs +65 -12
  29. package/src/loop/primer-evidence.mjs +375 -0
  30. package/src/loop/public-dev-loop-routing.mjs +7 -15
  31. package/src/loop/queue-board-sync.mjs +1 -26
  32. package/src/loop/queue-driver.mjs +14 -1
  33. package/src/loop/refinement-grill-state.mjs +3 -5
  34. package/src/loop/review-dispatch-plan.mjs +1034 -0
  35. package/src/loop/review-lineage.mjs +588 -0
  36. package/src/loop/reviewer-loop-state.mjs +8 -13
  37. package/src/loop/run-post-merge-actions.mjs +148 -0
  38. package/src/loop/size-budget-merge-gate.mjs +121 -0
  39. package/src/loop/tracker-pr-state.mjs +5 -15
  40. package/src/loop/ui-designer-review-scoping.mjs +171 -0
  41. package/src/loop/ui-review-drive.mjs +3 -1
  42. package/src/loop/ui-review-report.mjs +2 -5
  43. package/src/loop/ui-review-teardown.mjs +3 -1
  44. package/src/loop/worktree-guard.mjs +80 -0
  45. package/src/projects/list-queue-items.mjs +1 -27
  46. package/src/projects/move-queue-item.mjs +38 -28
  47. package/src/security/secret-scan.mjs +330 -0
@@ -0,0 +1,1034 @@
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). `buildAngleRequestGroups` partitions a
26
+ * caller's angle -> concrete-model resolutions into that plan's
27
+ * `requestGroups` shape, bucketing angles with no override into an
28
+ * explicit "inherit" key rather than merging them into a concrete group.
29
+ * 5. Primer-form default — deterministic default by harness capability
30
+ * (Section C/D): first-output-observable harnesses may let a lead reviewer
31
+ * prime; completion-only harnesses default to a short dedicated primer
32
+ * unless an adequate TTL is explicit; multiple concrete models partition
33
+ * into one primer group per model/request-prefix.
34
+ *
35
+ * This module is pure and offline: no GitHub, no harness, no clock. All runtime
36
+ * execution adapters consume it; it never executes a reviewer itself.
37
+ */
38
+ import { createHash } from "node:crypto";
39
+
40
+ /* ------------------------------------------------------------------ *
41
+ * 1. Harness capability model
42
+ * ------------------------------------------------------------------ */
43
+
44
+ /** Allowed values for each capability dimension (Section D). */
45
+ export const BREAKPOINT_CONTROL_VALUES = Object.freeze(["explicit", "automatic", "opaque"]);
46
+ export const BARRIER_SIGNAL_VALUES = Object.freeze(["first_output", "completion_only"]);
47
+ export const CACHE_TTL_CONTROL_VALUES = Object.freeze(["5m_1h", "fixed", "opaque"]);
48
+ export const USAGE_TELEMETRY_VALUES = Object.freeze(["available", "unavailable"]);
49
+
50
+ /** Allowed breakpoint/TTL intents a consumer may declare for a request group. */
51
+ export const TTL_INTENT_VALUES = Object.freeze(["5m", "1h", "harness_managed"]);
52
+
53
+ /** Cache boundary markers; the only current boundary is after the shared prefix. */
54
+ export const CACHE_BOUNDARY_AFTER_SHARED_PREFIX = "after_shared_prefix";
55
+ export const CACHE_BOUNDARY_VALUES = Object.freeze([CACHE_BOUNDARY_AFTER_SHARED_PREFIX]);
56
+
57
+ /**
58
+ * Default capability posture per harness name. These are conservative defaults:
59
+ * any harness whose provider-cache controls we cannot assert explicitly is
60
+ * represented with `opaque` / `unavailable` capabilities, which fail closed
61
+ * rather than over-claim. Consumers may pass explicit capabilities to
62
+ * {normalizeHarnessCapabilities}.
63
+ */
64
+ export const HARNESS_DEFAULT_CAPABILITIES = Object.freeze({
65
+ claude: Object.freeze({
66
+ breakpointControl: "automatic",
67
+ barrierSignal: "completion_only",
68
+ cacheTtlControl: "fixed",
69
+ usageTelemetry: "available",
70
+ }),
71
+ pi: Object.freeze({
72
+ breakpointControl: "opaque",
73
+ barrierSignal: "completion_only",
74
+ cacheTtlControl: "opaque",
75
+ usageTelemetry: "unavailable",
76
+ }),
77
+ });
78
+
79
+ const CAPABILITY_DIMENSIONS = [
80
+ ["breakpointControl", BREAKPOINT_CONTROL_VALUES],
81
+ ["barrierSignal", BARRIER_SIGNAL_VALUES],
82
+ ["cacheTtlControl", CACHE_TTL_CONTROL_VALUES],
83
+ ["usageTelemetry", USAGE_TELEMETRY_VALUES],
84
+ ];
85
+
86
+ /**
87
+ * Normalize + validate a harness capability object. Fails closed on unknown
88
+ * dimension names, unknown values, or a non-object. Returns a frozen canonical
89
+ * object.
90
+ *
91
+ * @param {object} input
92
+ * @param {string} [input.harness] - harness name; used only for the default merge
93
+ * (matches HARNESS_DEFAULT_CAPABILITIES keys, else fails closed on unknown).
94
+ * @param {object} [input.capabilities] - explicit per-dimension overrides.
95
+ * @returns {Readonly<Record<string,string>>} frozen capability object.
96
+ */
97
+ export function normalizeHarnessCapabilities({ harness, capabilities } = {}) {
98
+ let base = {};
99
+ if (harness != null) {
100
+ const key = String(harness).trim().toLowerCase();
101
+ if (!Object.prototype.hasOwnProperty.call(HARNESS_DEFAULT_CAPABILITIES, key)) {
102
+ throw new Error(
103
+ `Unknown harness ${JSON.stringify(harness)} — must be one of ${Object.keys(HARNESS_DEFAULT_CAPABILITIES).join(", ")} or supply explicit capabilities`,
104
+ );
105
+ }
106
+ base = HARNESS_DEFAULT_CAPABILITIES[key];
107
+ }
108
+ const merged = { ...base };
109
+ if (capabilities != null) {
110
+ if (typeof capabilities !== "object" || Array.isArray(capabilities)) {
111
+ throw new Error("capabilities must be an object of dimension -> value pairs");
112
+ }
113
+ for (const [dim, value] of Object.entries(capabilities)) {
114
+ const known = CAPABILITY_DIMENSIONS.find(([name]) => name === dim);
115
+ if (!known) {
116
+ throw new Error(`Unknown capability dimension ${JSON.stringify(dim)}`);
117
+ }
118
+ const [, allowed] = known;
119
+ if (!allowed.includes(value)) {
120
+ throw new Error(
121
+ `Invalid ${dim} ${JSON.stringify(value)} — must be one of ${allowed.join(", ")}`,
122
+ );
123
+ }
124
+ merged[dim] = value;
125
+ }
126
+ }
127
+ // Fail closed if any dimension was never resolved to a real value.
128
+ for (const [dim] of CAPABILITY_DIMENSIONS) {
129
+ if (merged[dim] == null) {
130
+ throw new Error(`Capability dimension ${dim} was not resolved (must be explicit or from a known harness)`);
131
+ }
132
+ }
133
+ return Object.freeze({
134
+ breakpointControl: merged.breakpointControl,
135
+ barrierSignal: merged.barrierSignal,
136
+ cacheTtlControl: merged.cacheTtlControl,
137
+ usageTelemetry: merged.usageTelemetry,
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Is this capability set honest about provider cache reuse? A harness whose
143
+ * usage telemetry is `unavailable` cannot claim verified `1 write + N reads`.
144
+ * This is the fail-closed honesty gate for telemetry evidence (Section D).
145
+ *
146
+ * @param {Readonly<Record<string,string>>} caps - normalized capabilities.
147
+ * @returns {{ verified: boolean, reason: string|null }}
148
+ */
149
+ export function cacheReuseVeracity(caps) {
150
+ if (!caps) return { verified: false, reason: "missing capability record" };
151
+ if (caps.usageTelemetry !== "available") {
152
+ return {
153
+ verified: false,
154
+ reason: `usageTelemetry=${caps.usageTelemetry} — provider reuse cannot be verified, only ordering + fingerprint invariants may be claimed`,
155
+ };
156
+ }
157
+ return { verified: true, reason: null };
158
+ }
159
+
160
+ /* ------------------------------------------------------------------ *
161
+ * 2. Request-prefix fingerprinting
162
+ * ------------------------------------------------------------------ */
163
+
164
+ const HEX = /^[0-9a-f]{64}$/;
165
+
166
+ /** @param {string} value @returns {boolean} */
167
+ function isSha256Hex(value) {
168
+ return typeof value === "string" && HEX.test(value.trim().toLowerCase());
169
+ }
170
+
171
+ /**
172
+ * Deterministic sha256 hex of a canonical-JSON serialization. Content may be a
173
+ * string, Buffer, or the live object; buffers are canonicalized by hex so a
174
+ * push/pull through memory vs disk yields identical bytes.
175
+ *
176
+ * @param {unknown} content
177
+ * @returns {string} `sha256:<64-hex>`
178
+ */
179
+ export function sha256Hex(content) {
180
+ const h = createHash("sha256");
181
+ if (Buffer.isBuffer(content)) {
182
+ h.update(content);
183
+ } else if (typeof content === "string") {
184
+ h.update(content);
185
+ } else {
186
+ // Deterministic canonical key ordering via stable stringify.
187
+ const canonical = stableStringify(content);
188
+ h.update(JSON.stringify(canonical));
189
+ }
190
+ return `sha256:${h.digest("hex")}`;
191
+ }
192
+
193
+ /** True for a non-null value whose prototype is exactly Object.prototype or null (a JSON-shaped record, never a Date/Map/Set/class instance). */
194
+ function isPlainObject(value) {
195
+ if (value === null || typeof value !== "object") return false;
196
+ const proto = Object.getPrototypeOf(value);
197
+ return proto === Object.prototype || proto === null;
198
+ }
199
+
200
+ /**
201
+ * Recursively sort object keys for a byte-deterministic serialization (arrays
202
+ * keep their order — order is itself cache-relevant for tool definitions and
203
+ * content-block boundaries).
204
+ *
205
+ * Trust-boundary validation, refusing loudly rather than silently colliding
206
+ * two distinct inputs onto one fingerprint: a non-finite number (NaN/
207
+ * Infinity) is rejected rather than let `JSON.stringify` collapse it to
208
+ * `null`, a non-plain object (Date/Map/Set/...) is rejected rather than let
209
+ * `Object.keys` see it as keyless (and therefore indistinguishable from
210
+ * `{}`), and undefined/function/symbol/bigint are rejected rather than
211
+ * silently dropped or crash-serialized by `JSON.stringify` itself. The
212
+ * accumulator is null-prototype so an own `__proto__` key (a realistic shape
213
+ * for JSON.parse'd input) is kept as a plain data property instead of
214
+ * vanishing into the prototype chain.
215
+ * @param {*} value
216
+ * @param {string} [keyPath] — dotted path to `value`, for the error message
217
+ * @returns {*}
218
+ */
219
+ function stableStringify(value, keyPath = "$") {
220
+ if (Array.isArray(value)) return value.map((entry, i) => stableStringify(entry, `${keyPath}[${i}]`));
221
+ // Canonicalize nested Buffers to hex (the `__buffer:` prefix keeps a Buffer
222
+ // distinct from a string that happens to equal its hex, so bytes and text can
223
+ // never collide). Mirrors sha256Hex's top-level Buffer handling so a push/pull
224
+ // through memory vs disk yields identical bytes even for nested buffers.
225
+ if (Buffer.isBuffer(value)) return `__buffer:${value.toString("hex")}`;
226
+ if (typeof value === "number" && !Number.isFinite(value)) {
227
+ throw new Error(`sha256Hex: fingerprint input at ${keyPath} is a non-finite number (${value}) — refusing to collapse it to JSON null`);
228
+ }
229
+ if (value === undefined || typeof value === "function" || typeof value === "symbol" || typeof value === "bigint") {
230
+ throw new Error(`sha256Hex: fingerprint input at ${keyPath} is a ${typeof value} — refusing to silently drop or crash-serialize it`);
231
+ }
232
+ if (value !== null && typeof value === "object") {
233
+ if (!isPlainObject(value)) {
234
+ throw new Error(`sha256Hex: fingerprint input at ${keyPath} is not a plain object (got ${Object.prototype.toString.call(value)}) — refusing to canonicalize a keyless collision`);
235
+ }
236
+ const out = Object.create(null);
237
+ for (const key of Object.keys(value).sort()) out[key] = stableStringify(value[key], `${keyPath}.${key}`);
238
+ return out;
239
+ }
240
+ return value;
241
+ }
242
+
243
+ /**
244
+ * Fingerprint the complete observable request prefix (Section A). Every
245
+ * cache-relevant value the dev-loops layer observes or controls is folded into
246
+ * the hash: concrete model, tool definitions/order, system/project/agent
247
+ * instructions, thinking/tool-choice settings, content-block boundaries, shared
248
+ * artifact bytes, and breakpoint/TTL intent. Values owned opaquely by a harness
249
+ * should be passed as `null`-free opaque markers (see `opaqueMarker`).
250
+ *
251
+ * @param {object} input
252
+ * @param {string} input.model - concrete model id for this request group.
253
+ * @param {string[]|object[]} [input.tools] - tool definitions/order.
254
+ * @param {string|string[]} [input.systemInstructions] - system/project/agent instructions.
255
+ * @param {object|string} [input.settings] - thinking/tool-choice settings.
256
+ * @param {object[]} [input.contentBlocks] - content-block boundaries + shared bytes.
257
+ * @param {string} [input.sharedArtifact] - shared artifact reference (path) or bytes.
258
+ * @param {string} [input.cacheBoundary] - e.g. `after_shared_prefix`.
259
+ * @param {string} [input.ttlIntent] - one of TTL_INTENT_VALUES.
260
+ * @param {string[]} [input.angleSuffix] - angle-specific suffix (excluded from the
261
+ * STABLE prefix fingerprint; included here only when invasive).
262
+ * @returns {{ fingerprint: string, canonical: object }}
263
+ */
264
+ export function fingerprintRequestPrefix(input) {
265
+ if (!input || typeof input !== "object") {
266
+ throw new Error("fingerprintRequestPrefix requires an object input");
267
+ }
268
+ if (typeof input.model !== "string" || input.model.trim().length === 0) {
269
+ throw new Error("fingerprintRequestPrefix requires a non-empty concrete model");
270
+ }
271
+ // Fail closed on invalid shapes (AC-2: the fingerprint covers the COMPLETE
272
+ // observable prefix). A silently-coerced non-array tools/contentBlocks would
273
+ // collapse two genuinely different request prefixes into one fingerprint, so
274
+ // reject rather than quietly omit.
275
+ if (input.tools != null && !Array.isArray(input.tools)) {
276
+ throw new Error("fingerprintRequestPrefix tools must be an array of tool definitions");
277
+ }
278
+ if (input.contentBlocks != null && !Array.isArray(input.contentBlocks)) {
279
+ throw new Error("fingerprintRequestPrefix contentBlocks must be an array");
280
+ }
281
+ // Fail closed on out-of-enum cacheBoundary/ttlIntent: a caller typo would
282
+ // fold an undefinable value into the fingerprint that no other caller could
283
+ // ever reproduce/validate, silently undermining the mechanically-checkable
284
+ // contract (AC-2 parity with validateRequestGroups).
285
+ const boundary = input.cacheBoundary ?? CACHE_BOUNDARY_AFTER_SHARED_PREFIX;
286
+ const ttl = input.ttlIntent ?? "harness_managed";
287
+ if (!CACHE_BOUNDARY_VALUES.includes(boundary)) {
288
+ throw new Error(`fingerprintRequestPrefix invalid cacheBoundary ${JSON.stringify(boundary)}`);
289
+ }
290
+ if (!TTL_INTENT_VALUES.includes(ttl)) {
291
+ throw new Error(`fingerprintRequestPrefix invalid ttlIntent ${JSON.stringify(ttl)}`);
292
+ }
293
+ const canonical = {
294
+ model: input.model.trim(),
295
+ tools: input.tools ?? [],
296
+ systemInstructions: input.systemInstructions ?? null,
297
+ settings: input.settings ?? null,
298
+ contentBlocks: input.contentBlocks ?? null,
299
+ sharedArtifact: input.sharedArtifact ?? null,
300
+ cacheBoundary: boundary,
301
+ ttlIntent: ttl,
302
+ // angleSuffix is excluded from the STABLE prefix fingerprint but is always
303
+ // folded into this full request-prefix fingerprint when present, so the
304
+ // claimed volatile difference stays assertable.
305
+ ...(input.angleSuffix != null ? { angleSuffix: input.angleSuffix } : {}),
306
+ };
307
+ return { fingerprint: sha256Hex(canonical), canonical };
308
+ }
309
+
310
+ /**
311
+ * Opaque placeholder for a value owned opaquely by the harness. Using this in a
312
+ * fingerprint input records that the value existed but was not byte-observable,
313
+ * so two runs that differ only in an unobservable harness value STILL collapse
314
+ * to the same fingerprint (they cannot be proven different) — and, symmetrically,
315
+ * a claimed difference under an opaque value is not assertable.
316
+ *
317
+ * @param {string} label
318
+ * @returns {string}
319
+ */
320
+ export function opaqueMarker(label) {
321
+ return `__opaque:${String(label)}`;
322
+ }
323
+
324
+ /* ------------------------------------------------------------------ *
325
+ * 3. Stable/volatile request separation
326
+ * ------------------------------------------------------------------ */
327
+
328
+ /**
329
+ * The stable-prefix fingerprint is computed ONLY over the stable prefix +
330
+ * materialized briefing block — never the volatile tail or angle suffix. This
331
+ * is the mechanical proof for AC-1: changing only `gateState` (or the angle
332
+ * suffix) MUST NOT change the shared request prefix block.
333
+ *
334
+ * @param {object} input
335
+ * @param {string|Buffer} input.stablePrefix - stable review-agent/system/tool prefix.
336
+ * @param {string|Buffer} input.briefingBlock - materialized shared briefing block bytes.
337
+ * @param {string} [input.cacheBoundary]
338
+ * @param {string} [input.ttlIntent]
339
+ * @returns {{ stableFingerprint: string, briefedBytes: string }}
340
+ */
341
+ export function fingerprintStablePrefix({ stablePrefix, briefingBlock, cacheBoundary, ttlIntent } = {}) {
342
+ const parts = [stablePrefix ?? "", briefingBlock ?? ""];
343
+ const stableFingerprint = sha256Hex({ stablePrefix: parts[0], briefingBlock: parts[1] });
344
+ return {
345
+ stableFingerprint,
346
+ // Canonicalize through stableStringify so a Buffer stablePrefix/briefingBlock
347
+ // becomes `__buffer:<hex>` here too — raw JSON.stringify would expand Buffers
348
+ // into large {type:"Buffer",data:[…]} decimal arrays (byte-unstable + costly),
349
+ // reintroducing exactly what sha256Hex avoids.
350
+ briefedBytes: JSON.stringify(stableStringify({
351
+ cacheBoundary: cacheBoundary ?? CACHE_BOUNDARY_AFTER_SHARED_PREFIX,
352
+ ttlIntent: ttlIntent ?? "harness_managed",
353
+ stableBytes: parts,
354
+ })),
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Compose a cache-aware request as ordered segments with a declared cache
360
+ * boundary after the stable briefing block (Section B):
361
+ *
362
+ * [stable review-agent/system/tool prefix]
363
+ * [materialized shared briefing block]
364
+ * <cache boundary>
365
+ * [late volatile gate state, when needed]
366
+ * [angle-specific suffix]
367
+ *
368
+ * Returns the ordered segment list plus the boundary index and the stable
369
+ * fingerprints, so a consumer can render the request and a verifier can assert
370
+ * stable-prefix equality byte-for-byte regardless of volatile/angle changes.
371
+ *
372
+ * @param {object} input
373
+ * @param {string|Buffer} input.stablePrefix
374
+ * @param {string|Buffer} input.briefingBlock
375
+ * @param {object} [input.volatileState] - late volatile gate state (serialized AFTER the boundary).
376
+ * @param {string|Buffer} [input.angleSuffix]
377
+ * @param {string} [input.cacheBoundary]
378
+ * @param {string} [input.ttlIntent]
379
+ * @returns {object} ordered segments + boundary + fingerprints.
380
+ */
381
+ export function composeCacheAwareRequest({ stablePrefix, briefingBlock, volatileState, angleSuffix, cacheBoundary, ttlIntent } = {}) {
382
+ const boundary = cacheBoundary ?? CACHE_BOUNDARY_AFTER_SHARED_PREFIX;
383
+ const ttl = ttlIntent ?? "harness_managed";
384
+ // Fail closed on out-of-enum cacheBoundary/ttlIntent (parity with
385
+ // fingerprintRequestPrefix / validateRequestGroups) so a caller typo cannot
386
+ // silently flow into the returned structure and break later parity checks.
387
+ if (!CACHE_BOUNDARY_VALUES.includes(boundary)) {
388
+ throw new Error(`composeCacheAwareRequest invalid cacheBoundary ${JSON.stringify(boundary)}`);
389
+ }
390
+ if (!TTL_INTENT_VALUES.includes(ttl)) {
391
+ throw new Error(`composeCacheAwareRequest invalid ttlIntent ${JSON.stringify(ttl)}`);
392
+ }
393
+ const { stableFingerprint, briefedBytes } = fingerprintStablePrefix({
394
+ stablePrefix,
395
+ briefingBlock,
396
+ cacheBoundary: boundary,
397
+ ttlIntent: ttl,
398
+ });
399
+ const late = (typeof volatileState === "object" && volatileState !== null && !Buffer.isBuffer(volatileState))
400
+ ? JSON.stringify(volatileState)
401
+ : (volatileState ?? "");
402
+ const segments = [
403
+ { slot: "stablePrefix", bytes: stablePrefix ?? "" },
404
+ { slot: "briefingBlock", bytes: briefingBlock ?? "" },
405
+ ];
406
+ // The cache boundary sits AFTER the stable prefix + briefing block. The marker
407
+ // segment is a structural pointer, NOT request bytes: it is byte-empty so a
408
+ // consumer concatenating segment bytes never injects the boundary label into
409
+ // the provider-visible prompt (the label lives in the separate cacheBoundary
410
+ // field).
411
+ const boundaryIndex = segments.length;
412
+ segments.push({ slot: "<cache boundary>", bytes: "" });
413
+ if (late.length > 0) segments.push({ slot: "volatileState", bytes: late });
414
+ if (angleSuffix != null && String(angleSuffix).length > 0) {
415
+ segments.push({ slot: "angleSuffix", bytes: String(angleSuffix) });
416
+ }
417
+ return {
418
+ cacheBoundary: boundary,
419
+ boundaryIndex,
420
+ stableFingerprint,
421
+ briefedBytes,
422
+ segments,
423
+ };
424
+ }
425
+
426
+ /* ------------------------------------------------------------------ *
427
+ * 4. Dispatch-plan builder (Section A)
428
+ * ------------------------------------------------------------------ */
429
+
430
+ /**
431
+ * Build a deterministic per-gate-round dispatch plan. The plan records the
432
+ * complete cache-relevant request shape WITHOUT duplicating briefing content
433
+ * (it stores the shared-prefix path + hash, not the bytes).
434
+ *
435
+ * @param {object} input
436
+ * @param {string} input.gate - draft_gate | pre_approval_gate | ...
437
+ * @param {string} input.headSha - full reviewed head SHA.
438
+ * @param {string} [input.sharedPrefixPath] - path to the materialized briefing-prefix file.
439
+ * @param {string} [input.sharedPrefixHash] - `sha256:<hex>` of those bytes.
440
+ * @param {Array<object>} [input.requestGroups] - each { model, requestPrefixFingerprint, cacheBoundary, ttlIntent, angles[] }.
441
+ * @param {object} [input.capabilities] - normalized harness capabilities.
442
+ * @param {object} [input.extra] - opaque consumer fields folded into the canonical hash but ignored by validation.
443
+ * @returns {object} validated dispatch-plan object.
444
+ */
445
+ export function buildReviewDispatchPlan({ gate, headSha, sharedPrefixPath, sharedPrefixHash, requestGroups = [], capabilities, extra } = {}) {
446
+ if (typeof gate !== "string" || gate.length === 0) {
447
+ throw new Error("buildReviewDispatchPlan requires a non-empty gate");
448
+ }
449
+ if (typeof headSha !== "string" || !/^[0-9a-f]{7,64}$/i.test(headSha.trim())) {
450
+ throw new Error("buildReviewDispatchPlan requires a hex headSha");
451
+ }
452
+ if (sharedPrefixHash != null && !isSha256Hex(String(sharedPrefixHash).replace(/^sha256:/, ""))) {
453
+ throw new Error(`sharedPrefixHash must be sha256:<64 hex> or absent, got ${JSON.stringify(sharedPrefixHash)}`);
454
+ }
455
+ // Normalize the stored artifact to a single canonical `sha256:<hex>` form so
456
+ // the plan (and its planHash) never depends on whether the caller passed a
457
+ // raw 64-hex string or an already-prefixed one for identical bytes (mixed-
458
+ // format artifacts are byte-non-deterministic across callers).
459
+ const normalizedSharedPrefixHash = sharedPrefixHash != null
460
+ ? `sha256:${String(sharedPrefixHash).replace(/^sha256:/, "").trim().toLowerCase()}`
461
+ : null;
462
+ const groups = validateRequestGroups(requestGroups);
463
+ let caps = capabilities;
464
+ if (caps != null) {
465
+ // A capability spec may carry a `harness` key plus dimension overrides.
466
+ const hasHarness = typeof caps === "object" && !Array.isArray(caps) && typeof caps.harness === "string";
467
+ if (hasHarness) {
468
+ const { harness: harnessName, ...dims } = caps;
469
+ caps = normalizeHarnessCapabilities({ harness: harnessName, capabilities: dims });
470
+ } else {
471
+ caps = normalizeHarnessCapabilities({ capabilities: caps });
472
+ }
473
+ }
474
+ const plan = {
475
+ gate,
476
+ headSha: headSha.trim().toLowerCase(),
477
+ ...(sharedPrefixPath != null ? { sharedPrefixPath } : {}),
478
+ ...(normalizedSharedPrefixHash != null ? { sharedPrefixHash: normalizedSharedPrefixHash } : {}),
479
+ requestGroups: groups,
480
+ ...(caps != null ? { capabilities: caps } : {}),
481
+ };
482
+ // Deterministic plan hash over the canonical plan, with opaque `extra` folded
483
+ // under its own namespace so an `extra` key can never shadow a real plan
484
+ // field (the fingerprint must always pin the plan's actual values).
485
+ const planHash = sha256Hex({ ...plan, extra: extra ?? {} }).replace(/^sha256:/, "");
486
+ return Object.freeze({ ...plan, planHash: `sha256:${planHash}` });
487
+ }
488
+
489
+ function validateRequestGroups(requestGroups) {
490
+ if (!Array.isArray(requestGroups)) {
491
+ throw new Error("requestGroups must be an array");
492
+ }
493
+ return requestGroups.map((g, i) => {
494
+ if (typeof g.model !== "string" || g.model.trim().length === 0) {
495
+ throw new Error(`requestGroups[${i}].model must be a non-empty concrete model`);
496
+ }
497
+ const fpRaw = typeof g.requestPrefixFingerprint === "string" ? g.requestPrefixFingerprint.trim() : null;
498
+ if (fpRaw != null && !isSha256Hex(fpRaw.replace(/^sha256:/, ""))) {
499
+ throw new Error(`requestGroups[${i}].requestPrefixFingerprint must be sha256:<hex> or absent`);
500
+ }
501
+ // Normalize to a single canonical `sha256:<hex>` form (same rationale as
502
+ // sharedPrefixHash) so the grouping/partition logic and the plan artifact
503
+ // see byte-identical fingerprints regardless of caller format.
504
+ const fp = fpRaw != null
505
+ ? `sha256:${fpRaw.replace(/^sha256:/, "").toLowerCase()}`
506
+ : null;
507
+ const cacheBoundary = g.cacheBoundary ?? CACHE_BOUNDARY_AFTER_SHARED_PREFIX;
508
+ if (!CACHE_BOUNDARY_VALUES.includes(cacheBoundary)) {
509
+ throw new Error(`requestGroups[${i}] invalid cacheBoundary ${JSON.stringify(cacheBoundary)}`);
510
+ }
511
+ const ttlIntent = g.ttlIntent ?? "harness_managed";
512
+ if (!TTL_INTENT_VALUES.includes(ttlIntent)) {
513
+ throw new Error(`requestGroups[${i}] invalid ttlIntent ${JSON.stringify(ttlIntent)}`);
514
+ }
515
+ if (!Array.isArray(g.angles) || g.angles.length === 0) {
516
+ throw new Error(`requestGroups[${i}].angles must be a non-empty array of angle names`);
517
+ }
518
+ return Object.freeze({
519
+ model: g.model.trim(),
520
+ ...(fpRaw != null ? { requestPrefixFingerprint: fp } : {}),
521
+ cacheBoundary,
522
+ ttlIntent,
523
+ angles: [...new Set(g.angles.map(String))],
524
+ });
525
+ });
526
+ }
527
+
528
+ /* ------------------------------------------------------------------ *
529
+ * 4b. Angle model bucketing (per-caller angle -> concrete model resolution)
530
+ * ------------------------------------------------------------------ */
531
+
532
+ /**
533
+ * `requestGroups` bucket key for angles whose model resolution is "no
534
+ * override" (inherit). Reserved: a caller-resolved concrete model literally
535
+ * named this would otherwise collide with the "no override" bucket and
536
+ * become indistinguishable from genuine inherit.
537
+ */
538
+ export const INHERIT_MODEL_KEY = "inherit";
539
+
540
+ /**
541
+ * Partition a caller's angle -> concrete-model resolutions into fingerprinted
542
+ * request groups ready for {@link buildReviewDispatchPlan}'s `requestGroups`
543
+ * input. Angles resolving to the same concrete model id share one group;
544
+ * angles with no override (`model: null`/`undefined`) form their own explicit
545
+ * {@link INHERIT_MODEL_KEY} bucket, never merged with a concrete id. An angle
546
+ * listed twice with two DIFFERENT models is a caller bug and throws (an angle
547
+ * cannot honestly belong to two request groups). A concrete model literally
548
+ * named {@link INHERIT_MODEL_KEY} also throws — it would otherwise silently
549
+ * collide with the reserved bucket key and become indistinguishable from
550
+ * genuine no-override.
551
+ *
552
+ * Each group's `requestPrefixFingerprint` is computed via
553
+ * {@link fingerprintRequestPrefix} over every cache-relevant input this layer
554
+ * observes for that group (the bucket's model, tool set/order, instructions,
555
+ * settings, content-block boundaries, the shared-prefix bytes, and the
556
+ * declared cache boundary/TTL intent) — changing only the angle set within a
557
+ * bucket never changes its fingerprint.
558
+ *
559
+ * @param {object} input
560
+ * @param {Array<{angle: string, model: string|null}>} input.angleModels
561
+ * @param {string} [input.sharedPrefixHash] — folded in as the fingerprint's shared-artifact reference.
562
+ * @param {Array<string|object>} [input.toolDefinitions] — tool names/definitions in dispatch order
563
+ * @param {string|string[]} [input.instructions] — system/project/agent instruction bytes (or a digest)
564
+ * @param {object} [input.settings] — thinking/tool-choice settings
565
+ * @param {string[]} [input.blockBoundaries] — content-block boundary markers, in order
566
+ * @param {string} [input.cacheBoundary]
567
+ * @param {string} [input.ttlIntent] — one of TTL_INTENT_VALUES
568
+ * @returns {RequestGroup[]} sorted by model (code-unit order, never localeCompare — ICU-dependent
569
+ * sorting could order the same two model ids differently across runtimes); angles sorted within a group.
570
+ */
571
+ export function buildAngleRequestGroups({
572
+ angleModels,
573
+ sharedPrefixHash,
574
+ toolDefinitions = [],
575
+ instructions = "",
576
+ settings = {},
577
+ blockBoundaries = [],
578
+ cacheBoundary = CACHE_BOUNDARY_AFTER_SHARED_PREFIX,
579
+ ttlIntent = "harness_managed",
580
+ } = {}) {
581
+ if (!Array.isArray(angleModels)) {
582
+ throw new Error("buildAngleRequestGroups: angleModels must be an array of { angle, model }");
583
+ }
584
+
585
+ const angleToModelKey = new Map();
586
+ const anglesByModelKey = new Map();
587
+ for (const entry of angleModels) {
588
+ const angle = typeof entry?.angle === "string" ? entry.angle.trim() : "";
589
+ if (angle.length === 0) {
590
+ throw new Error("buildAngleRequestGroups: every angleModels entry needs a non-empty string angle");
591
+ }
592
+ const rawModel = entry.model;
593
+ if (rawModel != null && (typeof rawModel !== "string" || rawModel.trim().length === 0)) {
594
+ throw new Error(`buildAngleRequestGroups: angleModels entry for "${angle}" has an invalid model (must be a non-empty string, or null/undefined for inherit)`);
595
+ }
596
+ const modelKey = rawModel == null ? INHERIT_MODEL_KEY : rawModel.trim();
597
+ if (rawModel != null && modelKey === INHERIT_MODEL_KEY) {
598
+ throw new Error(`buildAngleRequestGroups: angle "${angle}" has a concrete model literally named ${JSON.stringify(INHERIT_MODEL_KEY)}, which collides with the bucket key reserved for "no override" — rename the model, or resolve it to null/undefined instead of the literal string`);
599
+ }
600
+
601
+ const priorKey = angleToModelKey.get(angle);
602
+ if (priorKey !== undefined && priorKey !== modelKey) {
603
+ throw new Error(`buildAngleRequestGroups: angle "${angle}" is listed with two different models ("${priorKey}" and "${modelKey}") — an angle cannot belong to two request groups`);
604
+ }
605
+ angleToModelKey.set(angle, modelKey);
606
+
607
+ if (!anglesByModelKey.has(modelKey)) anglesByModelKey.set(modelKey, new Set());
608
+ anglesByModelKey.get(modelKey).add(angle);
609
+ }
610
+
611
+ return [...anglesByModelKey.entries()]
612
+ .map(([model, angleSet]) => {
613
+ const angles = [...angleSet].sort();
614
+ const { fingerprint } = fingerprintRequestPrefix({
615
+ model,
616
+ tools: toolDefinitions,
617
+ systemInstructions: instructions,
618
+ settings,
619
+ contentBlocks: blockBoundaries,
620
+ sharedArtifact: sharedPrefixHash,
621
+ cacheBoundary,
622
+ ttlIntent,
623
+ });
624
+ return { model, requestPrefixFingerprint: fingerprint, cacheBoundary, ttlIntent, angles };
625
+ })
626
+ .sort((a, b) => (a.model < b.model ? -1 : a.model > b.model ? 1 : 0));
627
+ }
628
+
629
+ /* ------------------------------------------------------------------ *
630
+ * 5. Primer-form default (Section C/D)
631
+ * ------------------------------------------------------------------ */
632
+
633
+ export const PRIMER_FORM_LEAD_REVIEWER = "lead_reviewer";
634
+ export const PRIMER_FORM_DEDICATED = "dedicated_primer";
635
+
636
+ /**
637
+ * Resolve the primer form for a request group by harness capability (Section
638
+ * C/D default-by-harness-capability):
639
+ * - first_output-observable harness + an adequate TTL (5m/1h) → may prime with
640
+ * a lead reviewer (`lead_reviewer`).
641
+ * - completion_only harness without an adequate explicit TTL → short dedicated
642
+ * primer (`dedicated_primer`) so a full review wait cannot silently outrun
643
+ * the cache TTL.
644
+ * - usageTelemetry unavailable + opaque controls → conservative dedicated
645
+ * primer (cannot observe barrier evidence), failing toward the safe option.
646
+ *
647
+ * @param {object} input
648
+ * @param {Readonly<Record<string,string>>} input.capabilities - normalized capabilities.
649
+ * @param {string} [input.ttlIntent] - declared TTL intent for the group.
650
+ * @returns {{ primerForm: "lead_reviewer"|"dedicated_primer", reason: string }}
651
+ */
652
+ export function resolvePrimerForm({ capabilities, ttlIntent } = {}) {
653
+ const caps = capabilities ?? {};
654
+ const adequateTtl = ttlIntent === "5m" || ttlIntent === "1h";
655
+ if (caps.barrierSignal === "first_output" && (adequateTtl || caps.cacheTtlControl === "5m_1h")) {
656
+ const reason = adequateTtl
657
+ ? "barrierSignal=first_output and an adequate TTL is available — a real lead reviewer may prime the group"
658
+ : "barrierSignal=first_output with cacheTtlControl=5m_1h capability — a real lead reviewer fits the cache control window";
659
+ return {
660
+ primerForm: PRIMER_FORM_LEAD_REVIEWER,
661
+ reason,
662
+ };
663
+ }
664
+ if (caps.barrierSignal === "completion_only" && caps.cacheTtlControl === "5m_1h" && ttlIntent === "1h") {
665
+ return {
666
+ primerForm: PRIMER_FORM_LEAD_REVIEWER,
667
+ reason: "completion_only harness with an explicit one-hour TTL — a long lead review fits the cache window",
668
+ };
669
+ }
670
+ return {
671
+ primerForm: PRIMER_FORM_DEDICATED,
672
+ reason: "completion_only or opaque controls without an adequate explicit TTL — a short dedicated primer avoids a full review outrunning the cache TTL",
673
+ };
674
+ }
675
+
676
+ /**
677
+ * Partition request groups into primer groups — one per distinct concrete
678
+ * model/request-prefix (Section C: heterogeneous per-angle model routing cannot
679
+ * silently reuse one model's primer to warm another).
680
+ *
681
+ * @param {object[]} requestGroups - validated request groups.
682
+ * @param {Readonly<Record<string,string>>} [capabilities]
683
+ * @returns {Array<{ model: string, requestPrefixFingerprint: string|null, groups: object[] }>}
684
+ */
685
+ export function partitionPrimerGroups(requestGroups, capabilities = {}) {
686
+ if (!Array.isArray(requestGroups)) throw new Error("partitionPrimerGroups requires an array");
687
+ // One primer group per distinct (model, request-prefix) — a primer warms ONE
688
+ // concrete model's provider cache under a specific request prefix. Groups that
689
+ // share a model AND a real (`sha256:<hex>`) fingerprint collapse into one
690
+ // bucket; groups without a proven fingerprint never collapse (see below). Use
691
+ // a nested Map keyed by model then by fingerprint (never a single delimiter-joined
692
+ // string), so a model id that itself contains "::" stays intact and two distinct
693
+ // (model, fp) pairs can never collide onto one bucket (dedup/identity safety).
694
+ const byModel = new Map();
695
+ for (let i = 0; i < requestGroups.length; i++) {
696
+ const g = requestGroups[i];
697
+ if (!byModel.has(g.model)) byModel.set(g.model, new Map());
698
+ const fpMap = byModel.get(g.model);
699
+ // Fail closed: only groups carrying a REAL (`sha256:<hex>`) fingerprint may
700
+ // share a primer bucket (that proves a common cache-relevant prefix). A
701
+ // fingerprint-less group is keyed by its own index so it NEVER collapses
702
+ // with another — without a fingerprint you cannot prove two groups share the
703
+ // same prefix, so merging them would let one primer silently cover multiple
704
+ // unknown prefixes (at best wasted priming, at worst misleading evidence).
705
+ const hasRealFp = typeof g.requestPrefixFingerprint === "string"
706
+ && g.requestPrefixFingerprint.startsWith("sha256:");
707
+ const key = hasRealFp ? g.requestPrefixFingerprint : `__unkeyed:${i}`;
708
+ if (!fpMap.has(key)) fpMap.set(key, []);
709
+ fpMap.get(key).push(g);
710
+ }
711
+ const out = [];
712
+ for (const [model, fpMap] of byModel.entries()) {
713
+ for (const [fp, groups] of fpMap.entries()) {
714
+ // Resolve the primer form CONSERVATIVELY across every collapsed group's TTL
715
+ // intent: a partition primes with a lead reviewer only when ALL its groups
716
+ // would; any group that needs a dedicated primer forces the whole partition
717
+ // down, so a mixed-TTL collapse is never decided by the first group alone.
718
+ const allLead = groups.every(
719
+ (g) => resolvePrimerForm({ capabilities, ttlIntent: g.ttlIntent }).primerForm === PRIMER_FORM_LEAD_REVIEWER,
720
+ );
721
+ out.push({
722
+ model,
723
+ requestPrefixFingerprint: fp.startsWith("sha256:") ? fp : null,
724
+ primerForm: allLead ? PRIMER_FORM_LEAD_REVIEWER : PRIMER_FORM_DEDICATED,
725
+ groups: groups.map((g) => ({ model: g.model, angles: g.angles, ttlIntent: g.ttlIntent })),
726
+ });
727
+ }
728
+ }
729
+ return out;
730
+ }
731
+
732
+ /* ------------------------------------------------------------------ *
733
+ * 6. Dispatch-prompt layout alignment (issue #1841, completes #1468)
734
+ * ------------------------------------------------------------------ */
735
+
736
+ // Leading-bytes capture cap for a dispatched reviewer prompt (issue #1841's
737
+ // record-dispatch-prompt-layout.mjs). Sized comfortably above
738
+ // write-gate-context.mjs's BRIEFING_PREFIX_INLINE_DIFF_CAP_BYTES (200 KiB) so
739
+ // a full byte-for-byte alignment check never runs out of captured bytes for
740
+ // an inline-mode round.
741
+ export const DISPATCH_PROMPT_LEADING_CAP_BYTES = 512 * 1024;
742
+
743
+ /**
744
+ * Render the byte-identical pointer LINE a reviewer prompt must lead with
745
+ * under pointer-seeding mode (GATE-EXEC-BRIEFING-PREFIX's "Cache alignment"
746
+ * paragraph): the orchestrator points every reviewer of the round at the SAME
747
+ * invariant-prefix file path rather than inlining its bytes. Deterministic in
748
+ * `prefixPath` alone, so two reviewers given the same path render the
749
+ * identical line, and a per-reviewer/angle-varying path (which would defeat
750
+ * prefix matching) renders a DIFFERENT line, exactly reproducing the defect
751
+ * this line exists to catch.
752
+ *
753
+ * @param {string} prefixPath — the invariant-prefix file path every reviewer
754
+ * of the round is pointed at (e.g. the `<gate>-<headSha>.briefing-prefix.txt`
755
+ * path).
756
+ * @returns {string}
757
+ */
758
+ export function renderBriefingPointerLine(prefixPath) {
759
+ if (typeof prefixPath !== "string" || prefixPath.trim().length === 0) {
760
+ throw new Error("renderBriefingPointerLine requires a non-empty prefixPath");
761
+ }
762
+ return `Read ${prefixPath.trim()} FIRST, in full, before anything else in this prompt — it is this round's byte-identical invariant briefing prefix (GATE-EXEC-BRIEFING-PREFIX). Your angle-specific instructions follow below, after it.`;
763
+ }
764
+
765
+ /**
766
+ * Deterministically compose a full reviewer prompt: the round's
767
+ * byte-identical invariant prefix INLINED as the leading bytes, followed by
768
+ * the (also round-invariant) volatile tail, followed by the per-group angle
769
+ * suffix (issue #1852). This is the ONE function every reviewer prompt on the
770
+ * canonical fan-out path is built from — never a hand-assembled per-group
771
+ * preamble that leads with dynamic prose ahead of the prefix (the
772
+ * "angle-first" / pointer-seeding failure mode `verifyPromptLeadingAlignment`
773
+ * exists to catch).
774
+ *
775
+ * Byte-identical-prefix-across-groups falls out of the arguments alone: any
776
+ * two calls sharing the same `prefixBytes`/`volatileBytes` (true for every
777
+ * dispatch unit of one round, since both are round-scoped, not group-scoped)
778
+ * produce prompts whose leading span is identical regardless of
779
+ * `angleSuffix` — the property AC1 requires, provable by construction rather
780
+ * than by review.
781
+ *
782
+ * Pure and offline: takes already-read bytes, never reads a file itself (the
783
+ * CLI wrapper, `compose-reviewer-prompt.mjs`, owns I/O and the
784
+ * record-dispatch-prompt-layout.mjs capture that makes the composed prompt's
785
+ * layout binding on `verify-dispatch-prompt-layout.mjs`).
786
+ *
787
+ * @param {object} input
788
+ * @param {string} input.prefixBytes — the round's invariant-prefix bytes
789
+ * (`<gate>-<headSha>.briefing-prefix.txt`), non-empty.
790
+ * @param {string} [input.volatileBytes] — the round's volatile-tail bytes
791
+ * (`<gate>-<headSha>.briefing-volatile.txt`); absent/non-string treated as
792
+ * "" (best-effort — a round that never wrote one still composes).
793
+ * @param {string} input.angleSuffix — the per-group/angle-specific prompt
794
+ * text, non-empty (an empty suffix would compose a prompt naming no work).
795
+ * @returns {string} the exact full reviewer prompt text.
796
+ */
797
+ export function composeReviewerPromptText({ prefixBytes, volatileBytes, angleSuffix } = {}) {
798
+ if (typeof prefixBytes !== "string" || prefixBytes.length === 0) {
799
+ throw new Error("composeReviewerPromptText requires non-empty prefixBytes (the round's invariant prefix)");
800
+ }
801
+ if (typeof angleSuffix !== "string" || angleSuffix.trim().length === 0) {
802
+ throw new Error("composeReviewerPromptText requires a non-empty angleSuffix (the per-group angle-specific prompt)");
803
+ }
804
+ const volatile = typeof volatileBytes === "string" ? volatileBytes : "";
805
+ return prefixBytes + volatile + angleSuffix;
806
+ }
807
+
808
+ /**
809
+ * Decide whether a dispatched reviewer prompt's LEADING bytes are
810
+ * cache-aligned (GATE-EXEC-BRIEFING-PREFIX layout, issue #1841): either the
811
+ * prompt's leading bytes are byte-identical to the round's invariant prefix
812
+ * (inline mode), or the prompt leads with the byte-identical pointer line
813
+ * naming the round's invariant-prefix path (pointer-seeding mode), with any
814
+ * angle-specific text strictly AFTER it. An angle-first prompt (dynamic
815
+ * per-unit prose ahead of the prefix/pointer) matches neither and is
816
+ * REJECTED — this is the mechanical proof the prose-only rule lacked.
817
+ *
818
+ * Pure and offline: takes the already-captured leading bytes and the already-
819
+ * read prefix bytes/path, never reads a file itself (the CLI wrapper owns
820
+ * I/O), so this is directly unit-testable with in-memory strings.
821
+ *
822
+ * @param {object} input
823
+ * @param {string} input.promptLeading — the captured leading bytes of the
824
+ * ACTUAL reviewer prompt (record-dispatch-prompt-layout.mjs's capture).
825
+ * @param {string} input.prefixBytes — the round's recorded byte-identical
826
+ * invariant-prefix content (the `<gate>-<headSha>.briefing-prefix.txt`
827
+ * bytes).
828
+ * @param {string} input.prefixPath — the path used to render this round's
829
+ * pointer line (must be the SAME path every reviewer was pointed at).
830
+ * @returns {{ aligned: boolean, mode: "inline"|"pointer"|null, reason: string|null }}
831
+ */
832
+ export function verifyPromptLeadingAlignment({ promptLeading, prefixBytes, prefixPath } = {}) {
833
+ const leading = typeof promptLeading === "string" ? promptLeading : "";
834
+ if (typeof prefixBytes === "string" && prefixBytes.length > 0 && leading.startsWith(prefixBytes)) {
835
+ return { aligned: true, mode: "inline", reason: null };
836
+ }
837
+ if (typeof prefixPath === "string" && prefixPath.trim().length > 0) {
838
+ const pointerLine = renderBriefingPointerLine(prefixPath);
839
+ if (leading.startsWith(pointerLine)) {
840
+ return { aligned: true, mode: "pointer", reason: null };
841
+ }
842
+ }
843
+ return {
844
+ aligned: false,
845
+ mode: null,
846
+ reason: "reviewer prompt does not LEAD with the round's byte-identical invariant prefix (inline mode) or its byte-identical pointer line (pointer-seeding mode) — an angle-first prompt (dynamic per-unit prose ahead of the prefix/pointer) defeats prefix matching (GATE-EXEC-BRIEFING-PREFIX)",
847
+ };
848
+ }
849
+
850
+ /* ------------------------------------------------------------------ *
851
+ * 7. Diff filtering for the shared per-head block (issue #1853)
852
+ * ------------------------------------------------------------------ */
853
+
854
+ /**
855
+ * Default excluded path patterns for the diff INLINED into a reviewer
856
+ * prompt's shared per-head block: lockfiles (high-churn, not
857
+ * review-relevant — the file's CHANGE is still listed in the changed-files
858
+ * summary, only its hunk text is dropped from the inlined diff) and common
859
+ * generated/vendored trees. ALWAYS applied on top of any caller-supplied
860
+ * `excludeGlobs` in {@link filterDiffForInline} — never replaced by it, so a
861
+ * project-specific config gap can't silently un-exclude a lockfile. A file
862
+ * excluded here is not deleted from the repo or the diff on disk; it stays
863
+ * readable on demand (`git diff -- <path>` in the reviewed worktree, or the
864
+ * full unfiltered `.diff` pointer file), only not inlined by default.
865
+ *
866
+ * Glob subset: `**\/` matches zero-or-more whole path segments, a lone `**`
867
+ * matches any suffix, a single `*` matches within one path segment only —
868
+ * see {@link matchesDiffExcludeGlob}.
869
+ */
870
+ export const DEFAULT_DIFF_EXCLUDE_GLOBS = Object.freeze([
871
+ // Lockfiles.
872
+ "package-lock.json", "**/package-lock.json",
873
+ "npm-shrinkwrap.json", "**/npm-shrinkwrap.json",
874
+ "yarn.lock", "**/yarn.lock",
875
+ "pnpm-lock.yaml", "**/pnpm-lock.yaml",
876
+ "*-lock.yaml", "**/*-lock.yaml",
877
+ "*-lock.yml", "**/*-lock.yml",
878
+ "Cargo.lock", "**/Cargo.lock",
879
+ "Gemfile.lock", "**/Gemfile.lock",
880
+ "composer.lock", "**/composer.lock",
881
+ // Generated/vendored trees.
882
+ "dist/**", "**/dist/**",
883
+ "lib/**", "**/lib/**",
884
+ "coverage/**", "**/coverage/**",
885
+ "node_modules/**", "**/node_modules/**",
886
+ ".claude/**", "**/.claude/**",
887
+ ]);
888
+
889
+ function escapeDiffGlobLiteral(ch) {
890
+ return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
891
+ }
892
+
893
+ const diffGlobPatternCache = new Map();
894
+
895
+ /**
896
+ * Minimal shell-glob subset compiler (mirrors the established pattern used
897
+ * elsewhere in this repo for config-driven path patterns): `**\/` matches
898
+ * zero-or-more whole path segments, a lone `**` matches any suffix
899
+ * (including `/`), a single `*` matches within one path segment only,
900
+ * everything else is literal. No glob dependency is installed in this repo
901
+ * and none of the callers need more than this subset.
902
+ * @param {string} relPath — POSIX-normalized repo-relative path
903
+ * @param {string} pattern
904
+ * @returns {boolean}
905
+ */
906
+ export function matchesDiffExcludeGlob(relPath, pattern) {
907
+ if (typeof relPath !== "string" || typeof pattern !== "string" || pattern.length === 0) return false;
908
+ let compiled = diffGlobPatternCache.get(pattern);
909
+ if (!compiled) {
910
+ let re = "";
911
+ for (let i = 0; i < pattern.length; i++) {
912
+ const ch = pattern[i];
913
+ if (ch === "*" && pattern[i + 1] === "*") {
914
+ if (pattern[i + 2] === "/") {
915
+ re += "(?:.*/)?";
916
+ i += 2;
917
+ } else {
918
+ re += ".*";
919
+ i += 1;
920
+ }
921
+ } else if (ch === "*") {
922
+ re += "[^/]*";
923
+ } else {
924
+ re += escapeDiffGlobLiteral(ch);
925
+ }
926
+ }
927
+ compiled = new RegExp(`^${re}$`);
928
+ diffGlobPatternCache.set(pattern, compiled);
929
+ }
930
+ return compiled.test(relPath);
931
+ }
932
+
933
+ /**
934
+ * Classify why a diff file is excluded from inlining, or `null` when it
935
+ * should be inlined. Checks {@link DEFAULT_DIFF_EXCLUDE_GLOBS} first, then
936
+ * any caller-supplied `excludeGlobs` — the default set can never be
937
+ * disabled by a caller's config.
938
+ * @param {string} relPath
939
+ * @param {{ excludeGlobs?: string[] }} [opts]
940
+ * @returns {"default"|"configured"|null}
941
+ */
942
+ export function classifyDiffFileExclusion(relPath, { excludeGlobs = [] } = {}) {
943
+ const posix = String(relPath).replace(/\\/g, "/");
944
+ for (const pattern of DEFAULT_DIFF_EXCLUDE_GLOBS) {
945
+ if (matchesDiffExcludeGlob(posix, pattern)) return "default";
946
+ }
947
+ for (const pattern of excludeGlobs) {
948
+ if (matchesDiffExcludeGlob(posix, pattern)) return "configured";
949
+ }
950
+ return null;
951
+ }
952
+
953
+ /**
954
+ * Extract a diff file-block's resulting path. Prefers the `+++ b/<path>`
955
+ * line (present for every non-deletion block), falls back to `--- a/<path>`
956
+ * (deletions), then to the `diff --git a/X b/Y` header's second token. This
957
+ * is a best-effort extraction for FILTERING purposes only (unlike a
958
+ * content-fidelity transform, a path this misses just fails open to
959
+ * "inlined" — never mis-drops a file), so it does not attempt full
960
+ * git-quoted-path decoding (rare: a path containing a quote/control
961
+ * byte/non-ASCII byte under core.quotePath) — see
962
+ * `scripts/github/write-gate-context.mjs`'s `decodeGitDiffPathToken` for
963
+ * that fuller decode if this ever needs it.
964
+ * @param {string[]} blockLines
965
+ * @returns {string|null}
966
+ */
967
+ function extractDiffBlockPath(blockLines) {
968
+ for (const line of blockLines) {
969
+ if (line.startsWith("+++ ") && !line.includes("/dev/null")) {
970
+ return line.slice(4).trim().replace(/^[abiwco]\//, "");
971
+ }
972
+ }
973
+ for (const line of blockLines) {
974
+ if (line.startsWith("--- ") && !line.includes("/dev/null")) {
975
+ return line.slice(4).trim().replace(/^[abiwco]\//, "");
976
+ }
977
+ }
978
+ const header = blockLines[0] ?? "";
979
+ const m = /^diff --git \S+ (\S+)$/.exec(header);
980
+ return m ? m[1].replace(/^[abiwco]\//, "") : null;
981
+ }
982
+
983
+ /**
984
+ * Filter a unified diff (`git diff` output) down to the files that should be
985
+ * INLINED into a reviewer prompt's shared per-head block (issue #1853):
986
+ * lockfiles, generated/vendored trees, and any caller-configured
987
+ * `excludeGlobs` are dropped whole-file (header + all hunks), every other
988
+ * file's block passes through byte-for-byte unchanged. Excluding a file here
989
+ * only affects what this function returns — it never touches the diff on
990
+ * disk or in the reviewed worktree, so an excluded file stays readable on
991
+ * demand.
992
+ *
993
+ * Pure and offline: string in, string out, no I/O — the caller (currently
994
+ * `write-gate-context.mjs`, before rendering the invariant prefix) supplies
995
+ * already-captured diff text.
996
+ *
997
+ * @param {string} diffText — `git diff` output (or `""`/absent).
998
+ * @param {{ excludeGlobs?: string[] }} [opts] — additional exclude globs,
999
+ * layered on top of {@link DEFAULT_DIFF_EXCLUDE_GLOBS} (never replacing it).
1000
+ * @returns {{ filteredDiff: string, excludedFiles: Array<{path: string, reason: "default"|"configured"}>, includedFiles: string[] }}
1001
+ */
1002
+ export function filterDiffForInline(diffText, { excludeGlobs = [] } = {}) {
1003
+ if (typeof diffText !== "string" || diffText.length === 0) {
1004
+ return { filteredDiff: typeof diffText === "string" ? diffText : "", excludedFiles: [], includedFiles: [] };
1005
+ }
1006
+ const lines = diffText.split("\n");
1007
+ const blockStarts = [];
1008
+ for (let i = 0; i < lines.length; i++) {
1009
+ if (lines[i].startsWith("diff --git ")) blockStarts.push(i);
1010
+ }
1011
+ // No recognizable `diff --git` file boundary — not a shape this filter
1012
+ // understands; pass through unfiltered rather than guess.
1013
+ if (blockStarts.length === 0) {
1014
+ return { filteredDiff: diffText, excludedFiles: [], includedFiles: [] };
1015
+ }
1016
+ const keptChunks = [];
1017
+ const excludedFiles = [];
1018
+ const includedFiles = [];
1019
+ if (blockStarts[0] > 0) keptChunks.push(lines.slice(0, blockStarts[0]).join("\n"));
1020
+ for (let b = 0; b < blockStarts.length; b++) {
1021
+ const start = blockStarts[b];
1022
+ const end = b + 1 < blockStarts.length ? blockStarts[b + 1] : lines.length;
1023
+ const blockLines = lines.slice(start, end);
1024
+ const relPath = extractDiffBlockPath(blockLines);
1025
+ const reason = relPath ? classifyDiffFileExclusion(relPath, { excludeGlobs }) : null;
1026
+ if (reason) {
1027
+ excludedFiles.push({ path: relPath, reason });
1028
+ } else {
1029
+ if (relPath) includedFiles.push(relPath);
1030
+ keptChunks.push(blockLines.join("\n"));
1031
+ }
1032
+ }
1033
+ return { filteredDiff: keptChunks.join("\n"), excludedFiles, includedFiles };
1034
+ }