@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.
- package/package.json +12 -1
- package/src/analysis/change-classifier.mjs +10 -0
- package/src/analysis/diff-analyzer.mjs +68 -1
- package/src/claude/hook-decisions.mjs +204 -5
- package/src/cli/primitives.mjs +51 -1
- package/src/config/config.mjs +307 -14
- package/src/config/extension-defaults.yaml +39 -1
- package/src/github/comment-id-guard.mjs +158 -0
- package/src/github/copilot-helpers.mjs +145 -5
- package/src/github/gh.mjs +94 -0
- package/src/github/issue-ops.mjs +13 -0
- package/src/loop/agent-stall.mjs +196 -0
- package/src/loop/bash-command-classify.mjs +277 -0
- package/src/loop/cache-telemetry-evidence.mjs +437 -0
- package/src/loop/copilot-loop-iterations.mjs +2 -1
- package/src/loop/default-branch-guard.mjs +35 -2
- package/src/loop/gate-carry-forward.mjs +19 -6
- package/src/loop/gate-fanin.mjs +190 -29
- package/src/loop/handoff-envelope.mjs +40 -20
- package/src/loop/issue-refinement-artifact.mjs +94 -0
- package/src/loop/lifecycle-state.mjs +21 -2
- package/src/loop/main-checkout-ff.mjs +73 -0
- package/src/loop/markdown-sections.mjs +40 -0
- package/src/loop/normalize.mjs +7 -0
- package/src/loop/plan-file-promote-contract.mjs +14 -1
- package/src/loop/plan-file-refine-contract.mjs +92 -8
- package/src/loop/policy-constants.mjs +9 -0
- package/src/loop/pr-gate-coordination.mjs +65 -12
- package/src/loop/primer-evidence.mjs +375 -0
- package/src/loop/public-dev-loop-routing.mjs +7 -15
- package/src/loop/queue-board-sync.mjs +1 -26
- package/src/loop/queue-driver.mjs +14 -1
- package/src/loop/refinement-grill-state.mjs +3 -5
- package/src/loop/review-dispatch-plan.mjs +1034 -0
- package/src/loop/review-lineage.mjs +588 -0
- package/src/loop/reviewer-loop-state.mjs +8 -13
- package/src/loop/run-post-merge-actions.mjs +148 -0
- package/src/loop/size-budget-merge-gate.mjs +121 -0
- package/src/loop/tracker-pr-state.mjs +5 -15
- package/src/loop/ui-designer-review-scoping.mjs +171 -0
- package/src/loop/ui-review-drive.mjs +3 -1
- package/src/loop/ui-review-report.mjs +2 -5
- package/src/loop/ui-review-teardown.mjs +3 -1
- package/src/loop/worktree-guard.mjs +80 -0
- package/src/projects/list-queue-items.mjs +1 -27
- package/src/projects/move-queue-item.mjs +38 -28
- 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
|
+
}
|