frontend-project-context 1.2.0 → 1.3.0

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,290 @@
1
+ import { canonicalValue, digestJson } from "./canonical-json.mjs";
2
+ import { fail } from "./errors.mjs";
3
+ import { normalizeRelativePath } from "./path-policy.mjs";
4
+
5
+ export const TASK_CONTEXT_PLAN_SCHEMA_VERSION = 1;
6
+ export const STAGE_RECEIPT_SCHEMA_VERSION = 1;
7
+ export const STAGE_CONTEXT_BUNDLE_SCHEMA_VERSION = 1;
8
+ export const INTEGRATION_REVIEW_BUNDLE_SCHEMA_VERSION = 1;
9
+ export const CONTEXT_BUDGET_UNIT = "canonical-utf8-bytes";
10
+
11
+ const ID = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/u;
12
+ const SHA256 = /^sha256:[a-f0-9]{64}$/u;
13
+ const FORBIDDEN = /^(?:write|approve|approval|by|token|secret|shell|command|provider|autoRun|authority|execute|execution)$/iu;
14
+
15
+ function invalid(kind, message, details) {
16
+ fail(`${kind}-schema-invalid`, message, { details });
17
+ }
18
+
19
+ function object(value, label, kind) {
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) invalid(kind, `${label} must be an object`);
21
+ }
22
+
23
+ function exactKeys(value, allowed, label, kind) {
24
+ for (const key of Object.keys(value)) {
25
+ if (FORBIDDEN.test(key)) invalid(kind, `${label} contains forbidden authority or execution field: ${key}`);
26
+ if (!allowed.has(key)) invalid(kind, `${label} contains unknown field: ${key}`);
27
+ }
28
+ }
29
+
30
+ function string(value, label, kind, options = {}) {
31
+ if (typeof value !== "string" || (!options.empty && value.length === 0)) invalid(kind, `${label} must be a non-empty string`);
32
+ }
33
+
34
+ function stableId(value, label, kind) {
35
+ string(value, label, kind);
36
+ if (!ID.test(value)) invalid(kind, `${label} must use stable lowercase dot/kebab naming`);
37
+ }
38
+
39
+ function digest(value, label, kind) {
40
+ string(value, label, kind);
41
+ if (!SHA256.test(value)) invalid(kind, `${label} must be sha256`);
42
+ }
43
+
44
+ function uniqueStrings(values, label, kind, options = {}) {
45
+ if (!Array.isArray(values) || (!options.empty && values.length === 0)) {
46
+ invalid(kind, `${label} must be ${options.empty ? "an" : "a non-empty"} array`);
47
+ }
48
+ const seen = new Set();
49
+ for (const value of values) {
50
+ string(value, `${label} entry`, kind);
51
+ if (seen.has(value)) invalid(kind, `${label} contains duplicate value: ${value}`);
52
+ seen.add(value);
53
+ }
54
+ return [...values].sort((left, right) => left.localeCompare(right));
55
+ }
56
+
57
+ function paths(values, label, kind, options = {}) {
58
+ const original = uniqueStrings(values, label, kind, options);
59
+ const normalized = original.map((value) => {
60
+ try {
61
+ return normalizeRelativePath(value, { label: `${label} entry` });
62
+ } catch (error) {
63
+ invalid(kind, `${label} must stay inside the project`, { path: value, reason: error.code ?? "invalid-path" });
64
+ }
65
+ });
66
+ if (new Set(normalized).size !== normalized.length) invalid(kind, `${label} contains duplicate normalized paths`);
67
+ return normalized.sort((left, right) => left.localeCompare(right));
68
+ }
69
+
70
+ function snapshots(value, label, kind) {
71
+ object(value, label, kind);
72
+ exactKeys(value, new Set(["contract", "sourcesLock", "projectionsLock"]), label, kind);
73
+ for (const key of ["contract", "sourcesLock", "projectionsLock"]) digest(value[key], `${label}.${key}`, kind);
74
+ return canonicalValue(value);
75
+ }
76
+
77
+ function isWithin(target, scope) {
78
+ return target === scope || target.startsWith(`${scope}/`);
79
+ }
80
+
81
+ function overlaps(left, right) {
82
+ return isWithin(left, right) || isWithin(right, left);
83
+ }
84
+
85
+ function transitiveDependencies(stages) {
86
+ const byId = new Map(stages.map((stage) => [stage.id, stage]));
87
+ const memo = new Map();
88
+ const visiting = new Set();
89
+ function visit(id) {
90
+ if (memo.has(id)) return memo.get(id);
91
+ if (visiting.has(id)) invalid("task-context-plan", `task context plan contains a dependency cycle at ${id}`);
92
+ visiting.add(id);
93
+ const dependencies = new Set();
94
+ for (const dependency of byId.get(id).dependsOn) {
95
+ if (!byId.has(dependency)) invalid("task-context-plan", `stage ${id} depends on unknown stage: ${dependency}`);
96
+ dependencies.add(dependency);
97
+ for (const inherited of visit(dependency)) dependencies.add(inherited);
98
+ }
99
+ visiting.delete(id);
100
+ memo.set(id, dependencies);
101
+ return dependencies;
102
+ }
103
+ for (const stage of stages) visit(stage.id);
104
+ return memo;
105
+ }
106
+
107
+ export function validateTaskContextPlan(input) {
108
+ const kind = "task-context-plan";
109
+ object(input, "task context plan", kind);
110
+ exactKeys(input, new Set(["schemaVersion", "kind", "projectId", "task", "workspace", "snapshots", "budget", "stages"]), "task context plan", kind);
111
+ if (input.schemaVersion !== TASK_CONTEXT_PLAN_SCHEMA_VERSION) invalid(kind, `task context plan schemaVersion must be ${TASK_CONTEXT_PLAN_SCHEMA_VERSION}`);
112
+ if (input.kind !== "task-context-plan") invalid(kind, "task context plan.kind must be task-context-plan");
113
+ stableId(input.projectId, "task context plan.projectId", kind);
114
+
115
+ object(input.task, "task context plan.task", kind);
116
+ exactKeys(input.task, new Set(["id", "title", "goal", "acceptance"]), "task context plan.task", kind);
117
+ stableId(input.task.id, "task context plan.task.id", kind);
118
+ string(input.task.title, "task context plan.task.title", kind);
119
+ string(input.task.goal, "task context plan.task.goal", kind);
120
+ if (!Array.isArray(input.task.acceptance) || input.task.acceptance.length === 0) invalid(kind, "task context plan.task.acceptance must be a non-empty array");
121
+ const acceptanceIds = new Set();
122
+ const acceptance = input.task.acceptance.map((entry, index) => {
123
+ const label = `task context plan.task.acceptance[${index}]`;
124
+ object(entry, label, kind);
125
+ exactKeys(entry, new Set(["id", "text"]), label, kind);
126
+ stableId(entry.id, `${label}.id`, kind);
127
+ string(entry.text, `${label}.text`, kind);
128
+ if (acceptanceIds.has(entry.id)) invalid(kind, `task context plan contains duplicate acceptance id: ${entry.id}`);
129
+ acceptanceIds.add(entry.id);
130
+ return { id: entry.id, text: entry.text };
131
+ }).sort((left, right) => left.id.localeCompare(right.id));
132
+
133
+ object(input.workspace, "task context plan.workspace", kind);
134
+ exactKeys(input.workspace, new Set(["branchLabel", "baseRevision"]), "task context plan.workspace", kind);
135
+ string(input.workspace.branchLabel, "task context plan.workspace.branchLabel", kind);
136
+ string(input.workspace.baseRevision, "task context plan.workspace.baseRevision", kind);
137
+
138
+ object(input.budget, "task context plan.budget", kind);
139
+ exactKeys(input.budget, new Set(["maxUtf8Bytes", "maxReadTargets"]), "task context plan.budget", kind);
140
+ for (const key of ["maxUtf8Bytes", "maxReadTargets"]) {
141
+ if (!Number.isSafeInteger(input.budget[key]) || input.budget[key] <= 0) invalid(kind, `task context plan.budget.${key} must be a positive integer`);
142
+ }
143
+
144
+ if (!Array.isArray(input.stages) || input.stages.length === 0) invalid(kind, "task context plan.stages must be a non-empty array");
145
+ const stageIds = new Set();
146
+ const stages = input.stages.map((stage, index) => {
147
+ const label = `task context plan.stages[${index}]`;
148
+ object(stage, label, kind);
149
+ exactKeys(stage, new Set(["id", "title", "objective", "dependsOn", "paths", "acceptanceIds"]), label, kind);
150
+ stableId(stage.id, `${label}.id`, kind);
151
+ if (stageIds.has(stage.id)) invalid(kind, `task context plan contains duplicate stage id: ${stage.id}`);
152
+ stageIds.add(stage.id);
153
+ string(stage.title, `${label}.title`, kind);
154
+ string(stage.objective, `${label}.objective`, kind);
155
+ const dependsOn = uniqueStrings(stage.dependsOn, `${label}.dependsOn`, kind, { empty: true });
156
+ dependsOn.forEach((id) => stableId(id, `${label}.dependsOn entry`, kind));
157
+ if (dependsOn.includes(stage.id)) invalid(kind, `stage ${stage.id} cannot depend on itself`);
158
+ const acceptanceForStage = uniqueStrings(stage.acceptanceIds, `${label}.acceptanceIds`, kind);
159
+ acceptanceForStage.forEach((id) => {
160
+ stableId(id, `${label}.acceptanceIds entry`, kind);
161
+ if (!acceptanceIds.has(id)) invalid(kind, `stage ${stage.id} references unknown acceptance id: ${id}`);
162
+ });
163
+ return {
164
+ id: stage.id,
165
+ title: stage.title,
166
+ objective: stage.objective,
167
+ dependsOn,
168
+ paths: paths(stage.paths, `${label}.paths`, kind),
169
+ acceptanceIds: acceptanceForStage,
170
+ };
171
+ }).sort((left, right) => left.id.localeCompare(right.id));
172
+ const dependencies = transitiveDependencies(stages);
173
+ for (let leftIndex = 0; leftIndex < stages.length; leftIndex += 1) {
174
+ for (let rightIndex = leftIndex + 1; rightIndex < stages.length; rightIndex += 1) {
175
+ const left = stages[leftIndex];
176
+ const right = stages[rightIndex];
177
+ if (dependencies.get(left.id).has(right.id) || dependencies.get(right.id).has(left.id)) continue;
178
+ const conflict = left.paths.find((leftPath) => right.paths.some((rightPath) => overlaps(leftPath, rightPath)));
179
+ if (conflict) invalid(kind, `independent stages ${left.id} and ${right.id} have overlapping paths`, { path: conflict });
180
+ }
181
+ }
182
+
183
+ return canonicalValue({
184
+ schemaVersion: TASK_CONTEXT_PLAN_SCHEMA_VERSION,
185
+ kind: "task-context-plan",
186
+ projectId: input.projectId,
187
+ task: { id: input.task.id, title: input.task.title, goal: input.task.goal, acceptance },
188
+ workspace: { branchLabel: input.workspace.branchLabel, baseRevision: input.workspace.baseRevision },
189
+ snapshots: snapshots(input.snapshots, "task context plan.snapshots", kind),
190
+ budget: { maxUtf8Bytes: input.budget.maxUtf8Bytes, maxReadTargets: input.budget.maxReadTargets },
191
+ stages,
192
+ });
193
+ }
194
+
195
+ export function taskContextPlanDigest(plan) {
196
+ return digestJson(validateTaskContextPlan(plan));
197
+ }
198
+
199
+ function acceptanceResult(value, label, kind) {
200
+ object(value, label, kind);
201
+ exactKeys(value, new Set(["id", "status", "evidence"]), label, kind);
202
+ stableId(value.id, `${label}.id`, kind);
203
+ if (!["observed", "not-observed"].includes(value.status)) invalid(kind, `${label}.status must be observed or not-observed`);
204
+ return { id: value.id, status: value.status, evidence: paths(value.evidence, `${label}.evidence`, kind, { empty: true }) };
205
+ }
206
+
207
+ function verificationResult(value, label, kind) {
208
+ object(value, label, kind);
209
+ exactKeys(value, new Set(["id", "status", "summary", "evidence"]), label, kind);
210
+ stableId(value.id, `${label}.id`, kind);
211
+ if (!["passed", "failed", "observed", "skipped"].includes(value.status)) invalid(kind, `${label}.status is invalid`);
212
+ string(value.summary, `${label}.summary`, kind);
213
+ return { id: value.id, status: value.status, summary: value.summary, evidence: paths(value.evidence, `${label}.evidence`, kind, { empty: true }) };
214
+ }
215
+
216
+ export function validateStageReceipt(input, planInput, options = {}) {
217
+ const kind = "stage-receipt";
218
+ object(input, "stage receipt", kind);
219
+ exactKeys(input, new Set([
220
+ "schemaVersion", "kind", "projectId", "taskId", "stageId", "planDigest", "inputBundleDigest", "status",
221
+ "changedPaths", "acceptanceResults", "verificationResults", "decisions", "openIssues", "nextStageId",
222
+ ]), "stage receipt", kind);
223
+ if (input.schemaVersion !== STAGE_RECEIPT_SCHEMA_VERSION) invalid(kind, `stage receipt schemaVersion must be ${STAGE_RECEIPT_SCHEMA_VERSION}`);
224
+ if (input.kind !== "stage-receipt") invalid(kind, "stage receipt.kind must be stage-receipt");
225
+ for (const [field, label] of [["projectId", "projectId"], ["taskId", "taskId"], ["stageId", "stageId"]]) stableId(input[field], `stage receipt.${label}`, kind);
226
+ digest(input.planDigest, "stage receipt.planDigest", kind);
227
+ digest(input.inputBundleDigest, "stage receipt.inputBundleDigest", kind);
228
+ if (options.expectedInputBundleDigest !== undefined && input.inputBundleDigest !== options.expectedInputBundleDigest) {
229
+ invalid(kind, "stage receipt input bundle digest is stale", { expected: options.expectedInputBundleDigest, actual: input.inputBundleDigest });
230
+ }
231
+ if (!["completed", "blocked"].includes(input.status)) invalid(kind, "stage receipt.status must be completed or blocked");
232
+ const changedPaths = paths(input.changedPaths, "stage receipt.changedPaths", kind, { empty: true });
233
+ if (!Array.isArray(input.acceptanceResults)) invalid(kind, "stage receipt.acceptanceResults must be an array");
234
+ const resultIds = new Set();
235
+ const acceptanceResults = input.acceptanceResults.map((entry, index) => {
236
+ const normalized = acceptanceResult(entry, `stage receipt.acceptanceResults[${index}]`, kind);
237
+ if (resultIds.has(normalized.id)) invalid(kind, `stage receipt contains duplicate acceptance result: ${normalized.id}`);
238
+ resultIds.add(normalized.id);
239
+ return normalized;
240
+ }).sort((left, right) => left.id.localeCompare(right.id));
241
+ if (!Array.isArray(input.verificationResults)) invalid(kind, "stage receipt.verificationResults must be an array");
242
+ const verificationIds = new Set();
243
+ const verificationResults = input.verificationResults.map((entry, index) => {
244
+ const normalized = verificationResult(entry, `stage receipt.verificationResults[${index}]`, kind);
245
+ if (verificationIds.has(normalized.id)) invalid(kind, `stage receipt contains duplicate verification result: ${normalized.id}`);
246
+ verificationIds.add(normalized.id);
247
+ return normalized;
248
+ }).sort((left, right) => left.id.localeCompare(right.id));
249
+ const decisions = uniqueStrings(input.decisions, "stage receipt.decisions", kind, { empty: true });
250
+ const openIssues = uniqueStrings(input.openIssues, "stage receipt.openIssues", kind, { empty: true });
251
+ if (input.status === "blocked" && openIssues.length === 0) invalid(kind, "blocked stage receipt requires at least one open issue");
252
+ if (input.status === "completed" && openIssues.length > 0) invalid(kind, "completed stage receipt cannot contain open issues");
253
+ if (input.nextStageId !== undefined) stableId(input.nextStageId, "stage receipt.nextStageId", kind);
254
+
255
+ if (planInput !== undefined) {
256
+ const plan = validateTaskContextPlan(planInput);
257
+ if (input.projectId !== plan.projectId || input.taskId !== plan.task.id) invalid(kind, "stage receipt project or task does not match its plan");
258
+ if (input.planDigest !== digestJson(plan)) invalid(kind, "stage receipt plan digest is stale");
259
+ const stage = plan.stages.find((entry) => entry.id === input.stageId);
260
+ if (!stage) invalid(kind, `stage receipt references unknown stage: ${input.stageId}`);
261
+ for (const id of resultIds) if (!stage.acceptanceIds.includes(id)) invalid(kind, `stage receipt references acceptance outside stage: ${id}`);
262
+ if (input.status === "completed") {
263
+ const missing = stage.acceptanceIds.filter((id) => !resultIds.has(id));
264
+ if (missing.length > 0) invalid(kind, "completed stage receipt does not cover all stage acceptance IDs", { missing });
265
+ if (acceptanceResults.some((entry) => entry.status !== "observed")) invalid(kind, "completed stage receipt requires observed acceptance results");
266
+ }
267
+ if (input.nextStageId !== undefined && !plan.stages.some((entry) => entry.id === input.nextStageId)) invalid(kind, `stage receipt references unknown next stage: ${input.nextStageId}`);
268
+ }
269
+
270
+ return canonicalValue({
271
+ schemaVersion: STAGE_RECEIPT_SCHEMA_VERSION,
272
+ kind: "stage-receipt",
273
+ projectId: input.projectId,
274
+ taskId: input.taskId,
275
+ stageId: input.stageId,
276
+ planDigest: input.planDigest,
277
+ inputBundleDigest: input.inputBundleDigest,
278
+ status: input.status,
279
+ changedPaths,
280
+ acceptanceResults,
281
+ verificationResults,
282
+ decisions,
283
+ openIssues,
284
+ ...(input.nextStageId !== undefined ? { nextStageId: input.nextStageId } : {}),
285
+ });
286
+ }
287
+
288
+ export function stageReceiptDigest(receipt, plan) {
289
+ return digestJson(validateStageReceipt(receipt, plan));
290
+ }
@@ -0,0 +1,361 @@
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
+ validateStageReceipt,
14
+ validateTaskContextPlan,
15
+ } from "./task-context-schema.mjs";
16
+
17
+ const LOCAL_SOURCE_KINDS = new Set(["file", "path", "json-pointer"]);
18
+ const EXCLUDED_BODIES = Object.freeze(["chat-history", "code-bodies", "git-diffs", "source-bodies", "verification-logs"]);
19
+
20
+ function uniqueSorted(values) {
21
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right));
22
+ }
23
+
24
+ function snapshots(project) {
25
+ return {
26
+ contract: project.contractDigest,
27
+ sourcesLock: project.sourcesLockDigest,
28
+ projectionsLock: project.projectionsLockDigest,
29
+ };
30
+ }
31
+
32
+ function pathWithin(target, scope) {
33
+ return target === scope || target.startsWith(`${scope}/`);
34
+ }
35
+
36
+ function pathsOverlap(left, right) {
37
+ return pathWithin(left, right) || pathWithin(right, left);
38
+ }
39
+
40
+ function finding(code, severity, details = {}) {
41
+ return { code, severity, ...details };
42
+ }
43
+
44
+ function stableFindings(findings) {
45
+ const byJson = new Map();
46
+ for (const entry of findings) byJson.set(canonicalJson(entry), entry);
47
+ return [...byJson.values()].sort((left, right) => canonicalJson(left).localeCompare(canonicalJson(right)));
48
+ }
49
+
50
+ async function validateProjectPaths(root, values, label) {
51
+ for (const value of values) {
52
+ try {
53
+ try {
54
+ await resolveExistingInside(root, value);
55
+ } catch (error) {
56
+ if (error?.code !== "source-missing") throw error;
57
+ await resolveWritableInside(root, value);
58
+ }
59
+ } catch (error) {
60
+ fail("task-context-path-invalid", `${label} leaves the project or resolves through an unsafe path: ${value}`, {
61
+ details: { path: value, reason: error.code ?? "invalid-path" },
62
+ });
63
+ }
64
+ }
65
+ }
66
+
67
+ function normalizeSignalPaths(values, label) {
68
+ const normalized = values.map((value) => normalizeRelativePath(value, { label }));
69
+ if (new Set(normalized).size !== normalized.length) fail("task-context-path-invalid", `${label} contains duplicate normalized paths`);
70
+ return normalized.sort((left, right) => left.localeCompare(right));
71
+ }
72
+
73
+ async function validatePlanPaths(root, plan) {
74
+ await validateProjectPaths(root, plan.stages.flatMap((stage) => stage.paths), "task stage path");
75
+ }
76
+
77
+ function normalizedReceipts(receiptInputs, plan) {
78
+ const digest = taskContextPlanDigest(plan);
79
+ const byStage = new Map();
80
+ for (const input of receiptInputs) {
81
+ const structural = validateStageReceipt(input);
82
+ if (byStage.has(structural.stageId)) fail("stage-receipt-duplicate", `multiple receipts were provided for stage: ${structural.stageId}`);
83
+ if (structural.planDigest === digest) validateStageReceipt(structural, plan);
84
+ else {
85
+ // Validate all plan-bound semantics independently while preserving the stale digest as a finding.
86
+ validateStageReceipt({ ...structural, planDigest: digest }, plan);
87
+ }
88
+ byStage.set(structural.stageId, structural);
89
+ }
90
+ return byStage;
91
+ }
92
+
93
+ function selectedItems(contract, targetPaths, findings) {
94
+ const byId = new Map();
95
+ for (const targetPath of targetPaths) {
96
+ try {
97
+ for (const item of effectiveItems(contract.items, targetPath)) byId.set(item.id, item);
98
+ } catch (error) {
99
+ if (!(error instanceof ProjectContextError) || error.code !== "contract-conflict") throw error;
100
+ findings.push(finding("contract-conflict", "blocked", { path: targetPath }));
101
+ for (const item of contract.items.filter((entry) => entry.status === "approved" && scopeApplies(entry.scope, targetPath))) byId.set(item.id, item);
102
+ }
103
+ }
104
+ return [...byId.values()].sort((left, right) => left.id.localeCompare(right.id)).map((item) => ({
105
+ id: item.id,
106
+ kind: item.kind,
107
+ subject: item.subject,
108
+ value: structuredClone(item.value),
109
+ statement: item.statement,
110
+ scope: structuredClone(item.scope),
111
+ sourceIds: [...item.sources].sort((left, right) => left.localeCompare(right)),
112
+ }));
113
+ }
114
+
115
+ function sourceReadTargets(contract, items) {
116
+ const sourceIds = new Set(items.flatMap((item) => item.sourceIds));
117
+ return contract.sources
118
+ .filter((source) => sourceIds.has(source.id) && LOCAL_SOURCE_KINDS.has(source.kind))
119
+ .map((source) => ({ path: source.path, reason: "contract-source", sourceId: source.id }));
120
+ }
121
+
122
+ function mergeReadTargets(targets) {
123
+ const byPath = new Map();
124
+ for (const target of targets) {
125
+ const current = byPath.get(target.path);
126
+ if (!current) byPath.set(target.path, target);
127
+ else if (canonicalJson(target).localeCompare(canonicalJson(current)) < 0) byPath.set(target.path, target);
128
+ }
129
+ return [...byPath.values()].sort((left, right) => left.path.localeCompare(right.path));
130
+ }
131
+
132
+ function receiptSummary(receipt) {
133
+ return {
134
+ stageId: receipt.stageId,
135
+ status: receipt.status,
136
+ inputBundleDigest: receipt.inputBundleDigest,
137
+ changedPaths: [...receipt.changedPaths],
138
+ acceptanceResults: structuredClone(receipt.acceptanceResults),
139
+ verificationResults: structuredClone(receipt.verificationResults),
140
+ decisions: [...receipt.decisions],
141
+ openIssues: [...receipt.openIssues],
142
+ };
143
+ }
144
+
145
+ function withDigestAndBytes(input, digestField) {
146
+ const value = structuredClone(input);
147
+ let used = 0;
148
+ for (let iteration = 0; iteration < 8; iteration += 1) {
149
+ if (value.budget) value.budget.usedUtf8Bytes = used;
150
+ delete value[digestField];
151
+ value[digestField] = digestJson(value);
152
+ const next = Buffer.byteLength(canonicalJson(value), "utf8");
153
+ if (next === used) break;
154
+ used = next;
155
+ }
156
+ if (value.budget) value.budget.usedUtf8Bytes = used;
157
+ delete value[digestField];
158
+ value[digestField] = digestJson(value);
159
+ return value;
160
+ }
161
+
162
+ function baselineFindings(plan, project) {
163
+ const current = snapshots(project);
164
+ const findings = [];
165
+ for (const key of Object.keys(current)) {
166
+ if (plan.snapshots[key] !== current[key]) findings.push(finding(`${key === "contract" ? "contract-baseline" : key === "sourcesLock" ? "sources-lock-baseline" : "projections-lock-baseline"}-stale`, "blocked", {
167
+ expected: plan.snapshots[key],
168
+ actual: current[key],
169
+ }));
170
+ }
171
+ return findings;
172
+ }
173
+
174
+ function checkerFindings(entries) {
175
+ const blocking = new Set(blockingContextFindings(entries).map((entry) => canonicalJson(entry)));
176
+ return entries.map((entry) => finding(
177
+ entry.code.startsWith("source-") ? "source-drift" : entry.code,
178
+ blocking.has(canonicalJson(entry)) ? "blocked" : "attention",
179
+ { projectFinding: entry },
180
+ ));
181
+ }
182
+
183
+ function acceptanceFor(plan, stage) {
184
+ const ids = new Set(stage.acceptanceIds);
185
+ return plan.task.acceptance.filter((entry) => ids.has(entry.id)).map((entry) => structuredClone(entry));
186
+ }
187
+
188
+ export async function buildStageContextBundle(root, project, planInput, options) {
189
+ const plan = validateTaskContextPlan(planInput);
190
+ if (plan.projectId !== project.contract.project.id) fail("task-context-project-mismatch", "task context plan belongs to a different project");
191
+ await validatePlanPaths(root, plan);
192
+ const stage = plan.stages.find((entry) => entry.id === options.stageId);
193
+ if (!stage) fail("task-context-stage-missing", `task context plan has no stage: ${options.stageId}`);
194
+ const changedPaths = normalizeSignalPaths(options.changedPaths ?? [], "changed path");
195
+ await validateProjectPaths(root, changedPaths, "changed path");
196
+ const receipts = normalizedReceipts(options.receipts ?? [], plan);
197
+ const planDigest = taskContextPlanDigest(plan);
198
+ const findings = [...baselineFindings(plan, project), ...checkerFindings(await checkProject(root, project))];
199
+ const dependencyReceipts = [];
200
+ for (const dependencyId of stage.dependsOn) {
201
+ const receipt = receipts.get(dependencyId);
202
+ if (!receipt) {
203
+ findings.push(finding("stage-dependency-missing", "blocked", { stageId: stage.id, dependencyStageId: dependencyId }));
204
+ continue;
205
+ }
206
+ dependencyReceipts.push(receiptSummary(receipt));
207
+ if (receipt.planDigest !== planDigest) findings.push(finding("stage-receipt-stale", "blocked", { stageId: dependencyId, reason: "plan-digest" }));
208
+ if (receipt.status !== "completed") findings.push(finding("stage-dependency-blocked", "blocked", { stageId: stage.id, dependencyStageId: dependencyId }));
209
+ }
210
+ const targetPaths = uniqueSorted([...stage.paths, ...changedPaths]);
211
+ const contractItems = selectedItems(project.contract, targetPaths, findings);
212
+ const readTargets = mergeReadTargets([
213
+ ...stage.paths.map((entry) => ({ path: entry, reason: "stage-scope" })),
214
+ ...changedPaths.map((entry) => ({ path: entry, reason: "host-changed-path-signal" })),
215
+ ...sourceReadTargets(project.contract, contractItems),
216
+ ]);
217
+ if (readTargets.length > plan.budget.maxReadTargets) findings.push(finding("read-target-budget-insufficient", "blocked", {
218
+ limit: plan.budget.maxReadTargets,
219
+ required: readTargets.length,
220
+ }));
221
+ let bundle = withDigestAndBytes({
222
+ schemaVersion: STAGE_CONTEXT_BUNDLE_SCHEMA_VERSION,
223
+ kind: "stage-context-bundle",
224
+ project: { id: project.contract.project.id, name: project.contract.project.name },
225
+ task: { id: plan.task.id, title: plan.task.title, goal: plan.task.goal },
226
+ stage: {
227
+ id: stage.id,
228
+ title: stage.title,
229
+ objective: stage.objective,
230
+ acceptance: acceptanceFor(plan, stage),
231
+ paths: [...stage.paths],
232
+ },
233
+ workspace: structuredClone(plan.workspace),
234
+ planDigest,
235
+ snapshots: snapshots(project),
236
+ dependencyReceipts: dependencyReceipts.sort((left, right) => left.stageId.localeCompare(right.stageId)),
237
+ changedPaths,
238
+ contractItems,
239
+ readTargets,
240
+ findings: stableFindings(findings),
241
+ excluded: [...EXCLUDED_BODIES],
242
+ budget: {
243
+ unit: CONTEXT_BUDGET_UNIT,
244
+ maxUtf8Bytes: plan.budget.maxUtf8Bytes,
245
+ maxReadTargets: plan.budget.maxReadTargets,
246
+ usedUtf8Bytes: 0,
247
+ readTargetCount: readTargets.length,
248
+ },
249
+ status: findings.some((entry) => entry.severity === "blocked") ? "blocked" : "ready",
250
+ }, "bundleDigest");
251
+ if (bundle.budget.usedUtf8Bytes > plan.budget.maxUtf8Bytes && !bundle.findings.some((entry) => entry.code === "context-budget-insufficient")) {
252
+ bundle.findings = stableFindings([...bundle.findings, finding("context-budget-insufficient", "blocked", {
253
+ limit: plan.budget.maxUtf8Bytes,
254
+ required: bundle.budget.usedUtf8Bytes,
255
+ })]);
256
+ bundle.status = "blocked";
257
+ bundle = withDigestAndBytes(bundle, "bundleDigest");
258
+ }
259
+ return bundle;
260
+ }
261
+
262
+ function receiptScopeEscapes(plan, receipt) {
263
+ const stage = plan.stages.find((entry) => entry.id === receipt.stageId);
264
+ if (!stage) return receipt.changedPaths;
265
+ return receipt.changedPaths.filter((changedPath) => !stage.paths.some((stagePath) => pathWithin(changedPath, stagePath)));
266
+ }
267
+
268
+ function pathOverlapFindings(mainPaths, branchPaths) {
269
+ const findings = [];
270
+ for (const mainPath of mainPaths) {
271
+ for (const branchPath of branchPaths) {
272
+ if (pathsOverlap(mainPath, branchPath)) findings.push(finding("path-overlap", "attention", { mainPath, branchPath }));
273
+ }
274
+ }
275
+ return findings;
276
+ }
277
+
278
+ function itemIdsForPaths(contract, paths, findings) {
279
+ return new Set(selectedItems(contract, paths, findings).map((item) => item.id));
280
+ }
281
+
282
+ export async function buildIntegrationReviewBundle(root, project, planInput, options = {}) {
283
+ const plan = validateTaskContextPlan(planInput);
284
+ if (plan.projectId !== project.contract.project.id) fail("task-context-project-mismatch", "task context plan belongs to a different project");
285
+ await validatePlanPaths(root, plan);
286
+ const mainChangedPaths = normalizeSignalPaths(options.mainChangedPaths ?? [], "main changed path");
287
+ const branchChangedPaths = normalizeSignalPaths(options.branchChangedPaths ?? [], "branch changed path");
288
+ await validateProjectPaths(root, [...mainChangedPaths, ...branchChangedPaths], "integration changed path");
289
+ const receipts = normalizedReceipts(options.receipts ?? [], plan);
290
+ const planDigest = taskContextPlanDigest(plan);
291
+ const findings = [
292
+ ...baselineFindings(plan, project),
293
+ ...checkerFindings(await checkProject(root, project)),
294
+ ...pathOverlapFindings(mainChangedPaths, branchChangedPaths),
295
+ ];
296
+ for (const stage of plan.stages) {
297
+ const receipt = receipts.get(stage.id);
298
+ if (!receipt || receipt.status !== "completed") {
299
+ findings.push(finding("stage-incomplete", "blocked", { stageId: stage.id, reason: receipt ? receipt.status : "missing-receipt" }));
300
+ continue;
301
+ }
302
+ if (receipt.planDigest !== planDigest) findings.push(finding("stage-receipt-stale", "blocked", { stageId: stage.id, reason: "plan-digest" }));
303
+ for (const escapedPath of receiptScopeEscapes(plan, receipt)) findings.push(finding("stage-scope-escaped", "blocked", { stageId: stage.id, path: escapedPath }));
304
+ }
305
+ const mainItems = itemIdsForPaths(project.contract, mainChangedPaths, findings);
306
+ const branchItems = itemIdsForPaths(project.contract, branchChangedPaths, findings);
307
+ for (const itemId of [...mainItems].filter((id) => branchItems.has(id)).sort()) findings.push(finding("contract-overlap", "attention", { itemId }));
308
+ const decisionCandidates = [...receipts.values()].flatMap((receipt) => receipt.decisions.map((decision) => ({ stageId: receipt.stageId, decision })))
309
+ .sort((left, right) => left.stageId.localeCompare(right.stageId) || left.decision.localeCompare(right.decision));
310
+ for (const candidate of decisionCandidates) findings.push(finding("decision-candidate", "attention", candidate));
311
+ const conflictPaths = uniqueSorted(findings.flatMap((entry) => [entry.mainPath, entry.branchPath, entry.path].filter(Boolean)));
312
+ const relevantItems = selectedItems(project.contract, uniqueSorted([...mainChangedPaths, ...branchChangedPaths]), findings);
313
+ const readTargets = mergeReadTargets([
314
+ ...conflictPaths.map((entry) => ({ path: entry, reason: "integration-conflict" })),
315
+ ...sourceReadTargets(project.contract, relevantItems),
316
+ ]);
317
+ const normalizedFindings = stableFindings(findings);
318
+ const bundle = {
319
+ schemaVersion: INTEGRATION_REVIEW_BUNDLE_SCHEMA_VERSION,
320
+ kind: "integration-review-bundle",
321
+ project: { id: project.contract.project.id, name: project.contract.project.name },
322
+ task: { id: plan.task.id, title: plan.task.title, goal: plan.task.goal },
323
+ workspace: structuredClone(plan.workspace),
324
+ planDigest,
325
+ planSnapshots: structuredClone(plan.snapshots),
326
+ currentSnapshots: snapshots(project),
327
+ mainChangedPaths,
328
+ branchChangedPaths,
329
+ receipts: [...receipts.values()].map(receiptSummary).sort((left, right) => left.stageId.localeCompare(right.stageId)),
330
+ contractOverlapItemIds: [...mainItems].filter((id) => branchItems.has(id)).sort(),
331
+ decisionCandidates,
332
+ readTargets,
333
+ findings: normalizedFindings,
334
+ excluded: [...EXCLUDED_BODIES, "git-operations", "test-execution"].sort(),
335
+ status: normalizedFindings.some((entry) => entry.severity === "blocked") ? "blocked" : "reviewable",
336
+ };
337
+ return { ...bundle, bundleDigest: digestJson(bundle) };
338
+ }
339
+
340
+ async function readInputs(root, planPath, receiptPaths) {
341
+ const resolvedPlan = await resolveExistingInside(root, planPath);
342
+ const plan = await readJsonFile(resolvedPlan.absolute, "task context plan");
343
+ const receipts = [];
344
+ for (const receiptPath of receiptPaths) {
345
+ const resolved = await resolveExistingInside(root, receiptPath);
346
+ receipts.push(await readJsonFile(resolved.absolute, "stage receipt"));
347
+ }
348
+ return { plan, receipts };
349
+ }
350
+
351
+ export async function buildStageContextBundleFiles(root, planPath, options) {
352
+ const { plan, receipts } = await readInputs(root, planPath, options.receiptPaths ?? []);
353
+ const project = await loadProject(root);
354
+ return buildStageContextBundle(root, project, plan, { ...options, receipts });
355
+ }
356
+
357
+ export async function buildIntegrationReviewBundleFiles(root, planPath, options) {
358
+ const { plan, receipts } = await readInputs(root, planPath, options.receiptPaths ?? []);
359
+ const project = await loadProject(root);
360
+ return buildIntegrationReviewBundle(root, project, plan, { ...options, receipts });
361
+ }