frontend-project-context 1.0.1 → 1.2.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.
- package/CHANGELOG.md +18 -0
- package/README.md +104 -8
- package/UPGRADING.md +28 -0
- package/docs/00-PRODUCT-CONSTITUTION.md +42 -10
- package/docs/04-PROGRAM-DESIGN.md +76 -2
- package/docs/05-ACCEPTANCE-CONTRACT.md +35 -3
- package/docs/08-INSTALLATION-AND-DISTRIBUTION.md +27 -15
- package/docs/16-GUIDED-ONBOARDING-AND-AI-RECONCILIATION-DESIGN.md +469 -0
- package/docs/17-AI-EXCHANGE-BOUNDARY-DESIGN.md +268 -0
- package/docs/README.md +12 -4
- package/examples/README.md +15 -2
- package/examples/package.json +6 -2
- package/package.json +4 -3
- package/schemas/action-plan.schema.json +250 -0
- package/schemas/assist-bundle.schema.json +75 -0
- package/schemas/capabilities.schema.json +123 -0
- package/schemas/review-bundle.schema.json +109 -0
- package/src/project-context/assist.mjs +422 -0
- package/src/project-context/cli.mjs +143 -13
- package/src/project-context/exchange-schema.mjs +528 -0
- package/src/project-context/exchange.mjs +623 -0
- package/src/project-context/project-store.mjs +22 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { digestJson } from "./canonical-json.mjs";
|
|
2
|
+
import { checkProject } from "./checker.mjs";
|
|
3
|
+
import { sourceStatus } from "./contract-schema.mjs";
|
|
4
|
+
import { fail } from "./errors.mjs";
|
|
5
|
+
import { reviewSource } from "./maintenance.mjs";
|
|
6
|
+
import { normalizeRelativePath } from "./path-policy.mjs";
|
|
7
|
+
import { effectiveItems, scopeApplies } from "./scope-compiler.mjs";
|
|
8
|
+
|
|
9
|
+
const LOCAL_SOURCE_KINDS = new Set(["file", "path", "json-pointer"]);
|
|
10
|
+
const LARGE_IMPACT_THRESHOLD = 100;
|
|
11
|
+
export const ASSIST_BUNDLE_SCHEMA_VERSION = 1;
|
|
12
|
+
|
|
13
|
+
function uniqueSorted(values) {
|
|
14
|
+
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function snapshots(project) {
|
|
18
|
+
return {
|
|
19
|
+
contract: project.contractDigest ?? digestJson(project.contract),
|
|
20
|
+
sourcesLock: project.sourcesLockDigest ?? digestJson(project.sourcesLock),
|
|
21
|
+
projectionsLock: project.projectionsLockDigest ?? digestJson(project.projectionsLock),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sourceLocator(source) {
|
|
26
|
+
if (source.kind === "json-pointer") return { path: source.path, pointer: source.pointer };
|
|
27
|
+
if (LOCAL_SOURCE_KINDS.has(source.kind)) return { path: source.path };
|
|
28
|
+
return { reference: source.reference };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sourceTarget(source, reason, priority) {
|
|
32
|
+
return {
|
|
33
|
+
sourceId: source.id,
|
|
34
|
+
kind: source.kind,
|
|
35
|
+
locator: sourceLocator(source),
|
|
36
|
+
reason,
|
|
37
|
+
priority,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stableFindings(findings) {
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
return findings.filter((finding) => {
|
|
44
|
+
const key = JSON.stringify(finding);
|
|
45
|
+
if (seen.has(key)) return false;
|
|
46
|
+
seen.add(key);
|
|
47
|
+
return true;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function setupWorkUnits(proposal) {
|
|
52
|
+
const factAndReferenceItems = proposal.items
|
|
53
|
+
.filter((item) => item.kind === "fact" || item.kind === "reference")
|
|
54
|
+
.map((item) => item.id);
|
|
55
|
+
const guidanceItems = proposal.items.filter((item) => item.kind === "reference");
|
|
56
|
+
const units = [];
|
|
57
|
+
if (factAndReferenceItems.length > 0) {
|
|
58
|
+
units.push({
|
|
59
|
+
id: "onboarding-facts",
|
|
60
|
+
kind: "onboarding-facts",
|
|
61
|
+
sourceIds: uniqueSorted(proposal.items
|
|
62
|
+
.filter((item) => factAndReferenceItems.includes(item.id))
|
|
63
|
+
.flatMap((item) => item.sources)),
|
|
64
|
+
itemIds: uniqueSorted(factAndReferenceItems),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (guidanceItems.length > 0) {
|
|
68
|
+
units.push({
|
|
69
|
+
id: "authoritative-guidance",
|
|
70
|
+
kind: "authoritative-guidance",
|
|
71
|
+
sourceIds: uniqueSorted(guidanceItems.flatMap((item) => item.sources)),
|
|
72
|
+
itemIds: uniqueSorted(guidanceItems.map((item) => item.id)),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return units;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function pendingSourceIds(items) {
|
|
79
|
+
return uniqueSorted(items.flatMap((item) => [
|
|
80
|
+
...item.sources,
|
|
81
|
+
...(item.verification?.source ? [item.verification.source] : []),
|
|
82
|
+
]));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function pendingReviewUnit(items) {
|
|
86
|
+
return {
|
|
87
|
+
id: "pending-review",
|
|
88
|
+
kind: "pending-review",
|
|
89
|
+
sourceIds: pendingSourceIds(items),
|
|
90
|
+
itemIds: items.map((item) => item.id),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function mergeReadTargets(targets) {
|
|
95
|
+
const bySource = new Map();
|
|
96
|
+
for (const target of targets) {
|
|
97
|
+
const existing = bySource.get(target.sourceId);
|
|
98
|
+
if (!existing || target.priority < existing.priority) bySource.set(target.sourceId, target);
|
|
99
|
+
}
|
|
100
|
+
return [...bySource.values()].sort((left, right) => left.sourceId.localeCompare(right.sourceId));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function buildSetupAssistBundle(project, proposal, options = {}) {
|
|
104
|
+
const guidanceSourceIds = new Set(
|
|
105
|
+
proposal.items.filter((item) => item.kind === "reference").flatMap((item) => item.sources),
|
|
106
|
+
);
|
|
107
|
+
const pending = project.contract.items
|
|
108
|
+
.filter((item) => item.status === "proposed")
|
|
109
|
+
.map(pendingItem)
|
|
110
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
111
|
+
const sourceById = new Map([
|
|
112
|
+
...project.contract.sources.map((source) => [source.id, source]),
|
|
113
|
+
...proposal.sources.map((source) => [source.id, source]),
|
|
114
|
+
]);
|
|
115
|
+
const guidanceTargets = [...guidanceSourceIds]
|
|
116
|
+
.map((id) => sourceById.get(id))
|
|
117
|
+
.filter(Boolean)
|
|
118
|
+
.map((source) => sourceTarget(source, "authoritative-guidance", 1));
|
|
119
|
+
const pendingTargets = pendingSourceIds(pending)
|
|
120
|
+
.map((id) => sourceById.get(id))
|
|
121
|
+
.filter((source) => source && sourceStatus(source) === "active" && LOCAL_SOURCE_KINDS.has(source.kind))
|
|
122
|
+
.map((source) => sourceTarget(source, "pending-review", 2));
|
|
123
|
+
const workUnits = setupWorkUnits(proposal);
|
|
124
|
+
if (pending.length > 0) workUnits.push(pendingReviewUnit(pending));
|
|
125
|
+
return {
|
|
126
|
+
schemaVersion: ASSIST_BUNDLE_SCHEMA_VERSION,
|
|
127
|
+
mode: "setup",
|
|
128
|
+
project: {
|
|
129
|
+
id: project.contract.project.id,
|
|
130
|
+
name: project.contract.project.name,
|
|
131
|
+
initialized: Boolean(options.initialized),
|
|
132
|
+
},
|
|
133
|
+
snapshots: snapshots(project),
|
|
134
|
+
summary: {
|
|
135
|
+
candidateSources: proposal.sources.length,
|
|
136
|
+
candidateItems: proposal.items.length,
|
|
137
|
+
changedSources: 0,
|
|
138
|
+
affectedItems: 0,
|
|
139
|
+
pendingItems: pending.length,
|
|
140
|
+
affectedProjections: 0,
|
|
141
|
+
},
|
|
142
|
+
sourceChanges: [],
|
|
143
|
+
affectedItems: [],
|
|
144
|
+
pendingItems: pending,
|
|
145
|
+
pathSignals: [],
|
|
146
|
+
projectionPaths: [],
|
|
147
|
+
findings: [],
|
|
148
|
+
readTargets: mergeReadTargets([...guidanceTargets, ...pendingTargets]),
|
|
149
|
+
workUnits: workUnits.sort((left, right) => left.id.localeCompare(right.id)),
|
|
150
|
+
proposal: structuredClone(proposal),
|
|
151
|
+
artifacts: [{
|
|
152
|
+
kind: "proposal",
|
|
153
|
+
path: options.proposalPath ?? ".project-context/setup.proposal.json",
|
|
154
|
+
action: options.proposalAction ?? "preview",
|
|
155
|
+
persisted: Boolean(options.proposalPersisted),
|
|
156
|
+
}],
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function syncPath(value) {
|
|
161
|
+
try {
|
|
162
|
+
return normalizeRelativePath(value, { allowRoot: true, label: "changed path" });
|
|
163
|
+
} catch (error) {
|
|
164
|
+
fail("sync-path-invalid", `changed path is invalid: ${value}`, {
|
|
165
|
+
details: { path: value, reason: error.code ?? "invalid-path" },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function registeredSourcesForPath(contract, targetPath) {
|
|
171
|
+
return contract.sources
|
|
172
|
+
.filter((source) => sourceStatus(source) === "active" && LOCAL_SOURCE_KINDS.has(source.kind))
|
|
173
|
+
.filter((source) => {
|
|
174
|
+
if (source.kind === "json-pointer") return targetPath === source.path;
|
|
175
|
+
return targetPath === source.path || targetPath.startsWith(`${source.path}/`);
|
|
176
|
+
})
|
|
177
|
+
.map((source) => source.id)
|
|
178
|
+
.sort((left, right) => left.localeCompare(right));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function pathSignals(contract, changedPaths) {
|
|
182
|
+
return uniqueSorted(changedPaths.map(syncPath)).map((targetPath) => {
|
|
183
|
+
const registeredSourceIds = registeredSourcesForPath(contract, targetPath);
|
|
184
|
+
let approvedItemIds = [];
|
|
185
|
+
try {
|
|
186
|
+
approvedItemIds = effectiveItems(contract.items, targetPath).map((item) => item.id);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error?.code !== "contract-conflict" && !error?.code?.startsWith("scope-")) throw error;
|
|
189
|
+
approvedItemIds = contract.items
|
|
190
|
+
.filter((item) => item.status === "approved" && scopeApplies(item.scope, targetPath))
|
|
191
|
+
.map((item) => item.id)
|
|
192
|
+
.sort((left, right) => left.localeCompare(right));
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
path: targetPath,
|
|
196
|
+
approvedItemIds,
|
|
197
|
+
registeredSourceIds,
|
|
198
|
+
classification: registeredSourceIds.length > 0
|
|
199
|
+
? "registered-source"
|
|
200
|
+
: approvedItemIds.length > 0
|
|
201
|
+
? "scope-only"
|
|
202
|
+
: "unrelated",
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function impactReasonsByItem(reviews) {
|
|
208
|
+
const byItem = new Map();
|
|
209
|
+
for (const review of reviews) {
|
|
210
|
+
if (!["changed", "missing", "unreadable"].includes(review.status)) continue;
|
|
211
|
+
for (const item of review.impact.items) {
|
|
212
|
+
if (!byItem.has(item.id)) byItem.set(item.id, []);
|
|
213
|
+
byItem.get(item.id).push({ sourceId: review.source.id, reasons: uniqueSorted(item.reasons ?? []) });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (const impacts of byItem.values()) impacts.sort((left, right) => left.sourceId.localeCompare(right.sourceId));
|
|
217
|
+
return byItem;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function affectedItem(item, sourceImpacts, largeImpact) {
|
|
221
|
+
const base = {
|
|
222
|
+
id: item.id,
|
|
223
|
+
kind: item.kind,
|
|
224
|
+
subject: item.subject,
|
|
225
|
+
scope: structuredClone(item.scope),
|
|
226
|
+
status: item.status,
|
|
227
|
+
itemDigest: digestJson(item),
|
|
228
|
+
sourceImpacts: structuredClone(sourceImpacts),
|
|
229
|
+
};
|
|
230
|
+
if (largeImpact) return { ...base, largeImpact: true };
|
|
231
|
+
return {
|
|
232
|
+
...base,
|
|
233
|
+
value: structuredClone(item.value),
|
|
234
|
+
statement: item.statement,
|
|
235
|
+
sources: [...item.sources],
|
|
236
|
+
overrides: [...item.overrides],
|
|
237
|
+
...(item.verification ? { verification: structuredClone(item.verification) } : {}),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function pendingItem(item) {
|
|
242
|
+
return {
|
|
243
|
+
id: item.id,
|
|
244
|
+
kind: item.kind,
|
|
245
|
+
subject: item.subject,
|
|
246
|
+
scope: structuredClone(item.scope),
|
|
247
|
+
status: item.status,
|
|
248
|
+
itemDigest: digestJson(item),
|
|
249
|
+
value: structuredClone(item.value),
|
|
250
|
+
statement: item.statement,
|
|
251
|
+
sources: [...item.sources],
|
|
252
|
+
overrides: [...item.overrides],
|
|
253
|
+
...(item.verification ? { verification: structuredClone(item.verification) } : {}),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function sourceChange(review) {
|
|
258
|
+
const hasProblem = ["changed", "missing", "unreadable"].includes(review.status);
|
|
259
|
+
const itemIds = hasProblem ? review.impact.items.map((item) => item.id) : [];
|
|
260
|
+
const largeImpact = itemIds.length > LARGE_IMPACT_THRESHOLD;
|
|
261
|
+
return {
|
|
262
|
+
sourceId: review.source.id,
|
|
263
|
+
kind: review.source.kind,
|
|
264
|
+
locator: sourceLocator(review.source),
|
|
265
|
+
status: review.status,
|
|
266
|
+
lockedDigest: review.lockedDigest,
|
|
267
|
+
currentDigest: review.currentDigest,
|
|
268
|
+
...(review.reason ? { reason: review.reason } : {}),
|
|
269
|
+
affectedItemIds: itemIds,
|
|
270
|
+
itemRelations: hasProblem && !largeImpact
|
|
271
|
+
? review.impact.items.map((item) => ({ id: item.id, reasons: uniqueSorted(item.reasons ?? []) }))
|
|
272
|
+
: [],
|
|
273
|
+
fallbackItemIds: hasProblem ? review.impact.fallbackItems.map((item) => item.id) : [],
|
|
274
|
+
directProjectionPaths: hasProblem ? [...review.impact.directProjectionPaths] : [],
|
|
275
|
+
staleProjectionPaths: hasProblem ? [...review.impact.staleProjectionPaths] : [],
|
|
276
|
+
...(largeImpact ? { largeImpact: true } : {}),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function nonLocalSourceChange(source) {
|
|
281
|
+
const deprecated = sourceStatus(source) === "deprecated";
|
|
282
|
+
return {
|
|
283
|
+
sourceId: source.id,
|
|
284
|
+
kind: source.kind,
|
|
285
|
+
locator: sourceLocator(source),
|
|
286
|
+
status: deprecated ? "deprecated" : "not-local-checkable",
|
|
287
|
+
lockedDigest: null,
|
|
288
|
+
currentDigest: null,
|
|
289
|
+
affectedItemIds: [],
|
|
290
|
+
itemRelations: [],
|
|
291
|
+
fallbackItemIds: [],
|
|
292
|
+
directProjectionPaths: [],
|
|
293
|
+
staleProjectionPaths: [],
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function syncWorkUnits(problemReviews, signals, findings, pendingItems) {
|
|
298
|
+
const units = problemReviews.map((review) => ({
|
|
299
|
+
id: `source-change:${review.source.id}`,
|
|
300
|
+
kind: "source-change",
|
|
301
|
+
sourceIds: [review.source.id],
|
|
302
|
+
itemIds: review.impact.items.map((item) => item.id),
|
|
303
|
+
fallbackItemIds: review.impact.fallbackItems.map((item) => item.id),
|
|
304
|
+
projectionPaths: uniqueSorted([
|
|
305
|
+
...review.impact.directProjectionPaths,
|
|
306
|
+
...review.impact.staleProjectionPaths,
|
|
307
|
+
]),
|
|
308
|
+
...(review.impact.items.length > LARGE_IMPACT_THRESHOLD ? { largeImpact: true } : {}),
|
|
309
|
+
}));
|
|
310
|
+
for (const signal of signals) {
|
|
311
|
+
units.push({
|
|
312
|
+
id: `path-signal:${signal.path}`,
|
|
313
|
+
kind: "path-signal",
|
|
314
|
+
paths: [signal.path],
|
|
315
|
+
sourceIds: [...signal.registeredSourceIds],
|
|
316
|
+
itemIds: [...signal.approvedItemIds],
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
if (pendingItems.length > 0) {
|
|
320
|
+
units.push(pendingReviewUnit(pendingItems));
|
|
321
|
+
}
|
|
322
|
+
const projectionFindings = findings.filter((finding) => finding.code.startsWith("projection-") && finding.path);
|
|
323
|
+
for (const projectionPath of uniqueSorted(projectionFindings.map((finding) => finding.path))) {
|
|
324
|
+
units.push({
|
|
325
|
+
id: `projection-review:${projectionPath}`,
|
|
326
|
+
kind: "projection-review",
|
|
327
|
+
projectionPaths: [projectionPath],
|
|
328
|
+
findingCodes: uniqueSorted(projectionFindings
|
|
329
|
+
.filter((finding) => finding.path === projectionPath)
|
|
330
|
+
.map((finding) => finding.code)),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
const conflictFindings = findings.filter((finding) =>
|
|
334
|
+
finding.code.includes("conflict") || finding.code.startsWith("scope-") || finding.code.startsWith("verification-"),
|
|
335
|
+
);
|
|
336
|
+
if (conflictFindings.length > 0) {
|
|
337
|
+
units.push({
|
|
338
|
+
id: "conflicts",
|
|
339
|
+
kind: "conflict",
|
|
340
|
+
findingCodes: uniqueSorted(conflictFindings.map((finding) => finding.code)),
|
|
341
|
+
itemIds: uniqueSorted(conflictFindings.flatMap((finding) => finding.items ?? (finding.item ? [finding.item] : []))),
|
|
342
|
+
sourceIds: uniqueSorted(conflictFindings.flatMap((finding) => finding.source ? [finding.source] : [])),
|
|
343
|
+
paths: uniqueSorted(conflictFindings.flatMap((finding) => finding.path ? [finding.path] : [])),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return units.sort((left, right) => left.id.localeCompare(right.id));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export async function buildSyncAssistBundle(root, project, changedPaths = []) {
|
|
350
|
+
const reviewResults = await Promise.all(project.contract.sources.map(async (source) => {
|
|
351
|
+
if (sourceStatus(source) === "deprecated" || !LOCAL_SOURCE_KINDS.has(source.kind)) {
|
|
352
|
+
return { source, nonLocal: nonLocalSourceChange(source) };
|
|
353
|
+
}
|
|
354
|
+
return { source, review: await reviewSource(root, project, source.id) };
|
|
355
|
+
}));
|
|
356
|
+
const reviews = reviewResults.filter((entry) => entry.review).map((entry) => entry.review);
|
|
357
|
+
const problemReviews = reviews.filter((review) => ["changed", "missing", "unreadable"].includes(review.status));
|
|
358
|
+
const sourceChanges = reviewResults
|
|
359
|
+
.filter((entry) => !entry.review || entry.review.status !== "unchanged")
|
|
360
|
+
.map((entry) => entry.review ? sourceChange(entry.review) : entry.nonLocal)
|
|
361
|
+
.sort((left, right) => left.sourceId.localeCompare(right.sourceId));
|
|
362
|
+
const sourceImpacts = impactReasonsByItem(problemReviews);
|
|
363
|
+
const largeImpactIds = new Set(problemReviews
|
|
364
|
+
.filter((review) => review.impact.items.length > LARGE_IMPACT_THRESHOLD)
|
|
365
|
+
.flatMap((review) => review.impact.items.map((item) => item.id)));
|
|
366
|
+
const itemById = new Map(project.contract.items.map((item) => [item.id, item]));
|
|
367
|
+
const affectedItems = [...sourceImpacts.keys()]
|
|
368
|
+
.map((id) => affectedItem(itemById.get(id), sourceImpacts.get(id), largeImpactIds.has(id)))
|
|
369
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
370
|
+
const signals = pathSignals(project.contract, changedPaths);
|
|
371
|
+
const findings = stableFindings(await checkProject(root, project));
|
|
372
|
+
const pending = project.contract.items
|
|
373
|
+
.filter((item) => item.status === "proposed")
|
|
374
|
+
.map(pendingItem)
|
|
375
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
376
|
+
const projectionPaths = uniqueSorted([
|
|
377
|
+
...problemReviews.flatMap((review) => [
|
|
378
|
+
...review.impact.directProjectionPaths,
|
|
379
|
+
...review.impact.staleProjectionPaths,
|
|
380
|
+
]),
|
|
381
|
+
...findings
|
|
382
|
+
.filter((finding) => finding.code.startsWith("projection-") && finding.path)
|
|
383
|
+
.map((finding) => finding.path),
|
|
384
|
+
]);
|
|
385
|
+
const changedTargets = problemReviews
|
|
386
|
+
.filter((review) => review.status === "changed")
|
|
387
|
+
.map((review) => sourceTarget(review.source, "source-change", 1));
|
|
388
|
+
const sourceById = new Map(project.contract.sources.map((source) => [source.id, source]));
|
|
389
|
+
const pendingTargets = pendingSourceIds(pending)
|
|
390
|
+
.map((id) => sourceById.get(id))
|
|
391
|
+
.filter((source) => source && sourceStatus(source) === "active" && LOCAL_SOURCE_KINDS.has(source.kind))
|
|
392
|
+
.map((source) => sourceTarget(source, "pending-review", 2));
|
|
393
|
+
const readTargets = mergeReadTargets([...changedTargets, ...pendingTargets]);
|
|
394
|
+
return {
|
|
395
|
+
schemaVersion: ASSIST_BUNDLE_SCHEMA_VERSION,
|
|
396
|
+
mode: "sync",
|
|
397
|
+
project: {
|
|
398
|
+
id: project.contract.project.id,
|
|
399
|
+
name: project.contract.project.name,
|
|
400
|
+
initialized: true,
|
|
401
|
+
},
|
|
402
|
+
snapshots: snapshots(project),
|
|
403
|
+
summary: {
|
|
404
|
+
candidateSources: 0,
|
|
405
|
+
candidateItems: 0,
|
|
406
|
+
changedSources: problemReviews.length,
|
|
407
|
+
affectedItems: affectedItems.length,
|
|
408
|
+
pendingItems: pending.length,
|
|
409
|
+
affectedProjections: projectionPaths.length,
|
|
410
|
+
},
|
|
411
|
+
sourceChanges,
|
|
412
|
+
affectedItems,
|
|
413
|
+
pendingItems: pending,
|
|
414
|
+
pathSignals: signals,
|
|
415
|
+
projectionPaths,
|
|
416
|
+
findings,
|
|
417
|
+
readTargets,
|
|
418
|
+
workUnits: syncWorkUnits(problemReviews, signals, findings, pending),
|
|
419
|
+
proposal: null,
|
|
420
|
+
artifacts: [],
|
|
421
|
+
};
|
|
422
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { buildSetupAssistBundle, buildSyncAssistBundle } from "./assist.mjs";
|
|
2
2
|
import { approvePendingItems, approveProposal } from "./approver.mjs";
|
|
3
3
|
import { buildItemProposal, registerSource } from "./authoring.mjs";
|
|
4
4
|
import { blockingContextFindings, checkExitCode, checkProject } from "./checker.mjs";
|
|
@@ -8,10 +8,12 @@ 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 { buildCapabilities, preflightActionPlanFile } from "./exchange.mjs";
|
|
12
|
+
import { normalizeProposalPath } from "./exchange-schema.mjs";
|
|
11
13
|
import { atomicCreateFileOrSame, atomicWriteFile, readJsonFile } from "./io.mjs";
|
|
12
14
|
import { acceptSourceChange, deprecateItem, deprecateSource, reviewSource, reviseItem } from "./maintenance.mjs";
|
|
13
15
|
import { normalizeRelativePath, resolveExistingInside, resolveProjectRoot, resolveWritableInside } from "./path-policy.mjs";
|
|
14
|
-
import {
|
|
16
|
+
import { initializeProject, inspectProjectInitialization, loadProject } from "./project-store.mjs";
|
|
15
17
|
import { publishProjection } from "./projection-store.mjs";
|
|
16
18
|
import { renderContextBundle } from "./renderer.mjs";
|
|
17
19
|
|
|
@@ -19,6 +21,8 @@ const HELP = `project-context — model-neutral project contract compiler
|
|
|
19
21
|
|
|
20
22
|
Usage:
|
|
21
23
|
project-context init --project PATH --id ID --name NAME [--write] [--json]
|
|
24
|
+
project-context capabilities --project PATH [--json]
|
|
25
|
+
project-context setup --project PATH --id ID --name NAME [--output FILE] [--write] [--json]
|
|
22
26
|
project-context register --project PATH --id SOURCE_ID --kind KIND [--path PATH] [--pointer POINTER] [--reference TEXT] [--write] [--json]
|
|
23
27
|
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]
|
|
24
28
|
project-context review-source --project PATH --id SOURCE_ID [--json]
|
|
@@ -32,6 +36,8 @@ Usage:
|
|
|
32
36
|
project-context publish --project PATH --target agents|ruler --output FILE [--path RELATIVE_PATH...] [--write] [--json]
|
|
33
37
|
project-context check --project PATH [--json]
|
|
34
38
|
project-context dashboard --project PATH [--json]
|
|
39
|
+
project-context sync --project PATH [--changed-path RELATIVE_PATH...] [--json]
|
|
40
|
+
project-context preflight --project PATH --plan FILE [--json]
|
|
35
41
|
|
|
36
42
|
All commands are read-only unless their own --write flag is present.
|
|
37
43
|
`;
|
|
@@ -39,12 +45,14 @@ const VALUE_FLAGS = new Set([
|
|
|
39
45
|
"project", "id", "name", "output", "proposal", "by", "task", "target", "rationale",
|
|
40
46
|
"kind", "pointer", "reference", "subject", "value", "value-json", "statement", "scope", "scope-path",
|
|
41
47
|
"verification", "verification-source", "verification-expected-json",
|
|
42
|
-
"expected-digest", "expected-item-digest", "expected-source-digest", "locale",
|
|
48
|
+
"expected-digest", "expected-item-digest", "expected-source-digest", "locale", "plan",
|
|
43
49
|
]);
|
|
44
|
-
const LIST_FLAGS = new Set(["ids", "path", "sources", "overrides", "affected-items"]);
|
|
50
|
+
const LIST_FLAGS = new Set(["ids", "path", "changed-path", "sources", "overrides", "affected-items"]);
|
|
45
51
|
const BOOLEAN_FLAGS = new Set(["write", "json", "full-json", "help", "pending"]);
|
|
46
52
|
const COMMAND_OPTIONS = new Map([
|
|
47
53
|
["init", new Set(["project", "id", "name", "write", "json", "help"])],
|
|
54
|
+
["capabilities", new Set(["project", "json", "help"])],
|
|
55
|
+
["setup", new Set(["project", "id", "name", "output", "write", "json", "help"])],
|
|
48
56
|
["register", new Set(["project", "id", "kind", "path", "pointer", "reference", "write", "json", "help"])],
|
|
49
57
|
["propose", new Set([
|
|
50
58
|
"project", "id", "kind", "subject", "value", "value-json", "statement", "sources", "scope", "scope-path",
|
|
@@ -71,6 +79,8 @@ const COMMAND_OPTIONS = new Map([
|
|
|
71
79
|
["publish", new Set(["project", "target", "output", "path", "write", "json", "help"])],
|
|
72
80
|
["check", new Set(["project", "json", "help"])],
|
|
73
81
|
["dashboard", new Set(["project", "json", "help"])],
|
|
82
|
+
["sync", new Set(["project", "changed-path", "json", "help"])],
|
|
83
|
+
["preflight", new Set(["project", "plan", "json", "help"])],
|
|
74
84
|
]);
|
|
75
85
|
|
|
76
86
|
export function parseArgs(argv) {
|
|
@@ -135,16 +145,14 @@ function rejectUnsupportedOptions(command, options, allowed) {
|
|
|
135
145
|
}
|
|
136
146
|
|
|
137
147
|
function validateProposalOutput(output) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
fail("invalid-proposal-output", "proposals must be non-store JSON files inside .project-context/");
|
|
148
|
+
try {
|
|
149
|
+
return normalizeProposalPath(output, "proposal output");
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error?.code === "action-plan-schema-invalid") {
|
|
152
|
+
fail("invalid-proposal-output", "proposals must be non-store JSON files inside .project-context/");
|
|
153
|
+
}
|
|
154
|
+
throw error;
|
|
146
155
|
}
|
|
147
|
-
return normalized;
|
|
148
156
|
}
|
|
149
157
|
|
|
150
158
|
function jsonOrText(options, value, text) {
|
|
@@ -195,11 +203,119 @@ function itemInput(options) {
|
|
|
195
203
|
};
|
|
196
204
|
}
|
|
197
205
|
|
|
206
|
+
function inMemoryProject(files, initial) {
|
|
207
|
+
return {
|
|
208
|
+
files,
|
|
209
|
+
contract: initial.contract,
|
|
210
|
+
sourcesLock: initial.sourcesLock,
|
|
211
|
+
projectionsLock: initial.projectionsLock,
|
|
212
|
+
contractDigest: digestJson(initial.contract),
|
|
213
|
+
sourcesLockDigest: digestJson(initial.sourcesLock),
|
|
214
|
+
projectionsLockDigest: digestJson(initial.projectionsLock),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function createSetupProposal(root, output, proposal) {
|
|
219
|
+
const normalized = validateProposalOutput(output);
|
|
220
|
+
const resolved = await resolveWritableInside(root, normalized);
|
|
221
|
+
try {
|
|
222
|
+
return await atomicCreateFileOrSame(resolved.absolute, prettyCanonicalJson(proposal));
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (error instanceof ProjectContextError && error.code === "proposal-output-conflict") {
|
|
225
|
+
fail("setup-proposal-conflict", `refusing to overwrite a different setup proposal: ${normalized}`, {
|
|
226
|
+
details: { path: normalized },
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function runSetup(root, options) {
|
|
234
|
+
const id = required(options, "id");
|
|
235
|
+
const name = required(options, "name");
|
|
236
|
+
const output = options.output ?? ".project-context/setup.proposal.json";
|
|
237
|
+
if (options.write || options.output) validateProposalOutput(output);
|
|
238
|
+
const initialization = await inspectProjectInitialization(root);
|
|
239
|
+
if (initialization.status === "partial") {
|
|
240
|
+
fail("setup-state-partial", "project context stores are incomplete; setup will not guess or repair state", {
|
|
241
|
+
details: { present: initialization.present, missing: initialization.missing },
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let project;
|
|
246
|
+
let initialized;
|
|
247
|
+
if (initialization.status === "initialized") {
|
|
248
|
+
project = await loadProject(root);
|
|
249
|
+
if (project.contract.project.id !== id || project.contract.project.name !== name) {
|
|
250
|
+
fail("setup-project-mismatch", "setup id and name must match the initialized project", {
|
|
251
|
+
details: {
|
|
252
|
+
expected: project.contract.project,
|
|
253
|
+
actual: { id, name },
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
initialized = true;
|
|
258
|
+
} else if (options.write) {
|
|
259
|
+
await initializeProject(root, id, name, true);
|
|
260
|
+
project = await loadProject(root);
|
|
261
|
+
initialized = true;
|
|
262
|
+
} else {
|
|
263
|
+
const preview = await initializeProject(root, id, name, false);
|
|
264
|
+
project = inMemoryProject(preview.files, preview.preview);
|
|
265
|
+
initialized = false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const proposal = validateProposal(await discoverProject(root, project.contract));
|
|
269
|
+
let proposalAction = "preview";
|
|
270
|
+
if (options.write) proposalAction = await createSetupProposal(root, output, proposal);
|
|
271
|
+
else if (options.output) validateProposalOutput(options.output);
|
|
272
|
+
|
|
273
|
+
const bundle = buildSetupAssistBundle(project, proposal, {
|
|
274
|
+
initialized,
|
|
275
|
+
proposalPath: output,
|
|
276
|
+
proposalAction,
|
|
277
|
+
proposalPersisted: Boolean(options.write),
|
|
278
|
+
});
|
|
279
|
+
const summary = [
|
|
280
|
+
`Setup ${initialized ? "initialized" : "preview"}: ${bundle.project.id}.`,
|
|
281
|
+
`Candidate sources: ${bundle.summary.candidateSources}`,
|
|
282
|
+
`Candidate items: ${bundle.summary.candidateItems}`,
|
|
283
|
+
`Proposal: ${options.write ? `${proposalAction} ${output}` : "not written"}`,
|
|
284
|
+
"Approval: required before any candidate becomes normative.",
|
|
285
|
+
].join("\n") + "\n";
|
|
286
|
+
return {
|
|
287
|
+
exitCode: 0,
|
|
288
|
+
stdout: jsonOrText(options, bundle, summary),
|
|
289
|
+
stderr: options.output && !options.write ? `Preview only; ${options.output} was not written.\n` : "",
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function syncSummary(bundle) {
|
|
294
|
+
const changed = bundle.sourceChanges.filter((source) => ["changed", "missing", "unreadable"].includes(source.status));
|
|
295
|
+
const lines = [
|
|
296
|
+
`Sync: ${bundle.summary.changedSources} changed source(s), ${bundle.summary.affectedItems} affected item(s), ${bundle.summary.pendingItems} pending item(s), ${bundle.summary.affectedProjections} affected projection(s).`,
|
|
297
|
+
];
|
|
298
|
+
for (const source of changed) lines.push(`- ${source.sourceId}: ${source.status}; ${source.affectedItemIds.join(", ") || "no affected items"}`);
|
|
299
|
+
if (bundle.findings.length === 0) lines.push("No contract, source, scope, or projection findings.");
|
|
300
|
+
else lines.push(`${bundle.findings.length} finding(s) require review.`);
|
|
301
|
+
return `${lines.join("\n")}\n`;
|
|
302
|
+
}
|
|
303
|
+
|
|
198
304
|
async function runCommand(command, options) {
|
|
199
305
|
if (!command || command === "help" || options.help) return { exitCode: 0, stdout: HELP, stderr: "" };
|
|
200
306
|
if (!COMMAND_OPTIONS.has(command)) fail("command-unknown", `unknown command: ${command}`);
|
|
201
307
|
rejectUnsupportedOptions(command, options, COMMAND_OPTIONS.get(command));
|
|
202
308
|
const root = await resolveProjectRoot(required(options, "project"));
|
|
309
|
+
if (command === "capabilities") {
|
|
310
|
+
const capabilities = await buildCapabilities(root);
|
|
311
|
+
const summary = [
|
|
312
|
+
`Frontend Project Context ${capabilities.package.version}; exchange protocol ${capabilities.exchangeProtocolVersion}.`,
|
|
313
|
+
`Initialization: ${capabilities.initialization}.`,
|
|
314
|
+
`Action kinds: ${capabilities.actionKinds.join(", ")}.`,
|
|
315
|
+
"No Provider, Agent Runtime, automatic approval, Git, network, or business-code writes.",
|
|
316
|
+
].join("\n") + "\n";
|
|
317
|
+
return { exitCode: 0, stdout: jsonOrText(options, capabilities, summary), stderr: "" };
|
|
318
|
+
}
|
|
203
319
|
if (command === "init") {
|
|
204
320
|
const result = await initializeProject(root, required(options, "id"), required(options, "name"), options.write);
|
|
205
321
|
return {
|
|
@@ -208,6 +324,12 @@ async function runCommand(command, options) {
|
|
|
208
324
|
stderr: "",
|
|
209
325
|
};
|
|
210
326
|
}
|
|
327
|
+
if (command === "setup") return runSetup(root, options);
|
|
328
|
+
if (command === "preflight") {
|
|
329
|
+
const bundle = await preflightActionPlanFile(root, required(options, "plan"));
|
|
330
|
+
const summary = `Preflight ${bundle.status}: ${bundle.summary.reviewable} reviewable, ${bundle.summary.blocked} blocked action(s).\n`;
|
|
331
|
+
return { exitCode: bundle.status === "reviewable" ? 0 : 1, stdout: jsonOrText(options, bundle, summary), stderr: "" };
|
|
332
|
+
}
|
|
211
333
|
const project = await loadProject(root);
|
|
212
334
|
if (command === "register") {
|
|
213
335
|
const result = await registerSource(root, project, {
|
|
@@ -363,6 +485,14 @@ async function runCommand(command, options) {
|
|
|
363
485
|
stderr: "",
|
|
364
486
|
};
|
|
365
487
|
}
|
|
488
|
+
if (command === "sync") {
|
|
489
|
+
const bundle = await buildSyncAssistBundle(root, project, options["changed-path"] ?? []);
|
|
490
|
+
return {
|
|
491
|
+
exitCode: checkExitCode(bundle.findings),
|
|
492
|
+
stdout: jsonOrText(options, bundle, syncSummary(bundle)),
|
|
493
|
+
stderr: "",
|
|
494
|
+
};
|
|
495
|
+
}
|
|
366
496
|
if (command === "context") {
|
|
367
497
|
const findings = blockingContextFindings(await checkProject(root, project));
|
|
368
498
|
if (findings.length > 0) fail("context-blocked", "context generation is blocked by contract or source findings", { exitCode: 1, details: { findings } });
|