frontend-project-context 1.7.0 → 1.9.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +17 -10
  3. package/UPGRADING.md +18 -0
  4. package/docs/08-INSTALLATION-AND-DISTRIBUTION.md +29 -6
  5. package/docs/14-FORMAL-RELEASE-READINESS.md +14 -0
  6. package/docs/24-A130-REAL-HOST-TARGET-PROJECT-COMPARISON.md +1 -1
  7. package/docs/26-A130-QUALITY-CLOSURE-AND-ADAPTIVE-DELIVERY-REPAIR-DESIGN.md +1 -1
  8. package/docs/27-TEAM-SHARED-CONTEXT-DIRECTION-DISCUSSION.md +30 -0
  9. package/docs/28-REAL-PROJECT-ONBOARDING-CLOSURE-DESIGN.md +544 -0
  10. package/docs/29-REAL-PROJECT-1.8.0-INITIALIZATION-OBSERVATIONS.md +228 -0
  11. package/docs/30-TASK-CONTEXT-CONSUMPTION-CLOSURE-DESIGN.md +563 -0
  12. package/docs/31-TASK-CONTEXT-INTEGRITY-REPAIR-DESIGN.md +281 -0
  13. package/docs/AI-PROJECT-INITIALIZATION.md +90 -0
  14. package/docs/PRODUCT-SHARING-AND-ADOPTION-GUIDE.md +111 -0
  15. package/docs/README.md +24 -0
  16. package/docs/USER-AND-AI-OPERATION-MANUAL.md +17 -13
  17. package/docs/assets/product-sharing-01-overview.svg +32 -0
  18. package/docs/assets/product-sharing-02-how-it-works.svg +17 -0
  19. package/docs/assets/product-sharing-03-example.svg +19 -0
  20. package/examples/README.md +2 -2
  21. package/examples/package.json +1 -1
  22. package/migration-manifest.json +40 -10
  23. package/package.json +2 -2
  24. package/schemas/capabilities.schema.json +29 -10
  25. package/schemas/context-bundle.schema.json +32 -0
  26. package/schemas/coverage-audit.schema.json +6 -4
  27. package/schemas/evidence-bundle.schema.json +1 -1
  28. package/schemas/initialization-instruction.schema.json +72 -0
  29. package/schemas/migration-manifest.schema.json +3 -3
  30. package/schemas/migration-plan.schema.json +2 -2
  31. package/schemas/project-status.schema.json +5 -4
  32. package/schemas/projection-lock.schema.json +1 -1
  33. package/schemas/upgrade-assessment.schema.json +2 -2
  34. package/schemas/upgrade-result-bundle.schema.json +1 -1
  35. package/src/project-context/adaptive-context-schema.mjs +1 -1
  36. package/src/project-context/adaptive-context.mjs +12 -23
  37. package/src/project-context/ai-entry.mjs +24 -9
  38. package/src/project-context/approver.mjs +6 -2
  39. package/src/project-context/authoring.mjs +28 -8
  40. package/src/project-context/capabilities.mjs +18 -1
  41. package/src/project-context/checker.mjs +1 -1
  42. package/src/project-context/cli.mjs +44 -16
  43. package/src/project-context/context-bundle.mjs +378 -0
  44. package/src/project-context/contract-schema.mjs +62 -9
  45. package/src/project-context/coverage-profile.mjs +127 -0
  46. package/src/project-context/exchange-schema.mjs +24 -7
  47. package/src/project-context/exchange.mjs +3 -0
  48. package/src/project-context/initialization-instruction.mjs +60 -0
  49. package/src/project-context/maintenance.mjs +5 -4
  50. package/src/project-context/migration-manifest.mjs +5 -5
  51. package/src/project-context/project-status.mjs +14 -3
  52. package/src/project-context/project-store.mjs +27 -2
  53. package/src/project-context/renderer.mjs +11 -4
  54. package/src/project-context/source-reader.mjs +27 -2
  55. package/src/project-context/upgrade-schema.mjs +2 -2
@@ -0,0 +1,378 @@
1
+ import { canonicalJson, canonicalValue, digestJson } from "./canonical-json.mjs";
2
+ import { sourceStatus } from "./contract-schema.mjs";
3
+ import { coverageDomainForPath, coverageProfile } from "./coverage-profile.mjs";
4
+ import { fail } from "./errors.mjs";
5
+ import { normalizeRelativePath, resolveExistingInside } from "./path-policy.mjs";
6
+ import { renderCollectedContextBundle } from "./renderer.mjs";
7
+ import { effectiveItems } from "./scope-compiler.mjs";
8
+
9
+ export const CONTEXT_BUNDLE_SCHEMA_VERSION = 1;
10
+
11
+ const LOCAL_SOURCE_KINDS = new Set(["file", "path", "json-pointer"]);
12
+ const ROLES = Object.freeze({ "provenance-only": 0, conditional: 1, required: 2 });
13
+ const SHA256 = /^sha256:[a-f0-9]{64}$/u;
14
+
15
+ function uniqueSorted(values) {
16
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right));
17
+ }
18
+
19
+ function snapshots(project) {
20
+ return {
21
+ contract: project.contractDigest,
22
+ sourcesLock: project.sourcesLockDigest,
23
+ projectionsLock: project.projectionsLockDigest,
24
+ };
25
+ }
26
+
27
+ function scopeCoverage(items) {
28
+ if (items.length === 0) return "none";
29
+ return items.some((item) => item.scope.kind !== "project") ? "scoped" : "project-only";
30
+ }
31
+
32
+ function itemRecord(item) {
33
+ return canonicalValue({
34
+ id: item.id,
35
+ kind: item.kind,
36
+ subject: item.subject,
37
+ scope: structuredClone(item.scope),
38
+ sourceIds: [...item.sources].sort(),
39
+ statement: item.statement,
40
+ value: structuredClone(item.value),
41
+ overrides: [...item.overrides].sort(),
42
+ ...(item.verification !== undefined ? { verification: structuredClone(item.verification) } : {}),
43
+ ...(item.consumption !== undefined ? { consumption: structuredClone(item.consumption) } : {}),
44
+ });
45
+ }
46
+
47
+ function sourceRole(item, sourceId) {
48
+ if (item.consumption?.requiredSources.includes(sourceId)) return "required";
49
+ if (item.consumption?.conditionalSources.includes(sourceId)) return "conditional";
50
+ if (item.consumption === undefined && item.kind === "reference") return "conditional";
51
+ return "provenance-only";
52
+ }
53
+
54
+ function sourceLocator(source) {
55
+ if (source.kind === "json-pointer") return { path: source.path, pointer: source.pointer };
56
+ if (LOCAL_SOURCE_KINDS.has(source.kind)) return { path: source.path };
57
+ return { reference: source.reference };
58
+ }
59
+
60
+ async function consumptionRecords(root, contract, items, paths) {
61
+ const sourceMap = new Map(contract.sources.filter((source) => sourceStatus(source) === "active").map((source) => [source.id, source]));
62
+ const records = new Map();
63
+ const merge = (value) => {
64
+ const current = records.get(value.path);
65
+ if (!current) {
66
+ records.set(value.path, value);
67
+ return;
68
+ }
69
+ if (ROLES[value.role] > ROLES[current.role]) current.role = value.role;
70
+ current.itemIds = uniqueSorted([...current.itemIds, ...value.itemIds]);
71
+ current.sourceIds = uniqueSorted([...current.sourceIds, ...value.sourceIds]);
72
+ current.reasons = uniqueSorted([...current.reasons, ...value.reasons]);
73
+ const pointers = uniqueSorted([...(current.pointers ?? []), ...(value.pointers ?? [])]);
74
+ if (pointers.length > 0) current.pointers = pointers;
75
+ else delete current.pointers;
76
+ };
77
+ for (const targetPath of paths) {
78
+ merge({
79
+ path: targetPath,
80
+ role: "required",
81
+ itemIds: [],
82
+ sourceIds: [],
83
+ reasons: ["task-target"],
84
+ });
85
+ }
86
+ for (const item of items) {
87
+ for (const sourceId of item.sources) {
88
+ const source = sourceMap.get(sourceId);
89
+ const role = sourceRole(item, sourceId);
90
+ if (!source || !LOCAL_SOURCE_KINDS.has(source.kind)) continue;
91
+ merge({
92
+ path: source.path,
93
+ role,
94
+ itemIds: [item.id],
95
+ sourceIds: [sourceId],
96
+ reasons: [`contract-source:${role}`],
97
+ ...(source.kind === "json-pointer" ? { pointers: [source.pointer] } : {}),
98
+ });
99
+ }
100
+ }
101
+ const all = [...records.values()].sort((left, right) => left.path.localeCompare(right.path));
102
+ const readTargets = all.filter((entry) => entry.role !== "provenance-only");
103
+ const provenancePaths = new Set(all.filter((entry) => entry.role === "provenance-only").map((entry) => entry.path));
104
+ const provenanceSources = [];
105
+ for (const item of items) {
106
+ for (const sourceId of item.sources) {
107
+ const source = sourceMap.get(sourceId);
108
+ if (!source || sourceRole(item, sourceId) !== "provenance-only") continue;
109
+ if (LOCAL_SOURCE_KINDS.has(source.kind) && records.get(source.path)?.role !== "provenance-only") continue;
110
+ provenanceSources.push({ sourceId, itemIds: [item.id], kind: source.kind, locator: sourceLocator(source) });
111
+ }
112
+ }
113
+ const bySource = new Map();
114
+ for (const record of provenanceSources) {
115
+ const current = bySource.get(record.sourceId);
116
+ if (current) current.itemIds = uniqueSorted([...current.itemIds, ...record.itemIds]);
117
+ else bySource.set(record.sourceId, record);
118
+ }
119
+ const unavailable = [];
120
+ for (const target of readTargets.filter((entry) => entry.role === "required" && entry.sourceIds.length > 0)) {
121
+ try {
122
+ await resolveExistingInside(root, target.path);
123
+ } catch (error) {
124
+ unavailable.push({ code: "required-read-target-unavailable", path: target.path, sourceIds: target.sourceIds, reason: error.code ?? "unavailable" });
125
+ }
126
+ }
127
+ return {
128
+ readTargets: readTargets.map((entry) => canonicalValue(entry)),
129
+ provenanceSources: [...bySource.values()].sort((left, right) => left.sourceId.localeCompare(right.sourceId)).map(canonicalValue),
130
+ unavailable,
131
+ provenancePaths,
132
+ };
133
+ }
134
+
135
+ function coverageFor(profile, targets) {
136
+ const domains = targets.map((target) => {
137
+ const domain = coverageDomainForPath(profile, target.path);
138
+ return {
139
+ path: target.path,
140
+ disposition: domain?.disposition ?? "outside-declared-coverage",
141
+ domainId: domain?.id ?? null,
142
+ };
143
+ });
144
+ let registrationCoverage = "closed-for-declared-scope";
145
+ if (!profile) registrationCoverage = "not-declared";
146
+ else if (domains.some((entry) => entry.disposition === "unresolved")) registrationCoverage = "unresolved";
147
+ else if (profile.version !== 2) registrationCoverage = "review-required";
148
+ return {
149
+ profileItemId: profile?.itemId ?? null,
150
+ profileVersion: profile?.version ?? null,
151
+ registrationCoverage,
152
+ targets: domains,
153
+ guarantee: "declared-scope-only-never-all-project-truth",
154
+ };
155
+ }
156
+
157
+ function stableGaps(gaps) {
158
+ const byJson = new Map();
159
+ for (const gap of gaps) byJson.set(canonicalJson(gap), canonicalValue(gap));
160
+ return [...byJson.values()].sort((left, right) => canonicalJson(left).localeCompare(canonicalJson(right)));
161
+ }
162
+
163
+ function authority() {
164
+ return {
165
+ productTaskExecution: false,
166
+ productBusinessCodeWrites: false,
167
+ hostTaskAuthority: "external-to-project-context",
168
+ resolveFrom: ["current-user-request", "human-authored-project-instructions", "higher-priority-safety-constraints"],
169
+ };
170
+ }
171
+
172
+ export async function buildContextBundle(root, project, input) {
173
+ const mode = input.mode;
174
+ if (!new Set(["locate", "targeted"]).has(mode)) fail("context-bundle-schema-invalid", "context bundle mode is invalid");
175
+ const paths = mode === "locate"
176
+ ? []
177
+ : uniqueSorted(input.paths.map((value) => normalizeRelativePath(value, { allowRoot: true, label: "target path" })));
178
+ const task = input.task ?? "";
179
+ const sections = mode === "locate"
180
+ ? [{ path: ".", items: effectiveItems(project.contract.items.filter((item) => item.scope.kind === "project"), ".") }]
181
+ : paths.map((targetPath) => ({ path: targetPath, items: effectiveItems(project.contract.items, targetPath) }));
182
+ const itemMap = new Map();
183
+ for (const section of sections) for (const item of section.items) itemMap.set(item.id, item);
184
+ const items = [...itemMap.values()].sort((left, right) => left.id.localeCompare(right.id));
185
+ const commonIds = sections.length === 0
186
+ ? []
187
+ : sections[0].items.map((item) => item.id).filter((id) => sections.every((section) => section.items.some((item) => item.id === id))).sort();
188
+ const targets = mode === "locate" ? [] : sections.map((section) => {
189
+ const ids = section.items.map((item) => item.id).sort();
190
+ return {
191
+ path: section.path,
192
+ scopeCoverage: scopeCoverage(section.items),
193
+ itemIds: ids,
194
+ scopedItemIds: section.items.filter((item) => item.scope.kind !== "project").map((item) => item.id).sort(),
195
+ deltaItemIds: ids.filter((id) => !commonIds.includes(id)),
196
+ };
197
+ });
198
+ const consumption = await consumptionRecords(root, project.contract, items, paths);
199
+ const profile = coverageProfile(project.contract);
200
+ const coverage = coverageFor(profile, targets);
201
+ const gaps = [...consumption.unavailable];
202
+ if (mode === "locate") gaps.push({ code: "target-path-not-yet-known" });
203
+ for (const target of targets) {
204
+ if (target.scopeCoverage === "project-only") gaps.push({ code: "project-scope-only", path: target.path });
205
+ if (target.scopeCoverage === "none") gaps.push({ code: "no-applicable-contract-item", path: target.path });
206
+ }
207
+ if (!profile) gaps.push({ code: "registration-coverage-not-declared" });
208
+ if (coverage.registrationCoverage === "review-required") gaps.push({ code: "registration-coverage-review-required" });
209
+ if (coverage.registrationCoverage === "unresolved") gaps.push({ code: "registration-coverage-review-required", disposition: "unresolved" });
210
+ const nextActions = mode === "locate"
211
+ ? ["locate-minimum-candidate-paths-inside-target-root", "recompile-targeted-context-with-all-candidate-paths"]
212
+ : ["consume-required-read-targets", "evaluate-conditional-read-targets", "recompile-if-affected-paths-change"];
213
+ const structured = {
214
+ schemaVersion: 1,
215
+ kind: "context-bundle",
216
+ project: { id: project.contract.project.id, name: project.contract.project.name },
217
+ mode,
218
+ task: { text: task, paths },
219
+ snapshots: snapshots(project),
220
+ semanticCompleteness: "not-claimed",
221
+ commonItemIds: commonIds,
222
+ targets,
223
+ items: items.map(itemRecord),
224
+ readTargets: consumption.readTargets,
225
+ provenanceSources: consumption.provenanceSources,
226
+ coverage,
227
+ authority: authority(),
228
+ gaps: stableGaps(gaps),
229
+ nextActions,
230
+ };
231
+ const content = renderCollectedContextBundle({
232
+ project: project.contract.project,
233
+ contractDigest: project.contractDigest,
234
+ paths: mode === "locate" ? ["."] : paths,
235
+ sections,
236
+ itemIds: items.map((item) => item.id),
237
+ }, project.contract.sources, task || undefined, {
238
+ locale: input.locale,
239
+ mode,
240
+ gaps: structured.gaps,
241
+ nextActions,
242
+ });
243
+ return validateContextBundle({ ...structured, content, bundleDigest: digestJson({ ...structured, content }) });
244
+ }
245
+
246
+ function requireObject(value, label) {
247
+ if (!value || typeof value !== "object" || Array.isArray(value)) fail("context-bundle-schema-invalid", `${label} must be an object`);
248
+ }
249
+
250
+ function exactKeys(value, expected, label) {
251
+ requireObject(value, label);
252
+ const actual = Object.keys(value).sort();
253
+ const wanted = [...expected].sort();
254
+ if (actual.length !== wanted.length || actual.some((entry, index) => entry !== wanted[index])) {
255
+ fail("context-bundle-schema-invalid", `${label} must contain exactly: ${wanted.join(", ")}`);
256
+ }
257
+ }
258
+
259
+ function allowedKeys(value, allowed, label) {
260
+ requireObject(value, label);
261
+ for (const key of Object.keys(value)) {
262
+ if (!allowed.has(key)) fail("context-bundle-schema-invalid", `${label} contains unknown field: ${key}`);
263
+ }
264
+ }
265
+
266
+ function nonEmptyString(value, label) {
267
+ if (typeof value !== "string" || value.length === 0) fail("context-bundle-schema-invalid", `${label} must be a non-empty string`);
268
+ }
269
+
270
+ function stableRecords(values, key, label) {
271
+ const selected = values.map((entry) => entry[key]);
272
+ sortedUnique(selected, `${label} ${key}s`);
273
+ }
274
+
275
+ function sortedUnique(values, label) {
276
+ if (!Array.isArray(values) || values.some((entry) => typeof entry !== "string")) fail("context-bundle-schema-invalid", `${label} must be a string array`);
277
+ const expected = uniqueSorted(values);
278
+ if (canonicalJson(values) !== canonicalJson(expected)) fail("context-bundle-schema-invalid", `${label} must be sorted and unique`);
279
+ }
280
+
281
+ export function validateContextBundle(input) {
282
+ const value = canonicalValue(input);
283
+ const keys = new Set(["schemaVersion", "kind", "project", "mode", "task", "snapshots", "semanticCompleteness", "commonItemIds", "targets", "items", "readTargets", "provenanceSources", "coverage", "authority", "gaps", "nextActions", "content", "bundleDigest"]);
284
+ exactKeys(value, keys, "context bundle");
285
+ if (value.schemaVersion !== 1 || value.kind !== "context-bundle") fail("context-bundle-schema-invalid", "context bundle identity is invalid");
286
+ if (!["locate", "targeted"].includes(value.mode)) fail("context-bundle-schema-invalid", "context bundle mode is invalid");
287
+ if (typeof value.content !== "string" || value.content.length === 0) fail("context-bundle-schema-invalid", "context bundle content is required");
288
+ if (!SHA256.test(value.bundleDigest)) fail("context-bundle-schema-invalid", "context bundle digest must be sha256");
289
+ const unsigned = structuredClone(value);
290
+ delete unsigned.bundleDigest;
291
+ if (digestJson(unsigned) !== value.bundleDigest) fail("context-bundle-schema-invalid", "context bundle digest does not match canonical content");
292
+ exactKeys(value.project, new Set(["id", "name"]), "context bundle project");
293
+ nonEmptyString(value.project.id, "context bundle project.id");
294
+ nonEmptyString(value.project.name, "context bundle project.name");
295
+ exactKeys(value.task, new Set(["text", "paths"]), "context bundle task");
296
+ if (typeof value.task.text !== "string") fail("context-bundle-schema-invalid", "context bundle task.text must be a string");
297
+ exactKeys(value.snapshots, new Set(["contract", "sourcesLock", "projectionsLock"]), "context bundle snapshots");
298
+ for (const digest of Object.values(value.snapshots)) if (!SHA256.test(digest)) fail("context-bundle-schema-invalid", "context bundle snapshot must be sha256");
299
+ if (value.semanticCompleteness !== "not-claimed") fail("context-bundle-schema-invalid", "semantic completeness cannot be claimed");
300
+ sortedUnique(value.task.paths, "context bundle task paths");
301
+ sortedUnique(value.commonItemIds, "context bundle common item IDs");
302
+ sortedUnique(value.nextActions, "context bundle next actions");
303
+ for (const collection of ["targets", "items", "readTargets", "provenanceSources", "gaps"]) {
304
+ if (!Array.isArray(value[collection])) fail("context-bundle-schema-invalid", `context bundle ${collection} must be an array`);
305
+ }
306
+ stableRecords(value.targets, "path", "context bundle targets");
307
+ if (canonicalJson(value.targets.map((target) => target.path)) !== canonicalJson(value.task.paths)) {
308
+ fail("context-bundle-schema-invalid", "context bundle targets must correspond exactly to task paths");
309
+ }
310
+ for (const [index, target] of value.targets.entries()) {
311
+ const label = `context bundle targets[${index}]`;
312
+ exactKeys(target, new Set(["path", "scopeCoverage", "itemIds", "scopedItemIds", "deltaItemIds"]), label);
313
+ if (normalizeRelativePath(target.path, { allowRoot: true, label: `${label}.path` }) !== target.path) fail("context-bundle-schema-invalid", `${label}.path must be normalized`);
314
+ if (!["scoped", "project-only", "none"].includes(target.scopeCoverage)) fail("context-bundle-schema-invalid", `${label}.scopeCoverage is invalid`);
315
+ for (const key of ["itemIds", "scopedItemIds", "deltaItemIds"]) sortedUnique(target[key], `${label}.${key}`);
316
+ for (const itemId of [...target.scopedItemIds, ...target.deltaItemIds]) if (!target.itemIds.includes(itemId)) fail("context-bundle-schema-invalid", `${label} contains an item outside itemIds`);
317
+ }
318
+ stableRecords(value.items, "id", "context bundle items");
319
+ const itemIds = new Set(value.items.map((item) => item.id));
320
+ for (const [index, item] of value.items.entries()) {
321
+ const label = `context bundle items[${index}]`;
322
+ allowedKeys(item, new Set(["id", "kind", "subject", "scope", "sourceIds", "statement", "value", "overrides", "verification", "consumption"]), label);
323
+ for (const required of ["id", "kind", "subject", "scope", "sourceIds", "statement", "value", "overrides"]) if (!Object.hasOwn(item, required)) fail("context-bundle-schema-invalid", `${label}.${required} is required`);
324
+ nonEmptyString(item.id, `${label}.id`);
325
+ if (!["fact", "policy", "reference", "validation-description"].includes(item.kind)) fail("context-bundle-schema-invalid", `${label}.kind is invalid`);
326
+ nonEmptyString(item.subject, `${label}.subject`);
327
+ nonEmptyString(item.statement, `${label}.statement`);
328
+ requireObject(item.scope, `${label}.scope`);
329
+ sortedUnique(item.sourceIds, `${label}.sourceIds`);
330
+ sortedUnique(item.overrides, `${label}.overrides`);
331
+ if (item.consumption !== undefined) {
332
+ exactKeys(item.consumption, new Set(["requiredSources", "conditionalSources"]), `${label}.consumption`);
333
+ sortedUnique(item.consumption.requiredSources, `${label}.consumption.requiredSources`);
334
+ sortedUnique(item.consumption.conditionalSources, `${label}.consumption.conditionalSources`);
335
+ }
336
+ }
337
+ for (const id of value.commonItemIds) if (!itemIds.has(id)) fail("context-bundle-schema-invalid", `context bundle common item is missing: ${id}`);
338
+ for (const target of value.targets) for (const id of target.itemIds) if (!itemIds.has(id)) fail("context-bundle-schema-invalid", `context bundle target item is missing: ${id}`);
339
+ stableRecords(value.readTargets, "path", "context bundle read targets");
340
+ for (const [index, target] of value.readTargets.entries()) {
341
+ const label = `context bundle readTargets[${index}]`;
342
+ allowedKeys(target, new Set(["path", "role", "itemIds", "sourceIds", "reasons", "pointers"]), label);
343
+ for (const required of ["path", "role", "itemIds", "sourceIds", "reasons"]) if (!Object.hasOwn(target, required)) fail("context-bundle-schema-invalid", `${label}.${required} is required`);
344
+ if (!["required", "conditional"].includes(target.role)) fail("context-bundle-schema-invalid", `${label}.role is invalid`);
345
+ for (const key of ["itemIds", "sourceIds", "reasons", ...(target.pointers === undefined ? [] : ["pointers"])]) sortedUnique(target[key], `${label}.${key}`);
346
+ }
347
+ stableRecords(value.provenanceSources, "sourceId", "context bundle provenance sources");
348
+ for (const [index, source] of value.provenanceSources.entries()) {
349
+ const label = `context bundle provenanceSources[${index}]`;
350
+ exactKeys(source, new Set(["sourceId", "itemIds", "kind", "locator"]), label);
351
+ nonEmptyString(source.sourceId, `${label}.sourceId`);
352
+ nonEmptyString(source.kind, `${label}.kind`);
353
+ sortedUnique(source.itemIds, `${label}.itemIds`);
354
+ requireObject(source.locator, `${label}.locator`);
355
+ }
356
+ exactKeys(value.coverage, new Set(["profileItemId", "profileVersion", "registrationCoverage", "targets", "guarantee"]), "context bundle coverage");
357
+ if (![null, 1, 2].includes(value.coverage.profileVersion)) fail("context-bundle-schema-invalid", "context bundle coverage profileVersion is invalid");
358
+ if (!["not-declared", "review-required", "unresolved", "closed-for-declared-scope"].includes(value.coverage.registrationCoverage)) fail("context-bundle-schema-invalid", "context bundle registrationCoverage is invalid");
359
+ if (value.coverage.guarantee !== "declared-scope-only-never-all-project-truth") fail("context-bundle-schema-invalid", "context bundle coverage guarantee is invalid");
360
+ if (!Array.isArray(value.coverage.targets)) fail("context-bundle-schema-invalid", "context bundle coverage targets must be an array");
361
+ stableRecords(value.coverage.targets, "path", "context bundle coverage targets");
362
+ for (const [index, target] of value.coverage.targets.entries()) exactKeys(target, new Set(["path", "disposition", "domainId"]), `context bundle coverage.targets[${index}]`);
363
+ exactKeys(value.authority, new Set(["productTaskExecution", "productBusinessCodeWrites", "hostTaskAuthority", "resolveFrom"]), "context bundle authority");
364
+ if (value.authority.productTaskExecution !== false || value.authority.productBusinessCodeWrites !== false || value.authority.hostTaskAuthority !== "external-to-project-context") fail("context-bundle-schema-invalid", "context bundle authority is invalid");
365
+ if (canonicalJson(value.authority.resolveFrom) !== canonicalJson(authority().resolveFrom)) fail("context-bundle-schema-invalid", "context bundle authority resolution order is invalid");
366
+ const gapCodes = new Set(["target-path-not-yet-known", "project-scope-only", "no-applicable-contract-item", "registration-coverage-not-declared", "registration-coverage-review-required", "required-read-target-unavailable"]);
367
+ for (const [index, gap] of value.gaps.entries()) {
368
+ const label = `context bundle gaps[${index}]`;
369
+ allowedKeys(gap, new Set(["code", "path", "sourceIds", "reason", "disposition"]), label);
370
+ if (!gapCodes.has(gap.code)) fail("context-bundle-schema-invalid", `${label}.code is invalid`);
371
+ if (gap.sourceIds !== undefined) sortedUnique(gap.sourceIds, `${label}.sourceIds`);
372
+ }
373
+ const stableGapValues = stableGaps(value.gaps);
374
+ if (canonicalJson(stableGapValues) !== canonicalJson(value.gaps)) fail("context-bundle-schema-invalid", "context bundle gaps must be stably sorted and unique");
375
+ if (value.mode === "locate" && (value.task.paths.length > 0 || value.targets.length > 0)) fail("context-bundle-schema-invalid", "locate context cannot contain target paths");
376
+ if (value.mode === "targeted" && value.task.paths.length === 0) fail("context-bundle-schema-invalid", "targeted context requires paths");
377
+ return value;
378
+ }
@@ -10,6 +10,7 @@ const STATUSES = new Set(["proposed", "approved", "deprecated"]);
10
10
  const SCOPE_KINDS = new Set(["project", "path-prefix", "file"]);
11
11
  const VERIFICATION_KINDS = new Set(["none", "file-exists", "json-value", "path-digest"]);
12
12
  const SHA256 = /^sha256:[a-f0-9]{64}$/u;
13
+ const DIGEST_MODES = new Set(["full-file", "outside-owned-ai-entry"]);
13
14
 
14
15
  function object(value, label) {
15
16
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -64,7 +65,14 @@ export function sourceStatus(source) {
64
65
  }
65
66
 
66
67
  export function sourceForContract(source, schemaVersion) {
67
- return schemaVersion === 2 ? { ...structuredClone(source), status: "active" } : structuredClone(source);
68
+ return schemaVersion >= 2 ? { ...structuredClone(source), status: "active" } : structuredClone(source);
69
+ }
70
+
71
+ export function promoteContractToSchema3(contract) {
72
+ if (contract.schemaVersion === 3) return contract;
73
+ contract.schemaVersion = 3;
74
+ contract.sources = contract.sources.map((source) => source.status === undefined ? { ...source, status: "active" } : source);
75
+ return contract;
68
76
  }
69
77
 
70
78
  export function sourceRegistrationShape(source) {
@@ -76,12 +84,14 @@ export function sourceRegistrationShape(source) {
76
84
 
77
85
  export function validateSource(source, label = "source", options = {}) {
78
86
  const contractSchemaVersion = options.contractSchemaVersion ?? 1;
87
+ const governedStatus = contractSchemaVersion >= 2 && options.proposal !== true;
79
88
  object(source, label);
80
89
  const allowed = new Set(["id", "kind", "path", "pointer", "reference", "digest"]);
81
- if (contractSchemaVersion === 2) {
90
+ if (governedStatus) {
82
91
  allowed.add("status");
83
92
  allowed.add("deprecation");
84
93
  }
94
+ if (contractSchemaVersion >= 3) allowed.add("digestMode");
85
95
  exactKeys(source, allowed, label);
86
96
  stableId(source.id, `${label}.id`);
87
97
  if (!SOURCE_KINDS.has(source.kind)) fail("schema-invalid-enum", `${label}.kind is invalid`);
@@ -97,6 +107,10 @@ export function validateSource(source, label = "source", options = {}) {
97
107
  } else if (source.pointer !== undefined) {
98
108
  fail("schema-invalid", `${label}.pointer is only valid for json-pointer sources`);
99
109
  }
110
+ if (source.digestMode !== undefined) {
111
+ if (source.kind !== "file") fail("schema-invalid", `${label}.digestMode is only valid for file sources`);
112
+ if (!DIGEST_MODES.has(source.digestMode)) fail("schema-invalid-enum", `${label}.digestMode is invalid`);
113
+ }
100
114
  if (source.reference !== undefined) fail("schema-invalid", `${label}.reference is not valid for local sources`);
101
115
  } else {
102
116
  string(source.reference, `${label}.reference`);
@@ -107,7 +121,7 @@ export function validateSource(source, label = "source", options = {}) {
107
121
  fail("schema-invalid", `${label}.digest must be null or omitted for unverifiable sources`);
108
122
  }
109
123
  }
110
- if (contractSchemaVersion === 2) {
124
+ if (governedStatus) {
111
125
  if (!SOURCE_STATUSES.has(source.status)) fail("schema-invalid-enum", `${label}.status is invalid`);
112
126
  if (source.status === "active" && source.deprecation !== undefined) {
113
127
  fail("schema-invalid", `${label}.deprecation is not allowed for active sources`);
@@ -123,6 +137,23 @@ export function validateSource(source, label = "source", options = {}) {
123
137
  return source;
124
138
  }
125
139
 
140
+ function validateConsumption(consumption, item, label) {
141
+ object(consumption, label);
142
+ exactKeys(consumption, new Set(["requiredSources", "conditionalSources"]), label);
143
+ uniqueStrings(consumption.requiredSources, `${label}.requiredSources`, { empty: true });
144
+ uniqueStrings(consumption.conditionalSources, `${label}.conditionalSources`, { empty: true });
145
+ for (const [name, values] of Object.entries(consumption)) {
146
+ const sorted = [...values].sort((left, right) => left.localeCompare(right));
147
+ if (JSON.stringify(values) !== JSON.stringify(sorted)) fail("schema-invalid", `${label}.${name} must be stably sorted`);
148
+ for (const source of values) {
149
+ stableId(source, `${label}.${name} entry`);
150
+ if (!item.sources.includes(source)) fail("source-reference-missing", `${label}.${name} must reference item sources: ${source}`);
151
+ }
152
+ }
153
+ const overlap = consumption.requiredSources.find((source) => consumption.conditionalSources.includes(source));
154
+ if (overlap) fail("schema-invalid", `${label} source roles must be mutually exclusive: ${overlap}`);
155
+ }
156
+
126
157
  function validateApproval(approval, label) {
127
158
  object(approval, label);
128
159
  exactKeys(approval, new Set(["by", "at", "rationale"]), label);
@@ -164,9 +195,12 @@ function validateVerification(verification, label) {
164
195
 
165
196
  export function validateItem(item, label = "item", options = {}) {
166
197
  object(item, label);
198
+ const contractSchemaVersion = options.contractSchemaVersion ?? 1;
199
+ const allowed = new Set(["id", "kind", "subject", "value", "statement", "scope", "status", "sources", "overrides", "approval", "verification"]);
200
+ if (contractSchemaVersion >= 3) allowed.add("consumption");
167
201
  exactKeys(
168
202
  item,
169
- new Set(["id", "kind", "subject", "value", "statement", "scope", "status", "sources", "overrides", "approval", "verification"]),
203
+ allowed,
170
204
  label,
171
205
  );
172
206
  stableId(item.id, `${label}.id`);
@@ -193,6 +227,7 @@ export function validateItem(item, label = "item", options = {}) {
193
227
  }
194
228
  if (item.approval !== undefined && item.status !== "proposed") validateApproval(item.approval, `${label}.approval`);
195
229
  if (item.verification != null) validateVerification(item.verification, `${label}.verification`);
230
+ if (item.consumption !== undefined) validateConsumption(item.consumption, item, `${label}.consumption`);
196
231
  if (options.proposal && item.status !== "proposed") {
197
232
  fail("proposal-not-proposed", `${label} must remain proposed`);
198
233
  }
@@ -210,7 +245,7 @@ function assertUnique(records, label) {
210
245
  export function validateContract(contract) {
211
246
  object(contract, "contract");
212
247
  exactKeys(contract, new Set(["schemaVersion", "project", "sources", "items"]), "contract");
213
- if (![1, 2].includes(contract.schemaVersion)) fail("schema-version-unsupported", "contract.schemaVersion must be 1 or 2");
248
+ if (![1, 2, 3].includes(contract.schemaVersion)) fail("schema-version-unsupported", "contract.schemaVersion must be 1, 2, or 3");
214
249
  object(contract.project, "contract.project");
215
250
  exactKeys(contract.project, new Set(["id", "name", "root"]), "contract.project");
216
251
  stableId(contract.project.id, "contract.project.id");
@@ -222,7 +257,7 @@ export function validateContract(contract) {
222
257
  contract.sources.forEach((source, index) => validateSource(source, `contract.sources[${index}]`, {
223
258
  contractSchemaVersion: contract.schemaVersion,
224
259
  }));
225
- contract.items.forEach((item, index) => validateItem(item, `contract.items[${index}]`));
260
+ contract.items.forEach((item, index) => validateItem(item, `contract.items[${index}]`, { contractSchemaVersion: contract.schemaVersion }));
226
261
  assertUnique(contract.sources, "contract.sources");
227
262
  assertUnique(contract.items, "contract.items");
228
263
  const sourcesById = new Map(contract.sources.map((source) => [source.id, source]));
@@ -234,6 +269,15 @@ export function validateContract(contract) {
234
269
  fail("source-reference-deprecated", `item ${item.id} references deprecated source ${source}`);
235
270
  }
236
271
  }
272
+ for (const sourceId of [
273
+ ...(item.consumption?.requiredSources ?? []),
274
+ ...(item.consumption?.conditionalSources ?? []),
275
+ ]) {
276
+ const referenced = sourcesById.get(sourceId);
277
+ if (!referenced || !["file", "path", "json-pointer"].includes(referenced.kind)) {
278
+ fail("schema-invalid", `item ${item.id} consumption source must be a local source: ${sourceId}`);
279
+ }
280
+ }
237
281
  if (item.verification?.kind !== "none" && item.verification?.source) {
238
282
  const referenced = sourcesById.get(item.verification.source);
239
283
  if (!referenced) {
@@ -255,8 +299,8 @@ export function validateProposal(proposal) {
255
299
  if (!Array.isArray(proposal.sources) || !Array.isArray(proposal.items)) {
256
300
  fail("schema-invalid", "proposal.sources and proposal.items must be arrays");
257
301
  }
258
- proposal.sources.forEach((source, index) => validateSource(source, `proposal.sources[${index}]`));
259
- proposal.items.forEach((item, index) => validateItem(item, `proposal.items[${index}]`, { proposal: true }));
302
+ proposal.sources.forEach((source, index) => validateSource(source, `proposal.sources[${index}]`, { contractSchemaVersion: 3, proposal: true }));
303
+ proposal.items.forEach((item, index) => validateItem(item, `proposal.items[${index}]`, { proposal: true, contractSchemaVersion: 3 }));
260
304
  assertUnique(proposal.sources, "proposal.sources");
261
305
  assertUnique(proposal.items, "proposal.items");
262
306
  const sourceIds = new Set(proposal.sources.map((source) => source.id));
@@ -264,6 +308,15 @@ export function validateProposal(proposal) {
264
308
  for (const source of item.sources) {
265
309
  if (!sourceIds.has(source)) fail("source-reference-missing", `proposal item ${item.id} references unknown source ${source}`);
266
310
  }
311
+ for (const sourceId of [
312
+ ...(item.consumption?.requiredSources ?? []),
313
+ ...(item.consumption?.conditionalSources ?? []),
314
+ ]) {
315
+ const source = proposal.sources.find((entry) => entry.id === sourceId);
316
+ if (!source || !["file", "path", "json-pointer"].includes(source.kind)) {
317
+ fail("schema-invalid", `proposal item ${item.id} consumption source must be local: ${sourceId}`);
318
+ }
319
+ }
267
320
  }
268
321
  return proposal;
269
322
  }
@@ -308,7 +361,7 @@ export function validateProjectionLock(lock) {
308
361
  if (entry.regionId !== "project-context-ai-entry") fail("schema-invalid-enum", "AI Entry regionId is invalid");
309
362
  string(entry.regionDigest, `${label}.regionDigest`);
310
363
  if (!SHA256.test(entry.regionDigest)) fail("schema-invalid", "AI Entry regionDigest must be sha256");
311
- if (![1, 2, 3].includes(entry.rendererVersion)) fail("schema-version-unsupported", "AI Entry rendererVersion must be 1, 2, or 3");
364
+ if (![1, 2, 3, 4, 5].includes(entry.rendererVersion)) fail("schema-version-unsupported", "AI Entry rendererVersion must be 1, 2, 3, 4, or 5");
312
365
  if (typeof entry.createdFile !== "boolean") fail("schema-invalid", `${label}.createdFile must be boolean`);
313
366
  } else {
314
367
  if (entry.target !== "agents" && entry.target !== "ruler") fail("schema-invalid-enum", "projection target is invalid");