frontend-project-context 1.3.0 → 1.6.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 (49) hide show
  1. package/CHANGELOG.md +39 -2
  2. package/README.md +94 -16
  3. package/UPGRADING.md +47 -2
  4. package/docs/04-PROGRAM-DESIGN.md +40 -4
  5. package/docs/05-ACCEPTANCE-CONTRACT.md +33 -3
  6. package/docs/08-INSTALLATION-AND-DISTRIBUTION.md +36 -6
  7. package/docs/14-FORMAL-RELEASE-READINESS.md +46 -0
  8. package/docs/18-BRANCH-AWARE-STAGED-CONTEXT-DESIGN.md +62 -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/README.md +21 -5
  14. package/docs/USER-AND-AI-OPERATION-MANUAL.md +797 -0
  15. package/examples/README.md +38 -0
  16. package/examples/package.json +6 -2
  17. package/migration-manifest.json +88 -0
  18. package/package.json +3 -2
  19. package/schemas/action-plan.schema.json +31 -3
  20. package/schemas/capabilities.schema.json +50 -18
  21. package/schemas/evidence-bundle.schema.json +64 -0
  22. package/schemas/evidence-input.schema.json +82 -0
  23. package/schemas/migration-manifest.schema.json +29 -0
  24. package/schemas/migration-plan.schema.json +32 -0
  25. package/schemas/project-status.schema.json +75 -0
  26. package/schemas/projection-lock.schema.json +48 -0
  27. package/schemas/review-bundle.schema.json +3 -3
  28. package/schemas/upgrade-assessment.schema.json +48 -0
  29. package/schemas/upgrade-result-bundle.schema.json +35 -0
  30. package/src/project-context/ai-entry.mjs +320 -0
  31. package/src/project-context/capabilities.mjs +44 -17
  32. package/src/project-context/checker.mjs +20 -3
  33. package/src/project-context/cli.mjs +84 -7
  34. package/src/project-context/contract-schema.mjs +30 -16
  35. package/src/project-context/dashboard-model.mjs +4 -4
  36. package/src/project-context/dashboard-renderer.mjs +3 -3
  37. package/src/project-context/discovery.mjs +6 -1
  38. package/src/project-context/evidence-schema.mjs +209 -0
  39. package/src/project-context/evidence.mjs +99 -0
  40. package/src/project-context/exchange-schema.mjs +21 -11
  41. package/src/project-context/exchange.mjs +26 -4
  42. package/src/project-context/maintenance.mjs +2 -2
  43. package/src/project-context/migration-manifest.mjs +166 -0
  44. package/src/project-context/project-status.mjs +157 -0
  45. package/src/project-context/projection-store.mjs +8 -1
  46. package/src/project-context/task-context-schema.mjs +237 -1
  47. package/src/project-context/task-context.mjs +154 -13
  48. package/src/project-context/upgrade-schema.mjs +215 -0
  49. package/src/project-context/upgrade.mjs +494 -0
@@ -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,81 @@ 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";
21
29
 
22
- function schemas() {
30
+ function schemas(projectionLockWritten = 1) {
23
31
  return {
24
32
  actionPlan: ACTION_PLAN_SCHEMA_VERSION,
25
33
  assistBundle: ASSIST_BUNDLE_SCHEMA_VERSION,
26
34
  capabilities: CAPABILITIES_SCHEMA_VERSION,
27
35
  contract: 2,
28
36
  dashboardViewModel: DASHBOARD_SCHEMA_VERSION,
37
+ evidenceBundle: EVIDENCE_BUNDLE_SCHEMA_VERSION,
38
+ evidenceInput: EVIDENCE_INPUT_SCHEMA_VERSION,
29
39
  integrationReviewBundle: INTEGRATION_REVIEW_BUNDLE_SCHEMA_VERSION,
30
- projectionLock: 1,
40
+ projectionLock: 2,
41
+ projectionLockReadable: [1, 2],
42
+ projectionLockWritten,
31
43
  projectionRenderer: RENDERER_VERSION,
32
44
  proposal: 1,
45
+ projectStatus: PROJECT_STATUS_SCHEMA_VERSION,
46
+ aiEntryRenderer: AI_ENTRY_RENDERER_VERSION,
47
+ migrationManifest: 2,
48
+ migrationPlan: MIGRATION_PLAN_SCHEMA_VERSION,
33
49
  reviewBundle: REVIEW_BUNDLE_SCHEMA_VERSION,
34
50
  sourceLock: 1,
35
51
  stageContextBundle: STAGE_CONTEXT_BUNDLE_SCHEMA_VERSION,
36
52
  stageReceipt: STAGE_RECEIPT_SCHEMA_VERSION,
37
53
  taskContextPlan: TASK_CONTEXT_PLAN_SCHEMA_VERSION,
54
+ upgradeAssessment: UPGRADE_ASSESSMENT_SCHEMA_VERSION,
55
+ upgradeResultBundle: UPGRADE_RESULT_BUNDLE_SCHEMA_VERSION,
38
56
  };
39
57
  }
40
58
 
59
+ export const PERMANENT_BOUNDARIES = Object.freeze({
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
+ telemetry: false,
73
+ selfUpdate: false,
74
+ automaticEvidenceUpload: false,
75
+ packageManager: false,
76
+ automaticUpgrade: false,
77
+ });
78
+
41
79
  export async function buildCapabilities(root) {
42
80
  const initialization = await inspectProjectInitialization(root);
43
81
  let project = null;
82
+ let projectionLockWritten = 1;
44
83
  if (initialization.status === "initialized") {
45
84
  const loaded = await loadProject(root);
46
85
  project = { id: loaded.contract.project.id, name: loaded.contract.project.name };
86
+ projectionLockWritten = loaded.projectionsLock.schemaVersion;
47
87
  }
48
88
  return {
49
89
  schemaVersion: CAPABILITIES_SCHEMA_VERSION,
50
90
  package: { name: "frontend-project-context", version: PACKAGE_VERSION },
51
91
  exchangeProtocolVersion: EXCHANGE_PROTOCOL_VERSION,
52
- schemas: schemas(),
92
+ schemas: schemas(projectionLockWritten),
53
93
  commands: [...COMMANDS],
54
94
  actionKinds: [...ACTION_KINDS],
55
95
  contextBudget: { unit: CONTEXT_BUDGET_UNIT, modelTokens: false, callerMustProvideLimit: true },
56
96
  initialization: initialization.status,
57
97
  initialized: initialization.status === "initialized",
58
98
  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
- },
99
+ boundaries: { ...PERMANENT_BOUNDARIES },
73
100
  };
74
101
  }
@@ -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";
@@ -74,7 +75,23 @@ export async function checkProject(root, project) {
74
75
  try {
75
76
  content = await readFile(resolved.absolute, "utf8");
76
77
  } catch (error) {
77
- findings.push({ code: error?.code === "ENOENT" ? "projection-missing" : "projection-unreadable", path: entry.path });
78
+ findings.push({
79
+ code: entry.ownership === "region"
80
+ ? error?.code === "ENOENT" ? "ai-entry-missing" : "projection-unreadable"
81
+ : error?.code === "ENOENT" ? "projection-missing" : "projection-unreadable",
82
+ path: entry.path,
83
+ });
84
+ continue;
85
+ }
86
+ if (entry.ownership === "region") {
87
+ const parsed = parseAiEntryRegion(content);
88
+ if (parsed.state !== "present" || sha256(parsed.region) !== entry.regionDigest) {
89
+ findings.push({ code: "ai-entry-ownership-conflict", path: entry.path });
90
+ continue;
91
+ }
92
+ if (entry.rendererVersion !== AI_ENTRY_RENDERER_VERSION) {
93
+ findings.push({ code: "ai-entry-renderer-stale", path: entry.path, expected: AI_ENTRY_RENDERER_VERSION, actual: entry.rendererVersion });
94
+ }
78
95
  continue;
79
96
  }
80
97
  const marker = parseProjectionMarker(content);
@@ -113,7 +130,7 @@ export async function checkProject(root, project) {
113
130
  }
114
131
 
115
132
  export function checkExitCode(findings) {
116
- return findings.some((finding) => finding.code === "projection-ownership-conflict") ? 3 : findings.length > 0 ? 1 : 0;
133
+ 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
134
  }
118
135
 
119
136
  export function blockingContextFindings(findings) {
@@ -127,6 +144,6 @@ export function blockingContextFindings(findings) {
127
144
  }
128
145
 
129
146
  export function findingSeverity(finding) {
130
- if (finding.code === "projection-ownership-conflict") return "conflict";
147
+ if (["projection-ownership-conflict", "ai-entry-ownership-conflict", "ai-entry-path-conflict"].includes(finding.code)) return "conflict";
131
148
  return blockingContextFindings([finding]).length > 0 ? "blocked" : "attention";
132
149
  }
@@ -8,21 +8,31 @@ import { buildDashboardModel } from "./dashboard-model.mjs";
8
8
  import { renderDashboardHtml } from "./dashboard-renderer.mjs";
9
9
  import { discoverProject } from "./discovery.mjs";
10
10
  import { ProjectContextError, fail } from "./errors.mjs";
11
+ import { buildEvidenceBundleFile } from "./evidence.mjs";
11
12
  import { buildCapabilities, preflightActionPlanFile } from "./exchange.mjs";
13
+ import { PERMANENT_BOUNDARIES } from "./capabilities.mjs";
12
14
  import { normalizeProposalPath } from "./exchange-schema.mjs";
13
15
  import { atomicCreateFileOrSame, atomicWriteFile, readJsonFile } from "./io.mjs";
14
16
  import { acceptSourceChange, deprecateItem, deprecateSource, reviewSource, reviseItem } from "./maintenance.mjs";
15
17
  import { normalizeRelativePath, resolveExistingInside, resolveProjectRoot, resolveWritableInside } from "./path-policy.mjs";
16
18
  import { initializeProject, inspectProjectInitialization, loadProject } from "./project-store.mjs";
17
19
  import { publishProjection } from "./projection-store.mjs";
20
+ import { publishAiEntry, removeAiEntry } from "./ai-entry.mjs";
21
+ import { buildProjectStatus } from "./project-status.mjs";
18
22
  import { renderContextBundle } from "./renderer.mjs";
19
23
  import { buildIntegrationReviewBundleFiles, buildStageContextBundleFiles } from "./task-context.mjs";
24
+ import { applyMigrationPlanFile, buildMigrationPlanFile, buildUpgradeAssessment } from "./upgrade.mjs";
20
25
 
21
26
  const HELP = `project-context — model-neutral project contract compiler
22
27
 
23
28
  Usage:
24
29
  project-context init --project PATH --id ID --name NAME [--write] [--json]
25
30
  project-context capabilities --project PATH [--json]
31
+ project-context status --project PATH [--json]
32
+ project-context evidence --project PATH --input FILE [--json]
33
+ project-context upgrade-check --project PATH --from-version VERSION [--json]
34
+ project-context upgrade-plan --project PATH --assessment FILE [--json]
35
+ project-context upgrade-apply --project PATH --plan FILE [--write] [--json]
26
36
  project-context setup --project PATH --id ID --name NAME [--output FILE] [--write] [--json]
27
37
  project-context register --project PATH --id SOURCE_ID --kind KIND [--path PATH] [--pointer POINTER] [--reference TEXT] [--write] [--json]
28
38
  project-context propose --project PATH --id ITEM_ID --kind KIND --subject SUBJECT (--value TEXT | --value-json JSON) --statement TEXT --sources SOURCE_ID... --scope SCOPE [--scope-path PATH] [--overrides ITEM_ID...] [--verification KIND] [--verification-source SOURCE_ID] [--verification-expected-json JSON] [--output FILE --write] [--json]
@@ -35,28 +45,35 @@ Usage:
35
45
  project-context approve --project PATH (--proposal FILE | --pending) --ids ID... --by NAME [--rationale TEXT] [--write] [--json | --full-json]
36
46
  project-context context --project PATH --path RELATIVE_PATH... [--task TEXT] [--locale zh-CN|en|all] [--json]
37
47
  project-context publish --project PATH --target agents|ruler --output FILE [--path RELATIVE_PATH...] [--write] [--json]
48
+ project-context publish-entry --project PATH --output AGENTS.md [--write] [--json]
49
+ project-context remove-entry --project PATH --output AGENTS.md [--write] [--json]
38
50
  project-context check --project PATH [--json]
39
51
  project-context dashboard --project PATH [--json]
40
52
  project-context sync --project PATH [--changed-path RELATIVE_PATH...] [--json]
41
53
  project-context preflight --project PATH --plan FILE [--json]
42
- project-context stage-context --project PATH --plan FILE --stage STAGE_ID [--receipt FILE...] [--changed-path RELATIVE_PATH...] [--json]
43
- project-context integration-review --project PATH --plan FILE [--receipt FILE...] [--main-changed-path RELATIVE_PATH...] [--branch-changed-path RELATIVE_PATH...] [--json]
54
+ project-context stage-context --project PATH --plan FILE --stage STAGE_ID [--receipt FILE...] [--receipt-bundle FILE...] [--changed-path RELATIVE_PATH...] [--json]
55
+ project-context integration-review --project PATH --plan FILE [--receipt FILE...] [--receipt-bundle FILE...] [--main-changed-path RELATIVE_PATH...] [--branch-changed-path RELATIVE_PATH...] [--json]
44
56
 
45
57
  All commands are read-only unless their own --write flag is present.
46
58
  `;
47
59
  const VALUE_FLAGS = new Set([
48
- "project", "id", "name", "output", "proposal", "by", "task", "target", "rationale",
60
+ "project", "id", "name", "input", "output", "proposal", "by", "task", "target", "rationale",
49
61
  "kind", "pointer", "reference", "subject", "value", "value-json", "statement", "scope", "scope-path",
50
62
  "verification", "verification-source", "verification-expected-json",
51
- "expected-digest", "expected-item-digest", "expected-source-digest", "locale", "plan", "stage",
63
+ "expected-digest", "expected-item-digest", "expected-source-digest", "locale", "plan", "stage", "from-version", "assessment",
52
64
  ]);
53
65
  const LIST_FLAGS = new Set([
54
- "ids", "path", "changed-path", "sources", "overrides", "affected-items", "receipt", "main-changed-path", "branch-changed-path",
66
+ "ids", "path", "changed-path", "sources", "overrides", "affected-items", "receipt", "receipt-bundle", "main-changed-path", "branch-changed-path",
55
67
  ]);
56
68
  const BOOLEAN_FLAGS = new Set(["write", "json", "full-json", "help", "pending"]);
57
69
  const COMMAND_OPTIONS = new Map([
58
70
  ["init", new Set(["project", "id", "name", "write", "json", "help"])],
59
71
  ["capabilities", new Set(["project", "json", "help"])],
72
+ ["status", new Set(["project", "json", "help"])],
73
+ ["evidence", new Set(["project", "input", "json", "help"])],
74
+ ["upgrade-check", new Set(["project", "from-version", "json", "help"])],
75
+ ["upgrade-plan", new Set(["project", "assessment", "json", "help"])],
76
+ ["upgrade-apply", new Set(["project", "plan", "write", "json", "help"])],
60
77
  ["setup", new Set(["project", "id", "name", "output", "write", "json", "help"])],
61
78
  ["register", new Set(["project", "id", "kind", "path", "pointer", "reference", "write", "json", "help"])],
62
79
  ["propose", new Set([
@@ -82,12 +99,14 @@ const COMMAND_OPTIONS = new Map([
82
99
  ["approve", new Set(["project", "proposal", "pending", "ids", "by", "rationale", "write", "json", "full-json", "help"])],
83
100
  ["context", new Set(["project", "path", "task", "locale", "json", "help"])],
84
101
  ["publish", new Set(["project", "target", "output", "path", "write", "json", "help"])],
102
+ ["publish-entry", new Set(["project", "output", "write", "json", "help"])],
103
+ ["remove-entry", new Set(["project", "output", "write", "json", "help"])],
85
104
  ["check", new Set(["project", "json", "help"])],
86
105
  ["dashboard", new Set(["project", "json", "help"])],
87
106
  ["sync", new Set(["project", "changed-path", "json", "help"])],
88
107
  ["preflight", new Set(["project", "plan", "json", "help"])],
89
- ["stage-context", new Set(["project", "plan", "stage", "receipt", "changed-path", "json", "help"])],
90
- ["integration-review", new Set(["project", "plan", "receipt", "main-changed-path", "branch-changed-path", "json", "help"])],
108
+ ["stage-context", new Set(["project", "plan", "stage", "receipt", "receipt-bundle", "changed-path", "json", "help"])],
109
+ ["integration-review", new Set(["project", "plan", "receipt", "receipt-bundle", "main-changed-path", "branch-changed-path", "json", "help"])],
91
110
  ]);
92
111
 
93
112
  export function parseArgs(argv) {
@@ -323,6 +342,51 @@ async function runCommand(command, options) {
323
342
  ].join("\n") + "\n";
324
343
  return { exitCode: 0, stdout: jsonOrText(options, capabilities, summary), stderr: "" };
325
344
  }
345
+ if (command === "status") {
346
+ const result = await buildProjectStatus(root, PERMANENT_BOUNDARIES);
347
+ const summary = `Project Context: ${result.status.initialization.state}; health ${result.status.health}; AI Entry ${result.status.entry.state}.\n`;
348
+ return { exitCode: result.exitCode, stdout: jsonOrText(options, result.status, summary), stderr: "" };
349
+ }
350
+ if (command === "evidence") {
351
+ const bundle = await buildEvidenceBundleFile(root, required(options, "input"));
352
+ const summary = [
353
+ `Evidence ${bundle.result}; project health ${bundle.projectContext.health}.`,
354
+ `Finding codes: ${bundle.projectContext.findingCodes.join(", ") || "none"}.`,
355
+ `Bundle digest: ${bundle.bundleDigest}.`,
356
+ "Human review is required before transfer.",
357
+ ].join("\n") + "\n";
358
+ return { exitCode: 0, stdout: jsonOrText(options, bundle, summary), stderr: "" };
359
+ }
360
+ if (command === "upgrade-check") {
361
+ const assessment = await buildUpgradeAssessment(root, required(options, "from-version"));
362
+ const summary = [
363
+ `Upgrade ${assessment.fromVersion} -> ${assessment.targetVersion}: ${assessment.state}.`,
364
+ `Health: ${assessment.health}; rollback: ${assessment.rollbackClass}.`,
365
+ `Finding codes: ${assessment.findingCodes.join(", ") || "none"}.`,
366
+ `Assessment digest: ${assessment.assessmentDigest}.`,
367
+ ].join("\n") + "\n";
368
+ return { exitCode: assessment.state === "blocked" || assessment.state === "not-applicable" ? 1 : 0, stdout: jsonOrText(options, assessment, summary), stderr: "" };
369
+ }
370
+ if (command === "upgrade-plan") {
371
+ const plan = await buildMigrationPlanFile(root, required(options, "assessment"));
372
+ const summary = [
373
+ `Upgrade plan: ${plan.nextAction.kind}.`,
374
+ `Targets: ${plan.nextAction.targets.join(", ") || "none"}.`,
375
+ `Writes: ${plan.nextAction.writes}; human review: ${plan.requiresHumanReview}.`,
376
+ `Plan digest: ${plan.planDigest}.`,
377
+ ].join("\n") + "\n";
378
+ return { exitCode: 0, stdout: jsonOrText(options, plan, summary), stderr: "" };
379
+ }
380
+ if (command === "upgrade-apply") {
381
+ const applied = await applyMigrationPlanFile(root, required(options, "plan"), { write: options.write });
382
+ const summary = [
383
+ `Upgrade ${applied.result.mode}: ${applied.result.coreMigration}.`,
384
+ `Action: ${applied.result.action.kind}; written: ${applied.result.written}.`,
385
+ `Overall upgrade: ${applied.result.overallUpgrade}.`,
386
+ `Result digest: ${applied.result.resultDigest}.`,
387
+ ].join("\n") + "\n";
388
+ return { exitCode: applied.exitCode, stdout: jsonOrText(options, applied.result, summary), stderr: "" };
389
+ }
326
390
  if (command === "init") {
327
391
  const result = await initializeProject(root, required(options, "id"), required(options, "name"), options.write);
328
392
  return {
@@ -341,6 +405,7 @@ async function runCommand(command, options) {
341
405
  const bundle = await buildStageContextBundleFiles(root, required(options, "plan"), {
342
406
  stageId: required(options, "stage"),
343
407
  receiptPaths: options.receipt ?? [],
408
+ receiptBundlePaths: options["receipt-bundle"] ?? [],
344
409
  changedPaths: options["changed-path"] ?? [],
345
410
  });
346
411
  const summary = `Stage context ${bundle.status}: ${bundle.stage.id}; ${bundle.contractItems.length} contract item(s), ${bundle.readTargets.length} read target(s), ${bundle.budget.usedUtf8Bytes}/${bundle.budget.maxUtf8Bytes} UTF-8 bytes.\n`;
@@ -349,6 +414,7 @@ async function runCommand(command, options) {
349
414
  if (command === "integration-review") {
350
415
  const bundle = await buildIntegrationReviewBundleFiles(root, required(options, "plan"), {
351
416
  receiptPaths: options.receipt ?? [],
417
+ receiptBundlePaths: options["receipt-bundle"] ?? [],
352
418
  mainChangedPaths: options["main-changed-path"] ?? [],
353
419
  branchChangedPaths: options["branch-changed-path"] ?? [],
354
420
  });
@@ -537,6 +603,17 @@ async function runCommand(command, options) {
537
603
  const summary = options.write ? `${result.action}: ${result.entry.path}\n` : `Preview ${result.action}: ${result.entry.path}\n\n${result.content}`;
538
604
  return { exitCode: 0, stdout: options.json ? prettyCanonicalJson(result) : summary, stderr: "" };
539
605
  }
606
+ if (command === "publish-entry" || command === "remove-entry") {
607
+ const operation = command === "publish-entry" ? publishAiEntry : removeAiEntry;
608
+ const result = await operation(root, project, {
609
+ output: required(options, "output"),
610
+ write: options.write,
611
+ });
612
+ const summary = options.write
613
+ ? `${result.action}: ${result.impact.paths[0]}\n`
614
+ : `Preview ${result.action}: ${result.impact.paths[0]}\n\n${result.proposed.region ?? ""}`;
615
+ return { exitCode: 0, stdout: options.json ? prettyCanonicalJson(result) : summary, stderr: "" };
616
+ }
540
617
  fail("command-unknown", `unknown command: ${command}`);
541
618
  }
542
619
 
@@ -288,26 +288,40 @@ export function validateSourceLock(lock) {
288
288
  export function validateProjectionLock(lock) {
289
289
  object(lock, "projections lock");
290
290
  exactKeys(lock, new Set(["schemaVersion", "projections"]), "projections lock");
291
- if (lock.schemaVersion !== 1 || !Array.isArray(lock.projections)) fail("schema-invalid", "projections lock is invalid");
291
+ if (![1, 2].includes(lock.schemaVersion) || !Array.isArray(lock.projections)) fail("schema-invalid", "projections lock is invalid");
292
292
  const seen = new Set();
293
293
  for (const [index, entry] of lock.projections.entries()) {
294
294
  object(entry, `projection entry ${index}`);
295
- exactKeys(
296
- entry,
297
- new Set(["path", "target", "paths", "contractDigest", "bundleDigest", "contentDigest", "itemIds", "rendererVersion"]),
298
- `projection entry ${index}`,
299
- );
300
- entry.path = normalizeRelativePath(entry.path, { label: `projection entry ${index}.path` });
301
- if (entry.target !== "agents" && entry.target !== "ruler") fail("schema-invalid-enum", "projection target is invalid");
302
- uniqueStrings(entry.paths, `projection entry ${index}.paths`);
303
- entry.paths = entry.paths.map((value) => normalizeRelativePath(value, { allowRoot: true, label: "projection scope path" }));
304
- for (const key of ["contractDigest", "bundleDigest", "contentDigest"]) {
305
- string(entry[key], `projection entry ${index}.${key}`);
306
- if (!SHA256.test(entry[key])) fail("schema-invalid", `projection ${key} must be sha256`);
295
+ const label = `projection entry ${index}`;
296
+ if (lock.schemaVersion === 1) {
297
+ exactKeys(entry, new Set(["path", "target", "paths", "contractDigest", "bundleDigest", "contentDigest", "itemIds", "rendererVersion"]), label);
298
+ } else if (entry.ownership === "file") {
299
+ exactKeys(entry, new Set(["path", "target", "ownership", "paths", "contractDigest", "bundleDigest", "contentDigest", "itemIds", "rendererVersion"]), label);
300
+ } else if (entry.ownership === "region") {
301
+ exactKeys(entry, new Set(["path", "target", "ownership", "regionId", "regionDigest", "rendererVersion", "createdFile"]), label);
302
+ } else {
303
+ fail("schema-invalid-enum", `${label}.ownership must be file or region`);
307
304
  }
308
- uniqueStrings(entry.itemIds, `projection entry ${index}.itemIds`, { empty: true });
309
- if (![1, 2, 3].includes(entry.rendererVersion)) {
310
- fail("schema-version-unsupported", "projection rendererVersion must be 1, 2, or 3");
305
+ entry.path = normalizeRelativePath(entry.path, { label: `projection entry ${index}.path` });
306
+ if (lock.schemaVersion === 2 && entry.ownership === "region") {
307
+ if (entry.target !== "ai-entry") fail("schema-invalid-enum", "region projection target must be ai-entry");
308
+ if (entry.regionId !== "project-context-ai-entry") fail("schema-invalid-enum", "AI Entry regionId is invalid");
309
+ string(entry.regionDigest, `${label}.regionDigest`);
310
+ if (!SHA256.test(entry.regionDigest)) fail("schema-invalid", "AI Entry regionDigest must be sha256");
311
+ if (entry.rendererVersion !== 1) fail("schema-version-unsupported", "AI Entry rendererVersion must be 1");
312
+ if (typeof entry.createdFile !== "boolean") fail("schema-invalid", `${label}.createdFile must be boolean`);
313
+ } else {
314
+ if (entry.target !== "agents" && entry.target !== "ruler") fail("schema-invalid-enum", "projection target is invalid");
315
+ uniqueStrings(entry.paths, `${label}.paths`);
316
+ entry.paths = entry.paths.map((value) => normalizeRelativePath(value, { allowRoot: true, label: "projection scope path" }));
317
+ for (const key of ["contractDigest", "bundleDigest", "contentDigest"]) {
318
+ string(entry[key], `${label}.${key}`);
319
+ if (!SHA256.test(entry[key])) fail("schema-invalid", `projection ${key} must be sha256`);
320
+ }
321
+ uniqueStrings(entry.itemIds, `${label}.itemIds`, { empty: true });
322
+ if (![1, 2, 3].includes(entry.rendererVersion)) {
323
+ fail("schema-version-unsupported", "projection rendererVersion must be 1, 2, or 3");
324
+ }
311
325
  }
312
326
  if (seen.has(entry.path)) fail("schema-duplicate", `projections lock contains duplicate path: ${entry.path}`);
313
327
  seen.add(entry.path);
@@ -201,11 +201,11 @@ function buildItems(contract) {
201
201
  }
202
202
 
203
203
  function projectionStatus(codes) {
204
- if (codes.includes("projection-ownership-conflict")) return "conflict";
205
- if (codes.includes("projection-missing")) return "missing";
204
+ if (codes.some((code) => code.endsWith("ownership-conflict"))) return "conflict";
205
+ if (codes.some((code) => code.endsWith("missing"))) return "missing";
206
206
  if (codes.includes("projection-unreadable") || codes.includes("projection-path-invalid")) return "unreadable";
207
207
  if (codes.includes("projection-diverged")) return "diverged";
208
- if (codes.some((code) => code === "projection-stale" || code === "projection-renderer-stale" || code === "projection-item-missing")) {
208
+ if (codes.some((code) => code === "projection-stale" || code.endsWith("renderer-stale") || code === "projection-item-missing")) {
209
209
  return "stale";
210
210
  }
211
211
  return codes.length > 0 ? "attention" : "healthy";
@@ -214,7 +214,7 @@ function projectionStatus(codes) {
214
214
  function buildProjections(project, findings) {
215
215
  return project.projectionsLock.projections.map((entry) => {
216
216
  const findingCodes = findings
217
- .filter((finding) => finding.path === entry.path && finding.code.startsWith("projection-"))
217
+ .filter((finding) => finding.path === entry.path && (finding.code.startsWith("projection-") || finding.code.startsWith("ai-entry-")))
218
218
  .map((finding) => finding.code);
219
219
  return {
220
220
  ...structuredClone(entry),
@@ -567,10 +567,10 @@ function renderScopes(model) {
567
567
 
568
568
  function renderProjection(projection) {
569
569
  return `<details class="record projection"><summary><strong class="mono">${escapeHtml(projection.path)}</strong><span>${escapeHtml(projection.target)} · ${bi(`渲染器 ${projection.rendererVersion}`, `Renderer ${projection.rendererVersion}`)}</span>${statusBadge(projection.status)}</summary><div class="detail-body"><dl class="audit-grid">
570
- ${auditField(bi("范围路径", "Scope paths"), tokenList(projection.paths), { htmlTerm: true })}
571
- ${auditField(bi("知识项 ID", "Item IDs"), tokenList(projection.itemIds), { htmlTerm: true })}
570
+ ${auditField(bi("范围路径", "Scope paths"), tokenList(projection.paths ?? []), { htmlTerm: true })}
571
+ ${auditField(bi("知识项 ID", "Item IDs"), tokenList(projection.itemIds ?? []), { htmlTerm: true })}
572
572
  ${auditField(bi("问题代码", "Finding codes"), tokenList(projection.findingCodes), { htmlTerm: true })}
573
- ${auditField(bi("合同指纹", "Contract digest"), escapeHtml(projection.contractDigest), { mono: true, htmlTerm: true })}
573
+ ${auditField(bi("合同指纹", "Contract digest"), escapeHtml(projection.contractDigest ?? projection.regionDigest ?? "not-applicable"), { mono: true, htmlTerm: true })}
574
574
  </dl></div></details>`;
575
575
  }
576
576
 
@@ -1,6 +1,7 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { sha256 } from "./canonical-json.mjs";
4
+ import { parseAiEntryRegion } from "./ai-entry.mjs";
4
5
  import { sourceRegistrationShape, sourceStatus } from "./contract-schema.mjs";
5
6
  import { DEFAULT_IGNORES, digestPath, digestPathIdentity, readSourceDigest } from "./source-reader.mjs";
6
7
 
@@ -199,7 +200,11 @@ export async function discoverProject(root, contract) {
199
200
  const ruleContents = new Map();
200
201
  for (const relative of rulePaths) {
201
202
  const contents = await readFile(path.join(root, relative), "utf8");
202
- if (!contents.startsWith("<!-- managed-by: project-context;")) ruleContents.set(relative, contents);
203
+ const aiEntry = parseAiEntryRegion(contents);
204
+ const outsideEntry = aiEntry.state === "present"
205
+ ? `${contents.slice(0, aiEntry.start)}${contents.slice(aiEntry.end)}`.replace(/^\ufeff/u, "").trim()
206
+ : null;
207
+ if (!contents.startsWith("<!-- managed-by: project-context;") && outsideEntry !== "") ruleContents.set(relative, contents);
203
208
  }
204
209
  const knownRulePaths = new Set(ruleContents.keys());
205
210
  const aliasTargets = new Map();