frontend-project-context 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,502 @@
1
+ import { canonicalJson, digestJson } from "./canonical-json.mjs";
2
+ import { blockingContextFindings, checkProject } from "./checker.mjs";
3
+ import { ProjectContextError, fail } from "./errors.mjs";
4
+ import { readJsonFile } from "./io.mjs";
5
+ import { normalizeRelativePath, resolveExistingInside, resolveWritableInside } from "./path-policy.mjs";
6
+ import { loadProject } from "./project-store.mjs";
7
+ import { effectiveItems, scopeApplies } from "./scope-compiler.mjs";
8
+ import {
9
+ CONTEXT_BUDGET_UNIT,
10
+ INTEGRATION_REVIEW_BUNDLE_SCHEMA_VERSION,
11
+ STAGE_CONTEXT_BUNDLE_SCHEMA_VERSION,
12
+ taskContextPlanDigest,
13
+ validateStageContextBundle,
14
+ validateStageReceipt,
15
+ validateTaskContextPlan,
16
+ } from "./task-context-schema.mjs";
17
+
18
+ const LOCAL_SOURCE_KINDS = new Set(["file", "path", "json-pointer"]);
19
+ const EXCLUDED_BODIES = Object.freeze(["chat-history", "code-bodies", "git-diffs", "source-bodies", "verification-logs"]);
20
+
21
+ function uniqueSorted(values) {
22
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right));
23
+ }
24
+
25
+ function snapshots(project) {
26
+ return {
27
+ contract: project.contractDigest,
28
+ sourcesLock: project.sourcesLockDigest,
29
+ projectionsLock: project.projectionsLockDigest,
30
+ };
31
+ }
32
+
33
+ function pathWithin(target, scope) {
34
+ return target === scope || target.startsWith(`${scope}/`);
35
+ }
36
+
37
+ function pathsOverlap(left, right) {
38
+ return pathWithin(left, right) || pathWithin(right, left);
39
+ }
40
+
41
+ function finding(code, severity, details = {}) {
42
+ return { code, severity, ...details };
43
+ }
44
+
45
+ function stableFindings(findings) {
46
+ const byJson = new Map();
47
+ for (const entry of findings) byJson.set(canonicalJson(entry), entry);
48
+ return [...byJson.values()].sort((left, right) => canonicalJson(left).localeCompare(canonicalJson(right)));
49
+ }
50
+
51
+ async function validateProjectPaths(root, values, label) {
52
+ for (const value of values) {
53
+ try {
54
+ try {
55
+ await resolveExistingInside(root, value);
56
+ } catch (error) {
57
+ if (error?.code !== "source-missing") throw error;
58
+ await resolveWritableInside(root, value);
59
+ }
60
+ } catch (error) {
61
+ fail("task-context-path-invalid", `${label} leaves the project or resolves through an unsafe path: ${value}`, {
62
+ details: { path: value, reason: error.code ?? "invalid-path" },
63
+ });
64
+ }
65
+ }
66
+ }
67
+
68
+ function normalizeSignalPaths(values, label) {
69
+ const normalized = values.map((value) => normalizeRelativePath(value, { label }));
70
+ if (new Set(normalized).size !== normalized.length) fail("task-context-path-invalid", `${label} contains duplicate normalized paths`);
71
+ return normalized.sort((left, right) => left.localeCompare(right));
72
+ }
73
+
74
+ async function validatePlanPaths(root, plan) {
75
+ await validateProjectPaths(root, plan.stages.flatMap((stage) => stage.paths), "task stage path");
76
+ }
77
+
78
+ function normalizedReceipts(receiptInputs, plan) {
79
+ const digest = taskContextPlanDigest(plan);
80
+ const byStage = new Map();
81
+ for (const input of receiptInputs) {
82
+ const structural = validateStageReceipt(input);
83
+ if (byStage.has(structural.stageId)) fail("stage-receipt-duplicate", `multiple receipts were provided for stage: ${structural.stageId}`);
84
+ if (structural.planDigest === digest) validateStageReceipt(structural, plan);
85
+ else {
86
+ // Validate all plan-bound semantics independently while preserving the stale digest as a finding.
87
+ validateStageReceipt({ ...structural, planDigest: digest }, plan);
88
+ }
89
+ byStage.set(structural.stageId, structural);
90
+ }
91
+ return byStage;
92
+ }
93
+
94
+ function selectedItems(contract, targetPaths, findings) {
95
+ const byId = new Map();
96
+ for (const targetPath of targetPaths) {
97
+ try {
98
+ for (const item of effectiveItems(contract.items, targetPath)) byId.set(item.id, item);
99
+ } catch (error) {
100
+ if (!(error instanceof ProjectContextError) || error.code !== "contract-conflict") throw error;
101
+ findings.push(finding("contract-conflict", "blocked", { path: targetPath }));
102
+ for (const item of contract.items.filter((entry) => entry.status === "approved" && scopeApplies(entry.scope, targetPath))) byId.set(item.id, item);
103
+ }
104
+ }
105
+ return [...byId.values()].sort((left, right) => left.id.localeCompare(right.id)).map((item) => ({
106
+ id: item.id,
107
+ kind: item.kind,
108
+ subject: item.subject,
109
+ value: structuredClone(item.value),
110
+ statement: item.statement,
111
+ scope: structuredClone(item.scope),
112
+ sourceIds: [...item.sources].sort((left, right) => left.localeCompare(right)),
113
+ }));
114
+ }
115
+
116
+ function sourceReadTargets(contract, items) {
117
+ const sourceIds = new Set(items.flatMap((item) => item.sourceIds));
118
+ return contract.sources
119
+ .filter((source) => sourceIds.has(source.id) && LOCAL_SOURCE_KINDS.has(source.kind))
120
+ .map((source) => ({ path: source.path, reason: "contract-source", sourceId: source.id }));
121
+ }
122
+
123
+ function mergeReadTargets(targets) {
124
+ const byPath = new Map();
125
+ for (const target of targets) {
126
+ const current = byPath.get(target.path);
127
+ if (!current) byPath.set(target.path, target);
128
+ else if (canonicalJson(target).localeCompare(canonicalJson(current)) < 0) byPath.set(target.path, target);
129
+ }
130
+ return [...byPath.values()].sort((left, right) => left.path.localeCompare(right.path));
131
+ }
132
+
133
+ function receiptSummary(receipt) {
134
+ return {
135
+ stageId: receipt.stageId,
136
+ status: receipt.status,
137
+ inputBundleDigest: receipt.inputBundleDigest,
138
+ changedPaths: [...receipt.changedPaths],
139
+ acceptanceResults: structuredClone(receipt.acceptanceResults),
140
+ verificationResults: structuredClone(receipt.verificationResults),
141
+ decisions: [...receipt.decisions],
142
+ openIssues: [...receipt.openIssues],
143
+ };
144
+ }
145
+
146
+ function withDigestAndBytes(input, digestField) {
147
+ const value = structuredClone(input);
148
+ let used = 0;
149
+ for (let iteration = 0; iteration < 8; iteration += 1) {
150
+ if (value.budget) value.budget.usedUtf8Bytes = used;
151
+ delete value[digestField];
152
+ value[digestField] = digestJson(value);
153
+ const next = Buffer.byteLength(canonicalJson(value), "utf8");
154
+ if (next === used) break;
155
+ used = next;
156
+ }
157
+ if (value.budget) value.budget.usedUtf8Bytes = used;
158
+ delete value[digestField];
159
+ value[digestField] = digestJson(value);
160
+ return value;
161
+ }
162
+
163
+ function baselineFindings(plan, project) {
164
+ const current = snapshots(project);
165
+ const findings = [];
166
+ for (const key of Object.keys(current)) {
167
+ if (plan.snapshots[key] !== current[key]) findings.push(finding(`${key === "contract" ? "contract-baseline" : key === "sourcesLock" ? "sources-lock-baseline" : "projections-lock-baseline"}-stale`, "blocked", {
168
+ expected: plan.snapshots[key],
169
+ actual: current[key],
170
+ }));
171
+ }
172
+ return findings;
173
+ }
174
+
175
+ function checkerFindings(entries) {
176
+ const blocking = new Set(blockingContextFindings(entries).map((entry) => canonicalJson(entry)));
177
+ return entries.map((entry) => finding(
178
+ entry.code.startsWith("source-") ? "source-drift" : entry.code,
179
+ blocking.has(canonicalJson(entry)) ? "blocked" : "attention",
180
+ { projectFinding: entry },
181
+ ));
182
+ }
183
+
184
+ function acceptanceFor(plan, stage) {
185
+ const ids = new Set(stage.acceptanceIds);
186
+ return plan.task.acceptance.filter((entry) => ids.has(entry.id)).map((entry) => structuredClone(entry));
187
+ }
188
+
189
+ async function buildStageContextBundleCore(root, project, plan, options, receipts, bindingFindings = []) {
190
+ const stage = plan.stages.find((entry) => entry.id === options.stageId);
191
+ if (!stage) fail("task-context-stage-missing", `task context plan has no stage: ${options.stageId}`);
192
+ const changedPaths = normalizeSignalPaths(options.changedPaths ?? [], "changed path");
193
+ await validateProjectPaths(root, changedPaths, "changed path");
194
+ const planDigest = taskContextPlanDigest(plan);
195
+ const findings = [...bindingFindings, ...baselineFindings(plan, project), ...checkerFindings(await checkProject(root, project))];
196
+ const dependencyReceipts = [];
197
+ for (const dependencyId of stage.dependsOn) {
198
+ const receipt = receipts.get(dependencyId);
199
+ if (!receipt) {
200
+ findings.push(finding("stage-dependency-missing", "blocked", { stageId: stage.id, dependencyStageId: dependencyId }));
201
+ continue;
202
+ }
203
+ dependencyReceipts.push(receiptSummary(receipt));
204
+ if (receipt.planDigest !== planDigest) findings.push(finding("stage-receipt-stale", "blocked", { stageId: dependencyId, reason: "plan-digest" }));
205
+ if (receipt.status !== "completed") findings.push(finding("stage-dependency-blocked", "blocked", { stageId: stage.id, dependencyStageId: dependencyId }));
206
+ }
207
+ const targetPaths = uniqueSorted([...stage.paths, ...changedPaths]);
208
+ const contractItems = selectedItems(project.contract, targetPaths, findings);
209
+ const readTargets = mergeReadTargets([
210
+ ...stage.paths.map((entry) => ({ path: entry, reason: "stage-scope" })),
211
+ ...changedPaths.map((entry) => ({ path: entry, reason: "host-changed-path-signal" })),
212
+ ...sourceReadTargets(project.contract, contractItems),
213
+ ]);
214
+ if (readTargets.length > plan.budget.maxReadTargets) findings.push(finding("read-target-budget-insufficient", "blocked", {
215
+ limit: plan.budget.maxReadTargets,
216
+ required: readTargets.length,
217
+ }));
218
+ let bundle = withDigestAndBytes({
219
+ schemaVersion: STAGE_CONTEXT_BUNDLE_SCHEMA_VERSION,
220
+ kind: "stage-context-bundle",
221
+ project: { id: project.contract.project.id, name: project.contract.project.name },
222
+ task: { id: plan.task.id, title: plan.task.title, goal: plan.task.goal },
223
+ stage: {
224
+ id: stage.id,
225
+ title: stage.title,
226
+ objective: stage.objective,
227
+ acceptance: acceptanceFor(plan, stage),
228
+ paths: [...stage.paths],
229
+ },
230
+ workspace: structuredClone(plan.workspace),
231
+ planDigest,
232
+ snapshots: snapshots(project),
233
+ dependencyReceipts: dependencyReceipts.sort((left, right) => left.stageId.localeCompare(right.stageId)),
234
+ changedPaths,
235
+ contractItems,
236
+ readTargets,
237
+ findings: stableFindings(findings),
238
+ excluded: [...EXCLUDED_BODIES],
239
+ budget: {
240
+ unit: CONTEXT_BUDGET_UNIT,
241
+ maxUtf8Bytes: plan.budget.maxUtf8Bytes,
242
+ maxReadTargets: plan.budget.maxReadTargets,
243
+ usedUtf8Bytes: 0,
244
+ readTargetCount: readTargets.length,
245
+ },
246
+ status: findings.some((entry) => entry.severity === "blocked") ? "blocked" : "ready",
247
+ }, "bundleDigest");
248
+ if (bundle.budget.usedUtf8Bytes > plan.budget.maxUtf8Bytes && !bundle.findings.some((entry) => entry.code === "context-budget-insufficient")) {
249
+ bundle.findings = stableFindings([...bundle.findings, finding("context-budget-insufficient", "blocked", {
250
+ limit: plan.budget.maxUtf8Bytes,
251
+ required: bundle.budget.usedUtf8Bytes,
252
+ })]);
253
+ bundle.status = "blocked";
254
+ bundle = withDigestAndBytes(bundle, "bundleDigest");
255
+ }
256
+ return bundle;
257
+ }
258
+
259
+ function sameJson(left, right) {
260
+ return canonicalJson(left) === canonicalJson(right);
261
+ }
262
+
263
+ function artifactStageId(input) {
264
+ return input && typeof input === "object" && !Array.isArray(input) && input.stage
265
+ && typeof input.stage === "object" && !Array.isArray(input.stage) && typeof input.stage.id === "string"
266
+ ? input.stage.id
267
+ : undefined;
268
+ }
269
+
270
+ async function verifyReceiptBundleBindings(root, project, plan, receipts, receiptBundleInputs) {
271
+ const findings = [];
272
+ const candidatesByStage = new Map();
273
+ for (const input of receiptBundleInputs) {
274
+ const stageId = artifactStageId(input);
275
+ let bundle;
276
+ try {
277
+ bundle = validateStageContextBundle(input);
278
+ } catch (error) {
279
+ if (!(error instanceof ProjectContextError) || error.code !== "stage-context-bundle-schema-invalid") throw error;
280
+ findings.push(finding("stage-receipt-input-bundle-invalid", "blocked", {
281
+ ...(stageId ? { stageId } : {}),
282
+ reason: error.message,
283
+ }));
284
+ }
285
+ if (!stageId) continue;
286
+ const candidates = candidatesByStage.get(stageId) ?? [];
287
+ candidates.push(bundle);
288
+ candidatesByStage.set(stageId, candidates);
289
+ }
290
+
291
+ for (const [stageId, candidates] of candidatesByStage) {
292
+ if (!receipts.has(stageId)) findings.push(finding("stage-receipt-input-bundle-mismatch", "blocked", {
293
+ stageId,
294
+ reason: "receipt-missing",
295
+ }));
296
+ if (candidates.length > 1) findings.push(finding("stage-receipt-input-bundle-duplicate", "blocked", {
297
+ stageId,
298
+ count: candidates.length,
299
+ }));
300
+ }
301
+
302
+ const planDigest = taskContextPlanDigest(plan);
303
+ const currentSnapshots = snapshots(project);
304
+ const verifiedReceipts = new Map();
305
+ const verification = new Map();
306
+
307
+ async function verify(stageId) {
308
+ if (verification.has(stageId)) return verification.get(stageId);
309
+ const pending = (async () => {
310
+ const receipt = receipts.get(stageId);
311
+ if (!receipt) return false;
312
+ const candidates = candidatesByStage.get(stageId) ?? [];
313
+ if (candidates.length === 0) {
314
+ findings.push(finding("stage-receipt-input-bundle-missing", "blocked", { stageId }));
315
+ return false;
316
+ }
317
+ if (candidates.length !== 1 || !candidates[0]) return false;
318
+ const bundle = candidates[0];
319
+ let valid = true;
320
+ const mismatch = (reason, details = {}) => {
321
+ valid = false;
322
+ findings.push(finding("stage-receipt-input-bundle-mismatch", "blocked", { stageId, reason, ...details }));
323
+ };
324
+ const stale = (reason, details = {}) => {
325
+ valid = false;
326
+ findings.push(finding("stage-receipt-input-bundle-stale", "blocked", { stageId, reason, ...details }));
327
+ };
328
+ if (bundle.project.id !== receipt.projectId) mismatch("project-id", { expected: receipt.projectId, actual: bundle.project.id });
329
+ if (bundle.task.id !== receipt.taskId) mismatch("task-id", { expected: receipt.taskId, actual: bundle.task.id });
330
+ if (bundle.stage.id !== receipt.stageId) mismatch("stage-id", { expected: receipt.stageId, actual: bundle.stage.id });
331
+ if (bundle.project.id !== plan.projectId || bundle.task.id !== plan.task.id || !plan.stages.some((entry) => entry.id === bundle.stage.id)) {
332
+ mismatch("plan-identity");
333
+ }
334
+ if (receipt.planDigest !== planDigest) {
335
+ stale("receipt-plan-digest", { expected: planDigest, actual: receipt.planDigest });
336
+ findings.push(finding("stage-receipt-stale", "blocked", { stageId, reason: "plan-digest" }));
337
+ }
338
+ if (bundle.planDigest !== planDigest) stale("bundle-plan-digest", { expected: planDigest, actual: bundle.planDigest });
339
+ for (const key of Object.keys(currentSnapshots)) {
340
+ if (bundle.snapshots[key] !== plan.snapshots[key] || bundle.snapshots[key] !== currentSnapshots[key]) {
341
+ stale(`${key}-snapshot`, {
342
+ plan: plan.snapshots[key],
343
+ current: currentSnapshots[key],
344
+ actual: bundle.snapshots[key],
345
+ });
346
+ }
347
+ }
348
+
349
+ const stage = plan.stages.find((entry) => entry.id === stageId);
350
+ const dependencyReceipts = new Map();
351
+ for (const dependencyId of stage.dependsOn) {
352
+ if (receipts.has(dependencyId) && await verify(dependencyId)) dependencyReceipts.set(dependencyId, receipts.get(dependencyId));
353
+ }
354
+ const rebuilt = await buildStageContextBundleCore(root, project, plan, {
355
+ stageId,
356
+ changedPaths: bundle.changedPaths,
357
+ }, dependencyReceipts);
358
+ if (!sameJson(bundle, rebuilt)) stale("canonical-content", {
359
+ expectedBundleDigest: rebuilt.bundleDigest,
360
+ actualBundleDigest: bundle.bundleDigest,
361
+ });
362
+ if (receipt.inputBundleDigest !== bundle.bundleDigest) mismatch("receipt-input-bundle-digest", {
363
+ expected: bundle.bundleDigest,
364
+ actual: receipt.inputBundleDigest,
365
+ });
366
+ if (receipt.status === "completed" && bundle.status === "blocked") {
367
+ valid = false;
368
+ findings.push(finding("stage-receipt-input-bundle-invalid", "blocked", {
369
+ stageId,
370
+ reason: "completed-receipt-bound-to-blocked-bundle",
371
+ }));
372
+ }
373
+ if (valid) verifiedReceipts.set(stageId, receipt);
374
+ return valid;
375
+ })();
376
+ verification.set(stageId, pending);
377
+ return pending;
378
+ }
379
+
380
+ for (const stage of plan.stages) {
381
+ if (receipts.has(stage.id)) await verify(stage.id);
382
+ }
383
+ return { receipts: verifiedReceipts, findings: stableFindings(findings) };
384
+ }
385
+
386
+ export async function buildStageContextBundle(root, project, planInput, options) {
387
+ const plan = validateTaskContextPlan(planInput);
388
+ if (plan.projectId !== project.contract.project.id) fail("task-context-project-mismatch", "task context plan belongs to a different project");
389
+ await validatePlanPaths(root, plan);
390
+ const receipts = normalizedReceipts(options.receipts ?? [], plan);
391
+ const binding = await verifyReceiptBundleBindings(root, project, plan, receipts, options.receiptBundles ?? []);
392
+ return buildStageContextBundleCore(root, project, plan, options, binding.receipts, binding.findings);
393
+ }
394
+
395
+ function receiptScopeEscapes(plan, receipt) {
396
+ const stage = plan.stages.find((entry) => entry.id === receipt.stageId);
397
+ if (!stage) return receipt.changedPaths;
398
+ return receipt.changedPaths.filter((changedPath) => !stage.paths.some((stagePath) => pathWithin(changedPath, stagePath)));
399
+ }
400
+
401
+ function pathOverlapFindings(mainPaths, branchPaths) {
402
+ const findings = [];
403
+ for (const mainPath of mainPaths) {
404
+ for (const branchPath of branchPaths) {
405
+ if (pathsOverlap(mainPath, branchPath)) findings.push(finding("path-overlap", "attention", { mainPath, branchPath }));
406
+ }
407
+ }
408
+ return findings;
409
+ }
410
+
411
+ function itemIdsForPaths(contract, paths, findings) {
412
+ return new Set(selectedItems(contract, paths, findings).map((item) => item.id));
413
+ }
414
+
415
+ export async function buildIntegrationReviewBundle(root, project, planInput, options = {}) {
416
+ const plan = validateTaskContextPlan(planInput);
417
+ if (plan.projectId !== project.contract.project.id) fail("task-context-project-mismatch", "task context plan belongs to a different project");
418
+ await validatePlanPaths(root, plan);
419
+ const mainChangedPaths = normalizeSignalPaths(options.mainChangedPaths ?? [], "main changed path");
420
+ const branchChangedPaths = normalizeSignalPaths(options.branchChangedPaths ?? [], "branch changed path");
421
+ await validateProjectPaths(root, [...mainChangedPaths, ...branchChangedPaths], "integration changed path");
422
+ const suppliedReceipts = normalizedReceipts(options.receipts ?? [], plan);
423
+ const binding = await verifyReceiptBundleBindings(root, project, plan, suppliedReceipts, options.receiptBundles ?? []);
424
+ const receipts = binding.receipts;
425
+ const planDigest = taskContextPlanDigest(plan);
426
+ const findings = [
427
+ ...binding.findings,
428
+ ...baselineFindings(plan, project),
429
+ ...checkerFindings(await checkProject(root, project)),
430
+ ...pathOverlapFindings(mainChangedPaths, branchChangedPaths),
431
+ ];
432
+ for (const stage of plan.stages) {
433
+ const receipt = receipts.get(stage.id);
434
+ if (!receipt || receipt.status !== "completed") {
435
+ findings.push(finding("stage-incomplete", "blocked", { stageId: stage.id, reason: receipt ? receipt.status : "missing-receipt" }));
436
+ continue;
437
+ }
438
+ if (receipt.planDigest !== planDigest) findings.push(finding("stage-receipt-stale", "blocked", { stageId: stage.id, reason: "plan-digest" }));
439
+ for (const escapedPath of receiptScopeEscapes(plan, receipt)) findings.push(finding("stage-scope-escaped", "blocked", { stageId: stage.id, path: escapedPath }));
440
+ }
441
+ const mainItems = itemIdsForPaths(project.contract, mainChangedPaths, findings);
442
+ const branchItems = itemIdsForPaths(project.contract, branchChangedPaths, findings);
443
+ for (const itemId of [...mainItems].filter((id) => branchItems.has(id)).sort()) findings.push(finding("contract-overlap", "attention", { itemId }));
444
+ const decisionCandidates = [...receipts.values()].flatMap((receipt) => receipt.decisions.map((decision) => ({ stageId: receipt.stageId, decision })))
445
+ .sort((left, right) => left.stageId.localeCompare(right.stageId) || left.decision.localeCompare(right.decision));
446
+ for (const candidate of decisionCandidates) findings.push(finding("decision-candidate", "attention", candidate));
447
+ const conflictPaths = uniqueSorted(findings.flatMap((entry) => [entry.mainPath, entry.branchPath, entry.path].filter(Boolean)));
448
+ const relevantItems = selectedItems(project.contract, uniqueSorted([...mainChangedPaths, ...branchChangedPaths]), findings);
449
+ const readTargets = mergeReadTargets([
450
+ ...conflictPaths.map((entry) => ({ path: entry, reason: "integration-conflict" })),
451
+ ...sourceReadTargets(project.contract, relevantItems),
452
+ ]);
453
+ const normalizedFindings = stableFindings(findings);
454
+ const bundle = {
455
+ schemaVersion: INTEGRATION_REVIEW_BUNDLE_SCHEMA_VERSION,
456
+ kind: "integration-review-bundle",
457
+ project: { id: project.contract.project.id, name: project.contract.project.name },
458
+ task: { id: plan.task.id, title: plan.task.title, goal: plan.task.goal },
459
+ workspace: structuredClone(plan.workspace),
460
+ planDigest,
461
+ planSnapshots: structuredClone(plan.snapshots),
462
+ currentSnapshots: snapshots(project),
463
+ mainChangedPaths,
464
+ branchChangedPaths,
465
+ receipts: [...receipts.values()].map(receiptSummary).sort((left, right) => left.stageId.localeCompare(right.stageId)),
466
+ contractOverlapItemIds: [...mainItems].filter((id) => branchItems.has(id)).sort(),
467
+ decisionCandidates,
468
+ readTargets,
469
+ findings: normalizedFindings,
470
+ excluded: [...EXCLUDED_BODIES, "git-operations", "test-execution"].sort(),
471
+ status: normalizedFindings.some((entry) => entry.severity === "blocked") ? "blocked" : "reviewable",
472
+ };
473
+ return { ...bundle, bundleDigest: digestJson(bundle) };
474
+ }
475
+
476
+ async function readInputs(root, planPath, receiptPaths, receiptBundlePaths) {
477
+ const resolvedPlan = await resolveExistingInside(root, planPath);
478
+ const plan = await readJsonFile(resolvedPlan.absolute, "task context plan");
479
+ const receipts = [];
480
+ for (const receiptPath of receiptPaths) {
481
+ const resolved = await resolveExistingInside(root, receiptPath);
482
+ receipts.push(await readJsonFile(resolved.absolute, "stage receipt"));
483
+ }
484
+ const receiptBundles = [];
485
+ for (const bundlePath of receiptBundlePaths) {
486
+ const resolved = await resolveExistingInside(root, bundlePath);
487
+ receiptBundles.push(await readJsonFile(resolved.absolute, "stage context bundle"));
488
+ }
489
+ return { plan, receipts, receiptBundles };
490
+ }
491
+
492
+ export async function buildStageContextBundleFiles(root, planPath, options) {
493
+ const { plan, receipts, receiptBundles } = await readInputs(root, planPath, options.receiptPaths ?? [], options.receiptBundlePaths ?? []);
494
+ const project = await loadProject(root);
495
+ return buildStageContextBundle(root, project, plan, { ...options, receipts, receiptBundles });
496
+ }
497
+
498
+ export async function buildIntegrationReviewBundleFiles(root, planPath, options) {
499
+ const { plan, receipts, receiptBundles } = await readInputs(root, planPath, options.receiptPaths ?? [], options.receiptBundlePaths ?? []);
500
+ const project = await loadProject(root);
501
+ return buildIntegrationReviewBundle(root, project, plan, { ...options, receipts, receiptBundles });
502
+ }