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,320 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { canonicalJson, digestJson, sha256 } from "./canonical-json.mjs";
4
+ import { sourceStatus, validateContract, validateProjectionLock } from "./contract-schema.mjs";
5
+ import { fail } from "./errors.mjs";
6
+ import { atomicWriteFile, atomicWriteJson, readJsonFile } from "./io.mjs";
7
+ import { normalizeRelativePath, resolveWritableInside } from "./path-policy.mjs";
8
+
9
+ export const AI_ENTRY_RENDERER_VERSION = 3;
10
+ export const AI_ENTRY_REGION_ID = "project-context-ai-entry";
11
+ export const AI_ENTRY_START = "<!-- project-context:ai-entry:start -->";
12
+ export const AI_ENTRY_END = "<!-- project-context:ai-entry:end -->";
13
+
14
+ const ENTRY_LINES = [
15
+ AI_ENTRY_START,
16
+ "<!-- project-context:ai-entry; schema-version: 1; renderer-version: 3 -->",
17
+ "## Project Context 启动流程",
18
+ "",
19
+ "开始项目工作前,使用项目本地安装的 `frontend-project-context` CLI,不要临时下载其他版本。",
20
+ "",
21
+ "1. 运行 `npm exec --offline -- project-context status --project . --json`;如果本地依赖不存在,停止并报告,不要临时下载同名包。",
22
+ "2. 如果状态为 `partial` 或 `invalid`,停止写入并报告准确的恢复证据。",
23
+ "3. 如果尚未初始化,先预览 `setup`;如果已初始化但需要处理,使用 `sync` 工作单元;如果为 `clean`,继续遵守本文件其余仓库规则,并执行已获授权的用户任务。",
24
+ "4. 任何 plan、bundle、receipt、review 或 AI 建议都不代表人工批准。",
25
+ "5. 声明完成前,将 Project Context 恢复为 `clean`,否则报告准确的阻断项。",
26
+ AI_ENTRY_END,
27
+ ];
28
+
29
+ function entryOutput(output) {
30
+ let normalized;
31
+ try {
32
+ normalized = normalizeRelativePath(output, { label: "AI Entry output" });
33
+ } catch (error) {
34
+ fail("ai-entry-output-invalid", "AI Entry output must stay inside the project", {
35
+ exitCode: 2,
36
+ cause: error,
37
+ details: { path: output },
38
+ });
39
+ }
40
+ if (path.posix.basename(normalized) !== "AGENTS.md") {
41
+ fail("ai-entry-output-invalid", "AI Entry output must be an AGENTS.md file", {
42
+ exitCode: 2,
43
+ details: { path: normalized },
44
+ });
45
+ }
46
+ return normalized;
47
+ }
48
+
49
+ async function resolveEntryOutput(root, output, write) {
50
+ const normalized = entryOutput(output);
51
+ try {
52
+ return await resolveWritableInside(root, normalized, { createParent: write });
53
+ } catch (error) {
54
+ fail("ai-entry-output-invalid", "AI Entry output is not safely writable inside the project", {
55
+ exitCode: 2,
56
+ cause: error,
57
+ details: { path: normalized, reason: error.code ?? "invalid-output" },
58
+ });
59
+ }
60
+ }
61
+
62
+ async function readIfPresent(filePath) {
63
+ try {
64
+ return await readFile(filePath, "utf8");
65
+ } catch (error) {
66
+ if (error?.code === "ENOENT") return null;
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ function count(content, needle) {
72
+ return content.split(needle).length - 1;
73
+ }
74
+
75
+ export function renderAiEntry(newline = "\n") {
76
+ return ENTRY_LINES.join(newline);
77
+ }
78
+
79
+ export function parseAiEntryRegion(content) {
80
+ if (typeof content !== "string") return { state: "absent", region: null };
81
+ const starts = count(content, AI_ENTRY_START);
82
+ const ends = count(content, AI_ENTRY_END);
83
+ if (starts === 0 && ends === 0) return { state: "absent", region: null };
84
+ if (starts !== 1 || ends !== 1) return { state: "conflict", reason: "marker-count", region: null };
85
+ const start = content.indexOf(AI_ENTRY_START);
86
+ const endStart = content.indexOf(AI_ENTRY_END);
87
+ if (endStart <= start) return { state: "conflict", reason: "marker-order", region: null };
88
+ const end = endStart + AI_ENTRY_END.length;
89
+ const between = content.slice(start + AI_ENTRY_START.length, endStart);
90
+ if (between.includes(AI_ENTRY_START) || between.includes(AI_ENTRY_END)) {
91
+ return { state: "conflict", reason: "nested-marker", region: null };
92
+ }
93
+ return { state: "present", start, end, region: content.slice(start, end) };
94
+ }
95
+
96
+ function newlineFor(content) {
97
+ return content?.includes("\r\n") ? "\r\n" : "\n";
98
+ }
99
+
100
+ function appendRegion(content, region, newline) {
101
+ if (content === null || content.length === 0) return region;
102
+ const trailingNewline = content.endsWith("\r\n") || content.endsWith("\n");
103
+ return `${content}${trailingNewline ? newline : `${newline}${newline}`}${region}`;
104
+ }
105
+
106
+ function schema2FileEntry(entry) {
107
+ return entry.ownership ? structuredClone(entry) : { ...structuredClone(entry), ownership: "file" };
108
+ }
109
+
110
+ function sourceImpact(project, output, afterDigest) {
111
+ const sources = project.contract.sources
112
+ .filter((source) => sourceStatus(source) === "active" && ["file", "path", "json-pointer"].includes(source.kind) && source.path === output)
113
+ .sort((left, right) => left.id.localeCompare(right.id));
114
+ const sourceIds = sources.map((source) => source.id);
115
+ const selected = new Set(sourceIds);
116
+ const itemIds = project.contract.items
117
+ .filter((item) => item.sources.some((id) => selected.has(id)) || selected.has(item.verification?.source))
118
+ .map((item) => item.id)
119
+ .sort((left, right) => left.localeCompare(right));
120
+ return { sourceIds, itemIds, afterDigest: sourceIds.length > 0 ? afterDigest : null };
121
+ }
122
+
123
+ function ownershipConflict(pathname, reason) {
124
+ fail("ai-entry-ownership-conflict", `AI Entry ownership is not trustworthy: ${pathname}`, {
125
+ exitCode: 3,
126
+ details: { path: pathname, reason },
127
+ });
128
+ }
129
+
130
+ function entryLock(project, output) {
131
+ return project.projectionsLock.projections.find((entry) => entry.path === output) ?? null;
132
+ }
133
+
134
+ function assertEntryOwnership(output, content, lockEntry, parsed) {
135
+ if (lockEntry && lockEntry.ownership !== "region") {
136
+ fail("ai-entry-path-conflict", `AI Entry conflicts with a whole-file projection: ${output}`, {
137
+ exitCode: 3,
138
+ details: { path: output },
139
+ });
140
+ }
141
+ if (parsed.state === "conflict") ownershipConflict(output, parsed.reason);
142
+ if (parsed.state === "present" && !lockEntry) ownershipConflict(output, "marker-without-lock");
143
+ if (parsed.state === "absent" && lockEntry) ownershipConflict(output, "lock-without-marker");
144
+ if (lockEntry && sha256(parsed.region) !== lockEntry.regionDigest) ownershipConflict(output, "region-digest-mismatch");
145
+ }
146
+
147
+ function nextProjectionLock(project, output, nextEntry) {
148
+ return validateProjectionLock({
149
+ schemaVersion: 2,
150
+ projections: [
151
+ ...project.projectionsLock.projections
152
+ .filter((entry) => entry.path !== output)
153
+ .map(schema2FileEntry),
154
+ ...(nextEntry ? [nextEntry] : []),
155
+ ].sort((left, right) => left.path.localeCompare(right.path)),
156
+ });
157
+ }
158
+
159
+ async function commitEntry(project, resolved, existing, nextContent, nextLock, dependencies) {
160
+ const readJson = dependencies.readJson ?? readJsonFile;
161
+ const readContent = dependencies.readFile ?? readIfPresent;
162
+ const writeJson = dependencies.writeJson ?? atomicWriteJson;
163
+ const writeFile = dependencies.writeFile ?? atomicWriteFile;
164
+ if (dependencies.beforeCommit) await dependencies.beforeCommit();
165
+ const currentContract = validateContract(await readJson(project.files.contract, "contract.json"));
166
+ const currentLock = validateProjectionLock(await readJson(project.files.projectionsLock, "projections.lock.json"));
167
+ if (digestJson(currentContract) !== project.contractDigest) {
168
+ fail("ai-entry-state-changed", "contract changed while the AI Entry was being prepared", { exitCode: 1 });
169
+ }
170
+ if (digestJson(currentLock) !== project.projectionsLockDigest) {
171
+ fail("ai-entry-state-changed", "projection lock changed while the AI Entry was being prepared", { exitCode: 1 });
172
+ }
173
+ if (await readContent(resolved.absolute) !== existing) {
174
+ fail("ai-entry-state-changed", "AGENTS.md changed while the AI Entry was being prepared", {
175
+ exitCode: 1,
176
+ details: { path: resolved.normalized },
177
+ });
178
+ }
179
+ await writeJson(project.files.projectionsLock, nextLock);
180
+ try {
181
+ await writeFile(resolved.absolute, nextContent);
182
+ } catch (error) {
183
+ try {
184
+ const recoveryCurrent = validateProjectionLock(await readJson(project.files.projectionsLock, "projections.lock.json"));
185
+ if (canonicalJson(recoveryCurrent) === canonicalJson(nextLock)) {
186
+ await writeJson(project.files.projectionsLock, project.projectionsLock);
187
+ } else if (error && typeof error === "object") {
188
+ error.details = { ...(error.details ?? {}), recovery: "projection-lock-restore-skipped-concurrent-change" };
189
+ }
190
+ } catch (recoveryError) {
191
+ if (error && typeof error === "object") {
192
+ error.details = { ...(error.details ?? {}), recovery: "projection-lock-restore-failed", recoveryMessage: recoveryError.message };
193
+ }
194
+ }
195
+ throw error;
196
+ }
197
+ }
198
+
199
+ export async function inspectAiEntry(root, project, output = "AGENTS.md") {
200
+ const resolved = await resolveEntryOutput(root, output, false);
201
+ const content = await readIfPresent(resolved.absolute);
202
+ const parsed = parseAiEntryRegion(content);
203
+ const lockEntry = entryLock(project, resolved.normalized);
204
+ if (lockEntry && lockEntry.ownership !== "region") {
205
+ return { state: "absent", path: null, rendererVersion: null };
206
+ }
207
+ if (content === null && lockEntry?.ownership === "region") {
208
+ return { state: "absent", path: resolved.normalized, rendererVersion: lockEntry.rendererVersion };
209
+ }
210
+ try {
211
+ assertEntryOwnership(resolved.normalized, content, lockEntry, parsed);
212
+ } catch (error) {
213
+ if (error?.code === "ai-entry-ownership-conflict" || error?.code === "ai-entry-path-conflict") {
214
+ return { state: "conflict", path: resolved.normalized, rendererVersion: lockEntry?.rendererVersion ?? null, reason: error.details?.reason ?? error.code };
215
+ }
216
+ throw error;
217
+ }
218
+ if (!lockEntry) return { state: "absent", path: null, rendererVersion: null };
219
+ return {
220
+ state: lockEntry.rendererVersion === AI_ENTRY_RENDERER_VERSION ? "current" : "stale",
221
+ path: resolved.normalized,
222
+ rendererVersion: lockEntry.rendererVersion,
223
+ };
224
+ }
225
+
226
+ export async function publishAiEntry(root, project, options, dependencies = {}) {
227
+ const write = Boolean(options.write);
228
+ const resolved = await resolveEntryOutput(root, options.output, write);
229
+ const readContent = dependencies.readFile ?? readIfPresent;
230
+ const existing = await readContent(resolved.absolute);
231
+ const parsed = parseAiEntryRegion(existing);
232
+ const currentEntry = entryLock(project, resolved.normalized);
233
+ assertEntryOwnership(resolved.normalized, existing, currentEntry, parsed);
234
+ const newline = newlineFor(existing);
235
+ const region = renderAiEntry(newline);
236
+ const nextContent = parsed.state === "present"
237
+ ? `${existing.slice(0, parsed.start)}${region}${existing.slice(parsed.end)}`
238
+ : appendRegion(existing, region, newline);
239
+ const nextEntry = {
240
+ path: resolved.normalized,
241
+ target: "ai-entry",
242
+ ownership: "region",
243
+ regionId: AI_ENTRY_REGION_ID,
244
+ regionDigest: sha256(region),
245
+ rendererVersion: AI_ENTRY_RENDERER_VERSION,
246
+ createdFile: existing === null || currentEntry?.createdFile === true,
247
+ };
248
+ const nextLock = nextProjectionLock(project, resolved.normalized, nextEntry);
249
+ const action = currentEntry && existing === nextContent && canonicalJson(currentEntry) === canonicalJson(nextEntry) ? "unchanged" : currentEntry ? "update" : "create";
250
+ const impact = sourceImpact(project, resolved.normalized, sha256(nextContent));
251
+ const result = {
252
+ action,
253
+ current: {
254
+ entry: currentEntry ? structuredClone(currentEntry) : null,
255
+ contentDigest: existing === null ? null : sha256(existing),
256
+ region: parsed.region,
257
+ },
258
+ proposed: {
259
+ entry: nextEntry,
260
+ contentDigest: sha256(nextContent),
261
+ region,
262
+ },
263
+ baselines: {
264
+ contract: project.contractDigest,
265
+ projectionsLock: project.projectionsLockDigest,
266
+ content: existing === null ? null : sha256(existing),
267
+ },
268
+ migration: {
269
+ required: project.projectionsLock.schemaVersion === 1,
270
+ from: project.projectionsLock.schemaVersion,
271
+ to: 2,
272
+ trigger: "publish-entry",
273
+ },
274
+ impact: { itemIds: impact.itemIds, sourceIds: impact.sourceIds, paths: [resolved.normalized], projectionPaths: [resolved.normalized], afterSourceDigest: impact.afterDigest },
275
+ written: false,
276
+ };
277
+ if (write && action !== "unchanged") {
278
+ await commitEntry(project, resolved, existing, nextContent, nextLock, dependencies);
279
+ result.written = true;
280
+ }
281
+ return result;
282
+ }
283
+
284
+ export async function removeAiEntry(root, project, options, dependencies = {}) {
285
+ const write = Boolean(options.write);
286
+ const resolved = await resolveEntryOutput(root, options.output, false);
287
+ const readContent = dependencies.readFile ?? readIfPresent;
288
+ const existing = await readContent(resolved.absolute);
289
+ const parsed = parseAiEntryRegion(existing);
290
+ const currentEntry = entryLock(project, resolved.normalized);
291
+ assertEntryOwnership(resolved.normalized, existing, currentEntry, parsed);
292
+ if (!currentEntry) {
293
+ return {
294
+ action: "unchanged",
295
+ current: { entry: null, contentDigest: existing === null ? null : sha256(existing), region: null },
296
+ proposed: { entry: null, contentDigest: existing === null ? null : sha256(existing), region: null },
297
+ baselines: { contract: project.contractDigest, projectionsLock: project.projectionsLockDigest, content: existing === null ? null : sha256(existing) },
298
+ migration: { required: false, from: project.projectionsLock.schemaVersion, to: project.projectionsLock.schemaVersion, trigger: null },
299
+ impact: { itemIds: [], sourceIds: [], paths: [resolved.normalized], projectionPaths: [resolved.normalized], afterSourceDigest: null },
300
+ written: false,
301
+ };
302
+ }
303
+ const nextContent = `${existing.slice(0, parsed.start)}${existing.slice(parsed.end)}`;
304
+ const nextLock = nextProjectionLock(project, resolved.normalized, null);
305
+ const impact = sourceImpact(project, resolved.normalized, sha256(nextContent));
306
+ const result = {
307
+ action: "remove",
308
+ current: { entry: structuredClone(currentEntry), contentDigest: sha256(existing), region: parsed.region },
309
+ proposed: { entry: null, contentDigest: sha256(nextContent), region: null },
310
+ baselines: { contract: project.contractDigest, projectionsLock: project.projectionsLockDigest, content: sha256(existing) },
311
+ migration: { required: false, from: project.projectionsLock.schemaVersion, to: 2, trigger: null },
312
+ impact: { itemIds: impact.itemIds, sourceIds: impact.sourceIds, paths: [resolved.normalized], projectionPaths: [resolved.normalized], afterSourceDigest: impact.afterDigest },
313
+ written: false,
314
+ };
315
+ if (write) {
316
+ await commitEntry(project, resolved, existing, nextContent, nextLock, dependencies);
317
+ result.written = true;
318
+ }
319
+ return result;
320
+ }
@@ -5,6 +5,7 @@ import { fail } from "./errors.mjs";
5
5
  import { reviewSource } from "./maintenance.mjs";
6
6
  import { normalizeRelativePath } from "./path-policy.mjs";
7
7
  import { effectiveItems, scopeApplies } from "./scope-compiler.mjs";
8
+ import { createSourceReadContext } from "./source-reader.mjs";
8
9
 
9
10
  const LOCAL_SOURCE_KINDS = new Set(["file", "path", "json-pointer"]);
10
11
  const LARGE_IMPACT_THRESHOLD = 100;
@@ -347,11 +348,12 @@ function syncWorkUnits(problemReviews, signals, findings, pendingItems) {
347
348
  }
348
349
 
349
350
  export async function buildSyncAssistBundle(root, project, changedPaths = []) {
351
+ const sourceReadContext = createSourceReadContext();
350
352
  const reviewResults = await Promise.all(project.contract.sources.map(async (source) => {
351
353
  if (sourceStatus(source) === "deprecated" || !LOCAL_SOURCE_KINDS.has(source.kind)) {
352
354
  return { source, nonLocal: nonLocalSourceChange(source) };
353
355
  }
354
- return { source, review: await reviewSource(root, project, source.id) };
356
+ return { source, review: await reviewSource(root, project, source.id, { sourceReadContext }) };
355
357
  }));
356
358
  const reviews = reviewResults.filter((entry) => entry.review).map((entry) => entry.review);
357
359
  const problemReviews = reviews.filter((review) => ["changed", "missing", "unreadable"].includes(review.status));
@@ -368,7 +370,7 @@ export async function buildSyncAssistBundle(root, project, changedPaths = []) {
368
370
  .map((id) => affectedItem(itemById.get(id), sourceImpacts.get(id), largeImpactIds.has(id)))
369
371
  .sort((left, right) => left.id.localeCompare(right.id));
370
372
  const signals = pathSignals(project.contract, changedPaths);
371
- const findings = stableFindings(await checkProject(root, project));
373
+ const findings = stableFindings(await checkProject(root, project, { sourceReadContext }));
372
374
  const pending = project.contract.items
373
375
  .filter((item) => item.status === "proposed")
374
376
  .map(pendingItem)
@@ -11,6 +11,9 @@ import {
11
11
  } from "./exchange-schema.mjs";
12
12
  import { inspectProjectInitialization, loadProject } from "./project-store.mjs";
13
13
  import { RENDERER_VERSION } from "./renderer.mjs";
14
+ import { AI_ENTRY_RENDERER_VERSION } from "./ai-entry.mjs";
15
+ import { PROJECT_STATUS_SCHEMA_VERSION } from "./project-status.mjs";
16
+ import { EVIDENCE_BUNDLE_SCHEMA_VERSION, EVIDENCE_INPUT_SCHEMA_VERSION } from "./evidence-schema.mjs";
14
17
  import {
15
18
  CONTEXT_BUDGET_UNIT,
16
19
  INTEGRATION_REVIEW_BUNDLE_SCHEMA_VERSION,
@@ -18,57 +21,99 @@ import {
18
21
  STAGE_RECEIPT_SCHEMA_VERSION,
19
22
  TASK_CONTEXT_PLAN_SCHEMA_VERSION,
20
23
  } from "./task-context-schema.mjs";
24
+ import {
25
+ MIGRATION_PLAN_SCHEMA_VERSION,
26
+ UPGRADE_ASSESSMENT_SCHEMA_VERSION,
27
+ UPGRADE_RESULT_BUNDLE_SCHEMA_VERSION,
28
+ } from "./upgrade-schema.mjs";
29
+ import {
30
+ ADAPTIVE_CONTEXT_BUNDLE_SCHEMA_VERSION,
31
+ CONTEXT_QUERY_SCHEMA_VERSION,
32
+ COVERAGE_AUDIT_SCHEMA_VERSION,
33
+ ROUTING_INDEX_SCHEMA_VERSION,
34
+ } from "./adaptive-context-schema.mjs";
35
+ import {
36
+ HOST_PROMOTION_EVIDENCE_SCHEMA_VERSION,
37
+ TRUTH_RECONCILIATION_INPUT_SCHEMA_VERSION,
38
+ TRUTH_RECONCILIATION_REVIEW_BUNDLE_SCHEMA_VERSION,
39
+ } from "./truth-reconciliation-schema.mjs";
21
40
 
22
- function schemas() {
41
+ function schemas(projectionLockWritten = 1) {
23
42
  return {
24
43
  actionPlan: ACTION_PLAN_SCHEMA_VERSION,
44
+ adaptiveContextBundle: ADAPTIVE_CONTEXT_BUNDLE_SCHEMA_VERSION,
25
45
  assistBundle: ASSIST_BUNDLE_SCHEMA_VERSION,
26
46
  capabilities: CAPABILITIES_SCHEMA_VERSION,
27
47
  contract: 2,
48
+ contextQuery: CONTEXT_QUERY_SCHEMA_VERSION,
49
+ coverageAudit: COVERAGE_AUDIT_SCHEMA_VERSION,
28
50
  dashboardViewModel: DASHBOARD_SCHEMA_VERSION,
51
+ evidenceBundle: EVIDENCE_BUNDLE_SCHEMA_VERSION,
52
+ evidenceInput: EVIDENCE_INPUT_SCHEMA_VERSION,
29
53
  integrationReviewBundle: INTEGRATION_REVIEW_BUNDLE_SCHEMA_VERSION,
30
- projectionLock: 1,
54
+ hostPromotionEvidence: HOST_PROMOTION_EVIDENCE_SCHEMA_VERSION,
55
+ projectionLock: 2,
56
+ projectionLockReadable: [1, 2],
57
+ projectionLockWritten,
31
58
  projectionRenderer: RENDERER_VERSION,
32
59
  proposal: 1,
60
+ projectStatus: PROJECT_STATUS_SCHEMA_VERSION,
61
+ aiEntryRenderer: AI_ENTRY_RENDERER_VERSION,
62
+ migrationManifest: 2,
63
+ migrationPlan: MIGRATION_PLAN_SCHEMA_VERSION,
33
64
  reviewBundle: REVIEW_BUNDLE_SCHEMA_VERSION,
65
+ routingIndex: ROUTING_INDEX_SCHEMA_VERSION,
34
66
  sourceLock: 1,
35
67
  stageContextBundle: STAGE_CONTEXT_BUNDLE_SCHEMA_VERSION,
36
68
  stageReceipt: STAGE_RECEIPT_SCHEMA_VERSION,
37
69
  taskContextPlan: TASK_CONTEXT_PLAN_SCHEMA_VERSION,
70
+ truthReconciliationInput: TRUTH_RECONCILIATION_INPUT_SCHEMA_VERSION,
71
+ truthReconciliationReviewBundle: TRUTH_RECONCILIATION_REVIEW_BUNDLE_SCHEMA_VERSION,
72
+ upgradeAssessment: UPGRADE_ASSESSMENT_SCHEMA_VERSION,
73
+ upgradeResultBundle: UPGRADE_RESULT_BUNDLE_SCHEMA_VERSION,
38
74
  };
39
75
  }
40
76
 
77
+ export const PERMANENT_BOUNDARIES = Object.freeze({
78
+ provider: false,
79
+ agentRuntime: false,
80
+ git: false,
81
+ network: false,
82
+ dependencyInstallation: false,
83
+ automaticApproval: false,
84
+ businessCodeWrites: false,
85
+ taskExecution: false,
86
+ stagePathBodyReads: false,
87
+ applyPlan: false,
88
+ scheduler: false,
89
+ daemon: false,
90
+ telemetry: false,
91
+ selfUpdate: false,
92
+ automaticEvidenceUpload: false,
93
+ packageManager: false,
94
+ automaticUpgrade: false,
95
+ });
96
+
41
97
  export async function buildCapabilities(root) {
42
98
  const initialization = await inspectProjectInitialization(root);
43
99
  let project = null;
100
+ let projectionLockWritten = 1;
44
101
  if (initialization.status === "initialized") {
45
102
  const loaded = await loadProject(root);
46
103
  project = { id: loaded.contract.project.id, name: loaded.contract.project.name };
104
+ projectionLockWritten = loaded.projectionsLock.schemaVersion;
47
105
  }
48
106
  return {
49
107
  schemaVersion: CAPABILITIES_SCHEMA_VERSION,
50
108
  package: { name: "frontend-project-context", version: PACKAGE_VERSION },
51
109
  exchangeProtocolVersion: EXCHANGE_PROTOCOL_VERSION,
52
- schemas: schemas(),
110
+ schemas: schemas(projectionLockWritten),
53
111
  commands: [...COMMANDS],
54
112
  actionKinds: [...ACTION_KINDS],
55
113
  contextBudget: { unit: CONTEXT_BUDGET_UNIT, modelTokens: false, callerMustProvideLimit: true },
56
114
  initialization: initialization.status,
57
115
  initialized: initialization.status === "initialized",
58
116
  project,
59
- boundaries: {
60
- provider: false,
61
- agentRuntime: false,
62
- git: false,
63
- network: false,
64
- dependencyInstallation: false,
65
- automaticApproval: false,
66
- businessCodeWrites: false,
67
- taskExecution: false,
68
- stagePathBodyReads: false,
69
- applyPlan: false,
70
- scheduler: false,
71
- daemon: false,
72
- },
117
+ boundaries: { ...PERMANENT_BOUNDARIES },
73
118
  };
74
119
  }
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { AI_ENTRY_RENDERER_VERSION, parseAiEntryRegion } from "./ai-entry.mjs";
3
4
  import { sha256 } from "./canonical-json.mjs";
4
5
  import { sourceStatus } from "./contract-schema.mjs";
5
6
  import { readSourceDigest, verifyItem } from "./source-reader.mjs";
@@ -7,7 +8,8 @@ import { findConflicts, validateOverrides } from "./scope-compiler.mjs";
7
8
  import { parseProjectionMarker, RENDERER_VERSION, renderProjection } from "./renderer.mjs";
8
9
  import { resolveWritableInside } from "./path-policy.mjs";
9
10
 
10
- export async function checkProject(root, project) {
11
+ export async function checkProject(root, project, options = {}) {
12
+ const sourceReadContext = options.sourceReadContext;
11
13
  const findings = [];
12
14
  const contractSourceIds = new Set(project.contract.sources.map((source) => source.id));
13
15
  const sourceLock = new Map(project.sourcesLock.sources.map((entry) => [entry.id, entry.digest]));
@@ -24,7 +26,7 @@ export async function checkProject(root, project) {
24
26
  continue;
25
27
  }
26
28
  try {
27
- const actual = await readSourceDigest(root, source);
29
+ const actual = await readSourceDigest(root, source, sourceReadContext);
28
30
  if (actual !== locked) findings.push({ code: "source-changed", source: source.id, path: source.path, expected: locked, actual });
29
31
  } catch (error) {
30
32
  const code = error.code === "source-missing"
@@ -50,7 +52,7 @@ export async function checkProject(root, project) {
50
52
  const sourceMap = new Map(project.contract.sources.map((source) => [source.id, source]));
51
53
  for (const item of project.contract.items.filter((candidate) => candidate.status === "approved")) {
52
54
  try {
53
- const verification = await verifyItem(root, item, sourceMap);
55
+ const verification = await verifyItem(root, item, sourceMap, sourceReadContext);
54
56
  if (verification) findings.push(verification);
55
57
  } catch (error) {
56
58
  findings.push({
@@ -74,7 +76,23 @@ export async function checkProject(root, project) {
74
76
  try {
75
77
  content = await readFile(resolved.absolute, "utf8");
76
78
  } catch (error) {
77
- findings.push({ code: error?.code === "ENOENT" ? "projection-missing" : "projection-unreadable", path: entry.path });
79
+ findings.push({
80
+ code: entry.ownership === "region"
81
+ ? error?.code === "ENOENT" ? "ai-entry-missing" : "projection-unreadable"
82
+ : error?.code === "ENOENT" ? "projection-missing" : "projection-unreadable",
83
+ path: entry.path,
84
+ });
85
+ continue;
86
+ }
87
+ if (entry.ownership === "region") {
88
+ const parsed = parseAiEntryRegion(content);
89
+ if (parsed.state !== "present" || sha256(parsed.region) !== entry.regionDigest) {
90
+ findings.push({ code: "ai-entry-ownership-conflict", path: entry.path });
91
+ continue;
92
+ }
93
+ if (entry.rendererVersion !== AI_ENTRY_RENDERER_VERSION) {
94
+ findings.push({ code: "ai-entry-renderer-stale", path: entry.path, expected: AI_ENTRY_RENDERER_VERSION, actual: entry.rendererVersion });
95
+ }
78
96
  continue;
79
97
  }
80
98
  const marker = parseProjectionMarker(content);
@@ -113,7 +131,7 @@ export async function checkProject(root, project) {
113
131
  }
114
132
 
115
133
  export function checkExitCode(findings) {
116
- return findings.some((finding) => finding.code === "projection-ownership-conflict") ? 3 : findings.length > 0 ? 1 : 0;
134
+ return findings.some((finding) => ["projection-ownership-conflict", "ai-entry-ownership-conflict", "ai-entry-path-conflict"].includes(finding.code)) ? 3 : findings.length > 0 ? 1 : 0;
117
135
  }
118
136
 
119
137
  export function blockingContextFindings(findings) {
@@ -127,6 +145,6 @@ export function blockingContextFindings(findings) {
127
145
  }
128
146
 
129
147
  export function findingSeverity(finding) {
130
- if (finding.code === "projection-ownership-conflict") return "conflict";
148
+ if (["projection-ownership-conflict", "ai-entry-ownership-conflict", "ai-entry-path-conflict"].includes(finding.code)) return "conflict";
131
149
  return blockingContextFindings([finding]).length > 0 ? "blocked" : "attention";
132
150
  }