frontend-project-context 1.7.0 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +17 -10
- package/UPGRADING.md +18 -0
- package/docs/08-INSTALLATION-AND-DISTRIBUTION.md +29 -6
- package/docs/14-FORMAL-RELEASE-READINESS.md +14 -0
- package/docs/24-A130-REAL-HOST-TARGET-PROJECT-COMPARISON.md +1 -1
- package/docs/26-A130-QUALITY-CLOSURE-AND-ADAPTIVE-DELIVERY-REPAIR-DESIGN.md +1 -1
- package/docs/27-TEAM-SHARED-CONTEXT-DIRECTION-DISCUSSION.md +30 -0
- package/docs/28-REAL-PROJECT-ONBOARDING-CLOSURE-DESIGN.md +544 -0
- package/docs/29-REAL-PROJECT-1.8.0-INITIALIZATION-OBSERVATIONS.md +228 -0
- package/docs/30-TASK-CONTEXT-CONSUMPTION-CLOSURE-DESIGN.md +563 -0
- package/docs/31-TASK-CONTEXT-INTEGRITY-REPAIR-DESIGN.md +281 -0
- package/docs/AI-PROJECT-INITIALIZATION.md +90 -0
- package/docs/PRODUCT-SHARING-AND-ADOPTION-GUIDE.md +111 -0
- package/docs/README.md +24 -0
- package/docs/USER-AND-AI-OPERATION-MANUAL.md +17 -13
- package/docs/assets/product-sharing-01-overview.svg +32 -0
- package/docs/assets/product-sharing-02-how-it-works.svg +17 -0
- package/docs/assets/product-sharing-03-example.svg +19 -0
- package/examples/README.md +2 -2
- package/examples/package.json +1 -1
- package/migration-manifest.json +40 -10
- package/package.json +2 -2
- package/schemas/capabilities.schema.json +29 -10
- package/schemas/context-bundle.schema.json +32 -0
- package/schemas/coverage-audit.schema.json +6 -4
- package/schemas/evidence-bundle.schema.json +1 -1
- package/schemas/initialization-instruction.schema.json +72 -0
- package/schemas/migration-manifest.schema.json +3 -3
- package/schemas/migration-plan.schema.json +2 -2
- package/schemas/project-status.schema.json +5 -4
- package/schemas/projection-lock.schema.json +1 -1
- package/schemas/upgrade-assessment.schema.json +2 -2
- package/schemas/upgrade-result-bundle.schema.json +1 -1
- package/src/project-context/adaptive-context-schema.mjs +1 -1
- package/src/project-context/adaptive-context.mjs +12 -23
- package/src/project-context/ai-entry.mjs +24 -9
- package/src/project-context/approver.mjs +6 -2
- package/src/project-context/authoring.mjs +28 -8
- package/src/project-context/capabilities.mjs +18 -1
- package/src/project-context/checker.mjs +1 -1
- package/src/project-context/cli.mjs +44 -16
- package/src/project-context/context-bundle.mjs +378 -0
- package/src/project-context/contract-schema.mjs +62 -9
- package/src/project-context/coverage-profile.mjs +127 -0
- package/src/project-context/exchange-schema.mjs +24 -7
- package/src/project-context/exchange.mjs +3 -0
- package/src/project-context/initialization-instruction.mjs +60 -0
- package/src/project-context/maintenance.mjs +5 -4
- package/src/project-context/migration-manifest.mjs +5 -5
- package/src/project-context/project-status.mjs +14 -3
- package/src/project-context/project-store.mjs +27 -2
- package/src/project-context/renderer.mjs +11 -4
- package/src/project-context/source-reader.mjs +27 -2
- package/src/project-context/upgrade-schema.mjs +2 -2
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { canonicalJson, digestJson } from "./canonical-json.mjs";
|
|
2
|
+
import { fail } from "./errors.mjs";
|
|
3
|
+
import { normalizeRelativePath } from "./path-policy.mjs";
|
|
4
|
+
import { scopeApplies } from "./scope-compiler.mjs";
|
|
5
|
+
|
|
6
|
+
const COVERAGE_SUBJECT = "project.registration-coverage";
|
|
7
|
+
const DISPOSITIONS = new Set(["contract-covered", "dynamic-investigation", "excluded-approved", "unresolved"]);
|
|
8
|
+
|
|
9
|
+
function uniqueSorted(values) {
|
|
10
|
+
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function object(value, label) {
|
|
14
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) fail("coverage-profile-invalid", `${label} must be an object`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function exactKeys(value, required, optional, label) {
|
|
18
|
+
object(value, label);
|
|
19
|
+
const allowed = new Set([...required, ...optional]);
|
|
20
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) fail("coverage-profile-invalid", `${label} contains unknown field: ${key}`);
|
|
21
|
+
for (const key of required) if (!Object.hasOwn(value, key)) fail("coverage-profile-invalid", `${label}.${key} is required`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function strings(values, label) {
|
|
25
|
+
if (!Array.isArray(values) || values.some((value) => typeof value !== "string")) fail("coverage-profile-invalid", `${label} must be a string array`);
|
|
26
|
+
return values;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizePath(value, label) {
|
|
30
|
+
if (typeof value !== "string") fail("coverage-profile-invalid", `${label} must be a string`);
|
|
31
|
+
try {
|
|
32
|
+
return normalizeRelativePath(value, { allowRoot: true, label });
|
|
33
|
+
} catch {
|
|
34
|
+
fail("coverage-profile-invalid", `${label} must be a valid project-relative path`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizedPaths(values, label) {
|
|
39
|
+
const normalized = strings(values, label).map((value) => normalizePath(value, `${label} entry`));
|
|
40
|
+
return uniqueSorted(normalized);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function coverageProfile(contract) {
|
|
44
|
+
const candidates = contract.items.filter((item) => item.status === "approved" && item.kind === "policy" && item.subject === COVERAGE_SUBJECT);
|
|
45
|
+
if (candidates.length === 0) return null;
|
|
46
|
+
if (candidates.length > 1) fail("coverage-profile-conflict", `multiple approved ${COVERAGE_SUBJECT} items are active`);
|
|
47
|
+
const item = candidates[0];
|
|
48
|
+
const value = item.value;
|
|
49
|
+
object(value, `${item.id} coverage value`);
|
|
50
|
+
if (value.version === 2) {
|
|
51
|
+
exactKeys(value, ["version", "domains"], [], `${item.id} coverage v2 value`);
|
|
52
|
+
if (!Array.isArray(value.domains)) fail("coverage-profile-invalid", `${item.id} coverage v2 domains must be an array`);
|
|
53
|
+
const approved = new Map(contract.items.filter((entry) => entry.status === "approved").map((entry) => [entry.id, entry]));
|
|
54
|
+
const ids = new Set();
|
|
55
|
+
const paths = new Set();
|
|
56
|
+
const domains = value.domains.map((domain, index) => {
|
|
57
|
+
const label = `${item.id} domains[${index}]`;
|
|
58
|
+
exactKeys(domain, ["id", "path", "disposition", "itemIds"], ["rationale"], label);
|
|
59
|
+
if (typeof domain.id !== "string" || domain.id.length === 0 || ids.has(domain.id)) fail("coverage-profile-invalid", `${label}.id must be unique`);
|
|
60
|
+
ids.add(domain.id);
|
|
61
|
+
const normalizedPath = normalizePath(domain.path, `${label}.path`);
|
|
62
|
+
if (normalizedPath !== domain.path || paths.has(normalizedPath)) fail("coverage-profile-invalid", `${label}.path must be normalized and unique`);
|
|
63
|
+
paths.add(normalizedPath);
|
|
64
|
+
if (!DISPOSITIONS.has(domain.disposition)) fail("coverage-profile-invalid", `${label}.disposition is invalid`);
|
|
65
|
+
const itemIds = uniqueSorted(strings(domain.itemIds, `${label}.itemIds`));
|
|
66
|
+
if (canonicalJson(itemIds) !== canonicalJson(domain.itemIds)) fail("coverage-profile-invalid", `${label}.itemIds must be sorted and unique`);
|
|
67
|
+
if (domain.disposition === "contract-covered") {
|
|
68
|
+
if (itemIds.length === 0) fail("coverage-profile-invalid", `${label} contract-covered domain requires itemIds`);
|
|
69
|
+
for (const itemId of itemIds) {
|
|
70
|
+
const covered = approved.get(itemId);
|
|
71
|
+
if (!covered || !scopeApplies(covered.scope, normalizedPath)) fail("coverage-profile-invalid", `${label} references an item that is not effective for its path: ${itemId}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (domain.disposition === "excluded-approved" && (typeof domain.rationale !== "string" || domain.rationale.length === 0)) {
|
|
75
|
+
fail("coverage-profile-invalid", `${label} excluded-approved domain requires rationale`);
|
|
76
|
+
}
|
|
77
|
+
if (domain.rationale !== undefined && typeof domain.rationale !== "string") fail("coverage-profile-invalid", `${label}.rationale must be a string`);
|
|
78
|
+
return {
|
|
79
|
+
id: domain.id,
|
|
80
|
+
path: normalizedPath,
|
|
81
|
+
disposition: domain.disposition,
|
|
82
|
+
itemIds,
|
|
83
|
+
...(domain.rationale !== undefined ? { rationale: domain.rationale } : {}),
|
|
84
|
+
};
|
|
85
|
+
}).sort((left, right) => left.id.localeCompare(right.id));
|
|
86
|
+
if (canonicalJson(domains) !== canonicalJson(value.domains)) fail("coverage-profile-invalid", `${item.id} domains must be stably sorted by id`);
|
|
87
|
+
return { itemId: item.id, itemDigest: digestJson(item), sourceIds: [...item.sources].sort(), version: 2, domains };
|
|
88
|
+
}
|
|
89
|
+
exactKeys(value, ["roots", "excludedPaths"], [], `${item.id} coverage value`);
|
|
90
|
+
return {
|
|
91
|
+
itemId: item.id,
|
|
92
|
+
itemDigest: digestJson(item),
|
|
93
|
+
sourceIds: [...item.sources].sort(),
|
|
94
|
+
version: 1,
|
|
95
|
+
roots: normalizedPaths(value.roots, "coverage roots"),
|
|
96
|
+
excludedPaths: normalizedPaths(value.excludedPaths, "coverage excluded paths"),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function pathWithin(target, parent) {
|
|
101
|
+
return parent === "." || target === parent || target.startsWith(`${parent}/`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function coverageDomainForPath(profile, candidatePath) {
|
|
105
|
+
if (!profile || profile.version !== 2) return null;
|
|
106
|
+
return profile.domains
|
|
107
|
+
.filter((entry) => pathWithin(candidatePath, entry.path))
|
|
108
|
+
.sort((left, right) => right.path.length - left.path.length || left.id.localeCompare(right.id))[0] ?? null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function coverageClass(profile, registeredPaths, candidatePath) {
|
|
112
|
+
if (registeredPaths.has(candidatePath)) return "registered";
|
|
113
|
+
if (!profile) return "outside-declared-coverage";
|
|
114
|
+
if (profile.version === 2) {
|
|
115
|
+
const domain = coverageDomainForPath(profile, candidatePath);
|
|
116
|
+
if (!domain) return "outside-declared-coverage";
|
|
117
|
+
return {
|
|
118
|
+
"contract-covered": "registered",
|
|
119
|
+
"dynamic-investigation": "dynamic-investigation",
|
|
120
|
+
"excluded-approved": "excluded-approved",
|
|
121
|
+
unresolved: "unresolved",
|
|
122
|
+
}[domain.disposition];
|
|
123
|
+
}
|
|
124
|
+
if (profile.excludedPaths.some((entry) => pathWithin(candidatePath, entry))) return "excluded-approved";
|
|
125
|
+
if (profile.roots.some((entry) => pathWithin(candidatePath, entry))) return "review-required";
|
|
126
|
+
return "outside-declared-coverage";
|
|
127
|
+
}
|
|
@@ -2,11 +2,11 @@ import { canonicalValue, digestJson, validateJsonValue } from "./canonical-json.
|
|
|
2
2
|
import { fail } from "./errors.mjs";
|
|
3
3
|
import { normalizeRelativePath } from "./path-policy.mjs";
|
|
4
4
|
|
|
5
|
-
export const PACKAGE_VERSION = "1.
|
|
6
|
-
export const EXCHANGE_PROTOCOL_VERSION =
|
|
5
|
+
export const PACKAGE_VERSION = "1.9.1";
|
|
6
|
+
export const EXCHANGE_PROTOCOL_VERSION = 9;
|
|
7
7
|
export const ACTION_PLAN_SCHEMA_VERSION = 2;
|
|
8
8
|
export const REVIEW_BUNDLE_SCHEMA_VERSION = 2;
|
|
9
|
-
export const CAPABILITIES_SCHEMA_VERSION =
|
|
9
|
+
export const CAPABILITIES_SCHEMA_VERSION = 9;
|
|
10
10
|
|
|
11
11
|
export const ACTION_KINDS = Object.freeze([
|
|
12
12
|
"accept-source-change",
|
|
@@ -24,7 +24,7 @@ export const ACTION_KINDS = Object.freeze([
|
|
|
24
24
|
export const COMMANDS = Object.freeze([
|
|
25
25
|
"accept-source-change", "approve", "capabilities", "check", "context", "context-query", "coverage-audit", "dashboard", "deprecate",
|
|
26
26
|
"deprecate-source", "discover", "evidence", "init", "integration-review", "preflight", "propose", "publish", "register",
|
|
27
|
-
"index-context", "publish-entry", "remove-entry", "review-source", "revise", "setup", "stage-context", "status", "sync",
|
|
27
|
+
"index-context", "instructions", "publish-entry", "remove-entry", "review-source", "revise", "setup", "stage-context", "status", "sync",
|
|
28
28
|
"upgrade-apply", "upgrade-check", "upgrade-plan",
|
|
29
29
|
"reconcile-truth",
|
|
30
30
|
]);
|
|
@@ -143,7 +143,7 @@ function normalizeScope(value, label) {
|
|
|
143
143
|
function normalizeItemInput(input, label, extra = []) {
|
|
144
144
|
object(input, label);
|
|
145
145
|
exactKeys(input, new Set([
|
|
146
|
-
"id", "kind", "subject", "value", "statement", "sources", "scope", "overrides", "verification", ...extra,
|
|
146
|
+
"id", "kind", "subject", "value", "statement", "sources", "scope", "overrides", "verification", "consumption", ...extra,
|
|
147
147
|
]), label);
|
|
148
148
|
stableId(input.id, `${label}.id`);
|
|
149
149
|
if (!ITEM_KINDS.has(input.kind)) invalid(`${label}.kind is invalid`);
|
|
@@ -159,6 +159,16 @@ function normalizeItemInput(input, label, extra = []) {
|
|
|
159
159
|
sources.forEach((entry) => stableId(entry, `${label}.sources entry`));
|
|
160
160
|
const overrides = uniqueStrings(input.overrides ?? [], `${label}.overrides`, { empty: true });
|
|
161
161
|
overrides.forEach((entry) => stableId(entry, `${label}.overrides entry`));
|
|
162
|
+
let consumption;
|
|
163
|
+
if (input.consumption !== undefined) {
|
|
164
|
+
object(input.consumption, `${label}.consumption`);
|
|
165
|
+
exactKeys(input.consumption, new Set(["requiredSources", "conditionalSources"]), `${label}.consumption`);
|
|
166
|
+
const requiredSources = uniqueStrings(input.consumption.requiredSources, `${label}.consumption.requiredSources`, { empty: true });
|
|
167
|
+
const conditionalSources = uniqueStrings(input.consumption.conditionalSources, `${label}.consumption.conditionalSources`, { empty: true });
|
|
168
|
+
for (const source of [...requiredSources, ...conditionalSources]) if (!sources.includes(source)) invalid(`${label}.consumption source must be included in sources: ${source}`);
|
|
169
|
+
if (requiredSources.some((source) => conditionalSources.includes(source))) invalid(`${label}.consumption roles must be mutually exclusive`);
|
|
170
|
+
consumption = { requiredSources, conditionalSources };
|
|
171
|
+
}
|
|
162
172
|
return {
|
|
163
173
|
id: input.id,
|
|
164
174
|
kind: input.kind,
|
|
@@ -168,18 +178,23 @@ function normalizeItemInput(input, label, extra = []) {
|
|
|
168
178
|
sources,
|
|
169
179
|
scope: normalizeScope(input.scope, `${label}.scope`),
|
|
170
180
|
overrides,
|
|
181
|
+
...(consumption ? { consumption } : {}),
|
|
171
182
|
...(input.verification !== undefined ? { verification: normalizeVerification(input.verification, `${label}.verification`) } : {}),
|
|
172
183
|
};
|
|
173
184
|
}
|
|
174
185
|
|
|
175
186
|
function normalizeSourceInput(input, label) {
|
|
176
187
|
object(input, label);
|
|
177
|
-
exactKeys(input, new Set(["id", "kind", "path", "pointer", "reference"]), label);
|
|
188
|
+
exactKeys(input, new Set(["id", "kind", "path", "pointer", "reference", "digestMode"]), label);
|
|
178
189
|
stableId(input.id, `${label}.id`);
|
|
179
190
|
if (!SOURCE_KINDS.has(input.kind)) invalid(`${label}.kind is invalid`);
|
|
180
191
|
if (["file", "path", "json-pointer"].includes(input.kind)) {
|
|
181
192
|
if (input.reference !== undefined) invalid(`${label}.reference is not allowed for local sources`);
|
|
182
193
|
const normalized = { id: input.id, kind: input.kind, path: projectPath(input.path, `${label}.path`) };
|
|
194
|
+
if (input.digestMode !== undefined) {
|
|
195
|
+
if (input.kind !== "file" || !["full-file", "outside-owned-ai-entry"].includes(input.digestMode)) invalid(`${label}.digestMode is invalid`);
|
|
196
|
+
normalized.digestMode = input.digestMode;
|
|
197
|
+
}
|
|
183
198
|
if (input.kind === "json-pointer") {
|
|
184
199
|
string(input.pointer, `${label}.pointer`, { empty: true });
|
|
185
200
|
if (input.pointer !== "" && !input.pointer.startsWith("/")) invalid(`${label}.pointer must use RFC 6901 syntax`);
|
|
@@ -187,7 +202,7 @@ function normalizeSourceInput(input, label) {
|
|
|
187
202
|
} else if (input.pointer !== undefined) invalid(`${label}.pointer is only allowed for json-pointer`);
|
|
188
203
|
return normalized;
|
|
189
204
|
}
|
|
190
|
-
if (input.path !== undefined || input.pointer !== undefined) invalid(`${label} cannot contain a local path`);
|
|
205
|
+
if (input.path !== undefined || input.pointer !== undefined || input.digestMode !== undefined) invalid(`${label} cannot contain a local path or digest mode`);
|
|
191
206
|
string(input.reference, `${label}.reference`);
|
|
192
207
|
return { id: input.id, kind: input.kind, reference: input.reference };
|
|
193
208
|
}
|
|
@@ -531,6 +546,8 @@ export function itemPrimitiveInput(input) {
|
|
|
531
546
|
scope: input.scope.kind,
|
|
532
547
|
scopePath: input.scope.path,
|
|
533
548
|
overrides: [...input.overrides],
|
|
549
|
+
requiredSources: [...(input.consumption?.requiredSources ?? [])],
|
|
550
|
+
conditionalSources: [...(input.consumption?.conditionalSources ?? [])],
|
|
534
551
|
verification: input.verification?.kind,
|
|
535
552
|
verificationSource: input.verification?.source,
|
|
536
553
|
verificationExpectedPresent: Boolean(input.verification && Object.hasOwn(input.verification, "expected")),
|
|
@@ -71,6 +71,8 @@ function itemArgs(input, options = {}) {
|
|
|
71
71
|
"--scope", input.scope.kind,
|
|
72
72
|
...(input.scope.path ? ["--scope-path", input.scope.path] : []),
|
|
73
73
|
...(input.overrides.length > 0 ? ["--overrides", ...input.overrides] : []),
|
|
74
|
+
...(input.consumption?.requiredSources?.length > 0 ? ["--required-source", ...input.consumption.requiredSources] : []),
|
|
75
|
+
...(input.consumption?.conditionalSources?.length > 0 ? ["--conditional-source", ...input.consumption.conditionalSources] : []),
|
|
74
76
|
];
|
|
75
77
|
if (input.verification) {
|
|
76
78
|
args.push("--verification", input.verification.kind);
|
|
@@ -89,6 +91,7 @@ function sourceArgs(input) {
|
|
|
89
91
|
...(input.path ? ["--path", input.path] : []),
|
|
90
92
|
...(input.pointer !== undefined ? ["--pointer", input.pointer] : []),
|
|
91
93
|
...(input.reference ? ["--reference", input.reference] : []),
|
|
94
|
+
...(input.digestMode ? ["--digest-mode", input.digestMode] : []),
|
|
92
95
|
];
|
|
93
96
|
}
|
|
94
97
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { sha256 } from "./canonical-json.mjs";
|
|
5
|
+
import { PACKAGE_VERSION } from "./exchange-schema.mjs";
|
|
6
|
+
|
|
7
|
+
export const INITIALIZATION_INSTRUCTION_SCHEMA_VERSION = 2;
|
|
8
|
+
export const INITIALIZATION_INSTRUCTION_ID = "ai-project-initialization";
|
|
9
|
+
export const INITIALIZATION_INSTRUCTION_VERSION = 2;
|
|
10
|
+
export const INITIALIZATION_INSTRUCTION_PACKAGE_PATH = "docs/AI-PROJECT-INITIALIZATION.md";
|
|
11
|
+
export const INITIALIZATION_INSTRUCTION_COMMAND = "project-context instructions --project PATH [--json | --prompt]";
|
|
12
|
+
|
|
13
|
+
const INSTRUCTION_FILE = fileURLToPath(new URL(`../../${INITIALIZATION_INSTRUCTION_PACKAGE_PATH}`, import.meta.url));
|
|
14
|
+
|
|
15
|
+
export async function readInitializationInstruction() {
|
|
16
|
+
const content = await readFile(INSTRUCTION_FILE, "utf8");
|
|
17
|
+
return {
|
|
18
|
+
id: INITIALIZATION_INSTRUCTION_ID,
|
|
19
|
+
version: INITIALIZATION_INSTRUCTION_VERSION,
|
|
20
|
+
packagePath: INITIALIZATION_INSTRUCTION_PACKAGE_PATH,
|
|
21
|
+
digest: sha256(content),
|
|
22
|
+
content,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function buildInitializationInstruction(targetRoot, boundaries) {
|
|
27
|
+
let entryContent = null;
|
|
28
|
+
try {
|
|
29
|
+
entryContent = await readFile(path.join(targetRoot, "AGENTS.md"), "utf8");
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error?.code !== "ENOENT") throw error;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
schemaVersion: INITIALIZATION_INSTRUCTION_SCHEMA_VERSION,
|
|
35
|
+
package: { name: "frontend-project-context", version: PACKAGE_VERSION },
|
|
36
|
+
instruction: await readInitializationInstruction(),
|
|
37
|
+
targetRoot,
|
|
38
|
+
boundaries: { ...boundaries },
|
|
39
|
+
entryRuleReview: {
|
|
40
|
+
path: "AGENTS.md",
|
|
41
|
+
present: entryContent !== null,
|
|
42
|
+
allowedDispositions: ["migrated-to-contract", "kept-in-entry-intentionally", "excluded-as-stale", "unresolved-blocker"],
|
|
43
|
+
records: entryContent === null || entryContent.trim().length === 0 ? [] : [{
|
|
44
|
+
id: "human-entry-content",
|
|
45
|
+
disposition: "unresolved-blocker",
|
|
46
|
+
rationale: "Classify every duplicated or legacy human entry rule in the concentrated initialization review before completion.",
|
|
47
|
+
}],
|
|
48
|
+
automaticDeletion: false,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function renderInitializationPrompt(result) {
|
|
54
|
+
return [
|
|
55
|
+
`<!-- frontend-project-context initialization instruction ${result.instruction.version}; ${result.instruction.digest} -->`,
|
|
56
|
+
`<!-- resolved-target-root: ${result.targetRoot} -->`,
|
|
57
|
+
result.instruction.content.trimEnd(),
|
|
58
|
+
"",
|
|
59
|
+
].join("\n");
|
|
60
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { digestJson } from "./canonical-json.mjs";
|
|
2
2
|
import { buildItemProposal } from "./authoring.mjs";
|
|
3
|
-
import { sourceStatus, validateContract, validateSourceLock } from "./contract-schema.mjs";
|
|
3
|
+
import { promoteContractToSchema3, sourceStatus, validateContract, validateSourceLock } from "./contract-schema.mjs";
|
|
4
4
|
import { fail } from "./errors.mjs";
|
|
5
5
|
import { writeProjectState } from "./project-store.mjs";
|
|
6
6
|
import { readSourceDigest } from "./source-reader.mjs";
|
|
@@ -104,7 +104,7 @@ export async function reviewSource(root, project, sourceId, options = {}) {
|
|
|
104
104
|
let status;
|
|
105
105
|
let reason;
|
|
106
106
|
try {
|
|
107
|
-
currentDigest = await readSourceDigest(root, source, options.sourceReadContext);
|
|
107
|
+
currentDigest = await readSourceDigest(root, source, options.sourceReadContext, { projectionsLock: project.projectionsLock });
|
|
108
108
|
if (lockedDigest === null || lockedDigest !== source.digest) {
|
|
109
109
|
status = "unreadable";
|
|
110
110
|
reason = lockedDigest === null ? "source-lock-missing" : "source-lock-mismatch";
|
|
@@ -179,7 +179,7 @@ export async function acceptSourceChange(root, project, input, options = {}) {
|
|
|
179
179
|
if (options.write) {
|
|
180
180
|
let actual;
|
|
181
181
|
try {
|
|
182
|
-
actual = await readSourceDigest(root, source);
|
|
182
|
+
actual = await readSourceDigest(root, source, undefined, { projectionsLock: project.projectionsLock });
|
|
183
183
|
} catch (error) {
|
|
184
184
|
fail("source-accept-digest-mismatch", "source became unavailable while acceptance was being prepared", {
|
|
185
185
|
exitCode: 1,
|
|
@@ -266,7 +266,7 @@ export async function deprecateSource(project, input, options = {}) {
|
|
|
266
266
|
|
|
267
267
|
const timestamp = input.at ?? new Date().toISOString();
|
|
268
268
|
const nextContract = structuredClone(project.contract);
|
|
269
|
-
nextContract.schemaVersion = 2;
|
|
269
|
+
if (nextContract.schemaVersion < 2) nextContract.schemaVersion = 2;
|
|
270
270
|
nextContract.sources = nextContract.sources.map((source) => {
|
|
271
271
|
const active = source.status === undefined ? { ...source, status: "active" } : source;
|
|
272
272
|
if (source.id !== input.id) return active;
|
|
@@ -326,6 +326,7 @@ export async function reviseItem(root, project, input, options = {}) {
|
|
|
326
326
|
});
|
|
327
327
|
}
|
|
328
328
|
const nextContract = structuredClone(project.contract);
|
|
329
|
+
if (replacement.consumption !== undefined) promoteContractToSchema3(nextContract);
|
|
329
330
|
nextContract.items[nextContract.items.findIndex((item) => item.id === input.id)] = replacement;
|
|
330
331
|
validateContract(nextContract);
|
|
331
332
|
if (options.write) await writeProjectState(project, nextContract, project.sourcesLock, options.storeOptions);
|
|
@@ -22,9 +22,9 @@ const MIGRATION_KINDS = new Set([
|
|
|
22
22
|
]);
|
|
23
23
|
const ROLLBACK_CLASSES = new Set(["package-only", "reversible-data", "forward-only"]);
|
|
24
24
|
const CONSUMER_CHANGE_KEYS = new Set([
|
|
25
|
-
"actionPlan", "adaptiveContextBundle", "capabilities", "contextQuery", "coverageAudit", "evidenceBundle", "evidenceInput", "exchange",
|
|
25
|
+
"actionPlan", "adaptiveContextBundle", "capabilities", "contextBundle", "contextQuery", "coverageAudit", "evidenceBundle", "evidenceInput", "exchange",
|
|
26
26
|
"hostPromotionEvidence", "integrationReviewBundle", "reviewBundle", "routingIndex", "stageContextBundle", "stageReceipt", "taskContextPlan",
|
|
27
|
-
"truthReconciliationInput", "truthReconciliationReviewBundle",
|
|
27
|
+
"truthReconciliationInput", "truthReconciliationReviewBundle", "initializationInstruction", "projectStatus",
|
|
28
28
|
]);
|
|
29
29
|
|
|
30
30
|
function invalid(message, details) {
|
|
@@ -103,7 +103,7 @@ export function validateMigrationManifest(input) {
|
|
|
103
103
|
]), "migration manifest");
|
|
104
104
|
if (manifest.schemaVersion !== MIGRATION_MANIFEST_SCHEMA_VERSION) invalid("migration manifest schemaVersion must be 2");
|
|
105
105
|
exactKeys(manifest.package, new Set(["name", "version"]), "migration manifest package");
|
|
106
|
-
if (manifest.package.name !== "frontend-project-context" || manifest.package.version !== "1.
|
|
106
|
+
if (manifest.package.name !== "frontend-project-context" || manifest.package.version !== "1.9.1") invalid("migration manifest package does not match this runtime");
|
|
107
107
|
versions(manifest.upgradeFrom, "upgradeFrom");
|
|
108
108
|
if (manifest.upgradeFrom.length === 0) invalid("upgradeFrom must not be empty");
|
|
109
109
|
exactKeys(manifest.stores, new Set(["contract", "projectionLock", "proposal", "sourceLock"]), "stores");
|
|
@@ -111,9 +111,9 @@ export function validateMigrationManifest(input) {
|
|
|
111
111
|
exactKeys(manifest.renderers, new Set(["aiEntry", "projection"]), "renderers");
|
|
112
112
|
for (const name of Object.keys(manifest.renderers)) versionMatrix(manifest.renderers[name], `renderers.${name}`);
|
|
113
113
|
exactKeys(manifest.protocols, new Set([
|
|
114
|
-
"exchange", "actionPlan", "adaptiveContextBundle", "contextQuery", "coverageAudit", "reviewBundle", "evidenceInput", "evidenceBundle", "taskContextPlan",
|
|
114
|
+
"exchange", "actionPlan", "adaptiveContextBundle", "contextBundle", "contextQuery", "coverageAudit", "reviewBundle", "evidenceInput", "evidenceBundle", "taskContextPlan",
|
|
115
115
|
"routingIndex", "stageReceipt", "stageContextBundle", "integrationReviewBundle", "hostPromotionEvidence",
|
|
116
|
-
"truthReconciliationInput", "truthReconciliationReviewBundle",
|
|
116
|
+
"truthReconciliationInput", "truthReconciliationReviewBundle", "initializationInstruction", "projectStatus",
|
|
117
117
|
]), "protocols");
|
|
118
118
|
for (const name of Object.keys(manifest.protocols)) versionMatrix(manifest.protocols[name], `protocols.${name}`);
|
|
119
119
|
if (!Array.isArray(manifest.builtInMigrations)) invalid("builtInMigrations must be an array");
|
|
@@ -4,8 +4,9 @@ import { checkExitCode, checkProject } from "./checker.mjs";
|
|
|
4
4
|
import { ProjectContextError } from "./errors.mjs";
|
|
5
5
|
import { PACKAGE_VERSION } from "./exchange-schema.mjs";
|
|
6
6
|
import { inspectProjectInitialization, loadProject } from "./project-store.mjs";
|
|
7
|
+
import { sourceStatus } from "./contract-schema.mjs";
|
|
7
8
|
|
|
8
|
-
export const PROJECT_STATUS_SCHEMA_VERSION =
|
|
9
|
+
export const PROJECT_STATUS_SCHEMA_VERSION = 2;
|
|
9
10
|
|
|
10
11
|
function uniqueSorted(values) {
|
|
11
12
|
return [...new Set(values.filter((value) => value !== undefined && value !== null))]
|
|
@@ -46,6 +47,7 @@ function base(initialization, health, nextActions) {
|
|
|
46
47
|
snapshots: null,
|
|
47
48
|
health,
|
|
48
49
|
entry: { state: "absent", path: null, rendererVersion: null },
|
|
50
|
+
contractReadiness: initialization === "uninitialized" ? "not-initialized" : "contract-incomplete",
|
|
49
51
|
summary: emptySummary(),
|
|
50
52
|
findingCodes: [],
|
|
51
53
|
sourceIds: [],
|
|
@@ -65,7 +67,7 @@ function statusBoundaries(boundaries) {
|
|
|
65
67
|
export async function buildProjectStatus(root, boundaries) {
|
|
66
68
|
const initialization = await inspectProjectInitialization(root);
|
|
67
69
|
if (initialization.status !== "initialized") {
|
|
68
|
-
const result = base(initialization.status, initialization.status, initialization.status === "uninitialized" ? ["
|
|
70
|
+
const result = base(initialization.status, initialization.status, initialization.status === "uninitialized" ? ["read-initialization-instructions"] : ["resolve-conflict"]);
|
|
69
71
|
result.initialization.present = [...initialization.present];
|
|
70
72
|
result.initialization.missing = [...initialization.missing];
|
|
71
73
|
if (initialization.status === "partial") result.findingCodes = ["project-state-partial"];
|
|
@@ -112,7 +114,11 @@ export async function buildProjectStatus(root, boundaries) {
|
|
|
112
114
|
if (sync.summary.affectedProjections > 0) nextActions.push("review-projection");
|
|
113
115
|
if (entry.state !== "current") nextActions.push("publish-ai-entry");
|
|
114
116
|
if (findings.length > 0 && nextActions.length === 0) nextActions.push("run-sync");
|
|
115
|
-
if (health === "clean")
|
|
117
|
+
if (health === "clean") {
|
|
118
|
+
const hasActiveSource = project.contract.sources.some((source) => sourceStatus(source) === "active");
|
|
119
|
+
const hasApprovedItem = project.contract.items.some((item) => item.status === "approved");
|
|
120
|
+
nextActions.push(hasActiveSource && hasApprovedItem && entry.state === "current" ? "compile-task-context" : "complete-onboarding");
|
|
121
|
+
}
|
|
116
122
|
}
|
|
117
123
|
const sourceIds = uniqueSorted([
|
|
118
124
|
...findings.flatMap((finding) => finding.source ? [finding.source] : []),
|
|
@@ -138,6 +144,11 @@ export async function buildProjectStatus(root, boundaries) {
|
|
|
138
144
|
},
|
|
139
145
|
health,
|
|
140
146
|
entry,
|
|
147
|
+
contractReadiness: health === "clean" && entry.state === "current" &&
|
|
148
|
+
project.contract.sources.some((source) => sourceStatus(source) === "active") &&
|
|
149
|
+
project.contract.items.some((item) => item.status === "approved")
|
|
150
|
+
? "contract-ready"
|
|
151
|
+
: "contract-incomplete",
|
|
141
152
|
summary: {
|
|
142
153
|
findings: findings.length,
|
|
143
154
|
changedSources: sync.summary.changedSources,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { access, mkdir, rename, rm } from "node:fs/promises";
|
|
1
|
+
import { access, lstat, mkdir, readdir, rename, rm, rmdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { canonicalJson, digestJson } from "./canonical-json.mjs";
|
|
@@ -43,8 +43,20 @@ async function exists(filePath) {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
async function isEmptyDirectory(filePath) {
|
|
47
|
+
try {
|
|
48
|
+
const stats = await lstat(filePath);
|
|
49
|
+
if (!stats.isDirectory()) return false;
|
|
50
|
+
return (await readdir(filePath)).length === 0;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error?.code === "ENOENT") return false;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
export async function inspectProjectInitialization(root) {
|
|
47
58
|
const files = projectFiles(root);
|
|
59
|
+
const emptyContainer = await isEmptyDirectory(files.directory);
|
|
48
60
|
const entries = [
|
|
49
61
|
[CONTEXT_DIRECTORY, files.directory],
|
|
50
62
|
[`${CONTEXT_DIRECTORY}/${CONTRACT_FILE}`, files.contract],
|
|
@@ -53,6 +65,7 @@ export async function inspectProjectInitialization(root) {
|
|
|
53
65
|
];
|
|
54
66
|
const present = [];
|
|
55
67
|
for (const [relative, absolute] of entries) {
|
|
68
|
+
if (relative === CONTEXT_DIRECTORY && emptyContainer) continue;
|
|
56
69
|
if (await exists(absolute)) present.push(relative);
|
|
57
70
|
}
|
|
58
71
|
const storePaths = entries.slice(1).map(([relative]) => relative);
|
|
@@ -71,8 +84,9 @@ export async function initializeProject(root, id, name, write) {
|
|
|
71
84
|
validateContract(initial.contract);
|
|
72
85
|
validateSourceLock(initial.sourcesLock);
|
|
73
86
|
validateProjectionLock(initial.projectionsLock);
|
|
87
|
+
const emptyContainer = await isEmptyDirectory(files.directory);
|
|
74
88
|
const existing = [];
|
|
75
|
-
if (await exists(files.directory)) existing.push(CONTEXT_DIRECTORY);
|
|
89
|
+
if ((await exists(files.directory)) && !emptyContainer) existing.push(CONTEXT_DIRECTORY);
|
|
76
90
|
for (const filePath of [files.contract, files.sourcesLock, files.projectionsLock]) {
|
|
77
91
|
if (await exists(filePath)) existing.push(path.relative(root, filePath));
|
|
78
92
|
}
|
|
@@ -80,6 +94,17 @@ export async function initializeProject(root, id, name, write) {
|
|
|
80
94
|
fail("project-already-initialized", "project context files already exist", { details: { paths: existing } });
|
|
81
95
|
}
|
|
82
96
|
if (write) {
|
|
97
|
+
if (emptyContainer) {
|
|
98
|
+
try {
|
|
99
|
+
await rmdir(files.directory);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error?.code !== "ENOENT") {
|
|
102
|
+
fail("project-already-initialized", "project context directory changed before initialization", {
|
|
103
|
+
details: { paths: [CONTEXT_DIRECTORY] },
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
83
108
|
const temporaryDirectory = `${files.directory}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
84
109
|
try {
|
|
85
110
|
await mkdir(temporaryDirectory, { recursive: false });
|
|
@@ -64,7 +64,7 @@ function groupedSections(sections) {
|
|
|
64
64
|
return groups;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
function
|
|
67
|
+
export function renderCollectedContextBundle(bundle, sources, task, options = {}) {
|
|
68
68
|
const locale = options.locale ?? "zh-CN";
|
|
69
69
|
const lines = [
|
|
70
70
|
"# Project Context Bundle",
|
|
@@ -100,11 +100,18 @@ function renderCollectedBundle(bundle, sources, task, options = {}) {
|
|
|
100
100
|
lines.push(`- \`${id}\` — ${source?.kind ?? "unknown"}: \`${location}\``);
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
|
+
if ((options.gaps ?? []).length > 0) {
|
|
104
|
+
lines.push("", "## Context gaps", "");
|
|
105
|
+
for (const gap of options.gaps) lines.push(`- \`${gap.code}\`${gap.path ? ` — \`${gap.path}\`` : ""}`);
|
|
106
|
+
}
|
|
107
|
+
if ((options.nextActions ?? []).length > 0) {
|
|
108
|
+
lines.push("", "## Next actions", "", ...options.nextActions.map((action) => `- \`${action}\``));
|
|
109
|
+
}
|
|
103
110
|
return `${lines.join("\n")}\n`;
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
export function renderContextBundle(contract, paths, task, options = {}) {
|
|
107
|
-
return
|
|
114
|
+
return renderCollectedContextBundle(collectBundle(contract, paths), contract.sources, task, options);
|
|
108
115
|
}
|
|
109
116
|
|
|
110
117
|
export function renderSelectedContextBundle(contract, paths, itemIds, task, options = {}) {
|
|
@@ -122,7 +129,7 @@ export function renderSelectedContextBundle(contract, paths, itemIds, task, opti
|
|
|
122
129
|
sections,
|
|
123
130
|
itemIds: [...selected].sort(),
|
|
124
131
|
};
|
|
125
|
-
return
|
|
132
|
+
return renderCollectedContextBundle(bundle, contract.sources, task, options);
|
|
126
133
|
}
|
|
127
134
|
|
|
128
135
|
// Task delivery deliberately has its own renderer. Managed AGENTS/Ruler projections
|
|
@@ -183,7 +190,7 @@ export function renderTaskContextBundle(contract, paths, itemIds, task, options
|
|
|
183
190
|
|
|
184
191
|
export function renderProjection(contract, paths, target) {
|
|
185
192
|
const bundle = collectBundle(contract, paths);
|
|
186
|
-
const body =
|
|
193
|
+
const body = renderCollectedContextBundle(bundle, contract.sources);
|
|
187
194
|
const bundleDigest = sha256(body);
|
|
188
195
|
const marker = `<!-- managed-by: project-context; renderer-version: ${RENDERER_VERSION}; target: ${target}; contract-digest: ${bundle.contractDigest}; bundle-digest: ${bundleDigest} -->`;
|
|
189
196
|
return {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { canonicalJson, sha256 } from "./canonical-json.mjs";
|
|
3
|
+
import { canonicalJson, digestJson, sha256 } from "./canonical-json.mjs";
|
|
4
|
+
import { AI_ENTRY_REGION_ID, parseAiEntryRegion } from "./ai-entry.mjs";
|
|
4
5
|
import { sourceStatus } from "./contract-schema.mjs";
|
|
5
6
|
import { fail } from "./errors.mjs";
|
|
6
7
|
import { resolveExistingInside } from "./path-policy.mjs";
|
|
@@ -97,10 +98,34 @@ export async function digestPathIdentity(projectRoot, relativePath, context) {
|
|
|
97
98
|
});
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
|
|
101
|
+
async function digestOutsideOwnedAiEntry(projectRoot, source, context, projectionsLock) {
|
|
102
|
+
const lock = projectionsLock?.projections?.find((entry) => entry.path === source.path);
|
|
103
|
+
if (!lock || lock.ownership !== "region" || lock.target !== "ai-entry" || lock.regionId !== AI_ENTRY_REGION_ID) {
|
|
104
|
+
fail("source-digest-ownership-invalid", `outside-owned-ai-entry requires a trusted AI Entry lock: ${source.path}`);
|
|
105
|
+
}
|
|
106
|
+
const { absolute } = await resolveExistingInside(projectRoot, source.path);
|
|
107
|
+
const content = await cached(context, `body:${absolute}`, async () => {
|
|
108
|
+
if (context?.metrics) context.metrics.sourceBodyReads += 1;
|
|
109
|
+
return readFile(absolute, "utf8");
|
|
110
|
+
});
|
|
111
|
+
const parsed = parseAiEntryRegion(content);
|
|
112
|
+
if (parsed.state !== "present") fail("source-digest-marker-invalid", `AI Entry markers are not valid: ${source.path}`);
|
|
113
|
+
if (sha256(parsed.region) !== lock.regionDigest) fail("source-digest-ownership-invalid", `AI Entry region digest does not match its lock: ${source.path}`);
|
|
114
|
+
const renderer = /project-context:ai-entry; schema-version: 1; renderer-version: (\d+)/u.exec(parsed.region)?.[1];
|
|
115
|
+
if (renderer === undefined || Number(renderer) !== lock.rendererVersion) {
|
|
116
|
+
fail("source-digest-ownership-invalid", `AI Entry renderer does not match its lock: ${source.path}`);
|
|
117
|
+
}
|
|
118
|
+
return digestJson({ before: content.slice(0, parsed.start), after: content.slice(parsed.end) });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function readSourceDigest(projectRoot, source, context, options = {}) {
|
|
101
122
|
if (sourceStatus(source) === "deprecated") return null;
|
|
102
123
|
if (source.kind === "human-decision" || source.kind === "external-reference") return null;
|
|
103
124
|
if (source.kind === "path") return digestPathIdentity(projectRoot, source.path, context);
|
|
125
|
+
if (source.kind === "file" && source.digestMode === "outside-owned-ai-entry") {
|
|
126
|
+
return cached(context, `digest:outside-owned-ai-entry:${source.path}`, () =>
|
|
127
|
+
digestOutsideOwnedAiEntry(projectRoot, source, context, options.projectionsLock));
|
|
128
|
+
}
|
|
104
129
|
if (source.kind === "file") return digestPath(projectRoot, source.path, context);
|
|
105
130
|
return sha256(canonicalJson(await readJsonSourceValue(projectRoot, source, context)));
|
|
106
131
|
}
|
|
@@ -121,9 +121,9 @@ export function validateUpgradeAssessment(input) {
|
|
|
121
121
|
stores: new Set(["contract", "projectionLock", "proposal", "sourceLock"]),
|
|
122
122
|
renderers: new Set(["aiEntry", "projection"]),
|
|
123
123
|
protocols: new Set([
|
|
124
|
-
"actionPlan", "adaptiveContextBundle", "contextQuery", "coverageAudit", "evidenceBundle", "evidenceInput", "exchange",
|
|
124
|
+
"actionPlan", "adaptiveContextBundle", "contextBundle", "contextQuery", "coverageAudit", "evidenceBundle", "evidenceInput", "exchange",
|
|
125
125
|
"hostPromotionEvidence", "integrationReviewBundle", "reviewBundle", "routingIndex", "stageContextBundle", "stageReceipt",
|
|
126
|
-
"taskContextPlan", "truthReconciliationInput", "truthReconciliationReviewBundle",
|
|
126
|
+
"taskContextPlan", "truthReconciliationInput", "truthReconciliationReviewBundle", "initializationInstruction", "projectStatus",
|
|
127
127
|
]),
|
|
128
128
|
};
|
|
129
129
|
for (const [name, group] of Object.entries(value.compatibility)) {
|