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,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.7.0";
6
- export const EXCHANGE_PROTOCOL_VERSION = 7;
5
+ export const PACKAGE_VERSION = "1.9.1";
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 = 7;
9
+ export const CAPABILITIES_SCHEMA_VERSION = 9;
10
10
 
11
11
  export const ACTION_KINDS = Object.freeze([
12
12
  "accept-source-change",
@@ -24,7 +24,7 @@ export const ACTION_KINDS = Object.freeze([
24
24
  export const COMMANDS = Object.freeze([
25
25
  "accept-source-change", "approve", "capabilities", "check", "context", "context-query", "coverage-audit", "dashboard", "deprecate",
26
26
  "deprecate-source", "discover", "evidence", "init", "integration-review", "preflight", "propose", "publish", "register",
27
- "index-context", "publish-entry", "remove-entry", "review-source", "revise", "setup", "stage-context", "status", "sync",
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
30
  ]);
@@ -143,7 +143,7 @@ function normalizeScope(value, label) {
143
143
  function normalizeItemInput(input, label, extra = []) {
144
144
  object(input, label);
145
145
  exactKeys(input, new Set([
146
- "id", "kind", "subject", "value", "statement", "sources", "scope", "overrides", "verification", ...extra,
146
+ "id", "kind", "subject", "value", "statement", "sources", "scope", "overrides", "verification", "consumption", ...extra,
147
147
  ]), label);
148
148
  stableId(input.id, `${label}.id`);
149
149
  if (!ITEM_KINDS.has(input.kind)) invalid(`${label}.kind is invalid`);
@@ -159,6 +159,16 @@ function normalizeItemInput(input, label, extra = []) {
159
159
  sources.forEach((entry) => stableId(entry, `${label}.sources entry`));
160
160
  const overrides = uniqueStrings(input.overrides ?? [], `${label}.overrides`, { empty: true });
161
161
  overrides.forEach((entry) => stableId(entry, `${label}.overrides entry`));
162
+ let consumption;
163
+ if (input.consumption !== undefined) {
164
+ object(input.consumption, `${label}.consumption`);
165
+ exactKeys(input.consumption, new Set(["requiredSources", "conditionalSources"]), `${label}.consumption`);
166
+ const requiredSources = uniqueStrings(input.consumption.requiredSources, `${label}.consumption.requiredSources`, { empty: true });
167
+ const conditionalSources = uniqueStrings(input.consumption.conditionalSources, `${label}.consumption.conditionalSources`, { empty: true });
168
+ for (const source of [...requiredSources, ...conditionalSources]) if (!sources.includes(source)) invalid(`${label}.consumption source must be included in sources: ${source}`);
169
+ if (requiredSources.some((source) => conditionalSources.includes(source))) invalid(`${label}.consumption roles must be mutually exclusive`);
170
+ consumption = { requiredSources, conditionalSources };
171
+ }
162
172
  return {
163
173
  id: input.id,
164
174
  kind: input.kind,
@@ -168,18 +178,23 @@ function normalizeItemInput(input, label, extra = []) {
168
178
  sources,
169
179
  scope: normalizeScope(input.scope, `${label}.scope`),
170
180
  overrides,
181
+ ...(consumption ? { consumption } : {}),
171
182
  ...(input.verification !== undefined ? { verification: normalizeVerification(input.verification, `${label}.verification`) } : {}),
172
183
  };
173
184
  }
174
185
 
175
186
  function normalizeSourceInput(input, label) {
176
187
  object(input, label);
177
- exactKeys(input, new Set(["id", "kind", "path", "pointer", "reference"]), label);
188
+ exactKeys(input, new Set(["id", "kind", "path", "pointer", "reference", "digestMode"]), label);
178
189
  stableId(input.id, `${label}.id`);
179
190
  if (!SOURCE_KINDS.has(input.kind)) invalid(`${label}.kind is invalid`);
180
191
  if (["file", "path", "json-pointer"].includes(input.kind)) {
181
192
  if (input.reference !== undefined) invalid(`${label}.reference is not allowed for local sources`);
182
193
  const normalized = { id: input.id, kind: input.kind, path: projectPath(input.path, `${label}.path`) };
194
+ if (input.digestMode !== undefined) {
195
+ if (input.kind !== "file" || !["full-file", "outside-owned-ai-entry"].includes(input.digestMode)) invalid(`${label}.digestMode is invalid`);
196
+ normalized.digestMode = input.digestMode;
197
+ }
183
198
  if (input.kind === "json-pointer") {
184
199
  string(input.pointer, `${label}.pointer`, { empty: true });
185
200
  if (input.pointer !== "" && !input.pointer.startsWith("/")) invalid(`${label}.pointer must use RFC 6901 syntax`);
@@ -187,7 +202,7 @@ function normalizeSourceInput(input, label) {
187
202
  } else if (input.pointer !== undefined) invalid(`${label}.pointer is only allowed for json-pointer`);
188
203
  return normalized;
189
204
  }
190
- if (input.path !== undefined || input.pointer !== undefined) invalid(`${label} cannot contain a local path`);
205
+ if (input.path !== undefined || input.pointer !== undefined || input.digestMode !== undefined) invalid(`${label} cannot contain a local path or digest mode`);
191
206
  string(input.reference, `${label}.reference`);
192
207
  return { id: input.id, kind: input.kind, reference: input.reference };
193
208
  }
@@ -531,6 +546,8 @@ export function itemPrimitiveInput(input) {
531
546
  scope: input.scope.kind,
532
547
  scopePath: input.scope.path,
533
548
  overrides: [...input.overrides],
549
+ requiredSources: [...(input.consumption?.requiredSources ?? [])],
550
+ conditionalSources: [...(input.consumption?.conditionalSources ?? [])],
534
551
  verification: input.verification?.kind,
535
552
  verificationSource: input.verification?.source,
536
553
  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
 
@@ -0,0 +1,60 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import path from "node:path";
4
+ import { sha256 } from "./canonical-json.mjs";
5
+ import { PACKAGE_VERSION } from "./exchange-schema.mjs";
6
+
7
+ export const INITIALIZATION_INSTRUCTION_SCHEMA_VERSION = 2;
8
+ export const INITIALIZATION_INSTRUCTION_ID = "ai-project-initialization";
9
+ export const INITIALIZATION_INSTRUCTION_VERSION = 2;
10
+ export const INITIALIZATION_INSTRUCTION_PACKAGE_PATH = "docs/AI-PROJECT-INITIALIZATION.md";
11
+ export const INITIALIZATION_INSTRUCTION_COMMAND = "project-context instructions --project PATH [--json | --prompt]";
12
+
13
+ const INSTRUCTION_FILE = fileURLToPath(new URL(`../../${INITIALIZATION_INSTRUCTION_PACKAGE_PATH}`, import.meta.url));
14
+
15
+ export async function readInitializationInstruction() {
16
+ const content = await readFile(INSTRUCTION_FILE, "utf8");
17
+ return {
18
+ id: INITIALIZATION_INSTRUCTION_ID,
19
+ version: INITIALIZATION_INSTRUCTION_VERSION,
20
+ packagePath: INITIALIZATION_INSTRUCTION_PACKAGE_PATH,
21
+ digest: sha256(content),
22
+ content,
23
+ };
24
+ }
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
+ }
33
+ return {
34
+ schemaVersion: INITIALIZATION_INSTRUCTION_SCHEMA_VERSION,
35
+ package: { name: "frontend-project-context", version: PACKAGE_VERSION },
36
+ instruction: await readInitializationInstruction(),
37
+ targetRoot,
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
+ },
50
+ };
51
+ }
52
+
53
+ export function renderInitializationPrompt(result) {
54
+ return [
55
+ `<!-- frontend-project-context initialization instruction ${result.instruction.version}; ${result.instruction.digest} -->`,
56
+ `<!-- resolved-target-root: ${result.targetRoot} -->`,
57
+ result.instruction.content.trimEnd(),
58
+ "",
59
+ ].join("\n");
60
+ }
@@ -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,9 +22,9 @@ 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
- "truthReconciliationInput", "truthReconciliationReviewBundle",
27
+ "truthReconciliationInput", "truthReconciliationReviewBundle", "initializationInstruction", "projectStatus",
28
28
  ]);
29
29
 
30
30
  function invalid(message, details) {
@@ -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.7.0") invalid("migration manifest package does not match this runtime");
106
+ if (manifest.package.name !== "frontend-project-context" || manifest.package.version !== "1.9.1") 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,9 +111,9 @@ 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
- "truthReconciliationInput", "truthReconciliationReviewBundle",
116
+ "truthReconciliationInput", "truthReconciliationReviewBundle", "initializationInstruction", "projectStatus",
117
117
  ]), "protocols");
118
118
  for (const name of Object.keys(manifest.protocols)) versionMatrix(manifest.protocols[name], `protocols.${name}`);
119
119
  if (!Array.isArray(manifest.builtInMigrations)) invalid("builtInMigrations must be an array");
@@ -4,8 +4,9 @@ import { checkExitCode, checkProject } from "./checker.mjs";
4
4
  import { ProjectContextError } from "./errors.mjs";
5
5
  import { PACKAGE_VERSION } from "./exchange-schema.mjs";
6
6
  import { inspectProjectInitialization, loadProject } from "./project-store.mjs";
7
+ import { sourceStatus } from "./contract-schema.mjs";
7
8
 
8
- export const PROJECT_STATUS_SCHEMA_VERSION = 1;
9
+ export const PROJECT_STATUS_SCHEMA_VERSION = 2;
9
10
 
10
11
  function uniqueSorted(values) {
11
12
  return [...new Set(values.filter((value) => value !== undefined && value !== null))]
@@ -46,6 +47,7 @@ function base(initialization, health, nextActions) {
46
47
  snapshots: null,
47
48
  health,
48
49
  entry: { state: "absent", path: null, rendererVersion: null },
50
+ contractReadiness: initialization === "uninitialized" ? "not-initialized" : "contract-incomplete",
49
51
  summary: emptySummary(),
50
52
  findingCodes: [],
51
53
  sourceIds: [],
@@ -65,7 +67,7 @@ function statusBoundaries(boundaries) {
65
67
  export async function buildProjectStatus(root, boundaries) {
66
68
  const initialization = await inspectProjectInitialization(root);
67
69
  if (initialization.status !== "initialized") {
68
- const result = base(initialization.status, initialization.status, initialization.status === "uninitialized" ? ["run-setup-preview"] : ["resolve-conflict"]);
70
+ const result = base(initialization.status, initialization.status, initialization.status === "uninitialized" ? ["read-initialization-instructions"] : ["resolve-conflict"]);
69
71
  result.initialization.present = [...initialization.present];
70
72
  result.initialization.missing = [...initialization.missing];
71
73
  if (initialization.status === "partial") result.findingCodes = ["project-state-partial"];
@@ -112,7 +114,11 @@ export async function buildProjectStatus(root, boundaries) {
112
114
  if (sync.summary.affectedProjections > 0) nextActions.push("review-projection");
113
115
  if (entry.state !== "current") nextActions.push("publish-ai-entry");
114
116
  if (findings.length > 0 && nextActions.length === 0) nextActions.push("run-sync");
115
- if (health === "clean") nextActions.push("ready-for-task");
117
+ if (health === "clean") {
118
+ const hasActiveSource = project.contract.sources.some((source) => sourceStatus(source) === "active");
119
+ const hasApprovedItem = project.contract.items.some((item) => item.status === "approved");
120
+ nextActions.push(hasActiveSource && hasApprovedItem && entry.state === "current" ? "compile-task-context" : "complete-onboarding");
121
+ }
116
122
  }
117
123
  const sourceIds = uniqueSorted([
118
124
  ...findings.flatMap((finding) => finding.source ? [finding.source] : []),
@@ -138,6 +144,11 @@ export async function buildProjectStatus(root, boundaries) {
138
144
  },
139
145
  health,
140
146
  entry,
147
+ contractReadiness: health === "clean" && entry.state === "current" &&
148
+ project.contract.sources.some((source) => sourceStatus(source) === "active") &&
149
+ project.contract.items.some((item) => item.status === "approved")
150
+ ? "contract-ready"
151
+ : "contract-incomplete",
141
152
  summary: {
142
153
  findings: findings.length,
143
154
  changedSources: sync.summary.changedSources,
@@ -1,4 +1,4 @@
1
- import { access, mkdir, rename, rm } from "node:fs/promises";
1
+ import { access, lstat, mkdir, readdir, rename, rm, rmdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { canonicalJson, digestJson } from "./canonical-json.mjs";
@@ -43,8 +43,20 @@ async function exists(filePath) {
43
43
  }
44
44
  }
45
45
 
46
+ async function isEmptyDirectory(filePath) {
47
+ try {
48
+ const stats = await lstat(filePath);
49
+ if (!stats.isDirectory()) return false;
50
+ return (await readdir(filePath)).length === 0;
51
+ } catch (error) {
52
+ if (error?.code === "ENOENT") return false;
53
+ throw error;
54
+ }
55
+ }
56
+
46
57
  export async function inspectProjectInitialization(root) {
47
58
  const files = projectFiles(root);
59
+ const emptyContainer = await isEmptyDirectory(files.directory);
48
60
  const entries = [
49
61
  [CONTEXT_DIRECTORY, files.directory],
50
62
  [`${CONTEXT_DIRECTORY}/${CONTRACT_FILE}`, files.contract],
@@ -53,6 +65,7 @@ export async function inspectProjectInitialization(root) {
53
65
  ];
54
66
  const present = [];
55
67
  for (const [relative, absolute] of entries) {
68
+ if (relative === CONTEXT_DIRECTORY && emptyContainer) continue;
56
69
  if (await exists(absolute)) present.push(relative);
57
70
  }
58
71
  const storePaths = entries.slice(1).map(([relative]) => relative);
@@ -71,8 +84,9 @@ export async function initializeProject(root, id, name, write) {
71
84
  validateContract(initial.contract);
72
85
  validateSourceLock(initial.sourcesLock);
73
86
  validateProjectionLock(initial.projectionsLock);
87
+ const emptyContainer = await isEmptyDirectory(files.directory);
74
88
  const existing = [];
75
- if (await exists(files.directory)) existing.push(CONTEXT_DIRECTORY);
89
+ if ((await exists(files.directory)) && !emptyContainer) existing.push(CONTEXT_DIRECTORY);
76
90
  for (const filePath of [files.contract, files.sourcesLock, files.projectionsLock]) {
77
91
  if (await exists(filePath)) existing.push(path.relative(root, filePath));
78
92
  }
@@ -80,6 +94,17 @@ export async function initializeProject(root, id, name, write) {
80
94
  fail("project-already-initialized", "project context files already exist", { details: { paths: existing } });
81
95
  }
82
96
  if (write) {
97
+ if (emptyContainer) {
98
+ try {
99
+ await rmdir(files.directory);
100
+ } catch (error) {
101
+ if (error?.code !== "ENOENT") {
102
+ fail("project-already-initialized", "project context directory changed before initialization", {
103
+ details: { paths: [CONTEXT_DIRECTORY] },
104
+ });
105
+ }
106
+ }
107
+ }
83
108
  const temporaryDirectory = `${files.directory}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
84
109
  try {
85
110
  await mkdir(temporaryDirectory, { recursive: false });
@@ -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
  }
@@ -121,9 +121,9 @@ export function validateUpgradeAssessment(input) {
121
121
  stores: new Set(["contract", "projectionLock", "proposal", "sourceLock"]),
122
122
  renderers: new Set(["aiEntry", "projection"]),
123
123
  protocols: new Set([
124
- "actionPlan", "adaptiveContextBundle", "contextQuery", "coverageAudit", "evidenceBundle", "evidenceInput", "exchange",
124
+ "actionPlan", "adaptiveContextBundle", "contextBundle", "contextQuery", "coverageAudit", "evidenceBundle", "evidenceInput", "exchange",
125
125
  "hostPromotionEvidence", "integrationReviewBundle", "reviewBundle", "routingIndex", "stageContextBundle", "stageReceipt",
126
- "taskContextPlan", "truthReconciliationInput", "truthReconciliationReviewBundle",
126
+ "taskContextPlan", "truthReconciliationInput", "truthReconciliationReviewBundle", "initializationInstruction", "projectStatus",
127
127
  ]),
128
128
  };
129
129
  for (const [name, group] of Object.entries(value.compatibility)) {