frontend-project-context 1.8.0 → 1.10.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/README.md +26 -7
  3. package/UPGRADING.md +24 -0
  4. package/docs/00-PRODUCT-CONSTITUTION.md +44 -12
  5. package/docs/08-INSTALLATION-AND-DISTRIBUTION.md +9 -3
  6. package/docs/14-FORMAL-RELEASE-READINESS.md +14 -0
  7. package/docs/28-REAL-PROJECT-ONBOARDING-CLOSURE-DESIGN.md +10 -0
  8. package/docs/29-REAL-PROJECT-1.8.0-INITIALIZATION-OBSERVATIONS.md +228 -0
  9. package/docs/30-TASK-CONTEXT-CONSUMPTION-CLOSURE-DESIGN.md +563 -0
  10. package/docs/31-TASK-CONTEXT-INTEGRITY-REPAIR-DESIGN.md +281 -0
  11. package/docs/32-PROJECT-TO-TASK-INTERACTION-PROPOSAL.md +173 -0
  12. package/docs/33-BOUNDED-TASK-HANDOFF-REDESIGN.md +207 -0
  13. package/docs/34-A0-CODEX-HOST-FEASIBILITY.md +61 -0
  14. package/docs/35-CONTEXT-FIRST-TASK-HANDOFF-DESIGN.md +248 -0
  15. package/docs/36-PRODUCT-VALUE-AND-USABILITY-REVIEW.md +158 -0
  16. package/docs/37-EVERYDAY-HANDOFF-FLOW-DESIGN.md +271 -0
  17. package/docs/38-EVERYDAY-HANDOFF-OPERATION.md +147 -0
  18. package/docs/39-SELF-HOST-RELEASE-ACCEPTANCE.md +39 -0
  19. package/docs/AI-PROJECT-INITIALIZATION.md +8 -3
  20. package/docs/PRODUCT-SHARING-AND-ADOPTION-GUIDE.md +111 -0
  21. package/docs/README.md +26 -2
  22. package/docs/USER-AND-AI-OPERATION-MANUAL.md +9 -3
  23. package/docs/assets/product-sharing-01-overview.svg +32 -0
  24. package/docs/assets/product-sharing-02-how-it-works.svg +17 -0
  25. package/docs/assets/product-sharing-03-example.svg +19 -0
  26. package/examples/package.json +1 -1
  27. package/migration-manifest.json +38 -12
  28. package/package.json +2 -2
  29. package/schemas/capabilities.schema.json +26 -12
  30. package/schemas/context-bundle.schema.json +32 -0
  31. package/schemas/coverage-audit.schema.json +6 -4
  32. package/schemas/evidence-bundle.schema.json +1 -1
  33. package/schemas/initialization-instruction.schema.json +17 -5
  34. package/schemas/migration-manifest.schema.json +3 -3
  35. package/schemas/migration-plan.schema.json +1 -1
  36. package/schemas/project-brief.schema.json +400 -0
  37. package/schemas/projection-lock.schema.json +1 -1
  38. package/schemas/task-context-record.schema.json +27 -0
  39. package/schemas/task-handoff-input.schema.json +23 -0
  40. package/schemas/task-handoff-result.schema.json +320 -0
  41. package/schemas/upgrade-assessment.schema.json +1 -1
  42. package/schemas/upgrade-result-bundle.schema.json +1 -1
  43. package/schemas/work-view.schema.json +541 -0
  44. package/src/project-context/adaptive-context-schema.mjs +1 -1
  45. package/src/project-context/adaptive-context.mjs +16 -29
  46. package/src/project-context/ai-entry.mjs +28 -12
  47. package/src/project-context/approver.mjs +6 -2
  48. package/src/project-context/authoring.mjs +28 -8
  49. package/src/project-context/capabilities.mjs +13 -1
  50. package/src/project-context/checker.mjs +1 -1
  51. package/src/project-context/cli.mjs +74 -17
  52. package/src/project-context/context-bundle.mjs +379 -0
  53. package/src/project-context/contract-schema.mjs +62 -9
  54. package/src/project-context/coverage-profile.mjs +127 -0
  55. package/src/project-context/exchange-schema.mjs +24 -6
  56. package/src/project-context/exchange.mjs +3 -0
  57. package/src/project-context/initialization-instruction.mjs +20 -2
  58. package/src/project-context/maintenance.mjs +5 -4
  59. package/src/project-context/migration-manifest.mjs +3 -3
  60. package/src/project-context/path-policy.mjs +13 -0
  61. package/src/project-context/renderer.mjs +11 -4
  62. package/src/project-context/source-reader.mjs +27 -2
  63. package/src/project-context/task-context.mjs +4 -14
  64. package/src/project-context/task-handoff-schema.mjs +185 -0
  65. package/src/project-context/task-handoff.mjs +259 -0
  66. package/src/project-context/upgrade-schema.mjs +1 -1
  67. package/src/project-context/upgrade.mjs +1 -0
  68. package/src/project-context/work-view-schema.mjs +125 -0
  69. package/src/project-context/work-view.mjs +135 -0
@@ -0,0 +1,127 @@
1
+ import { canonicalJson, digestJson } from "./canonical-json.mjs";
2
+ import { fail } from "./errors.mjs";
3
+ import { normalizeRelativePath } from "./path-policy.mjs";
4
+ import { scopeApplies } from "./scope-compiler.mjs";
5
+
6
+ const COVERAGE_SUBJECT = "project.registration-coverage";
7
+ const DISPOSITIONS = new Set(["contract-covered", "dynamic-investigation", "excluded-approved", "unresolved"]);
8
+
9
+ function uniqueSorted(values) {
10
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right));
11
+ }
12
+
13
+ function object(value, label) {
14
+ if (!value || typeof value !== "object" || Array.isArray(value)) fail("coverage-profile-invalid", `${label} must be an object`);
15
+ }
16
+
17
+ function exactKeys(value, required, optional, label) {
18
+ object(value, label);
19
+ const allowed = new Set([...required, ...optional]);
20
+ for (const key of Object.keys(value)) if (!allowed.has(key)) fail("coverage-profile-invalid", `${label} contains unknown field: ${key}`);
21
+ for (const key of required) if (!Object.hasOwn(value, key)) fail("coverage-profile-invalid", `${label}.${key} is required`);
22
+ }
23
+
24
+ function strings(values, label) {
25
+ if (!Array.isArray(values) || values.some((value) => typeof value !== "string")) fail("coverage-profile-invalid", `${label} must be a string array`);
26
+ return values;
27
+ }
28
+
29
+ function normalizePath(value, label) {
30
+ if (typeof value !== "string") fail("coverage-profile-invalid", `${label} must be a string`);
31
+ try {
32
+ return normalizeRelativePath(value, { allowRoot: true, label });
33
+ } catch {
34
+ fail("coverage-profile-invalid", `${label} must be a valid project-relative path`);
35
+ }
36
+ }
37
+
38
+ function normalizedPaths(values, label) {
39
+ const normalized = strings(values, label).map((value) => normalizePath(value, `${label} entry`));
40
+ return uniqueSorted(normalized);
41
+ }
42
+
43
+ export function coverageProfile(contract) {
44
+ const candidates = contract.items.filter((item) => item.status === "approved" && item.kind === "policy" && item.subject === COVERAGE_SUBJECT);
45
+ if (candidates.length === 0) return null;
46
+ if (candidates.length > 1) fail("coverage-profile-conflict", `multiple approved ${COVERAGE_SUBJECT} items are active`);
47
+ const item = candidates[0];
48
+ const value = item.value;
49
+ object(value, `${item.id} coverage value`);
50
+ if (value.version === 2) {
51
+ exactKeys(value, ["version", "domains"], [], `${item.id} coverage v2 value`);
52
+ if (!Array.isArray(value.domains)) fail("coverage-profile-invalid", `${item.id} coverage v2 domains must be an array`);
53
+ const approved = new Map(contract.items.filter((entry) => entry.status === "approved").map((entry) => [entry.id, entry]));
54
+ const ids = new Set();
55
+ const paths = new Set();
56
+ const domains = value.domains.map((domain, index) => {
57
+ const label = `${item.id} domains[${index}]`;
58
+ exactKeys(domain, ["id", "path", "disposition", "itemIds"], ["rationale"], label);
59
+ if (typeof domain.id !== "string" || domain.id.length === 0 || ids.has(domain.id)) fail("coverage-profile-invalid", `${label}.id must be unique`);
60
+ ids.add(domain.id);
61
+ const normalizedPath = normalizePath(domain.path, `${label}.path`);
62
+ if (normalizedPath !== domain.path || paths.has(normalizedPath)) fail("coverage-profile-invalid", `${label}.path must be normalized and unique`);
63
+ paths.add(normalizedPath);
64
+ if (!DISPOSITIONS.has(domain.disposition)) fail("coverage-profile-invalid", `${label}.disposition is invalid`);
65
+ const itemIds = uniqueSorted(strings(domain.itemIds, `${label}.itemIds`));
66
+ if (canonicalJson(itemIds) !== canonicalJson(domain.itemIds)) fail("coverage-profile-invalid", `${label}.itemIds must be sorted and unique`);
67
+ if (domain.disposition === "contract-covered") {
68
+ if (itemIds.length === 0) fail("coverage-profile-invalid", `${label} contract-covered domain requires itemIds`);
69
+ for (const itemId of itemIds) {
70
+ const covered = approved.get(itemId);
71
+ if (!covered || !scopeApplies(covered.scope, normalizedPath)) fail("coverage-profile-invalid", `${label} references an item that is not effective for its path: ${itemId}`);
72
+ }
73
+ }
74
+ if (domain.disposition === "excluded-approved" && (typeof domain.rationale !== "string" || domain.rationale.length === 0)) {
75
+ fail("coverage-profile-invalid", `${label} excluded-approved domain requires rationale`);
76
+ }
77
+ if (domain.rationale !== undefined && typeof domain.rationale !== "string") fail("coverage-profile-invalid", `${label}.rationale must be a string`);
78
+ return {
79
+ id: domain.id,
80
+ path: normalizedPath,
81
+ disposition: domain.disposition,
82
+ itemIds,
83
+ ...(domain.rationale !== undefined ? { rationale: domain.rationale } : {}),
84
+ };
85
+ }).sort((left, right) => left.id.localeCompare(right.id));
86
+ if (canonicalJson(domains) !== canonicalJson(value.domains)) fail("coverage-profile-invalid", `${item.id} domains must be stably sorted by id`);
87
+ return { itemId: item.id, itemDigest: digestJson(item), sourceIds: [...item.sources].sort(), version: 2, domains };
88
+ }
89
+ exactKeys(value, ["roots", "excludedPaths"], [], `${item.id} coverage value`);
90
+ return {
91
+ itemId: item.id,
92
+ itemDigest: digestJson(item),
93
+ sourceIds: [...item.sources].sort(),
94
+ version: 1,
95
+ roots: normalizedPaths(value.roots, "coverage roots"),
96
+ excludedPaths: normalizedPaths(value.excludedPaths, "coverage excluded paths"),
97
+ };
98
+ }
99
+
100
+ function pathWithin(target, parent) {
101
+ return parent === "." || target === parent || target.startsWith(`${parent}/`);
102
+ }
103
+
104
+ export function coverageDomainForPath(profile, candidatePath) {
105
+ if (!profile || profile.version !== 2) return null;
106
+ return profile.domains
107
+ .filter((entry) => pathWithin(candidatePath, entry.path))
108
+ .sort((left, right) => right.path.length - left.path.length || left.id.localeCompare(right.id))[0] ?? null;
109
+ }
110
+
111
+ export function coverageClass(profile, registeredPaths, candidatePath) {
112
+ if (registeredPaths.has(candidatePath)) return "registered";
113
+ if (!profile) return "outside-declared-coverage";
114
+ if (profile.version === 2) {
115
+ const domain = coverageDomainForPath(profile, candidatePath);
116
+ if (!domain) return "outside-declared-coverage";
117
+ return {
118
+ "contract-covered": "registered",
119
+ "dynamic-investigation": "dynamic-investigation",
120
+ "excluded-approved": "excluded-approved",
121
+ unresolved: "unresolved",
122
+ }[domain.disposition];
123
+ }
124
+ if (profile.excludedPaths.some((entry) => pathWithin(candidatePath, entry))) return "excluded-approved";
125
+ if (profile.roots.some((entry) => pathWithin(candidatePath, entry))) return "review-required";
126
+ return "outside-declared-coverage";
127
+ }
@@ -2,11 +2,11 @@ import { canonicalValue, digestJson, validateJsonValue } from "./canonical-json.
2
2
  import { fail } from "./errors.mjs";
3
3
  import { normalizeRelativePath } from "./path-policy.mjs";
4
4
 
5
- export const PACKAGE_VERSION = "1.8.0";
6
- export const EXCHANGE_PROTOCOL_VERSION = 8;
5
+ export const PACKAGE_VERSION = "1.10.0";
6
+ export const EXCHANGE_PROTOCOL_VERSION = 9;
7
7
  export const ACTION_PLAN_SCHEMA_VERSION = 2;
8
8
  export const REVIEW_BUNDLE_SCHEMA_VERSION = 2;
9
- export const CAPABILITIES_SCHEMA_VERSION = 8;
9
+ export const CAPABILITIES_SCHEMA_VERSION = 11;
10
10
 
11
11
  export const ACTION_KINDS = Object.freeze([
12
12
  "accept-source-change",
@@ -27,6 +27,7 @@ export const COMMANDS = Object.freeze([
27
27
  "index-context", "instructions", "publish-entry", "remove-entry", "review-source", "revise", "setup", "stage-context", "status", "sync",
28
28
  "upgrade-apply", "upgrade-check", "upgrade-plan",
29
29
  "reconcile-truth",
30
+ "project-brief", "task-handoff",
30
31
  ]);
31
32
 
32
33
  const ACTION_KIND_SET = new Set(ACTION_KINDS);
@@ -143,7 +144,7 @@ function normalizeScope(value, label) {
143
144
  function normalizeItemInput(input, label, extra = []) {
144
145
  object(input, label);
145
146
  exactKeys(input, new Set([
146
- "id", "kind", "subject", "value", "statement", "sources", "scope", "overrides", "verification", ...extra,
147
+ "id", "kind", "subject", "value", "statement", "sources", "scope", "overrides", "verification", "consumption", ...extra,
147
148
  ]), label);
148
149
  stableId(input.id, `${label}.id`);
149
150
  if (!ITEM_KINDS.has(input.kind)) invalid(`${label}.kind is invalid`);
@@ -159,6 +160,16 @@ function normalizeItemInput(input, label, extra = []) {
159
160
  sources.forEach((entry) => stableId(entry, `${label}.sources entry`));
160
161
  const overrides = uniqueStrings(input.overrides ?? [], `${label}.overrides`, { empty: true });
161
162
  overrides.forEach((entry) => stableId(entry, `${label}.overrides entry`));
163
+ let consumption;
164
+ if (input.consumption !== undefined) {
165
+ object(input.consumption, `${label}.consumption`);
166
+ exactKeys(input.consumption, new Set(["requiredSources", "conditionalSources"]), `${label}.consumption`);
167
+ const requiredSources = uniqueStrings(input.consumption.requiredSources, `${label}.consumption.requiredSources`, { empty: true });
168
+ const conditionalSources = uniqueStrings(input.consumption.conditionalSources, `${label}.consumption.conditionalSources`, { empty: true });
169
+ for (const source of [...requiredSources, ...conditionalSources]) if (!sources.includes(source)) invalid(`${label}.consumption source must be included in sources: ${source}`);
170
+ if (requiredSources.some((source) => conditionalSources.includes(source))) invalid(`${label}.consumption roles must be mutually exclusive`);
171
+ consumption = { requiredSources, conditionalSources };
172
+ }
162
173
  return {
163
174
  id: input.id,
164
175
  kind: input.kind,
@@ -168,18 +179,23 @@ function normalizeItemInput(input, label, extra = []) {
168
179
  sources,
169
180
  scope: normalizeScope(input.scope, `${label}.scope`),
170
181
  overrides,
182
+ ...(consumption ? { consumption } : {}),
171
183
  ...(input.verification !== undefined ? { verification: normalizeVerification(input.verification, `${label}.verification`) } : {}),
172
184
  };
173
185
  }
174
186
 
175
187
  function normalizeSourceInput(input, label) {
176
188
  object(input, label);
177
- exactKeys(input, new Set(["id", "kind", "path", "pointer", "reference"]), label);
189
+ exactKeys(input, new Set(["id", "kind", "path", "pointer", "reference", "digestMode"]), label);
178
190
  stableId(input.id, `${label}.id`);
179
191
  if (!SOURCE_KINDS.has(input.kind)) invalid(`${label}.kind is invalid`);
180
192
  if (["file", "path", "json-pointer"].includes(input.kind)) {
181
193
  if (input.reference !== undefined) invalid(`${label}.reference is not allowed for local sources`);
182
194
  const normalized = { id: input.id, kind: input.kind, path: projectPath(input.path, `${label}.path`) };
195
+ if (input.digestMode !== undefined) {
196
+ if (input.kind !== "file" || !["full-file", "outside-owned-ai-entry"].includes(input.digestMode)) invalid(`${label}.digestMode is invalid`);
197
+ normalized.digestMode = input.digestMode;
198
+ }
183
199
  if (input.kind === "json-pointer") {
184
200
  string(input.pointer, `${label}.pointer`, { empty: true });
185
201
  if (input.pointer !== "" && !input.pointer.startsWith("/")) invalid(`${label}.pointer must use RFC 6901 syntax`);
@@ -187,7 +203,7 @@ function normalizeSourceInput(input, label) {
187
203
  } else if (input.pointer !== undefined) invalid(`${label}.pointer is only allowed for json-pointer`);
188
204
  return normalized;
189
205
  }
190
- if (input.path !== undefined || input.pointer !== undefined) invalid(`${label} cannot contain a local path`);
206
+ if (input.path !== undefined || input.pointer !== undefined || input.digestMode !== undefined) invalid(`${label} cannot contain a local path or digest mode`);
191
207
  string(input.reference, `${label}.reference`);
192
208
  return { id: input.id, kind: input.kind, reference: input.reference };
193
209
  }
@@ -531,6 +547,8 @@ export function itemPrimitiveInput(input) {
531
547
  scope: input.scope.kind,
532
548
  scopePath: input.scope.path,
533
549
  overrides: [...input.overrides],
550
+ requiredSources: [...(input.consumption?.requiredSources ?? [])],
551
+ conditionalSources: [...(input.consumption?.conditionalSources ?? [])],
534
552
  verification: input.verification?.kind,
535
553
  verificationSource: input.verification?.source,
536
554
  verificationExpectedPresent: Boolean(input.verification && Object.hasOwn(input.verification, "expected")),
@@ -71,6 +71,8 @@ function itemArgs(input, options = {}) {
71
71
  "--scope", input.scope.kind,
72
72
  ...(input.scope.path ? ["--scope-path", input.scope.path] : []),
73
73
  ...(input.overrides.length > 0 ? ["--overrides", ...input.overrides] : []),
74
+ ...(input.consumption?.requiredSources?.length > 0 ? ["--required-source", ...input.consumption.requiredSources] : []),
75
+ ...(input.consumption?.conditionalSources?.length > 0 ? ["--conditional-source", ...input.consumption.conditionalSources] : []),
74
76
  ];
75
77
  if (input.verification) {
76
78
  args.push("--verification", input.verification.kind);
@@ -89,6 +91,7 @@ function sourceArgs(input) {
89
91
  ...(input.path ? ["--path", input.path] : []),
90
92
  ...(input.pointer !== undefined ? ["--pointer", input.pointer] : []),
91
93
  ...(input.reference ? ["--reference", input.reference] : []),
94
+ ...(input.digestMode ? ["--digest-mode", input.digestMode] : []),
92
95
  ];
93
96
  }
94
97
 
@@ -1,11 +1,12 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { fileURLToPath } from "node:url";
3
+ import path from "node:path";
3
4
  import { sha256 } from "./canonical-json.mjs";
4
5
  import { PACKAGE_VERSION } from "./exchange-schema.mjs";
5
6
 
6
- export const INITIALIZATION_INSTRUCTION_SCHEMA_VERSION = 1;
7
+ export const INITIALIZATION_INSTRUCTION_SCHEMA_VERSION = 2;
7
8
  export const INITIALIZATION_INSTRUCTION_ID = "ai-project-initialization";
8
- export const INITIALIZATION_INSTRUCTION_VERSION = 1;
9
+ export const INITIALIZATION_INSTRUCTION_VERSION = 2;
9
10
  export const INITIALIZATION_INSTRUCTION_PACKAGE_PATH = "docs/AI-PROJECT-INITIALIZATION.md";
10
11
  export const INITIALIZATION_INSTRUCTION_COMMAND = "project-context instructions --project PATH [--json | --prompt]";
11
12
 
@@ -23,12 +24,29 @@ export async function readInitializationInstruction() {
23
24
  }
24
25
 
25
26
  export async function buildInitializationInstruction(targetRoot, boundaries) {
27
+ let entryContent = null;
28
+ try {
29
+ entryContent = await readFile(path.join(targetRoot, "AGENTS.md"), "utf8");
30
+ } catch (error) {
31
+ if (error?.code !== "ENOENT") throw error;
32
+ }
26
33
  return {
27
34
  schemaVersion: INITIALIZATION_INSTRUCTION_SCHEMA_VERSION,
28
35
  package: { name: "frontend-project-context", version: PACKAGE_VERSION },
29
36
  instruction: await readInitializationInstruction(),
30
37
  targetRoot,
31
38
  boundaries: { ...boundaries },
39
+ entryRuleReview: {
40
+ path: "AGENTS.md",
41
+ present: entryContent !== null,
42
+ allowedDispositions: ["migrated-to-contract", "kept-in-entry-intentionally", "excluded-as-stale", "unresolved-blocker"],
43
+ records: entryContent === null || entryContent.trim().length === 0 ? [] : [{
44
+ id: "human-entry-content",
45
+ disposition: "unresolved-blocker",
46
+ rationale: "Classify every duplicated or legacy human entry rule in the concentrated initialization review before completion.",
47
+ }],
48
+ automaticDeletion: false,
49
+ },
32
50
  };
33
51
  }
34
52
 
@@ -1,6 +1,6 @@
1
1
  import { digestJson } from "./canonical-json.mjs";
2
2
  import { buildItemProposal } from "./authoring.mjs";
3
- import { sourceStatus, validateContract, validateSourceLock } from "./contract-schema.mjs";
3
+ import { promoteContractToSchema3, sourceStatus, validateContract, validateSourceLock } from "./contract-schema.mjs";
4
4
  import { fail } from "./errors.mjs";
5
5
  import { writeProjectState } from "./project-store.mjs";
6
6
  import { readSourceDigest } from "./source-reader.mjs";
@@ -104,7 +104,7 @@ export async function reviewSource(root, project, sourceId, options = {}) {
104
104
  let status;
105
105
  let reason;
106
106
  try {
107
- currentDigest = await readSourceDigest(root, source, options.sourceReadContext);
107
+ currentDigest = await readSourceDigest(root, source, options.sourceReadContext, { projectionsLock: project.projectionsLock });
108
108
  if (lockedDigest === null || lockedDigest !== source.digest) {
109
109
  status = "unreadable";
110
110
  reason = lockedDigest === null ? "source-lock-missing" : "source-lock-mismatch";
@@ -179,7 +179,7 @@ export async function acceptSourceChange(root, project, input, options = {}) {
179
179
  if (options.write) {
180
180
  let actual;
181
181
  try {
182
- actual = await readSourceDigest(root, source);
182
+ actual = await readSourceDigest(root, source, undefined, { projectionsLock: project.projectionsLock });
183
183
  } catch (error) {
184
184
  fail("source-accept-digest-mismatch", "source became unavailable while acceptance was being prepared", {
185
185
  exitCode: 1,
@@ -266,7 +266,7 @@ export async function deprecateSource(project, input, options = {}) {
266
266
 
267
267
  const timestamp = input.at ?? new Date().toISOString();
268
268
  const nextContract = structuredClone(project.contract);
269
- nextContract.schemaVersion = 2;
269
+ if (nextContract.schemaVersion < 2) nextContract.schemaVersion = 2;
270
270
  nextContract.sources = nextContract.sources.map((source) => {
271
271
  const active = source.status === undefined ? { ...source, status: "active" } : source;
272
272
  if (source.id !== input.id) return active;
@@ -326,6 +326,7 @@ export async function reviseItem(root, project, input, options = {}) {
326
326
  });
327
327
  }
328
328
  const nextContract = structuredClone(project.contract);
329
+ if (replacement.consumption !== undefined) promoteContractToSchema3(nextContract);
329
330
  nextContract.items[nextContract.items.findIndex((item) => item.id === input.id)] = replacement;
330
331
  validateContract(nextContract);
331
332
  if (options.write) await writeProjectState(project, nextContract, project.sourcesLock, options.storeOptions);
@@ -22,7 +22,7 @@ const MIGRATION_KINDS = new Set([
22
22
  ]);
23
23
  const ROLLBACK_CLASSES = new Set(["package-only", "reversible-data", "forward-only"]);
24
24
  const CONSUMER_CHANGE_KEYS = new Set([
25
- "actionPlan", "adaptiveContextBundle", "capabilities", "contextQuery", "coverageAudit", "evidenceBundle", "evidenceInput", "exchange",
25
+ "actionPlan", "adaptiveContextBundle", "capabilities", "contextBundle", "contextQuery", "coverageAudit", "evidenceBundle", "evidenceInput", "exchange",
26
26
  "hostPromotionEvidence", "integrationReviewBundle", "reviewBundle", "routingIndex", "stageContextBundle", "stageReceipt", "taskContextPlan",
27
27
  "truthReconciliationInput", "truthReconciliationReviewBundle", "initializationInstruction", "projectStatus",
28
28
  ]);
@@ -103,7 +103,7 @@ export function validateMigrationManifest(input) {
103
103
  ]), "migration manifest");
104
104
  if (manifest.schemaVersion !== MIGRATION_MANIFEST_SCHEMA_VERSION) invalid("migration manifest schemaVersion must be 2");
105
105
  exactKeys(manifest.package, new Set(["name", "version"]), "migration manifest package");
106
- if (manifest.package.name !== "frontend-project-context" || manifest.package.version !== "1.8.0") invalid("migration manifest package does not match this runtime");
106
+ if (manifest.package.name !== "frontend-project-context" || manifest.package.version !== "1.10.0") invalid("migration manifest package does not match this runtime");
107
107
  versions(manifest.upgradeFrom, "upgradeFrom");
108
108
  if (manifest.upgradeFrom.length === 0) invalid("upgradeFrom must not be empty");
109
109
  exactKeys(manifest.stores, new Set(["contract", "projectionLock", "proposal", "sourceLock"]), "stores");
@@ -111,7 +111,7 @@ export function validateMigrationManifest(input) {
111
111
  exactKeys(manifest.renderers, new Set(["aiEntry", "projection"]), "renderers");
112
112
  for (const name of Object.keys(manifest.renderers)) versionMatrix(manifest.renderers[name], `renderers.${name}`);
113
113
  exactKeys(manifest.protocols, new Set([
114
- "exchange", "actionPlan", "adaptiveContextBundle", "contextQuery", "coverageAudit", "reviewBundle", "evidenceInput", "evidenceBundle", "taskContextPlan",
114
+ "exchange", "actionPlan", "adaptiveContextBundle", "contextBundle", "contextQuery", "coverageAudit", "reviewBundle", "evidenceInput", "evidenceBundle", "taskContextPlan",
115
115
  "routingIndex", "stageReceipt", "stageContextBundle", "integrationReviewBundle", "hostPromotionEvidence",
116
116
  "truthReconciliationInput", "truthReconciliationReviewBundle", "initializationInstruction", "projectStatus",
117
117
  ]), "protocols");
@@ -134,6 +134,19 @@ export async function resolveWritableInside(root, relativePath, options = {}) {
134
134
  return { normalized, absolute };
135
135
  }
136
136
 
137
+ // Context can name a file that has not been created yet. Validate the nearest
138
+ // existing parent as well as existing targets, without creating any directory.
139
+ export async function inspectContextTarget(root, relativePath) {
140
+ try {
141
+ await resolveExistingInside(root, relativePath, { allowRoot: true, label: "context target" });
142
+ return "existing";
143
+ } catch (error) {
144
+ if (error?.code !== "source-missing") throw error;
145
+ await resolveWritableInside(root, relativePath, { allowRoot: true, label: "context target" });
146
+ return "prospective-or-deleted";
147
+ }
148
+ }
149
+
137
150
  export function assertProjectionPath(target, normalized) {
138
151
  if (target === "agents") {
139
152
  if (path.posix.basename(normalized) !== "AGENTS.md") {
@@ -64,7 +64,7 @@ function groupedSections(sections) {
64
64
  return groups;
65
65
  }
66
66
 
67
- function renderCollectedBundle(bundle, sources, task, options = {}) {
67
+ export function renderCollectedContextBundle(bundle, sources, task, options = {}) {
68
68
  const locale = options.locale ?? "zh-CN";
69
69
  const lines = [
70
70
  "# Project Context Bundle",
@@ -100,11 +100,18 @@ function renderCollectedBundle(bundle, sources, task, options = {}) {
100
100
  lines.push(`- \`${id}\` — ${source?.kind ?? "unknown"}: \`${location}\``);
101
101
  }
102
102
  }
103
+ if ((options.gaps ?? []).length > 0) {
104
+ lines.push("", "## Context gaps", "");
105
+ for (const gap of options.gaps) lines.push(`- \`${gap.code}\`${gap.path ? ` — \`${gap.path}\`` : ""}`);
106
+ }
107
+ if ((options.nextActions ?? []).length > 0) {
108
+ lines.push("", "## Next actions", "", ...options.nextActions.map((action) => `- \`${action}\``));
109
+ }
103
110
  return `${lines.join("\n")}\n`;
104
111
  }
105
112
 
106
113
  export function renderContextBundle(contract, paths, task, options = {}) {
107
- return renderCollectedBundle(collectBundle(contract, paths), contract.sources, task, options);
114
+ return renderCollectedContextBundle(collectBundle(contract, paths), contract.sources, task, options);
108
115
  }
109
116
 
110
117
  export function renderSelectedContextBundle(contract, paths, itemIds, task, options = {}) {
@@ -122,7 +129,7 @@ export function renderSelectedContextBundle(contract, paths, itemIds, task, opti
122
129
  sections,
123
130
  itemIds: [...selected].sort(),
124
131
  };
125
- return renderCollectedBundle(bundle, contract.sources, task, options);
132
+ return renderCollectedContextBundle(bundle, contract.sources, task, options);
126
133
  }
127
134
 
128
135
  // Task delivery deliberately has its own renderer. Managed AGENTS/Ruler projections
@@ -183,7 +190,7 @@ export function renderTaskContextBundle(contract, paths, itemIds, task, options
183
190
 
184
191
  export function renderProjection(contract, paths, target) {
185
192
  const bundle = collectBundle(contract, paths);
186
- const body = renderCollectedBundle(bundle, contract.sources);
193
+ const body = renderCollectedContextBundle(bundle, contract.sources);
187
194
  const bundleDigest = sha256(body);
188
195
  const marker = `<!-- managed-by: project-context; renderer-version: ${RENDERER_VERSION}; target: ${target}; contract-digest: ${bundle.contractDigest}; bundle-digest: ${bundleDigest} -->`;
189
196
  return {
@@ -1,6 +1,7 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { canonicalJson, sha256 } from "./canonical-json.mjs";
3
+ import { canonicalJson, digestJson, sha256 } from "./canonical-json.mjs";
4
+ import { AI_ENTRY_REGION_ID, parseAiEntryRegion } from "./ai-entry.mjs";
4
5
  import { sourceStatus } from "./contract-schema.mjs";
5
6
  import { fail } from "./errors.mjs";
6
7
  import { resolveExistingInside } from "./path-policy.mjs";
@@ -97,10 +98,34 @@ export async function digestPathIdentity(projectRoot, relativePath, context) {
97
98
  });
98
99
  }
99
100
 
100
- export async function readSourceDigest(projectRoot, source, context) {
101
+ async function digestOutsideOwnedAiEntry(projectRoot, source, context, projectionsLock) {
102
+ const lock = projectionsLock?.projections?.find((entry) => entry.path === source.path);
103
+ if (!lock || lock.ownership !== "region" || lock.target !== "ai-entry" || lock.regionId !== AI_ENTRY_REGION_ID) {
104
+ fail("source-digest-ownership-invalid", `outside-owned-ai-entry requires a trusted AI Entry lock: ${source.path}`);
105
+ }
106
+ const { absolute } = await resolveExistingInside(projectRoot, source.path);
107
+ const content = await cached(context, `body:${absolute}`, async () => {
108
+ if (context?.metrics) context.metrics.sourceBodyReads += 1;
109
+ return readFile(absolute, "utf8");
110
+ });
111
+ const parsed = parseAiEntryRegion(content);
112
+ if (parsed.state !== "present") fail("source-digest-marker-invalid", `AI Entry markers are not valid: ${source.path}`);
113
+ if (sha256(parsed.region) !== lock.regionDigest) fail("source-digest-ownership-invalid", `AI Entry region digest does not match its lock: ${source.path}`);
114
+ const renderer = /project-context:ai-entry; schema-version: 1; renderer-version: (\d+)/u.exec(parsed.region)?.[1];
115
+ if (renderer === undefined || Number(renderer) !== lock.rendererVersion) {
116
+ fail("source-digest-ownership-invalid", `AI Entry renderer does not match its lock: ${source.path}`);
117
+ }
118
+ return digestJson({ before: content.slice(0, parsed.start), after: content.slice(parsed.end) });
119
+ }
120
+
121
+ export async function readSourceDigest(projectRoot, source, context, options = {}) {
101
122
  if (sourceStatus(source) === "deprecated") return null;
102
123
  if (source.kind === "human-decision" || source.kind === "external-reference") return null;
103
124
  if (source.kind === "path") return digestPathIdentity(projectRoot, source.path, context);
125
+ if (source.kind === "file" && source.digestMode === "outside-owned-ai-entry") {
126
+ return cached(context, `digest:outside-owned-ai-entry:${source.path}`, () =>
127
+ digestOutsideOwnedAiEntry(projectRoot, source, context, options.projectionsLock));
128
+ }
104
129
  if (source.kind === "file") return digestPath(projectRoot, source.path, context);
105
130
  return sha256(canonicalJson(await readJsonSourceValue(projectRoot, source, context)));
106
131
  }
@@ -1,5 +1,4 @@
1
1
  import { canonicalJson, digestJson } from "./canonical-json.mjs";
2
- import { selectAdaptiveItems } from "./adaptive-context.mjs";
3
2
  import { blockingContextFindings, checkProject } from "./checker.mjs";
4
3
  import { ProjectContextError, fail } from "./errors.mjs";
5
4
  import { readJsonFile } from "./io.mjs";
@@ -92,18 +91,10 @@ function normalizedReceipts(receiptInputs, plan) {
92
91
  return byStage;
93
92
  }
94
93
 
95
- function selectedItems(contract, targetPaths, findings, adaptiveText) {
94
+ function selectedItems(contract, targetPaths, findings) {
96
95
  const byId = new Map();
97
- if (adaptiveText !== undefined) {
98
- const adaptiveFindings = [];
99
- const selection = selectAdaptiveItems(contract, null, {
100
- task: { text: adaptiveText, topics: [], paths: targetPaths, changedPaths: [], itemIds: [] },
101
- level: "initial",
102
- }, { findings: adaptiveFindings });
103
- findings.push(...adaptiveFindings);
104
- for (const item of selection.selected) byId.set(item.id, item);
105
- }
106
- if (adaptiveText === undefined) {
96
+ // Schema-1 plans have no adaptive delivery/expansion contract. Preserve their
97
+ // complete scoped context; the existing budget gate blocks oversized bundles.
107
98
  for (const targetPath of targetPaths) {
108
99
  try {
109
100
  for (const item of effectiveItems(contract.items, targetPath)) byId.set(item.id, item);
@@ -113,7 +104,6 @@ function selectedItems(contract, targetPaths, findings, adaptiveText) {
113
104
  for (const item of contract.items.filter((entry) => entry.status === "approved" && scopeApplies(entry.scope, targetPath))) byId.set(item.id, item);
114
105
  }
115
106
  }
116
- }
117
107
  return [...byId.values()].sort((left, right) => left.id.localeCompare(right.id)).map((item) => ({
118
108
  id: item.id,
119
109
  kind: item.kind,
@@ -217,7 +207,7 @@ async function buildStageContextBundleCore(root, project, plan, options, receipt
217
207
  if (receipt.status !== "completed") findings.push(finding("stage-dependency-blocked", "blocked", { stageId: stage.id, dependencyStageId: dependencyId }));
218
208
  }
219
209
  const targetPaths = uniqueSorted([...stage.paths, ...changedPaths]);
220
- const contractItems = selectedItems(project.contract, targetPaths, findings, `${plan.task.goal}\n${stage.objective}`);
210
+ const contractItems = selectedItems(project.contract, targetPaths, findings);
221
211
  const readTargets = mergeReadTargets([
222
212
  ...stage.paths.map((entry) => ({ path: entry, reason: "stage-scope" })),
223
213
  ...changedPaths.map((entry) => ({ path: entry, reason: "host-changed-path-signal" })),