@dev-loops/core 1.0.0-rc.5 → 1.0.0-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -1
- package/src/claude/hook-decisions.mjs +172 -5
- package/src/cli/primitives.mjs +21 -0
- package/src/config/config.mjs +53 -1
- package/src/config/extension-defaults.yaml +5 -0
- package/src/github/comment-id-guard.mjs +70 -0
- package/src/github/copilot-helpers.mjs +31 -0
- package/src/github/issue-ops.mjs +6 -0
- package/src/loop/agent-stall.mjs +194 -0
- package/src/loop/bash-command-classify.mjs +277 -0
- package/src/loop/cache-telemetry-evidence.mjs +437 -0
- package/src/loop/default-branch-guard.mjs +1 -1
- package/src/loop/handoff-envelope.mjs +28 -1
- package/src/loop/issue-refinement-artifact.mjs +94 -0
- package/src/loop/main-checkout-ff.mjs +39 -0
- package/src/loop/primer-evidence.mjs +375 -0
- package/src/loop/review-dispatch-plan.mjs +595 -0
- package/src/loop/review-lineage.mjs +588 -0
- package/src/loop/worktree-guard.mjs +80 -0
- package/src/projects/move-queue-item.mjs +37 -1
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* review-lineage.mjs — additive review-lineage base + per-fix-round delta
|
|
3
|
+
* composition (issue #1468 slice 5).
|
|
4
|
+
*
|
|
5
|
+
* A new head after a fix used to rebuild a full head-specific briefing. This
|
|
6
|
+
* module introduces a stable review-lineage base plus deterministic per-round
|
|
7
|
+
* delta artifacts, so round 2+ appends only what changed instead of replacing
|
|
8
|
+
* the whole context.
|
|
9
|
+
*
|
|
10
|
+
* Artifact model (Section E of the #1468 spec):
|
|
11
|
+
*
|
|
12
|
+
* review-lineage-base
|
|
13
|
+
* lineage identity + gate + stable contracts/instructions + original
|
|
14
|
+
* review target + original full diff.
|
|
15
|
+
*
|
|
16
|
+
* round-N-delta
|
|
17
|
+
* exact base/reviewed SHAs + the fix diff + validation evidence + an
|
|
18
|
+
* independent findings verification checklist.
|
|
19
|
+
*
|
|
20
|
+
* Composition contract:
|
|
21
|
+
*
|
|
22
|
+
* round-N request = [lineage base][delta 1][delta 2]...[delta N][angle suffix]
|
|
23
|
+
*
|
|
24
|
+
* Composition is append-only and byte-deterministic: the composed request is
|
|
25
|
+
* the ordered concatenation of the lineage base and the individual delta
|
|
26
|
+
* artifacts, never a parse/reserialize of the full PR context as a replacement
|
|
27
|
+
* block. Round N+1 appends exactly one new delta segment; every prior segment
|
|
28
|
+
* is byte-identical (same ref + same hash) — that is what the
|
|
29
|
+
* "does not rebuild the full PR context" test asserts.
|
|
30
|
+
*
|
|
31
|
+
* Carry-forward semantics are unchanged: a carried clean angle still records
|
|
32
|
+
* its original reviewer and prior head. This module only preserves that
|
|
33
|
+
* provenance in the composed request; it does not decide carry-forward (that
|
|
34
|
+
* stays in gate-carry-forward.mjs) and it never fabricates a verdict.
|
|
35
|
+
*
|
|
36
|
+
* This module is pure and offline: no GitHub, no harness, no clock.
|
|
37
|
+
*/
|
|
38
|
+
import { sha256Hex } from "./review-dispatch-plan.mjs";
|
|
39
|
+
|
|
40
|
+
// Full-length SHA only: GitHub abbreviated prefixes (7-39 hex) must NOT validate
|
|
41
|
+
// as an "exact SHA" (findings: input-validation). Accept only a full 40-hex
|
|
42
|
+
// (SHA-1) or 64-hex (SHA-256) digest.
|
|
43
|
+
const HEX = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
|
|
44
|
+
|
|
45
|
+
/** @param {string} value @returns {boolean} */
|
|
46
|
+
function isHexSha(value) {
|
|
47
|
+
return typeof value === "string" && HEX.test(value.trim().toLowerCase());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const isPlainObject = (v) => v != null && typeof v === "object" && !Array.isArray(v) && !Buffer.isBuffer(v);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Recursively sort object keys for a byte-deterministic serialization. Nested
|
|
54
|
+
* caller-supplied objects (validationEvidence, findingsChecklist entries) may
|
|
55
|
+
* arrive with arbitrary key insertion order; canonicalizing here guarantees the
|
|
56
|
+
* rendered segment bytes are key-order-independent (findings: determinism).
|
|
57
|
+
*/
|
|
58
|
+
function stableStringify(value) {
|
|
59
|
+
if (Array.isArray(value)) return value.map(stableStringify);
|
|
60
|
+
if (Buffer.isBuffer(value)) return `__buffer:${value.toString("hex")}`;
|
|
61
|
+
if (isPlainObject(value)) {
|
|
62
|
+
const out = {};
|
|
63
|
+
for (const key of Object.keys(value).sort()) out[key] = stableStringify(value[key]);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Canonical byte-serialization of an artifact (sorted keys, JSON). */
|
|
70
|
+
function canonicalJson(value) {
|
|
71
|
+
return JSON.stringify(stableStringify(value));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
/** Normalize a text-ish field (string | string[] | Buffer) to a canonical string. */
|
|
76
|
+
function normalizeText(value, label) {
|
|
77
|
+
if (value == null) return "";
|
|
78
|
+
if (Buffer.isBuffer(value)) return `__buffer:${value.toString("hex")}`;
|
|
79
|
+
if (Array.isArray(value)) return value.map((s) => String(s)).join("\n");
|
|
80
|
+
if (typeof value === "string") return value;
|
|
81
|
+
return JSON.stringify(value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const HEX64 = /^sha256:[0-9a-f]{64}$/;
|
|
85
|
+
|
|
86
|
+
/** @param {string} v */
|
|
87
|
+
function isSha256(v) {
|
|
88
|
+
return typeof v === "string" && HEX64.test(v);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function requireLineageId(lineageId) {
|
|
92
|
+
if (typeof lineageId !== "string" || lineageId.trim().length === 0) {
|
|
93
|
+
throw new Error("review-lineage requires a non-empty lineageId");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function requireGate(gate) {
|
|
98
|
+
if (typeof gate !== "string" || gate.trim().length === 0) {
|
|
99
|
+
throw new Error("review-lineage requires a non-empty gate");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* ------------------------------------------------------------------ *
|
|
104
|
+
* Review-lineage base
|
|
105
|
+
* ------------------------------------------------------------------ */
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build a deterministic review-lineage base artifact for a PR review lineage.
|
|
109
|
+
*
|
|
110
|
+
* The base holds the stable material that is identical across every fix round
|
|
111
|
+
* of the lineage: the gate, the stable contracts/instructions, and the ORIGINAL
|
|
112
|
+
* review target with the ORIGINAL full diff. `baseHash` is a deterministic
|
|
113
|
+
* fingerprint over the canonicalized base so any consumer can prove two runs
|
|
114
|
+
* share a byte-identical base without re-comparing bodies.
|
|
115
|
+
*
|
|
116
|
+
* @param {object} input
|
|
117
|
+
* @param {string} input.lineageId - lineage identity (per PR review lineage).
|
|
118
|
+
* @param {string} input.gate - draft_gate | pre_approval_gate | ...
|
|
119
|
+
* @param {string} input.originalHead - the head SHA the original (round-1) review targeted.
|
|
120
|
+
* @param {string|string[]|Buffer} input.originalDiff - the original full diff.
|
|
121
|
+
* @param {string|string[]|Buffer} [input.stableContracts] - stable contracts/instructions.
|
|
122
|
+
* @returns {Readonly<object>} frozen review-lineage-base artifact.
|
|
123
|
+
*/
|
|
124
|
+
export function buildReviewLineageBase({ lineageId, gate, originalHead, originalDiff, stableContracts } = {}) {
|
|
125
|
+
requireLineageId(lineageId);
|
|
126
|
+
requireGate(gate);
|
|
127
|
+
if (!isHexSha(originalHead)) {
|
|
128
|
+
throw new Error(`review-lineage originalHead must be a hex sha, got ${JSON.stringify(originalHead)}`);
|
|
129
|
+
}
|
|
130
|
+
if (originalDiff == null || String(originalDiff).length === 0) {
|
|
131
|
+
throw new Error("review-lineage base requires an originalDiff (the original full diff)");
|
|
132
|
+
}
|
|
133
|
+
const base = {
|
|
134
|
+
kind: "review-lineage-base",
|
|
135
|
+
lineageId,
|
|
136
|
+
gate,
|
|
137
|
+
originalHead: originalHead.trim().toLowerCase(),
|
|
138
|
+
originalDiff: normalizeText(originalDiff, "originalDiff"),
|
|
139
|
+
stableContracts: normalizeText(stableContracts, "stableContracts"),
|
|
140
|
+
};
|
|
141
|
+
return Object.freeze({
|
|
142
|
+
...base,
|
|
143
|
+
baseHash: sha256Hex(base),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* ------------------------------------------------------------------ *
|
|
148
|
+
* Per-fix-round delta
|
|
149
|
+
* ------------------------------------------------------------------ */
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Build a deterministic per-fix-round delta artifact.
|
|
153
|
+
*
|
|
154
|
+
* A delta records, for one fix round, the exact base/head SHAs, the actual fix
|
|
155
|
+
* diff, the validation evidence, and an INDEPENDENT findings verification
|
|
156
|
+
* checklist (not agreement-seeking verdict prose). `deltaHash` fingerprints the
|
|
157
|
+
* canonicalized delta so identical inputs always yield byte-identical deltas.
|
|
158
|
+
*
|
|
159
|
+
* @param {object} input
|
|
160
|
+
* @param {string} input.lineageId - must match the owning lineage.
|
|
161
|
+
* @param {number} input.round - 1-based fix-round number.
|
|
162
|
+
* @param {string} input.gate
|
|
163
|
+
* @param {string} input.baseHead - the head assumed as the delta's base.
|
|
164
|
+
* @param {string} input.reviewedHead - the head actually reviewed in this round.
|
|
165
|
+
* @param {string|string[]|Buffer} input.fixDiff - the actual fix diff for this round.
|
|
166
|
+
* @param {string|string[]|Buffer|object} [input.validationEvidence] - validation evidence.
|
|
167
|
+
* @param {Array<object>} [input.findingsChecklist] - independent findings checklist.
|
|
168
|
+
* @returns {Readonly<object>} frozen round-delta artifact.
|
|
169
|
+
*/
|
|
170
|
+
export function buildFixRoundDelta({ lineageId, round, gate, baseHead, reviewedHead, fixDiff, validationEvidence, findingsChecklist } = {}) {
|
|
171
|
+
requireLineageId(lineageId);
|
|
172
|
+
requireGate(gate);
|
|
173
|
+
if (!Number.isInteger(round) || round < 1) {
|
|
174
|
+
throw new Error(`buildFixRoundDelta round must be a positive integer, got ${JSON.stringify(round)}`);
|
|
175
|
+
}
|
|
176
|
+
if (!isHexSha(baseHead) || !isHexSha(reviewedHead)) {
|
|
177
|
+
throw new Error("buildFixRoundDelta baseHead and reviewedHead must be hex shas");
|
|
178
|
+
}
|
|
179
|
+
if (fixDiff == null || String(fixDiff).length === 0) {
|
|
180
|
+
throw new Error("buildFixRoundDelta requires a fixDiff (the actual fix diff)");
|
|
181
|
+
}
|
|
182
|
+
if (findingsChecklist != null) {
|
|
183
|
+
if (!Array.isArray(findingsChecklist)) {
|
|
184
|
+
throw new Error("buildFixRoundDelta findingsChecklist must be an array");
|
|
185
|
+
}
|
|
186
|
+
for (let i = 0; i < findingsChecklist.length; i++) {
|
|
187
|
+
const f = findingsChecklist[i];
|
|
188
|
+
if (typeof f !== "object" || f === null || Array.isArray(f)) {
|
|
189
|
+
throw new Error(`findingsChecklist[${i}] must be an object entry`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const delta = {
|
|
194
|
+
kind: "round-delta",
|
|
195
|
+
lineageId,
|
|
196
|
+
round,
|
|
197
|
+
gate,
|
|
198
|
+
baseHead: baseHead.trim().toLowerCase(),
|
|
199
|
+
reviewedHead: reviewedHead.trim().toLowerCase(),
|
|
200
|
+
fixDiff: normalizeText(fixDiff, "fixDiff"),
|
|
201
|
+
...(validationEvidence != null
|
|
202
|
+
? {
|
|
203
|
+
validationEvidence:
|
|
204
|
+
typeof validationEvidence === "object" &&
|
|
205
|
+
!Array.isArray(validationEvidence) &&
|
|
206
|
+
!Buffer.isBuffer(validationEvidence)
|
|
207
|
+
? JSON.parse(JSON.stringify(validationEvidence))
|
|
208
|
+
: normalizeText(validationEvidence, "validationEvidence"),
|
|
209
|
+
}
|
|
210
|
+
: {}),
|
|
211
|
+
...(findingsChecklist != null ? { findingsChecklist: JSON.parse(JSON.stringify(findingsChecklist)) } : {}),
|
|
212
|
+
};
|
|
213
|
+
return Object.freeze({
|
|
214
|
+
...delta,
|
|
215
|
+
deltaHash: sha256Hex(delta),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/* ------------------------------------------------------------------ *
|
|
220
|
+
* Append-only round request composition
|
|
221
|
+
* ------------------------------------------------------------------ */
|
|
222
|
+
|
|
223
|
+
export const LINEAGE_BASE_SLOT = "lineageBase";
|
|
224
|
+
export const ROUND_DELTA_SLOT = "roundDelta";
|
|
225
|
+
export const ANGLE_SUFFIX_SLOT = "angleSuffix";
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Compose the round-N request as an append-only ordered segment list:
|
|
229
|
+
*
|
|
230
|
+
* [lineage base][delta 1]...[delta N][angle suffix]
|
|
231
|
+
*
|
|
232
|
+
* The returned `segments` carry individual artifact bytes + hashes so a
|
|
233
|
+
* consumer can render the request by concatenating segment bytes IN ORDER
|
|
234
|
+
* (never parsing/reserializing prior segments). Round N+1 appends exactly one
|
|
235
|
+
* new delta segment: callers should REUSE the prior composed segments (or the
|
|
236
|
+
* base + prior deltas) rather than rebuilding the full PR context, and this
|
|
237
|
+
* function's contract + tests pin that reuse property.
|
|
238
|
+
*
|
|
239
|
+
* Segments before the new delta are byte-identical to the prior round's
|
|
240
|
+
* (same `ref` + same `hash`), which is the mechanical proof of append-only
|
|
241
|
+
* composition (AC-2: does not rebuild the full PR context as a replacement
|
|
242
|
+
* block).
|
|
243
|
+
*
|
|
244
|
+
* `carriedAngles` provenance is preserved unchanged (carry-forward semantics):
|
|
245
|
+
* each entry is { angle, originalReviewer, priorHead } and is folded into the
|
|
246
|
+
* composed hash so a carried angle's provenance is pinned — but never
|
|
247
|
+
* fabricated. A carried clean angle keeps exactly the original reviewer and
|
|
248
|
+
* prior head it was recorded with.
|
|
249
|
+
*
|
|
250
|
+
* @param {object} input
|
|
251
|
+
* @param {object} input.lineageBase - a valid review-lineage-base artifact.
|
|
252
|
+
* @param {object[]} [input.priorDeltas] - ordered round deltas (1..N-1).
|
|
253
|
+
* @param {object} input.newDelta - the delta being appended for this round.
|
|
254
|
+
* @param {string|Buffer} [input.angleSuffix] - angle-specific suffix.
|
|
255
|
+
* @param {Array<{angle:string, originalReviewer:string, priorHead:string}>} [input.carriedAngles]
|
|
256
|
+
* @returns {Readonly<object>} composed round request.
|
|
257
|
+
*/
|
|
258
|
+
export function composeRoundRequest({ lineageBase, priorDeltas = [], newDelta, angleSuffix, carriedAngles = [] } = {}) {
|
|
259
|
+
if (!lineageBase || lineageBase.kind !== "review-lineage-base" || !isSha256(lineageBase.baseHash)) {
|
|
260
|
+
throw new Error("composeRoundRequest requires a valid lineage base artifact");
|
|
261
|
+
}
|
|
262
|
+
if (!Array.isArray(priorDeltas)) throw new Error("composeRoundRequest priorDeltas must be an array");
|
|
263
|
+
if (!newDelta || newDelta.kind !== "round-delta" || !isSha256(newDelta.deltaHash)) {
|
|
264
|
+
throw new Error("composeRoundRequest requires a valid round-delta artifact");
|
|
265
|
+
}
|
|
266
|
+
if (newDelta.lineageId !== lineageBase.lineageId) {
|
|
267
|
+
throw new Error("composeRoundRequest newDelta lineageId must match the lineage base");
|
|
268
|
+
}
|
|
269
|
+
if (!Array.isArray(carriedAngles)) throw new Error("composeRoundRequest carriedAngles must be an array");
|
|
270
|
+
// Every delta in the lineage (prior + new) must share the base's gate
|
|
271
|
+
// (findings: scope — gate consistency) and carry a valid deltaHash
|
|
272
|
+
// (findings: input-validation — prior-delta hash validation).
|
|
273
|
+
const allDeltas = [...priorDeltas, newDelta];
|
|
274
|
+
for (let i = 0; i < priorDeltas.length; i++) {
|
|
275
|
+
const d = priorDeltas[i];
|
|
276
|
+
if (!d || d.kind !== "round-delta" || d.lineageId !== lineageBase.lineageId) {
|
|
277
|
+
throw new Error("composeRoundRequest priorDeltas must be valid round-delta artifacts of the same lineage");
|
|
278
|
+
}
|
|
279
|
+
if (!isSha256(d.deltaHash)) {
|
|
280
|
+
throw new Error(`composeRoundRequest priorDeltas[${i}].deltaHash must be a valid sha256:<64hex>`);
|
|
281
|
+
}
|
|
282
|
+
if (d.gate !== lineageBase.gate) {
|
|
283
|
+
throw new Error("composeRoundRequest priorDeltas gate must match the lineage base gate");
|
|
284
|
+
}
|
|
285
|
+
if (d.round !== i + 1) {
|
|
286
|
+
throw new Error(`composeRoundRequest priorDeltas must be contiguous from round 1; expected round ${i + 1}, got ${d.round}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (newDelta.round !== priorDeltas.length + 1) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`composeRoundRequest newDelta.round must follow the prior deltas; expected ${priorDeltas.length + 1}, got ${newDelta.round}`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (newDelta.gate !== lineageBase.gate) {
|
|
295
|
+
throw new Error("composeRoundRequest newDelta gate must match the lineage base gate");
|
|
296
|
+
}
|
|
297
|
+
// SHA-chain continuity (findings: correctness / scope): the delta chain must
|
|
298
|
+
// be anchored to the lineage base and head-linked end-to-end — round-1's
|
|
299
|
+
// baseHead must equal the base's originalHead, and each later delta's
|
|
300
|
+
// baseHead must equal the immediately prior delta's reviewedHead. This keeps
|
|
301
|
+
// the "exact SHAs" record truthful: a composed request can never claim a base
|
|
302
|
+
// that was not actually the prior reviewed head.
|
|
303
|
+
for (let i = 0; i < allDeltas.length; i++) {
|
|
304
|
+
const d = allDeltas[i];
|
|
305
|
+
if (i === 0) {
|
|
306
|
+
if (d.baseHead !== lineageBase.originalHead) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
`composeRoundRequest round-1 baseHead ${d.baseHead} must equal the lineage base originalHead ${lineageBase.originalHead}`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
} else if (d.baseHead !== allDeltas[i - 1].reviewedHead) {
|
|
312
|
+
throw new Error(
|
|
313
|
+
`composeRoundRequest round-${d.round} baseHead ${d.baseHead} must equal prior round reviewedHead ${allDeltas[i - 1].reviewedHead}`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const segments = [
|
|
319
|
+
{
|
|
320
|
+
slot: LINEAGE_BASE_SLOT,
|
|
321
|
+
ref: "lineage-base",
|
|
322
|
+
hash: lineageBase.baseHash,
|
|
323
|
+
bytes: canonicalJson(lineageBase),
|
|
324
|
+
},
|
|
325
|
+
];
|
|
326
|
+
const deltas = [...priorDeltas, newDelta];
|
|
327
|
+
for (const d of deltas) {
|
|
328
|
+
segments.push({
|
|
329
|
+
slot: ROUND_DELTA_SLOT,
|
|
330
|
+
ref: `round-${String(d.round).padStart(2, "0")}`,
|
|
331
|
+
round: d.round,
|
|
332
|
+
hash: d.deltaHash,
|
|
333
|
+
bytes: canonicalJson(d),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
if (angleSuffix != null && String(angleSuffix).length > 0) {
|
|
337
|
+
segments.push({
|
|
338
|
+
slot: ANGLE_SUFFIX_SLOT,
|
|
339
|
+
ref: "angle-suffix",
|
|
340
|
+
bytes: Buffer.isBuffer(angleSuffix) ? angleSuffix.toString("utf8") : String(angleSuffix),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const normalizedCarried = carriedAngles.map((c) => {
|
|
345
|
+
if (typeof c !== "object" || c === null) throw new Error("composeRoundRequest carriedAngles entries must be objects");
|
|
346
|
+
if (typeof c.angle !== "string" || typeof c.originalReviewer !== "string" || !isHexSha(c.priorHead)) {
|
|
347
|
+
throw new Error("composeRoundRequest carriedAngles entries require { angle, originalReviewer, priorHead }");
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
angle: c.angle,
|
|
351
|
+
originalReviewer: c.originalReviewer,
|
|
352
|
+
priorHead: c.priorHead.trim().toLowerCase(), // trim + lower, consistent with other SHA fields
|
|
353
|
+
};
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const composedRequest = {
|
|
357
|
+
kind: "composed-round-request",
|
|
358
|
+
lineageId: lineageBase.lineageId,
|
|
359
|
+
gate: lineageBase.gate,
|
|
360
|
+
round: newDelta.round,
|
|
361
|
+
segments,
|
|
362
|
+
carriedAngles: normalizedCarried,
|
|
363
|
+
};
|
|
364
|
+
return Object.freeze({
|
|
365
|
+
...composedRequest,
|
|
366
|
+
composedHash: sha256Hex(composedRequest),
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Render a composed round request to a single byte string by concatenating the
|
|
372
|
+
* ordered segment bytes. Purely a convenience for consumers that need a flat
|
|
373
|
+
* briefing block; the append-only/exact-reuse contract is honored by the
|
|
374
|
+
* segment list itself (callers may also render segments individually).
|
|
375
|
+
*
|
|
376
|
+
* @param {object} composed - a composed-round-request artifact.
|
|
377
|
+
* @returns {string} concatenated segment bytes in order.
|
|
378
|
+
*/
|
|
379
|
+
export function renderComposedRequest(composed) {
|
|
380
|
+
if (!composed || composed.kind !== "composed-round-request" || !Array.isArray(composed.segments)) {
|
|
381
|
+
throw new Error("renderComposedRequest requires a composed-round-request artifact");
|
|
382
|
+
}
|
|
383
|
+
return composed.segments.map((s) => (s.bytes == null ? "" : String(s.bytes))).join("");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/* ------------------------------------------------------------------ *
|
|
387
|
+
* Compaction / rebase policy (issue #1468 slice 6)
|
|
388
|
+
* ------------------------------------------------------------------ */
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Default lineage compaction threshold: the maximum number of accumulated
|
|
392
|
+
* round-delta segments a review lineage may carry before it MUST be compacted
|
|
393
|
+
* (rebased). Provider prompt caches are bounded by breakpoint/lookback limits
|
|
394
|
+
* and context-window size; unbounded delta accumulation would eventually
|
|
395
|
+
* overflow them. Consumers may raise or lower this, and may additionally set a
|
|
396
|
+
* byte budget (`maxLineageBytes`) to bound provider-visible context size
|
|
397
|
+
* directly.
|
|
398
|
+
*
|
|
399
|
+
* A rebase is triggered when EITHER bound is exceeded:
|
|
400
|
+
* - round-delta count exceeds `maxRounds` (default 20), OR
|
|
401
|
+
* - the composed lineage byte size exceeds `maxLineageBytes` when provided.
|
|
402
|
+
*/
|
|
403
|
+
export const DEFAULT_LINEAGE_MAX_ROUNDS = 20;
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Total byte size of a lineage's accumulated artifact content (base + all
|
|
407
|
+
* accumulated deltas), using the same canonical byte-serialization that
|
|
408
|
+
* `composeRoundRequest` renders. Used to enforce a byte budget on the only part
|
|
409
|
+
* of a round request that grows across fix rounds (round-delta accumulation).
|
|
410
|
+
* It counts the lineage artifacts themselves, not the per-round angle-suffix /
|
|
411
|
+
* carried-angle context (constant regardless of round count).
|
|
412
|
+
*
|
|
413
|
+
* @param {object} input
|
|
414
|
+
* @param {object} input.lineageBase - valid review-lineage-base.
|
|
415
|
+
* @param {object[]} [input.deltas] - accumulated round deltas.
|
|
416
|
+
* @returns {number} total composed byte size.
|
|
417
|
+
*/
|
|
418
|
+
export function lineageByteSize({ lineageBase, deltas = [] } = {}) {
|
|
419
|
+
if (!lineageBase || lineageBase.kind !== "review-lineage-base" || !isSha256(lineageBase.baseHash)) {
|
|
420
|
+
throw new Error("lineageByteSize requires a valid lineage base artifact");
|
|
421
|
+
}
|
|
422
|
+
if (!Array.isArray(deltas)) throw new Error("lineageByteSize deltas must be an array");
|
|
423
|
+
const utf8Length = (v) => Buffer.byteLength(canonicalJson(v), "utf8");
|
|
424
|
+
let total = utf8Length(lineageBase);
|
|
425
|
+
for (let i = 0; i < deltas.length; i++) {
|
|
426
|
+
const d = deltas[i];
|
|
427
|
+
if (!d || d.kind !== "round-delta" || !isSha256(d.deltaHash)) {
|
|
428
|
+
throw new Error(`lineageByteSize deltas[${i}] must be a valid round-delta artifact`);
|
|
429
|
+
}
|
|
430
|
+
if (d.lineageId !== lineageBase.lineageId || d.gate !== lineageBase.gate) {
|
|
431
|
+
throw new Error(
|
|
432
|
+
`lineageByteSize deltas[${i}] must share the lineage base's lineageId and gate (parity with rebaseLineage/composeRoundRequest)`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
total += utf8Length(d);
|
|
436
|
+
}
|
|
437
|
+
return total;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Decide whether a review lineage must be compacted (rebased) now, against the
|
|
442
|
+
* compaction threshold(s). Pure predicate — it never mutates the lineage.
|
|
443
|
+
*
|
|
444
|
+
* @param {object} input
|
|
445
|
+
* @param {object} input.lineageBase - valid review-lineage-base.
|
|
446
|
+
* @param {object[]} [input.deltas] - accumulated round deltas.
|
|
447
|
+
* @param {number} [input.maxRounds] - max delta rounds before a rebase (default
|
|
448
|
+
* {@link DEFAULT_LINEAGE_MAX_ROUNDS}).
|
|
449
|
+
* @param {number} [input.maxLineageBytes] - optional byte budget over the
|
|
450
|
+
* accumulated lineage (base + deltas — the ONLY part of a round request that
|
|
451
|
+
* grows with fix rounds); a lineage whose accumulated size exceeds it must
|
|
452
|
+
* be rebased. Per-round angle-suffix and carried-angle context are constant
|
|
453
|
+
* regardless of round count, so they are deliberately not part of this
|
|
454
|
+
* growing-lineage bound.
|
|
455
|
+
* @returns {{ requiresCompaction: boolean, reason: string|null, deltaCount: number, lineageBytes: number, maxRounds: number, maxLineageBytes: number|null }}
|
|
456
|
+
*/
|
|
457
|
+
export function checkLineageCompaction({ lineageBase, deltas = [], maxRounds = DEFAULT_LINEAGE_MAX_ROUNDS, maxLineageBytes } = {}) {
|
|
458
|
+
if (!Number.isInteger(maxRounds) || maxRounds < 1) {
|
|
459
|
+
throw new Error(`checkLineageCompaction maxRounds must be a positive integer, got ${JSON.stringify(maxRounds)}`);
|
|
460
|
+
}
|
|
461
|
+
if (maxLineageBytes != null && (!Number.isInteger(maxLineageBytes) || maxLineageBytes < 1)) {
|
|
462
|
+
throw new Error(`checkLineageCompaction maxLineageBytes must be a positive integer, got ${JSON.stringify(maxLineageBytes)}`);
|
|
463
|
+
}
|
|
464
|
+
if (!Array.isArray(deltas)) throw new Error("checkLineageCompaction deltas must be an array");
|
|
465
|
+
const deltaCount = deltas.length;
|
|
466
|
+
const lineageBytes = lineageByteSize({ lineageBase, deltas });
|
|
467
|
+
let reason = null;
|
|
468
|
+
if (deltaCount > maxRounds) {
|
|
469
|
+
reason = `delta count ${deltaCount} exceeds maxRounds ${maxRounds}`;
|
|
470
|
+
} else if (maxLineageBytes != null && lineageBytes > maxLineageBytes) {
|
|
471
|
+
reason = `composed lineage bytes ${lineageBytes} exceed maxLineageBytes ${maxLineageBytes}`;
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
requiresCompaction: reason !== null,
|
|
475
|
+
reason,
|
|
476
|
+
deltaCount,
|
|
477
|
+
lineageBytes,
|
|
478
|
+
maxRounds,
|
|
479
|
+
maxLineageBytes: maxLineageBytes ?? null,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Compact (rebase) a review lineage.
|
|
485
|
+
*
|
|
486
|
+
* When the delta accumulation crosses the compaction threshold, the lineage is
|
|
487
|
+
* rebased: the accumulated deltas are folded into a NEW compacted base whose
|
|
488
|
+
* `originalHead` advances to the current (latest reviewed) head and whose
|
|
489
|
+
* `originalDiff` becomes the cumulative diff (the base's original full diff
|
|
490
|
+
* merged with every accepted fix diff, in order). Future rounds append fresh
|
|
491
|
+
* deltas to this compacted base, so the COMPOSED request stays within the
|
|
492
|
+
* provider breakpoint/lookback + context budget instead of growing unbounded.
|
|
493
|
+
*
|
|
494
|
+
* Rebase behaviour guarantees:
|
|
495
|
+
* - The compacted base keeps the same `lineageId` and `gate` and is itself a
|
|
496
|
+
* valid `review-lineage-base`; `composeRoundRequest` accepts it unchanged.
|
|
497
|
+
* - Composition rules are preserved: a new round-1 delta whose `baseHead`
|
|
498
|
+
* equals the compacted base's `originalHead` composes cleanly and
|
|
499
|
+
* byte-deterministically (SHA-chain continuity + append-only contract).
|
|
500
|
+
* - The rebase is traceable: the compacted base records `rebaseSourceBaseHash`
|
|
501
|
+
* (the base it was compacted from) and `compactedRoundCount` (the total
|
|
502
|
+
* number of fix rounds folded into this compacted lineage so far). Prior
|
|
503
|
+
* delta artifacts remain available (append-only history); only the COMPOSED
|
|
504
|
+
* request is recomposed from the compacted base.
|
|
505
|
+
*
|
|
506
|
+
* @param {object} input
|
|
507
|
+
* @param {object} input.lineageBase - valid review-lineage-base.
|
|
508
|
+
* @param {object[]} [input.deltas] - all accumulated round deltas in order.
|
|
509
|
+
* @param {string|string[]|Buffer} [input.currentDiff] - optional cumulative diff
|
|
510
|
+
* for the rebased `originalDiff`; defaults to the base's original diff merged
|
|
511
|
+
* with every delta's fix diff.
|
|
512
|
+
* @returns {Readonly<object>} compacted review-lineage-base artifact.
|
|
513
|
+
*/
|
|
514
|
+
export function rebaseLineage({ lineageBase, deltas = [], currentDiff } = {}) {
|
|
515
|
+
if (!lineageBase || lineageBase.kind !== "review-lineage-base" || !isSha256(lineageBase.baseHash)) {
|
|
516
|
+
throw new Error("rebaseLineage requires a valid lineage base artifact");
|
|
517
|
+
}
|
|
518
|
+
if (!Array.isArray(deltas)) throw new Error("rebaseLineage deltas must be an array");
|
|
519
|
+
for (let i = 0; i < deltas.length; i++) {
|
|
520
|
+
const d = deltas[i];
|
|
521
|
+
if (!d || d.kind !== "round-delta" || d.lineageId !== lineageBase.lineageId) {
|
|
522
|
+
throw new Error("rebaseLineage deltas must be valid round-delta artifacts of the same lineage");
|
|
523
|
+
}
|
|
524
|
+
if (!isSha256(d.deltaHash)) {
|
|
525
|
+
throw new Error(`rebaseLineage deltas[${i}].deltaHash must be a valid sha256:<64hex>`);
|
|
526
|
+
}
|
|
527
|
+
if (!isHexSha(d.baseHead) || !isHexSha(d.reviewedHead)) {
|
|
528
|
+
throw new Error(`rebaseLineage deltas[${i}].baseHead/reviewedHead must be full hex SHAs`);
|
|
529
|
+
}
|
|
530
|
+
if (d.gate !== lineageBase.gate) {
|
|
531
|
+
throw new Error("rebaseLineage deltas gate must match the lineage base gate");
|
|
532
|
+
}
|
|
533
|
+
if (d.round !== i + 1) {
|
|
534
|
+
throw new Error(`rebaseLineage deltas must be contiguous from round 1; expected round ${i + 1}, got ${d.round}`);
|
|
535
|
+
}
|
|
536
|
+
if (d.fixDiff == null || String(d.fixDiff).length === 0) {
|
|
537
|
+
throw new Error(`rebaseLineage deltas[${i}] requires a non-empty fixDiff (the actual fix diff)`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
// SHA-chain continuity across the accumulated deltas (keep the composed
|
|
541
|
+
// request truthful — same rule `composeRoundRequest` enforces).
|
|
542
|
+
for (let i = 0; i < deltas.length; i++) {
|
|
543
|
+
const d = deltas[i];
|
|
544
|
+
if (i === 0) {
|
|
545
|
+
if (d.baseHead !== lineageBase.originalHead) {
|
|
546
|
+
throw new Error(
|
|
547
|
+
`rebaseLineage round-1 baseHead ${d.baseHead} must equal the lineage base originalHead ${lineageBase.originalHead}`,
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
} else if (d.baseHead !== deltas[i - 1].reviewedHead) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
`rebaseLineage round-${d.round} baseHead ${d.baseHead} must equal prior round reviewedHead ${deltas[i - 1].reviewedHead}`,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
let newOriginalHead = lineageBase.originalHead;
|
|
558
|
+
if (deltas.length > 0) newOriginalHead = deltas[deltas.length - 1].reviewedHead.trim().toLowerCase();
|
|
559
|
+
|
|
560
|
+
let diff;
|
|
561
|
+
if (currentDiff != null) {
|
|
562
|
+
const isEmpty = Buffer.isBuffer(currentDiff) ? currentDiff.length === 0 : String(currentDiff).length === 0;
|
|
563
|
+
if (isEmpty) {
|
|
564
|
+
throw new Error("rebaseLineage currentDiff must be non-empty");
|
|
565
|
+
}
|
|
566
|
+
diff = normalizeText(currentDiff, "currentDiff");
|
|
567
|
+
} else if (deltas.length === 0) {
|
|
568
|
+
diff = lineageBase.originalDiff;
|
|
569
|
+
} else {
|
|
570
|
+
diff = [lineageBase.originalDiff, ...deltas.map((d) => normalizeText(d.fixDiff, "fixDiff"))].join("\n");
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const compacted = {
|
|
574
|
+
kind: "review-lineage-base",
|
|
575
|
+
lineageId: lineageBase.lineageId,
|
|
576
|
+
gate: lineageBase.gate,
|
|
577
|
+
originalHead: newOriginalHead,
|
|
578
|
+
originalDiff: diff,
|
|
579
|
+
stableContracts: lineageBase.stableContracts,
|
|
580
|
+
compaction: true,
|
|
581
|
+
rebaseSourceBaseHash: lineageBase.baseHash,
|
|
582
|
+
compactedRoundCount: (lineageBase.compactedRoundCount ?? 0) + deltas.length,
|
|
583
|
+
};
|
|
584
|
+
return Object.freeze({
|
|
585
|
+
...compacted,
|
|
586
|
+
baseHash: sha256Hex(compacted),
|
|
587
|
+
});
|
|
588
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { realpathSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
11
12
|
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
13
14
|
// Worktree path helpers
|
|
@@ -111,6 +112,85 @@ export function isListedWorktree(cwd, worktreePaths) {
|
|
|
111
112
|
});
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Resolve the root of the listed git worktree that contains `cwd`.
|
|
117
|
+
*
|
|
118
|
+
* Mirrors `isListedWorktree`'s matching (realpath-resolved, tmp/worktrees/-scoped,
|
|
119
|
+
* exact-or-subdirectory) but returns the worktree ROOT instead of a boolean, so
|
|
120
|
+
* callers can address files relative to the worktree's own subtree (`packages/`,
|
|
121
|
+
* `node_modules/`) rather than the possibly-nested `cwd`.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} cwd - Absolute or relative path inside the worktree.
|
|
124
|
+
* @param {string[]} worktreePaths - Array of paths from `parseAllWorktreePaths`.
|
|
125
|
+
* @returns {string | null} The worktree root path, or null when `cwd` is not inside a listed worktree.
|
|
126
|
+
*/
|
|
127
|
+
export function resolveContainingWorktreeRoot(cwd, worktreePaths) {
|
|
128
|
+
let resolvedCwd;
|
|
129
|
+
try { resolvedCwd = realpathSync(cwd); } catch { resolvedCwd = cwd; }
|
|
130
|
+
const normalizedCwd = resolvedCwd.replace(/\\/g, "/").replace(/\/+$/u, "");
|
|
131
|
+
for (const p of worktreePaths) {
|
|
132
|
+
let resolvedP;
|
|
133
|
+
try { resolvedP = realpathSync(p); } catch { resolvedP = p; }
|
|
134
|
+
const normalizedP = resolvedP.replace(/\\/g, "/").replace(/\/+$/u, "");
|
|
135
|
+
if (!isUnderWorktreePath(normalizedP)) continue;
|
|
136
|
+
if (normalizedCwd === normalizedP || normalizedCwd.startsWith(normalizedP + "/")) {
|
|
137
|
+
return normalizedP;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Check whether a worktree's `node_modules/@dev-loops/core` resolves into the
|
|
145
|
+
* worktree's OWN `packages/core`, not the main checkout's (#1627).
|
|
146
|
+
*
|
|
147
|
+
* A link escaping to the main checkout silently tests main's core instead of the
|
|
148
|
+
* branch's (WORKTREE-DEPS-ISOLATED / WORKTREE-CREATE-PROVISION): the forbidden
|
|
149
|
+
* state is a worktree whose node_modules resolves dependencies from the main
|
|
150
|
+
* checkout.
|
|
151
|
+
*
|
|
152
|
+
* Tolerates consumer repos with no `packages/core` (no monorepo core to isolate,
|
|
153
|
+
* so the requirement is vacuously satisfied), and worktrees whose
|
|
154
|
+
* `node_modules/@dev-loops/core` link is absent (nothing resolves out of tree, so
|
|
155
|
+
* there is no escape to refuse). Only a link that RESOLVES and points outside the
|
|
156
|
+
* worktree's own `packages/core` is treated as the escaping (non-isolated) state.
|
|
157
|
+
* The worktree root is resolved from `cwd` via the listed worktree paths so a
|
|
158
|
+
* nested `cwd` still addresses the worktree's own subtree.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} cwd - Absolute or relative path inside the worktree.
|
|
161
|
+
* @param {string[]} worktreePaths - Array of paths from `parseAllWorktreePaths`.
|
|
162
|
+
* @returns {boolean} true when isolated (or no core to isolate); false when the
|
|
163
|
+
* core link escapes the worktree's own `packages/core`.
|
|
164
|
+
*/
|
|
165
|
+
export function isWorktreeCoreIsolated(cwd, worktreePaths) {
|
|
166
|
+
const root = resolveContainingWorktreeRoot(cwd, worktreePaths);
|
|
167
|
+
if (root === null) {
|
|
168
|
+
// Not resolving to a listed worktree — isolation is enforced elsewhere
|
|
169
|
+
// (isListedWorktree / isUnderWorktreePath); vacuously satisfied here.
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
const coreDir = path.join(root, "packages", "core");
|
|
173
|
+
const linkPath = path.join(root, "node_modules", "@dev-loops", "core");
|
|
174
|
+
const normalize = (p) => {
|
|
175
|
+
try {
|
|
176
|
+
return realpathSync(p).replace(/\\/g, "/").replace(/\/+$/u, "");
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
const coreReal = normalize(coreDir);
|
|
182
|
+
if (coreReal === null) {
|
|
183
|
+
// Consumer repo with no packages/core — no monorepo core to isolate.
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
const linkReal = normalize(linkPath);
|
|
187
|
+
if (linkReal === null) {
|
|
188
|
+
// node_modules/@dev-loops/core absent — nothing resolves out of tree.
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
return linkReal === coreReal;
|
|
192
|
+
}
|
|
193
|
+
|
|
114
194
|
|
|
115
195
|
|
|
116
196
|
// ---------------------------------------------------------------------------
|