frontend-project-context 1.3.1 → 1.7.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 (67) hide show
  1. package/CHANGELOG.md +51 -2
  2. package/README.md +156 -40
  3. package/UPGRADING.md +55 -1
  4. package/docs/04-PROGRAM-DESIGN.md +34 -4
  5. package/docs/05-ACCEPTANCE-CONTRACT.md +40 -3
  6. package/docs/08-INSTALLATION-AND-DISTRIBUTION.md +67 -22
  7. package/docs/14-FORMAL-RELEASE-READINESS.md +30 -1
  8. package/docs/18-BRANCH-AWARE-STAGED-CONTEXT-DESIGN.md +2 -2
  9. package/docs/19-POST-1.3.1-AI-TAKEOVER-EVIDENCE-AND-UPGRADE-PLAN.md +579 -0
  10. package/docs/20-PHASE-A-AI-TAKEOVER-AND-HEALTH-CLOSURE-DESIGN.md +535 -0
  11. package/docs/21-PHASE-B-EVIDENCE-FEEDBACK-PROTOCOL-DESIGN.md +347 -0
  12. package/docs/22-PHASE-C-TARGET-UPGRADE-PROTOCOL-DESIGN.md +398 -0
  13. package/docs/23-ADAPTIVE-BOUNDED-TASK-CONTEXT-DESIGN.md +432 -0
  14. package/docs/24-A130-REAL-HOST-TARGET-PROJECT-COMPARISON.md +210 -0
  15. package/docs/25-REAL-PROJECT-SOURCE-OF-TRUTH-MAINTENANCE-DESIGN.md +409 -0
  16. package/docs/26-A130-QUALITY-CLOSURE-AND-ADAPTIVE-DELIVERY-REPAIR-DESIGN.md +609 -0
  17. package/docs/README.md +38 -6
  18. package/docs/USER-AND-AI-OPERATION-MANUAL.md +840 -0
  19. package/examples/README.md +29 -2
  20. package/examples/package.json +6 -2
  21. package/migration-manifest.json +110 -0
  22. package/package.json +3 -2
  23. package/schemas/action-plan.schema.json +31 -3
  24. package/schemas/adaptive-context-bundle.schema.json +70 -0
  25. package/schemas/capabilities.schema.json +64 -18
  26. package/schemas/context-query.schema.json +69 -0
  27. package/schemas/coverage-audit.schema.json +32 -0
  28. package/schemas/evidence-bundle.schema.json +64 -0
  29. package/schemas/evidence-input.schema.json +82 -0
  30. package/schemas/host-promotion-evidence.schema.json +33 -0
  31. package/schemas/migration-manifest.schema.json +29 -0
  32. package/schemas/migration-plan.schema.json +32 -0
  33. package/schemas/project-status.schema.json +75 -0
  34. package/schemas/projection-lock.schema.json +48 -0
  35. package/schemas/review-bundle.schema.json +3 -3
  36. package/schemas/routing-index.schema.json +58 -0
  37. package/schemas/truth-reconciliation-input.schema.json +60 -0
  38. package/schemas/truth-reconciliation-review-bundle.schema.json +155 -0
  39. package/schemas/upgrade-assessment.schema.json +48 -0
  40. package/schemas/upgrade-result-bundle.schema.json +35 -0
  41. package/src/project-context/a130-evaluation.mjs +91 -0
  42. package/src/project-context/adaptive-context-schema.mjs +392 -0
  43. package/src/project-context/adaptive-context.mjs +547 -0
  44. package/src/project-context/ai-entry.mjs +320 -0
  45. package/src/project-context/assist.mjs +4 -2
  46. package/src/project-context/capabilities.mjs +62 -17
  47. package/src/project-context/checker.mjs +24 -6
  48. package/src/project-context/cli.mjs +113 -3
  49. package/src/project-context/contract-schema.mjs +30 -16
  50. package/src/project-context/dashboard-model.mjs +4 -4
  51. package/src/project-context/dashboard-renderer.mjs +3 -3
  52. package/src/project-context/discovery.mjs +13 -8
  53. package/src/project-context/evidence-schema.mjs +209 -0
  54. package/src/project-context/evidence.mjs +99 -0
  55. package/src/project-context/exchange-schema.mjs +23 -12
  56. package/src/project-context/exchange.mjs +26 -4
  57. package/src/project-context/maintenance.mjs +4 -4
  58. package/src/project-context/migration-manifest.mjs +168 -0
  59. package/src/project-context/project-status.mjs +157 -0
  60. package/src/project-context/projection-store.mjs +8 -1
  61. package/src/project-context/renderer.mjs +75 -1
  62. package/src/project-context/source-reader.mjs +63 -30
  63. package/src/project-context/task-context.mjs +14 -2
  64. package/src/project-context/truth-reconciliation-schema.mjs +488 -0
  65. package/src/project-context/truth-reconciliation.mjs +543 -0
  66. package/src/project-context/upgrade-schema.mjs +219 -0
  67. package/src/project-context/upgrade.mjs +494 -0
@@ -0,0 +1,99 @@
1
+ import { lstat, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { PERMANENT_BOUNDARIES } from "./capabilities.mjs";
4
+ import { fail, ProjectContextError } from "./errors.mjs";
5
+ import { EVIDENCE_BUNDLE_SCHEMA_VERSION, finalizeEvidenceBundle, normalizeEvidenceInput } from "./evidence-schema.mjs";
6
+ import { EXCHANGE_PROTOCOL_VERSION, PACKAGE_VERSION } from "./exchange-schema.mjs";
7
+ import { normalizeRelativePath, resolveExistingInside } from "./path-policy.mjs";
8
+ import { buildProjectStatus } from "./project-status.mjs";
9
+
10
+ async function readEvidenceInput(root, inputPath) {
11
+ let normalized;
12
+ try {
13
+ normalized = normalizeRelativePath(inputPath, { label: "evidence input" });
14
+ } catch (error) {
15
+ fail("evidence-input-outside-project", "evidence input must stay inside the project", { cause: error, details: { path: inputPath } });
16
+ }
17
+ const candidate = path.join(root, normalized);
18
+ let info;
19
+ try {
20
+ info = await lstat(candidate);
21
+ } catch (error) {
22
+ if (error?.code === "ENOENT") fail("evidence-input-missing", `evidence input does not exist: ${normalized}`, { cause: error });
23
+ fail("evidence-input-invalid", `cannot inspect evidence input: ${normalized}`, { cause: error });
24
+ }
25
+ if (info.isSymbolicLink()) {
26
+ fail("evidence-input-outside-project", "evidence input cannot be a symlink", { details: { path: normalized } });
27
+ }
28
+ if (!info.isFile()) fail("evidence-input-invalid", "evidence input must be an ordinary file", { details: { path: normalized } });
29
+ let resolved;
30
+ try {
31
+ resolved = await resolveExistingInside(root, normalized);
32
+ } catch (error) {
33
+ if (error?.code === "path-outside-project") {
34
+ fail("evidence-input-outside-project", "evidence input must stay inside the project", { cause: error, details: { path: normalized } });
35
+ }
36
+ if (error?.code === "source-missing") fail("evidence-input-missing", `evidence input does not exist: ${normalized}`, { cause: error });
37
+ throw error;
38
+ }
39
+ let source;
40
+ try {
41
+ source = await readFile(resolved.absolute, "utf8");
42
+ } catch (error) {
43
+ fail("evidence-input-invalid", `cannot read evidence input: ${normalized}`, { cause: error });
44
+ }
45
+ try {
46
+ return JSON.parse(source);
47
+ } catch (error) {
48
+ fail("evidence-input-invalid", "evidence input contains invalid JSON", { cause: error });
49
+ }
50
+ }
51
+
52
+ function minimalProjectContext(status) {
53
+ return {
54
+ initialization: status.initialization.state,
55
+ health: status.health,
56
+ entryState: status.entry.state,
57
+ findingCodes: [...new Set(status.findingCodes)].sort((left, right) => left.localeCompare(right)),
58
+ schemas: {
59
+ contract: 2,
60
+ sourcesLock: 1,
61
+ projectionsLock: 2,
62
+ },
63
+ };
64
+ }
65
+
66
+ export async function buildEvidenceBundle(root, input) {
67
+ const normalized = normalizeEvidenceInput(input);
68
+ let statusResult;
69
+ try {
70
+ statusResult = await buildProjectStatus(root, PERMANENT_BOUNDARIES);
71
+ } catch (error) {
72
+ fail("evidence-project-state-unreadable", "cannot form minimal Project Context status", {
73
+ cause: error,
74
+ details: { reason: error instanceof ProjectContextError ? error.code : "internal-state-error" },
75
+ });
76
+ }
77
+ const { schemaVersion: _inputSchemaVersion, ...observation } = normalized;
78
+ return finalizeEvidenceBundle({
79
+ schemaVersion: EVIDENCE_BUNDLE_SCHEMA_VERSION,
80
+ kind: "target-project-evidence",
81
+ product: {
82
+ name: "frontend-project-context",
83
+ version: PACKAGE_VERSION,
84
+ exchangeProtocolVersion: EXCHANGE_PROTOCOL_VERSION,
85
+ },
86
+ ...observation,
87
+ projectContext: minimalProjectContext(statusResult.status),
88
+ transfer: {
89
+ state: "human-review-required",
90
+ automaticUpload: false,
91
+ destination: null,
92
+ },
93
+ boundaries: { ...PERMANENT_BOUNDARIES },
94
+ });
95
+ }
96
+
97
+ export async function buildEvidenceBundleFile(root, inputPath) {
98
+ return buildEvidenceBundle(root, await readEvidenceInput(root, inputPath));
99
+ }
@@ -2,27 +2,31 @@ 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.3.1";
6
- export const EXCHANGE_PROTOCOL_VERSION = 1;
7
- export const ACTION_PLAN_SCHEMA_VERSION = 1;
8
- export const REVIEW_BUNDLE_SCHEMA_VERSION = 1;
9
- export const CAPABILITIES_SCHEMA_VERSION = 1;
5
+ export const PACKAGE_VERSION = "1.7.0";
6
+ export const EXCHANGE_PROTOCOL_VERSION = 7;
7
+ export const ACTION_PLAN_SCHEMA_VERSION = 2;
8
+ export const REVIEW_BUNDLE_SCHEMA_VERSION = 2;
9
+ export const CAPABILITIES_SCHEMA_VERSION = 7;
10
10
 
11
11
  export const ACTION_KINDS = Object.freeze([
12
12
  "accept-source-change",
13
13
  "deprecate-item",
14
14
  "deprecate-source",
15
15
  "propose-item",
16
+ "publish-ai-entry",
16
17
  "publish-projection",
17
18
  "register-source",
19
+ "remove-ai-entry",
18
20
  "request-item-approval",
19
21
  "revise-item",
20
22
  ]);
21
23
 
22
24
  export const COMMANDS = Object.freeze([
23
- "accept-source-change", "approve", "capabilities", "check", "context", "dashboard", "deprecate",
24
- "deprecate-source", "discover", "init", "integration-review", "preflight", "propose", "publish", "register",
25
- "review-source", "revise", "setup", "stage-context", "sync",
25
+ "accept-source-change", "approve", "capabilities", "check", "context", "context-query", "coverage-audit", "dashboard", "deprecate",
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",
28
+ "upgrade-apply", "upgrade-check", "upgrade-plan",
29
+ "reconcile-truth",
26
30
  ]);
27
31
 
28
32
  const ACTION_KIND_SET = new Set(ACTION_KINDS);
@@ -253,13 +257,20 @@ function normalizeActionInput(kind, input, label) {
253
257
  digest(input.expectedContentDigest, `${label}.expectedContentDigest`, { nullable: true });
254
258
  return { target: input.target, output, paths: [...paths].sort(), expectedContentDigest: input.expectedContentDigest };
255
259
  }
260
+ if (kind === "publish-ai-entry" || kind === "remove-ai-entry") {
261
+ object(input, label);
262
+ exactKeys(input, new Set(["output"]), label);
263
+ const output = projectPath(input.output, `${label}.output`);
264
+ if (output.split("/").at(-1) !== "AGENTS.md") invalid(`${label}.output must name AGENTS.md`);
265
+ return { output };
266
+ }
256
267
  invalid(`${label} kind is unsupported: ${kind}`);
257
268
  }
258
269
 
259
270
  export function validateActionPlan(input) {
260
271
  object(input, "action plan");
261
272
  exactKeys(input, new Set(["schemaVersion", "projectId", "baselines", "actions"]), "action plan");
262
- if (input.schemaVersion !== ACTION_PLAN_SCHEMA_VERSION) invalid(`action plan schemaVersion must be ${ACTION_PLAN_SCHEMA_VERSION}`);
273
+ if (![1, ACTION_PLAN_SCHEMA_VERSION].includes(input.schemaVersion)) invalid(`action plan schemaVersion must be 1 or ${ACTION_PLAN_SCHEMA_VERSION}`);
263
274
  stableId(input.projectId, "action plan.projectId");
264
275
  object(input.baselines, "action plan.baselines");
265
276
  exactKeys(input.baselines, new Set(["contract", "sourcesLock", "projectionsLock"]), "action plan.baselines");
@@ -273,10 +284,10 @@ export function validateActionPlan(input) {
273
284
  stableId(action.id, `${label}.id`);
274
285
  if (ids.has(action.id)) invalid(`action plan contains duplicate action id: ${action.id}`);
275
286
  ids.add(action.id);
276
- if (!ACTION_KIND_SET.has(action.kind)) invalid(`${label}.kind is invalid`);
287
+ if (!ACTION_KIND_SET.has(action.kind) || (input.schemaVersion === 1 && ["publish-ai-entry", "remove-ai-entry"].includes(action.kind))) invalid(`${label}.kind is invalid`);
277
288
  return { id: action.id, kind: action.kind, input: normalizeActionInput(action.kind, action.input, `${label}.input`) };
278
289
  }).sort((left, right) => left.id.localeCompare(right.id));
279
- return canonicalValue({ schemaVersion: ACTION_PLAN_SCHEMA_VERSION, projectId: input.projectId, baselines: canonicalValue(input.baselines), actions });
290
+ return canonicalValue({ schemaVersion: input.schemaVersion, projectId: input.projectId, baselines: canonicalValue(input.baselines), actions });
280
291
  }
281
292
 
282
293
  export function actionPlanDigest(plan) {
@@ -496,7 +507,7 @@ export function assertActionPlanConflictFree(plan) {
496
507
  if (["register-source", "accept-source-change", "deprecate-source"].includes(action.kind)) targets.push(`source:${action.input.id}`);
497
508
  if (["propose-item", "revise-item", "deprecate-item"].includes(action.kind)) targets.push(`item:${action.input.id}`);
498
509
  if (action.kind === "request-item-approval") targets.push(...action.input.ids.map((id) => `item:${id}`));
499
- if (action.kind === "publish-projection") targets.push(`projection:${action.input.output}`);
510
+ if (["publish-projection", "publish-ai-entry", "remove-ai-entry"].includes(action.kind)) targets.push(`projection:${action.input.output}`);
500
511
  for (const target of targets) {
501
512
  const existing = owners.get(target);
502
513
  if (existing) conflicts.push({ target, actions: [existing, action.id].sort() });
@@ -16,6 +16,7 @@ import { acceptSourceChange, deprecateItem, deprecateSource, reviewSource, revis
16
16
  import { resolveExistingInside, resolveWritableInside } from "./path-policy.mjs";
17
17
  import { loadProject } from "./project-store.mjs";
18
18
  import { publishProjection } from "./projection-store.mjs";
19
+ import { publishAiEntry, removeAiEntry } from "./ai-entry.mjs";
19
20
  export { buildCapabilities } from "./capabilities.mjs";
20
21
 
21
22
  const INTERNAL_AUTHORITY_SENTINEL = "exchange-preflight-not-authority";
@@ -29,6 +30,8 @@ const GROUP_BY_ACTION = new Map([
29
30
  ["deprecate-source", "deprecation"],
30
31
  ["request-item-approval", "approval-request"],
31
32
  ["publish-projection", "projection"],
33
+ ["publish-ai-entry", "projection"],
34
+ ["remove-ai-entry", "projection"],
32
35
  ]);
33
36
 
34
37
  function uniqueSorted(values) {
@@ -37,13 +40,13 @@ function uniqueSorted(values) {
37
40
 
38
41
 
39
42
  function allProjectionPaths(project) {
40
- return project.projectionsLock.projections.map((entry) => entry.path).sort();
43
+ return project.projectionsLock.projections.filter((entry) => entry.ownership !== "region").map((entry) => entry.path).sort();
41
44
  }
42
45
 
43
46
  function directProjectionPaths(project, itemIds) {
44
47
  const ids = new Set(itemIds);
45
48
  return project.projectionsLock.projections
46
- .filter((entry) => entry.itemIds.some((id) => ids.has(id)))
49
+ .filter((entry) => entry.ownership !== "region" && entry.itemIds.some((id) => ids.has(id)))
47
50
  .map((entry) => entry.path)
48
51
  .sort();
49
52
  }
@@ -414,6 +417,24 @@ async function previewPublish(root, project, action) {
414
417
  };
415
418
  }
416
419
 
420
+ async function previewEntry(root, project, action) {
421
+ const result = action.kind === "publish-ai-entry"
422
+ ? await publishAiEntry(root, project, { output: action.input.output, write: false })
423
+ : await removeAiEntry(root, project, { output: action.input.output, write: false });
424
+ return {
425
+ current: result.current,
426
+ proposed: { ...result.proposed, action: result.action, migration: result.migration, afterSourceDigest: result.impact.afterSourceDigest },
427
+ baselines: actionBaseline(project, { currentContentDigest: result.baselines.content }),
428
+ impact: {
429
+ itemIds: result.impact.itemIds,
430
+ sourceIds: result.impact.sourceIds,
431
+ paths: result.impact.paths,
432
+ projectionPaths: result.impact.projectionPaths,
433
+ },
434
+ invocation: invocation(root, action.kind === "publish-ai-entry" ? "publish-entry" : "remove-entry", ["--output", action.input.output]),
435
+ };
436
+ }
437
+
417
438
  async function previewAction(root, project, action) {
418
439
  if (action.kind === "register-source") return previewRegister(root, project, action);
419
440
  if (action.kind === "propose-item") return previewPropose(root, project, action);
@@ -423,6 +444,7 @@ async function previewAction(root, project, action) {
423
444
  if (action.kind === "deprecate-source") return previewDeprecateSource(root, project, action);
424
445
  if (action.kind === "request-item-approval") return previewApproval(root, project, action);
425
446
  if (action.kind === "publish-projection") return previewPublish(root, project, action);
447
+ if (action.kind === "publish-ai-entry" || action.kind === "remove-ai-entry") return previewEntry(root, project, action);
426
448
  fail("action-plan-schema-invalid", `unsupported action kind: ${action.kind}`);
427
449
  }
428
450
 
@@ -452,9 +474,9 @@ function blockedContext(project, action, error) {
452
474
  current = action.input.ids.map((id) => structuredClone(project.contract.items.find((item) => item.id === id) ?? null));
453
475
  impact.itemIds = [...action.input.ids];
454
476
  }
455
- if (action.kind === "publish-projection") {
477
+ if (["publish-projection", "publish-ai-entry", "remove-ai-entry"].includes(action.kind)) {
456
478
  current = structuredClone(project.projectionsLock.projections.find((entry) => entry.path === action.input.output) ?? null);
457
- impact.paths = uniqueSorted([...action.input.paths, action.input.output]);
479
+ impact.paths = uniqueSorted([...(action.input.paths ?? []), action.input.output]);
458
480
  impact.projectionPaths = [action.input.output];
459
481
  }
460
482
  return {
@@ -72,14 +72,14 @@ export function sourceImpact(project, sourceId) {
72
72
  const relations = relationDetails(project.contract, sourceId);
73
73
  const affectedIds = new Set(relations.items.map((item) => item.id));
74
74
  const directProjectionPaths = project.projectionsLock.projections
75
- .filter((entry) => entry.itemIds.some((id) => affectedIds.has(id)))
75
+ .filter((entry) => entry.ownership !== "region" && entry.itemIds.some((id) => affectedIds.has(id)))
76
76
  .map((entry) => entry.path)
77
77
  .sort();
78
- const staleProjectionPaths = project.projectionsLock.projections.map((entry) => entry.path).sort();
78
+ const staleProjectionPaths = project.projectionsLock.projections.filter((entry) => entry.ownership !== "region").map((entry) => entry.path).sort();
79
79
  return { ...relations, directProjectionPaths, staleProjectionPaths };
80
80
  }
81
81
 
82
- export async function reviewSource(root, project, sourceId) {
82
+ export async function reviewSource(root, project, sourceId, options = {}) {
83
83
  const source = project.contract.sources.find((entry) => entry.id === sourceId);
84
84
  if (!source) fail("source-not-found", `source is not registered: ${sourceId}`, { details: { source: sourceId } });
85
85
  const sourceObjectDigest = digestJson(source);
@@ -104,7 +104,7 @@ export async function reviewSource(root, project, sourceId) {
104
104
  let status;
105
105
  let reason;
106
106
  try {
107
- currentDigest = await readSourceDigest(root, source);
107
+ currentDigest = await readSourceDigest(root, source, options.sourceReadContext);
108
108
  if (lockedDigest === null || lockedDigest !== source.digest) {
109
109
  status = "unreadable";
110
110
  reason = lockedDigest === null ? "source-lock-missing" : "source-lock-mismatch";
@@ -0,0 +1,168 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import { digestJson } from "./canonical-json.mjs";
4
+ import { fail } from "./errors.mjs";
5
+
6
+ export const MIGRATION_MANIFEST_SCHEMA_VERSION = 2;
7
+
8
+ export const BUILT_IN_MIGRATIONS = Object.freeze({
9
+ "upgrade.republish-ai-entry.v1": "republish-ai-entry",
10
+ "upgrade.republish-projection.v1": "republish-projection",
11
+ });
12
+
13
+ const MANIFEST_FILE = fileURLToPath(new URL("../../migration-manifest.json", import.meta.url));
14
+ const SHA256 = /^sha256:[a-f0-9]{64}$/u;
15
+ const VERSION = /^\d+\.\d+\.\d+$/u;
16
+ const MIGRATION_KINDS = new Set([
17
+ "package-only",
18
+ "republish-ai-entry",
19
+ "republish-projection",
20
+ "built-in-store-migration",
21
+ "invalidate-ephemeral-protocol",
22
+ ]);
23
+ const ROLLBACK_CLASSES = new Set(["package-only", "reversible-data", "forward-only"]);
24
+ const CONSUMER_CHANGE_KEYS = new Set([
25
+ "actionPlan", "adaptiveContextBundle", "capabilities", "contextQuery", "coverageAudit", "evidenceBundle", "evidenceInput", "exchange",
26
+ "hostPromotionEvidence", "integrationReviewBundle", "reviewBundle", "routingIndex", "stageContextBundle", "stageReceipt", "taskContextPlan",
27
+ "truthReconciliationInput", "truthReconciliationReviewBundle",
28
+ ]);
29
+
30
+ function invalid(message, details) {
31
+ fail("upgrade-manifest-invalid", message, { details });
32
+ }
33
+
34
+ function object(value, label) {
35
+ if (!value || typeof value !== "object" || Array.isArray(value)) invalid(`${label} must be an object`);
36
+ }
37
+
38
+ function exactKeys(value, allowed, label) {
39
+ object(value, label);
40
+ const keys = Object.keys(value).sort();
41
+ const expected = [...allowed].sort();
42
+ if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
43
+ invalid(`${label} must contain exactly: ${expected.join(", ")}`);
44
+ }
45
+ }
46
+
47
+ function string(value, label) {
48
+ if (typeof value !== "string" || value.length === 0) invalid(`${label} must be a non-empty string`);
49
+ }
50
+
51
+ function boolean(value, label) {
52
+ if (typeof value !== "boolean") invalid(`${label} must be a boolean`);
53
+ }
54
+
55
+ function integer(value, label) {
56
+ if (!Number.isInteger(value) || value < 1) invalid(`${label} must be a positive integer`);
57
+ }
58
+
59
+ function sortedUnique(values, label, validate = string) {
60
+ if (!Array.isArray(values)) invalid(`${label} must be an array`);
61
+ values.forEach((value) => validate(value, `${label} entry`));
62
+ const sorted = [...values].sort((left, right) => String(left).localeCompare(String(right)));
63
+ if (new Set(values).size !== values.length || values.some((value, index) => value !== sorted[index])) {
64
+ invalid(`${label} must be unique and sorted`);
65
+ }
66
+ }
67
+
68
+ function versions(values, label) {
69
+ sortedUnique(values, label, (value, entryLabel) => {
70
+ string(value, entryLabel);
71
+ if (!VERSION.test(value)) invalid(`${entryLabel} must be an exact semantic version`);
72
+ });
73
+ }
74
+
75
+ function versionMatrix(value, label) {
76
+ exactKeys(value, new Set(["readable", "written"]), label);
77
+ sortedUnique(value.readable, `${label}.readable`, integer);
78
+ if (Array.isArray(value.written)) sortedUnique(value.written, `${label}.written`, integer);
79
+ else integer(value.written, `${label}.written`);
80
+ }
81
+
82
+ function validateMigration(entry, index) {
83
+ const label = `builtInMigrations[${index}]`;
84
+ exactKeys(entry, new Set(["id", "kind", "input", "targetType", "writes", "rollbackClass", "reverseMigrationId"]), label);
85
+ string(entry.id, `${label}.id`);
86
+ if (!MIGRATION_KINDS.has(entry.kind)) invalid(`${label}.kind is unsupported`);
87
+ exactKeys(entry.input, new Set(["schemaVersions", "beforeDigestRequired"]), `${label}.input`);
88
+ sortedUnique(entry.input.schemaVersions, `${label}.input.schemaVersions`, integer);
89
+ boolean(entry.input.beforeDigestRequired, `${label}.input.beforeDigestRequired`);
90
+ if (!["none", "ai-entry", "projection", "store", "ephemeral-protocol"].includes(entry.targetType)) invalid(`${label}.targetType is invalid`);
91
+ boolean(entry.writes, `${label}.writes`);
92
+ if (!ROLLBACK_CLASSES.has(entry.rollbackClass)) invalid(`${label}.rollbackClass is invalid`);
93
+ if (entry.reverseMigrationId !== null) string(entry.reverseMigrationId, `${label}.reverseMigrationId`);
94
+ if (entry.rollbackClass === "reversible-data" && entry.reverseMigrationId === null) invalid(`${label} requires a reverse migration`);
95
+ if (BUILT_IN_MIGRATIONS[entry.id] !== entry.kind) invalid(`${label} does not match the runtime migration registry`);
96
+ }
97
+
98
+ export function validateMigrationManifest(input) {
99
+ const manifest = structuredClone(input);
100
+ exactKeys(manifest, new Set([
101
+ "schemaVersion", "package", "upgradeFrom", "stores", "renderers", "protocols", "paths",
102
+ "builtInMigrations", "consumerChanges", "acceptanceCommands", "rollback", "externalEffects", "manifestDigest",
103
+ ]), "migration manifest");
104
+ if (manifest.schemaVersion !== MIGRATION_MANIFEST_SCHEMA_VERSION) invalid("migration manifest schemaVersion must be 2");
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");
107
+ versions(manifest.upgradeFrom, "upgradeFrom");
108
+ if (manifest.upgradeFrom.length === 0) invalid("upgradeFrom must not be empty");
109
+ exactKeys(manifest.stores, new Set(["contract", "projectionLock", "proposal", "sourceLock"]), "stores");
110
+ for (const name of Object.keys(manifest.stores)) versionMatrix(manifest.stores[name], `stores.${name}`);
111
+ exactKeys(manifest.renderers, new Set(["aiEntry", "projection"]), "renderers");
112
+ for (const name of Object.keys(manifest.renderers)) versionMatrix(manifest.renderers[name], `renderers.${name}`);
113
+ exactKeys(manifest.protocols, new Set([
114
+ "exchange", "actionPlan", "adaptiveContextBundle", "contextQuery", "coverageAudit", "reviewBundle", "evidenceInput", "evidenceBundle", "taskContextPlan",
115
+ "routingIndex", "stageReceipt", "stageContextBundle", "integrationReviewBundle", "hostPromotionEvidence",
116
+ "truthReconciliationInput", "truthReconciliationReviewBundle",
117
+ ]), "protocols");
118
+ for (const name of Object.keys(manifest.protocols)) versionMatrix(manifest.protocols[name], `protocols.${name}`);
119
+ if (!Array.isArray(manifest.builtInMigrations)) invalid("builtInMigrations must be an array");
120
+ manifest.builtInMigrations.forEach(validateMigration);
121
+ const migrationIds = manifest.builtInMigrations.map((entry) => entry.id);
122
+ sortedUnique(migrationIds, "builtInMigrations ids");
123
+ const runtimeMigrationIds = Object.keys(BUILT_IN_MIGRATIONS).sort((left, right) => left.localeCompare(right));
124
+ if (migrationIds.length !== runtimeMigrationIds.length || migrationIds.some((id, index) => id !== runtimeMigrationIds[index])) {
125
+ invalid("builtInMigrations must exactly match the runtime migration registry");
126
+ }
127
+ if (!Array.isArray(manifest.paths) || manifest.paths.length !== manifest.upgradeFrom.length) invalid("paths must contain one path for every upgradeFrom version");
128
+ manifest.paths.forEach((entry, index) => {
129
+ const label = `paths[${index}]`;
130
+ exactKeys(entry, new Set(["fromVersion", "classification", "migrationIds", "requiresHumanReview", "rollbackClass", "acceptance"]), label);
131
+ if (entry.fromVersion !== manifest.upgradeFrom[index]) invalid("paths must be ordered exactly like upgradeFrom");
132
+ if (!VERSION.test(entry.fromVersion)) invalid(`${label}.fromVersion must be exact`);
133
+ if (!MIGRATION_KINDS.has(entry.classification)) invalid(`${label}.classification is invalid`);
134
+ sortedUnique(entry.migrationIds, `${label}.migrationIds`);
135
+ for (const id of entry.migrationIds) if (!migrationIds.includes(id)) invalid(`${label} references unknown migration: ${id}`);
136
+ boolean(entry.requiresHumanReview, `${label}.requiresHumanReview`);
137
+ if (!ROLLBACK_CLASSES.has(entry.rollbackClass)) invalid(`${label}.rollbackClass is invalid`);
138
+ sortedUnique(entry.acceptance, `${label}.acceptance`);
139
+ });
140
+ exactKeys(manifest.consumerChanges, CONSUMER_CHANGE_KEYS, "consumerChanges");
141
+ for (const [name, change] of Object.entries(manifest.consumerChanges)) {
142
+ exactKeys(change, new Set(["state", "reason"]), `consumerChanges.${name}`);
143
+ if (!["preserve", "regenerate", "invalidate"].includes(change.state)) invalid(`consumerChanges.${name}.state is invalid`);
144
+ string(change.reason, `consumerChanges.${name}.reason`);
145
+ }
146
+ sortedUnique(manifest.acceptanceCommands, "acceptanceCommands");
147
+ exactKeys(manifest.rollback, new Set(["defaultClass", "externalRestoreRequired", "automatic"]), "rollback");
148
+ if (!ROLLBACK_CLASSES.has(manifest.rollback.defaultClass)) invalid("rollback.defaultClass is invalid");
149
+ boolean(manifest.rollback.externalRestoreRequired, "rollback.externalRestoreRequired");
150
+ if (manifest.rollback.automatic !== false) invalid("rollback.automatic must be false");
151
+ exactKeys(manifest.externalEffects, new Set(["packageManager", "network", "git", "projectTests", "businessCode", "automaticUpgrade"]), "externalEffects");
152
+ for (const [name, value] of Object.entries(manifest.externalEffects)) if (value !== false) invalid(`externalEffects.${name} must be false`);
153
+ if (!SHA256.test(manifest.manifestDigest)) invalid("manifestDigest must be sha256");
154
+ const unsigned = Object.fromEntries(Object.entries(manifest).filter(([key]) => key !== "manifestDigest"));
155
+ if (digestJson(unsigned) !== manifest.manifestDigest) invalid("manifestDigest does not match manifest bytes");
156
+ return manifest;
157
+ }
158
+
159
+ export async function loadMigrationManifest(dependencies = {}) {
160
+ const read = dependencies.readFile ?? readFile;
161
+ let parsed;
162
+ try {
163
+ parsed = JSON.parse(await read(MANIFEST_FILE, "utf8"));
164
+ } catch (error) {
165
+ invalid("cannot read the package migration manifest", { reason: error.message });
166
+ }
167
+ return validateMigrationManifest(parsed);
168
+ }
@@ -0,0 +1,157 @@
1
+ import { buildSyncAssistBundle } from "./assist.mjs";
2
+ import { inspectAiEntry } from "./ai-entry.mjs";
3
+ import { checkExitCode, checkProject } from "./checker.mjs";
4
+ import { ProjectContextError } from "./errors.mjs";
5
+ import { PACKAGE_VERSION } from "./exchange-schema.mjs";
6
+ import { inspectProjectInitialization, loadProject } from "./project-store.mjs";
7
+
8
+ export const PROJECT_STATUS_SCHEMA_VERSION = 1;
9
+
10
+ function uniqueSorted(values) {
11
+ return [...new Set(values.filter((value) => value !== undefined && value !== null))]
12
+ .sort((left, right) => String(left).localeCompare(String(right)));
13
+ }
14
+
15
+ function emptySummary() {
16
+ return { findings: 0, changedSources: 0, pendingItems: 0, affectedProjections: 0 };
17
+ }
18
+
19
+ function compactReadTarget(target) {
20
+ return {
21
+ sourceId: target.sourceId,
22
+ locator: structuredClone(target.locator),
23
+ reason: target.reason,
24
+ priority: target.priority,
25
+ };
26
+ }
27
+
28
+ function compactWorkUnit(unit) {
29
+ const compact = { kind: unit.kind };
30
+ for (const key of ["sourceIds", "itemIds", "fallbackItemIds", "paths", "projectionPaths", "findingCodes"]) {
31
+ if (unit[key] !== undefined) compact[key] = [...unit[key]];
32
+ }
33
+ return compact;
34
+ }
35
+
36
+ function base(initialization, health, nextActions) {
37
+ return {
38
+ schemaVersion: PROJECT_STATUS_SCHEMA_VERSION,
39
+ package: { name: "frontend-project-context", version: PACKAGE_VERSION },
40
+ initialization: {
41
+ state: initialization,
42
+ present: [],
43
+ missing: [],
44
+ },
45
+ project: null,
46
+ snapshots: null,
47
+ health,
48
+ entry: { state: "absent", path: null, rendererVersion: null },
49
+ summary: emptySummary(),
50
+ findingCodes: [],
51
+ sourceIds: [],
52
+ itemIds: [],
53
+ projectionPaths: [],
54
+ readTargets: [],
55
+ workUnits: [],
56
+ nextActions,
57
+ boundaries: {},
58
+ };
59
+ }
60
+
61
+ function statusBoundaries(boundaries) {
62
+ return structuredClone(boundaries);
63
+ }
64
+
65
+ export async function buildProjectStatus(root, boundaries) {
66
+ const initialization = await inspectProjectInitialization(root);
67
+ if (initialization.status !== "initialized") {
68
+ const result = base(initialization.status, initialization.status, initialization.status === "uninitialized" ? ["run-setup-preview"] : ["resolve-conflict"]);
69
+ result.initialization.present = [...initialization.present];
70
+ result.initialization.missing = [...initialization.missing];
71
+ if (initialization.status === "partial") result.findingCodes = ["project-state-partial"];
72
+ result.summary.findings = result.findingCodes.length;
73
+ result.boundaries = statusBoundaries(boundaries);
74
+ return { status: result, exitCode: initialization.status === "uninitialized" ? 1 : 2 };
75
+ }
76
+
77
+ let project;
78
+ try {
79
+ project = await loadProject(root);
80
+ } catch (error) {
81
+ if (!(error instanceof ProjectContextError)) throw error;
82
+ const result = base("invalid", "invalid", ["resolve-conflict"]);
83
+ result.initialization.present = [...initialization.present];
84
+ result.initialization.missing = [...initialization.missing];
85
+ result.findingCodes = ["project-state-invalid"];
86
+ result.summary.findings = 1;
87
+ result.boundaries = statusBoundaries(boundaries);
88
+ return { status: result, exitCode: 2 };
89
+ }
90
+
91
+ const regionEntries = project.projectionsLock.projections.filter((entry) => entry.ownership === "region" && entry.target === "ai-entry");
92
+ const entryPath = regionEntries[0]?.path ?? "AGENTS.md";
93
+ const [sync, entry] = await Promise.all([
94
+ buildSyncAssistBundle(root, project),
95
+ inspectAiEntry(root, project, entryPath),
96
+ ]);
97
+ const findings = [...sync.findings];
98
+ if (entry.state === "absent") findings.push({ code: "ai-entry-missing", path: null });
99
+ if (entry.state === "conflict" && !findings.some((finding) => finding.code === "ai-entry-ownership-conflict")) {
100
+ findings.push({ code: "ai-entry-ownership-conflict", path: entry.path });
101
+ }
102
+ const conflict = entry.state === "conflict" || findings.some((finding) => finding.code.includes("ownership-conflict"));
103
+ const sourceOrContractBlocker = findings.some((finding) =>
104
+ finding.code.startsWith("source-") || finding.code.startsWith("verification-") || finding.code.startsWith("scope-") || finding.code === "contract-conflict",
105
+ );
106
+ const health = conflict ? "conflict" : findings.length > 0 || entry.state !== "current" ? "attention" : "clean";
107
+ const nextActions = [];
108
+ if (conflict) nextActions.push("resolve-conflict");
109
+ else {
110
+ if (sync.summary.changedSources > 0 || sourceOrContractBlocker) nextActions.push("review-source-change");
111
+ if (sync.summary.pendingItems > 0) nextActions.push("review-pending");
112
+ if (sync.summary.affectedProjections > 0) nextActions.push("review-projection");
113
+ if (entry.state !== "current") nextActions.push("publish-ai-entry");
114
+ if (findings.length > 0 && nextActions.length === 0) nextActions.push("run-sync");
115
+ if (health === "clean") nextActions.push("ready-for-task");
116
+ }
117
+ const sourceIds = uniqueSorted([
118
+ ...findings.flatMap((finding) => finding.source ? [finding.source] : []),
119
+ ...sync.workUnits.flatMap((unit) => unit.sourceIds ?? []),
120
+ ]);
121
+ const itemIds = uniqueSorted([
122
+ ...findings.flatMap((finding) => finding.items ?? (finding.item ? [finding.item] : [])),
123
+ ...sync.workUnits.flatMap((unit) => unit.itemIds ?? []),
124
+ ]);
125
+ const projectionPaths = uniqueSorted([
126
+ ...sync.projectionPaths,
127
+ ...findings.flatMap((finding) => finding.path ? [finding.path] : []),
128
+ ]);
129
+ const status = {
130
+ schemaVersion: PROJECT_STATUS_SCHEMA_VERSION,
131
+ package: { name: "frontend-project-context", version: PACKAGE_VERSION },
132
+ initialization: { state: "initialized", present: [...initialization.present], missing: [] },
133
+ project: { id: project.contract.project.id, name: project.contract.project.name },
134
+ snapshots: {
135
+ contract: project.contractDigest,
136
+ sourcesLock: project.sourcesLockDigest,
137
+ projectionsLock: project.projectionsLockDigest,
138
+ },
139
+ health,
140
+ entry,
141
+ summary: {
142
+ findings: findings.length,
143
+ changedSources: sync.summary.changedSources,
144
+ pendingItems: sync.summary.pendingItems,
145
+ affectedProjections: projectionPaths.length,
146
+ },
147
+ findingCodes: uniqueSorted(findings.map((finding) => finding.code)),
148
+ sourceIds,
149
+ itemIds,
150
+ projectionPaths,
151
+ readTargets: sync.readTargets.map(compactReadTarget),
152
+ workUnits: sync.workUnits.map(compactWorkUnit),
153
+ nextActions: uniqueSorted(nextActions),
154
+ boundaries: statusBoundaries(boundaries),
155
+ };
156
+ return { status, exitCode: health === "clean" ? 0 : conflict ? 3 : Math.max(1, checkExitCode(findings)) };
157
+ }
@@ -28,6 +28,12 @@ export async function publishProjection(root, project, options, dependencies = {
28
28
  const rendered = renderProjection(project.contract, paths, target);
29
29
  const existing = await readContent(resolved.absolute);
30
30
  const lockEntry = project.projectionsLock.projections.find((entry) => entry.path === resolved.normalized);
31
+ if (lockEntry?.ownership === "region") {
32
+ fail("ai-entry-path-conflict", `refusing whole-file projection over managed AI Entry: ${resolved.normalized}`, {
33
+ exitCode: 3,
34
+ details: { path: resolved.normalized },
35
+ });
36
+ }
31
37
  if (existing !== null) {
32
38
  const marker = parseProjectionMarker(existing);
33
39
  if (
@@ -46,6 +52,7 @@ export async function publishProjection(root, project, options, dependencies = {
46
52
  const nextEntry = {
47
53
  path: resolved.normalized,
48
54
  target,
55
+ ...(project.projectionsLock.schemaVersion === 2 ? { ownership: "file" } : {}),
49
56
  paths: rendered.paths,
50
57
  contractDigest: rendered.contractDigest,
51
58
  bundleDigest: rendered.bundleDigest,
@@ -54,7 +61,7 @@ export async function publishProjection(root, project, options, dependencies = {
54
61
  rendererVersion: rendered.rendererVersion,
55
62
  };
56
63
  const nextLock = {
57
- schemaVersion: 1,
64
+ schemaVersion: project.projectionsLock.schemaVersion,
58
65
  projections: [
59
66
  ...project.projectionsLock.projections.filter((entry) => entry.path !== resolved.normalized),
60
67
  nextEntry,