frontend-project-context 1.3.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +51 -2
  2. package/README.md +156 -40
  3. package/UPGRADING.md +55 -1
  4. package/docs/04-PROGRAM-DESIGN.md +34 -4
  5. package/docs/05-ACCEPTANCE-CONTRACT.md +40 -3
  6. package/docs/08-INSTALLATION-AND-DISTRIBUTION.md +67 -22
  7. package/docs/14-FORMAL-RELEASE-READINESS.md +30 -1
  8. package/docs/18-BRANCH-AWARE-STAGED-CONTEXT-DESIGN.md +2 -2
  9. package/docs/19-POST-1.3.1-AI-TAKEOVER-EVIDENCE-AND-UPGRADE-PLAN.md +579 -0
  10. package/docs/20-PHASE-A-AI-TAKEOVER-AND-HEALTH-CLOSURE-DESIGN.md +535 -0
  11. package/docs/21-PHASE-B-EVIDENCE-FEEDBACK-PROTOCOL-DESIGN.md +347 -0
  12. package/docs/22-PHASE-C-TARGET-UPGRADE-PROTOCOL-DESIGN.md +398 -0
  13. package/docs/23-ADAPTIVE-BOUNDED-TASK-CONTEXT-DESIGN.md +432 -0
  14. package/docs/24-A130-REAL-HOST-TARGET-PROJECT-COMPARISON.md +210 -0
  15. package/docs/25-REAL-PROJECT-SOURCE-OF-TRUTH-MAINTENANCE-DESIGN.md +409 -0
  16. package/docs/26-A130-QUALITY-CLOSURE-AND-ADAPTIVE-DELIVERY-REPAIR-DESIGN.md +609 -0
  17. package/docs/README.md +38 -6
  18. package/docs/USER-AND-AI-OPERATION-MANUAL.md +840 -0
  19. package/examples/README.md +29 -2
  20. package/examples/package.json +6 -2
  21. package/migration-manifest.json +110 -0
  22. package/package.json +3 -2
  23. package/schemas/action-plan.schema.json +31 -3
  24. package/schemas/adaptive-context-bundle.schema.json +70 -0
  25. package/schemas/capabilities.schema.json +64 -18
  26. package/schemas/context-query.schema.json +69 -0
  27. package/schemas/coverage-audit.schema.json +32 -0
  28. package/schemas/evidence-bundle.schema.json +64 -0
  29. package/schemas/evidence-input.schema.json +82 -0
  30. package/schemas/host-promotion-evidence.schema.json +33 -0
  31. package/schemas/migration-manifest.schema.json +29 -0
  32. package/schemas/migration-plan.schema.json +32 -0
  33. package/schemas/project-status.schema.json +75 -0
  34. package/schemas/projection-lock.schema.json +48 -0
  35. package/schemas/review-bundle.schema.json +3 -3
  36. package/schemas/routing-index.schema.json +58 -0
  37. package/schemas/truth-reconciliation-input.schema.json +60 -0
  38. package/schemas/truth-reconciliation-review-bundle.schema.json +155 -0
  39. package/schemas/upgrade-assessment.schema.json +48 -0
  40. package/schemas/upgrade-result-bundle.schema.json +35 -0
  41. package/src/project-context/a130-evaluation.mjs +91 -0
  42. package/src/project-context/adaptive-context-schema.mjs +392 -0
  43. package/src/project-context/adaptive-context.mjs +547 -0
  44. package/src/project-context/ai-entry.mjs +320 -0
  45. package/src/project-context/assist.mjs +4 -2
  46. package/src/project-context/capabilities.mjs +62 -17
  47. package/src/project-context/checker.mjs +24 -6
  48. package/src/project-context/cli.mjs +113 -3
  49. package/src/project-context/contract-schema.mjs +30 -16
  50. package/src/project-context/dashboard-model.mjs +4 -4
  51. package/src/project-context/dashboard-renderer.mjs +3 -3
  52. package/src/project-context/discovery.mjs +13 -8
  53. package/src/project-context/evidence-schema.mjs +209 -0
  54. package/src/project-context/evidence.mjs +99 -0
  55. package/src/project-context/exchange-schema.mjs +23 -12
  56. package/src/project-context/exchange.mjs +26 -4
  57. package/src/project-context/maintenance.mjs +4 -4
  58. package/src/project-context/migration-manifest.mjs +168 -0
  59. package/src/project-context/project-status.mjs +157 -0
  60. package/src/project-context/projection-store.mjs +8 -1
  61. package/src/project-context/renderer.mjs +75 -1
  62. package/src/project-context/source-reader.mjs +63 -30
  63. package/src/project-context/task-context.mjs +14 -2
  64. package/src/project-context/truth-reconciliation-schema.mjs +488 -0
  65. package/src/project-context/truth-reconciliation.mjs +543 -0
  66. package/src/project-context/upgrade-schema.mjs +219 -0
  67. package/src/project-context/upgrade.mjs +494 -0
@@ -0,0 +1,543 @@
1
+ import { canonicalJson, digestJson } from "./canonical-json.mjs";
2
+ import { fail } from "./errors.mjs";
3
+ import { readJsonFile } from "./io.mjs";
4
+ import { resolveExistingInside } from "./path-policy.mjs";
5
+ import { loadProject } from "./project-store.mjs";
6
+ import {
7
+ finalizeTruthReviewBundle,
8
+ normalizeTruthReconciliationInput,
9
+ TRUTH_RECONCILIATION_REVIEW_BUNDLE_SCHEMA_VERSION,
10
+ validateTruthReviewBundle,
11
+ } from "./truth-reconciliation-schema.mjs";
12
+
13
+ const RESOLUTIONS = Object.freeze({
14
+ duplicate: ["keep-current-baseline"],
15
+ "compatible-compose": ["compose-compatible-changes"],
16
+ "scope-split": ["split-scope-or-override"],
17
+ supersede: ["accept-branch-change", "replace-or-deprecate-source"],
18
+ "semantic-conflict": ["keep-current-baseline", "accept-branch-change", "defer-scoped-conflict"],
19
+ "implementation-only": ["external-implementation-fix-required"],
20
+ });
21
+
22
+ function uniqueSorted(values) {
23
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right));
24
+ }
25
+
26
+ function scopeKey(scope) {
27
+ return scope.kind === "project" ? "project" : `${scope.kind}:${scope.path}`;
28
+ }
29
+
30
+ function scopesOverlapPath(scope, target) {
31
+ if (scope.kind === "project") return true;
32
+ if (scope.kind === "file") return target === scope.path;
33
+ return target === scope.path || target.startsWith(`${scope.path}/`);
34
+ }
35
+
36
+ function isTaskRelated(scopes, taskPaths) {
37
+ return scopes.some((scope) => taskPaths.some((target) => scopesOverlapPath(scope, target)));
38
+ }
39
+
40
+ function invocation(root, command, args) {
41
+ return { command, args: ["--project", root, ...args] };
42
+ }
43
+
44
+ function recoveryPreviews(root, resolutionKinds, impacts, actualDigest) {
45
+ return resolutionKinds.map((resolutionKind) => {
46
+ if (resolutionKind === "accept-branch-change" && impacts.sourceIds.length === 1 && actualDigest) {
47
+ return {
48
+ resolutionKind,
49
+ invocation: invocation(root, "accept-source-change", [
50
+ "--id", impacts.sourceIds[0], "--expected-digest", actualDigest,
51
+ ...(impacts.itemIds.length > 0 ? ["--affected-items", ...impacts.itemIds] : []),
52
+ ]),
53
+ };
54
+ }
55
+ if (resolutionKind === "fix-source-and-rerun" || resolutionKind === "refresh-promotion-evidence") {
56
+ return { resolutionKind, externalAction: { kind: resolutionKind === "fix-source-and-rerun" ? "refresh-project-baseline-and-contract-state" : "refresh-host-promotion-evidence", affectedSourceIds: impacts.sourceIds, affectedItemIds: impacts.itemIds } };
57
+ }
58
+ if (resolutionKind === "replace-or-deprecate-source" && impacts.sourceIds.length === 1) {
59
+ return { resolutionKind, invocation: invocation(root, "review-source", ["--id", impacts.sourceIds[0]]) };
60
+ }
61
+ if (resolutionKind === "external-implementation-fix-required") {
62
+ return { resolutionKind, externalAction: { kind: "fix-implementation-outside-project-context", affectedPaths: impacts.scopes.map((scope) => scope.path ?? ".") } };
63
+ }
64
+ return { resolutionKind, requiredHumanInputs: resolutionKind === "compose-compatible-changes" ? ["composedValue", "sources", "scope"] : resolutionKind === "split-scope-or-override" ? ["scopes", "overrideIds"] : ["decisionRationale"] };
65
+ });
66
+ }
67
+
68
+ function finding(root, code, severity, impacts, options = {}) {
69
+ const normalized = {
70
+ sourceIds: uniqueSorted(impacts.sourceIds ?? []),
71
+ itemIds: uniqueSorted(impacts.itemIds ?? []),
72
+ subjects: uniqueSorted(impacts.subjects ?? []),
73
+ scopes: [...new Map((impacts.scopes ?? []).map((scope) => [scopeKey(scope), scope])).values()]
74
+ .sort((left, right) => scopeKey(left).localeCompare(scopeKey(right))),
75
+ };
76
+ const resolutionKinds = uniqueSorted(options.resolutionKinds ?? ["keep-current-baseline"]);
77
+ if (resolutionKinds.length === 0) {
78
+ return finding(root, "recovery-contract-incomplete", "blocked", normalized, {
79
+ resolutionKinds: ["fix-source-and-rerun"], blocksTask: true,
80
+ });
81
+ }
82
+ const base = {
83
+ code,
84
+ severity,
85
+ affectedSourceIds: normalized.sourceIds,
86
+ affectedItemIds: normalized.itemIds,
87
+ affectedSubjects: normalized.subjects,
88
+ affectedScopes: normalized.scopes,
89
+ baseline: {
90
+ expectedDigest: options.expectedDigest ?? null,
91
+ actualDigest: options.actualDigest ?? null,
92
+ expectedLabel: options.expectedLabel ?? null,
93
+ actualLabel: options.actualLabel ?? null,
94
+ },
95
+ blocksTask: options.blocksTask ?? false,
96
+ blocksExistingDelivery: false,
97
+ resolutionKinds,
98
+ recoveryPreviews: recoveryPreviews(root, resolutionKinds, normalized, options.actualDigest),
99
+ humanRoles: uniqueSorted(options.humanRoles ?? ["truth-maintainer"]),
100
+ validateAfterResolution: uniqueSorted(options.validateAfterResolution ?? ["reconcile-truth", "status", "check"]),
101
+ recomputeFromCurrentBaseline: true,
102
+ };
103
+ const previews = recoveryPreviews(root, resolutionKinds, normalized, options.actualDigest);
104
+ const resolutionState = resolutionKinds.includes("external-implementation-fix-required")
105
+ ? "external-fix-required"
106
+ : resolutionKinds.some((entry) => ["accept-branch-change", "compose-compatible-changes", "replace-or-deprecate-source", "split-scope-or-override"].includes(entry))
107
+ ? "contract-action-required"
108
+ : resolutionKinds.some((entry) => ["fix-source-and-rerun", "refresh-promotion-evidence"].includes(entry))
109
+ ? "revalidation-required"
110
+ : "needs-human-decision";
111
+ const resolutionContract = {
112
+ resolutionState,
113
+ resolutionKinds,
114
+ requiredHumanInputs: uniqueSorted(previews.flatMap((entry) => entry.requiredHumanInputs ?? [])),
115
+ nextActions: previews,
116
+ requiredPostResolutionEvidence: uniqueSorted(["current-project-snapshots", "current-source-and-item-baselines", "project-context-clean", ...(resolutionKinds.includes("external-implementation-fix-required") ? ["external-code-and-test-evidence"] : [])]),
117
+ completionCriteria: [
118
+ "previous-finding-absent-on-current-baseline",
119
+ "all-related-items-approved-and-effective",
120
+ "all-related-validation-current-and-passing",
121
+ "project-context-impact-closure-clean",
122
+ "no-replacement-blocked-finding-with-same-root-cause",
123
+ ],
124
+ recomputeFromCurrentBaseline: true,
125
+ };
126
+ const findingDigest = digestJson({ code: base.code, severity: base.severity, affectedSourceIds: base.affectedSourceIds, affectedItemIds: base.affectedItemIds, affectedSubjects: base.affectedSubjects, affectedScopes: base.affectedScopes, baseline: base.baseline, blocksTask: base.blocksTask });
127
+ return { ...base, findingDigest, resolutionContract };
128
+ }
129
+
130
+ function classifyChanges(changes) {
131
+ const digests = uniqueSorted(changes.map((entry) => entry.change.candidateDigest));
132
+ if (digests.length === 1) return "duplicate";
133
+ const classes = uniqueSorted(changes.map((entry) => entry.change.classification).filter((entry) => entry !== "duplicate"));
134
+ return classes.length === 1 ? classes[0] : "semantic-conflict";
135
+ }
136
+
137
+ function groupChanges(candidates, kind, keyOf) {
138
+ const groups = new Map();
139
+ for (const candidate of candidates) {
140
+ const changes = kind === "source" ? candidate.sourceChanges : candidate.itemChanges;
141
+ for (const change of changes) {
142
+ const key = keyOf(change);
143
+ if (!groups.has(key)) groups.set(key, []);
144
+ groups.get(key).push({ candidateId: candidate.id, taskId: candidate.taskId, change });
145
+ }
146
+ }
147
+ return [...groups.entries()]
148
+ .filter(([, changes]) => new Set(changes.map((entry) => entry.candidateId)).size > 1)
149
+ .map(([key, changes]) => ({ kind, key, classification: classifyChanges(changes), changes }))
150
+ .sort((left, right) => `${left.kind}:${left.key}`.localeCompare(`${right.kind}:${right.key}`));
151
+ }
152
+
153
+ function groupSubjectChanges(candidates) {
154
+ const groups = new Map();
155
+ for (const candidate of candidates) {
156
+ for (const change of candidate.sourceChanges) {
157
+ for (const subject of change.subjects) {
158
+ if (!groups.has(subject)) groups.set(subject, []);
159
+ groups.get(subject).push({ candidateId: candidate.id, taskId: candidate.taskId, change });
160
+ }
161
+ }
162
+ for (const change of candidate.itemChanges) {
163
+ if (!groups.has(change.subject)) groups.set(change.subject, []);
164
+ groups.get(change.subject).push({ candidateId: candidate.id, taskId: candidate.taskId, change });
165
+ }
166
+ }
167
+ return [...groups.entries()]
168
+ .filter(([, changes]) => new Set(changes.map((entry) => entry.candidateId)).size > 1)
169
+ .map(([key, changes]) => ({ kind: "subject", key, classification: classifyChanges(changes), changes }))
170
+ .sort((left, right) => left.key.localeCompare(right.key));
171
+ }
172
+
173
+ function groupImpacts(group) {
174
+ const sourceIds = [];
175
+ const itemIds = [];
176
+ const subjects = [];
177
+ const scopes = [];
178
+ for (const { change } of group.changes) {
179
+ if (change.sourceId) sourceIds.push(change.sourceId);
180
+ if (change.itemId) itemIds.push(change.itemId);
181
+ sourceIds.push(...(change.sourceIds ?? []));
182
+ itemIds.push(...(change.affectedItemIds ?? []));
183
+ subjects.push(...(change.subjects ?? []), ...(change.subject ? [change.subject] : []));
184
+ scopes.push(...(change.scopes ?? []), ...(change.scope ? [change.scope] : []));
185
+ }
186
+ return { sourceIds, itemIds, subjects, scopes };
187
+ }
188
+
189
+ function snapshotsEqual(left, right) {
190
+ return canonicalJson(left) === canonicalJson(right);
191
+ }
192
+
193
+ function successfulEvidence(evidence) {
194
+ return evidence.verificationResults.length > 0 && evidence.verificationResults.every((result) => ["passed", "observed"].includes(result.status));
195
+ }
196
+
197
+ function promotionState(candidate) {
198
+ const applicable = candidate.promotionEvidence.filter((entry) => entry.taskId === candidate.taskId && successfulEvidence(entry));
199
+ const current = applicable.filter((entry) => (
200
+ entry.codeSnapshotDigest === candidate.currentCodeSnapshotDigest ||
201
+ entry.equivalentToCodeSnapshotDigest === candidate.currentCodeSnapshotDigest
202
+ ) && entry.sourceSnapshotDigest === candidate.currentSourceSnapshotDigest &&
203
+ entry.artifactDigest === candidate.currentArtifactDigest &&
204
+ entry.acceptanceSuiteDigest === candidate.currentAcceptanceSuiteDigest &&
205
+ snapshotsEqual(entry.projectSnapshots, candidate.expectedProjectSnapshots));
206
+ const stale = applicable.filter((entry) => !current.includes(entry));
207
+ const currentQa = current.filter((entry) => entry.stage === "qa");
208
+ const integration = current.filter((entry) => entry.integration && ["preproduction", "production"].includes(entry.stage));
209
+ return { applicable, current, stale, currentQa, integration };
210
+ }
211
+
212
+ function promotionBinding(value) {
213
+ return digestJson({
214
+ codeSnapshotDigest: value.codeSnapshotDigest ?? value.currentCodeSnapshotDigest,
215
+ sourceSnapshotDigest: value.sourceSnapshotDigest ?? value.currentSourceSnapshotDigest,
216
+ artifactDigest: value.artifactDigest ?? value.currentArtifactDigest,
217
+ acceptanceSuiteDigest: value.acceptanceSuiteDigest ?? value.currentAcceptanceSuiteDigest,
218
+ projectSnapshots: value.projectSnapshots ?? value.expectedProjectSnapshots,
219
+ });
220
+ }
221
+
222
+ function candidateImpacts(candidate) {
223
+ return {
224
+ sourceIds: candidate.sourceChanges.map((entry) => entry.sourceId),
225
+ itemIds: [
226
+ ...candidate.sourceChanges.flatMap((entry) => entry.affectedItemIds),
227
+ ...candidate.itemChanges.map((entry) => entry.itemId),
228
+ ],
229
+ subjects: [
230
+ ...candidate.sourceChanges.flatMap((entry) => entry.subjects),
231
+ ...candidate.itemChanges.map((entry) => entry.subject),
232
+ ],
233
+ scopes: [
234
+ ...candidate.sourceChanges.flatMap((entry) => entry.scopes),
235
+ ...candidate.itemChanges.map((entry) => entry.scope),
236
+ ],
237
+ };
238
+ }
239
+
240
+ function mergeInputs(inputs) {
241
+ const first = inputs[0];
242
+ for (const input of inputs.slice(1)) {
243
+ if (input.projectId !== first.projectId || canonicalJson(input.current) !== canonicalJson(first.current) || canonicalJson(input.task) !== canonicalJson(first.task)) {
244
+ fail("truth-reconciliation-input-invalid", "all reconciliation inputs must share one project, current baseline, and task");
245
+ }
246
+ }
247
+ const candidates = inputs.flatMap((input) => input.candidates);
248
+ const candidateIds = candidates.map((entry) => entry.id);
249
+ if (new Set(candidateIds).size !== candidateIds.length) fail("truth-reconciliation-input-invalid", "candidate ids must be unique across input files");
250
+ return {
251
+ ...first,
252
+ candidates: candidates.sort((left, right) => left.id.localeCompare(right.id)),
253
+ globalFindings: [...new Map(inputs.flatMap((input) => input.globalFindings).map((entry) => [canonicalJson(entry), entry])).values()]
254
+ .sort((left, right) => canonicalJson(left).localeCompare(canonicalJson(right))),
255
+ };
256
+ }
257
+
258
+ function findingRootKey(value) {
259
+ return canonicalJson({ code: value.code, affectedSourceIds: value.affectedSourceIds, affectedItemIds: value.affectedItemIds, affectedSubjects: value.affectedSubjects, affectedScopes: value.affectedScopes });
260
+ }
261
+
262
+ function validateResolutionAttempt(input, previousReview) {
263
+ const attempt = input.resolutionAttempt;
264
+ if (!attempt && previousReview) fail("truth-resolution-previous-unexpected", "initial reconciliation cannot use --previous-review");
265
+ if (attempt && !previousReview) fail("truth-resolution-previous-required", "resolution attempt requires --previous-review", { exitCode: 1 });
266
+ if (!attempt) return;
267
+ if (attempt.previousReviewDigest !== previousReview.bundleDigest) fail("truth-resolution-previous-digest-mismatch", "resolution attempt does not bind the supplied previous review", { exitCode: 1 });
268
+ if (previousReview.project.id !== input.projectId || previousReview.task.id !== input.task.id || canonicalJson(previousReview.task.paths) !== canonicalJson(input.task.paths)) {
269
+ fail("truth-resolution-previous-scope-mismatch", "previous review belongs to a different project, task, or scope", { exitCode: 1 });
270
+ }
271
+ const previousFindings = new Map(previousReview.findings.map((entry) => [entry.findingDigest, entry]));
272
+ for (const decision of attempt.decisions) {
273
+ const finding = previousFindings.get(decision.findingDigest);
274
+ if (!finding) fail("truth-resolution-finding-unavailable", "resolution decision references a finding not exposed by the previous review", { exitCode: 1 });
275
+ if (!finding.resolutionKinds.includes(decision.resolutionKind)) fail("truth-resolution-kind-unavailable", "resolution kind was not exposed by the previous finding", { exitCode: 1 });
276
+ }
277
+ }
278
+
279
+ function resolutionOutcomes(input, previousReview, currentFindings) {
280
+ if (!input.resolutionAttempt || !previousReview) return [];
281
+ const currentRoots = new Map(currentFindings.map((entry) => [findingRootKey(entry), entry]));
282
+ const previousByDigest = new Map(previousReview.findings.map((entry) => [entry.findingDigest, entry]));
283
+ return input.resolutionAttempt.decisions.map((decision) => {
284
+ const previous = previousByDigest.get(decision.findingDigest);
285
+ const stillOpen = currentRoots.get(findingRootKey(previous));
286
+ const replacement = currentFindings.find((entry) => entry.severity === "blocked" && entry.affectedSubjects.some((subject) => previous.affectedSubjects.includes(subject)));
287
+ let state = "revalidation-required";
288
+ let outcome = "open";
289
+ if (decision.resolutionKind === "defer-scoped-conflict") { state = "deferred-open"; outcome = "deferred"; }
290
+ else if (decision.resolutionKind === "external-implementation-fix-required" && !decision.externalAction?.completed) { state = "external-fix-required"; outcome = "open"; }
291
+ else if (!stillOpen && !replacement && input.resolutionAttempt.projectContextStatus === "clean") {
292
+ const discarded = input.candidates.length === 0 || input.candidates.every((candidate) => ["withdrawn", "rolled-back"].includes(candidate.lifecycle));
293
+ if (decision.resolutionKind !== "keep-current-baseline" || discarded) { state = "resolved"; outcome = decision.resolutionKind === "keep-current-baseline" ? "rejected" : "resolved"; }
294
+ }
295
+ return {
296
+ findingDigest: decision.findingDigest,
297
+ resolutionKind: decision.resolutionKind,
298
+ resolutionState: state,
299
+ outcome,
300
+ completionCriteriaMet: state === "resolved" ? [...previous.resolutionContract.completionCriteria] : [],
301
+ recomputedFromCurrentBaseline: true,
302
+ };
303
+ }).sort((left, right) => left.findingDigest.localeCompare(right.findingDigest));
304
+ }
305
+
306
+ export function buildTruthReconciliationReview(root, project, inputValues, options = {}) {
307
+ if (!Array.isArray(inputValues) || inputValues.length === 0) fail("truth-reconciliation-input-invalid", "at least one input is required");
308
+ const input = mergeInputs(inputValues.map(normalizeTruthReconciliationInput));
309
+ if (input.projectId !== project.contract.project.id) fail("truth-reconciliation-input-invalid", "input projectId does not match the initialized project");
310
+ const previousReview = options.previousReview ? validateTruthReviewBundle(options.previousReview) : null;
311
+ validateResolutionAttempt(input, previousReview);
312
+
313
+ const findings = [];
314
+ const currentSources = new Map(input.current.sources.map((entry) => [entry.id, entry.digest]));
315
+ const currentItems = new Map(input.current.items.map((entry) => [entry.id, entry]));
316
+ const liveSources = new Map(project.sourcesLock.sources.map((entry) => [entry.id, entry.digest]));
317
+ const liveItems = new Map(project.contract.items.map((entry) => [entry.id, entry]));
318
+ const actualSnapshots = {
319
+ contract: project.contractDigest,
320
+ sourcesLock: project.sourcesLockDigest,
321
+ projectionsLock: project.projectionsLockDigest,
322
+ };
323
+ let currentBaselineValid = snapshotsEqual(input.current.projectSnapshots, actualSnapshots);
324
+ if (!currentBaselineValid) {
325
+ findings.push(finding(root, "truth-current-baseline-invalid", "blocked", {}, {
326
+ expectedDigest: digestJson(input.current.projectSnapshots), actualDigest: digestJson(actualSnapshots), blocksTask: true,
327
+ resolutionKinds: ["fix-source-and-rerun"], humanRoles: ["truth-maintainer"],
328
+ }));
329
+ }
330
+ for (const source of input.current.sources) {
331
+ const actual = liveSources.get(source.id) ?? null;
332
+ if (actual === source.digest) continue;
333
+ currentBaselineValid = false;
334
+ findings.push(finding(root, "truth-current-baseline-invalid", "blocked", { sourceIds: [source.id] }, {
335
+ expectedDigest: source.digest, actualDigest: actual, blocksTask: true, resolutionKinds: ["fix-source-and-rerun"],
336
+ humanRoles: ["truth-maintainer"],
337
+ }));
338
+ }
339
+ for (const [sourceId, actual] of liveSources) {
340
+ if (currentSources.has(sourceId)) continue;
341
+ currentBaselineValid = false;
342
+ findings.push(finding(root, "truth-current-baseline-invalid", "blocked", { sourceIds: [sourceId] }, {
343
+ actualDigest: actual, blocksTask: true, resolutionKinds: ["fix-source-and-rerun"], humanRoles: ["truth-maintainer"],
344
+ }));
345
+ }
346
+ for (const item of input.current.items) {
347
+ const live = liveItems.get(item.id);
348
+ const actual = live ? digestJson(live) : null;
349
+ const metadataMatches = live && live.subject === item.subject && live.kind === item.kind && snapshotsEqual(live.scope, item.scope);
350
+ if (actual === item.digest && metadataMatches) continue;
351
+ currentBaselineValid = false;
352
+ findings.push(finding(root, "truth-current-baseline-invalid", "blocked", {
353
+ sourceIds: live?.sources ?? [], itemIds: [item.id], subjects: [item.subject], scopes: [item.scope],
354
+ }, {
355
+ expectedDigest: item.digest, actualDigest: actual, blocksTask: true, resolutionKinds: ["fix-source-and-rerun"],
356
+ humanRoles: ["truth-maintainer"],
357
+ }));
358
+ }
359
+ for (const [itemId, live] of liveItems) {
360
+ if (currentItems.has(itemId)) continue;
361
+ currentBaselineValid = false;
362
+ findings.push(finding(root, "truth-current-baseline-invalid", "blocked", {
363
+ sourceIds: live.sources, itemIds: [itemId], subjects: [live.subject], scopes: [live.scope],
364
+ }, {
365
+ actualDigest: digestJson(live), blocksTask: true, resolutionKinds: ["fix-source-and-rerun"], humanRoles: ["truth-maintainer"],
366
+ }));
367
+ }
368
+
369
+ const candidateOutcomes = [];
370
+ const eligible = [];
371
+ for (const candidate of input.candidates) {
372
+ const candidateFindings = [];
373
+ const impacts = candidateImpacts(candidate);
374
+ let stale = !currentBaselineValid || !snapshotsEqual(candidate.expectedProjectSnapshots, input.current.projectSnapshots);
375
+ if (!snapshotsEqual(candidate.expectedProjectSnapshots, input.current.projectSnapshots)) {
376
+ candidateFindings.push(finding(root, "truth-candidate-baseline-stale", "blocked", impacts, {
377
+ expectedDigest: digestJson(candidate.expectedProjectSnapshots), actualDigest: digestJson(input.current.projectSnapshots),
378
+ expectedLabel: candidate.baseRevision, actualLabel: input.current.revision, blocksTask: true,
379
+ resolutionKinds: ["fix-source-and-rerun"],
380
+ }));
381
+ }
382
+ for (const change of candidate.sourceChanges) {
383
+ const actual = currentSources.get(change.sourceId) ?? null;
384
+ if (actual !== change.expectedDigest) {
385
+ stale = true;
386
+ candidateFindings.push(finding(root, "truth-candidate-baseline-stale", "blocked", {
387
+ sourceIds: [change.sourceId], itemIds: change.affectedItemIds, subjects: change.subjects, scopes: change.scopes,
388
+ }, {
389
+ expectedDigest: change.expectedDigest, actualDigest: actual, expectedLabel: candidate.baseRevision,
390
+ actualLabel: input.current.revision, blocksTask: true, resolutionKinds: ["fix-source-and-rerun"],
391
+ }));
392
+ }
393
+ }
394
+ for (const change of candidate.itemChanges) {
395
+ const actual = currentItems.get(change.itemId)?.digest ?? null;
396
+ if (actual !== change.expectedDigest) {
397
+ stale = true;
398
+ candidateFindings.push(finding(root, "truth-candidate-baseline-stale", "blocked", {
399
+ sourceIds: change.sourceIds, itemIds: [change.itemId], subjects: [change.subject], scopes: [change.scope],
400
+ }, {
401
+ expectedDigest: change.expectedDigest, actualDigest: actual, expectedLabel: candidate.baseRevision,
402
+ actualLabel: input.current.revision, blocksTask: true, resolutionKinds: ["fix-source-and-rerun"],
403
+ }));
404
+ }
405
+ }
406
+
407
+ const promotion = promotionState(candidate);
408
+ if (candidate.targetStage !== "development" && promotion.applicable.length === 0) {
409
+ candidateFindings.push(finding(root, "promotion-code-snapshot-unbound", "blocked", impacts, {
410
+ actualDigest: candidate.currentCodeSnapshotDigest, blocksTask: true, resolutionKinds: ["refresh-promotion-evidence"],
411
+ humanRoles: ["qa-owner", "host-operator"], validateAfterResolution: ["reconcile-truth", "host-verification"],
412
+ }));
413
+ }
414
+ if (promotion.stale.length > 0) {
415
+ candidateFindings.push(finding(root, "promotion-evidence-stale", "blocked", impacts, {
416
+ expectedDigest: promotionBinding(promotion.stale[0]), actualDigest: promotionBinding(candidate),
417
+ blocksTask: candidate.targetStage !== "development", resolutionKinds: ["refresh-promotion-evidence"], humanRoles: ["qa-owner", "host-operator"],
418
+ validateAfterResolution: ["reconcile-truth", "host-verification"],
419
+ }));
420
+ }
421
+ if (["preproduction", "production"].includes(candidate.targetStage) && promotion.integration.length === 0) {
422
+ candidateFindings.push(finding(root, "promotion-integration-verification-required", "blocked", impacts, {
423
+ actualDigest: candidate.currentArtifactDigest, blocksTask: true, resolutionKinds: ["refresh-promotion-evidence"],
424
+ humanRoles: ["qa-owner", "release-owner", "host-operator"], validateAfterResolution: ["reconcile-truth", "integration-verification"],
425
+ }));
426
+ }
427
+ const discarded = ["withdrawn", "rolled-back"].includes(candidate.lifecycle);
428
+ if (discarded && candidate.decisionCandidateIds.length > 0) {
429
+ candidateFindings.push(finding(root, "truth-owner-review-required", "attention", {
430
+ itemIds: candidate.decisionCandidateIds,
431
+ }, {
432
+ blocksTask: false, resolutionKinds: ["keep-current-baseline"], humanRoles: ["truth-maintainer", "business-owner"],
433
+ }));
434
+ }
435
+ if (!stale && !discarded) eligible.push(candidate);
436
+ findings.push(...candidateFindings);
437
+ candidateOutcomes.push({
438
+ candidateId: candidate.id,
439
+ lifecycle: candidate.lifecycle,
440
+ baseline: stale ? "stale" : "current",
441
+ promotionEvidence: {
442
+ assurance: "host-asserted",
443
+ currentEvidenceCount: promotion.current.length,
444
+ staleEvidenceCount: promotion.stale.length,
445
+ localQaReusable: promotion.currentQa.length > 0,
446
+ integrationVerified: promotion.integration.length > 0,
447
+ },
448
+ decisionCandidates: discarded ? "discarded-not-promoted" : "authority-free-candidates",
449
+ eligibleForReconciliation: !stale && !discarded,
450
+ });
451
+ }
452
+
453
+ const collisionGroups = [
454
+ ...groupChanges(eligible, "source", (change) => change.sourceId),
455
+ ...groupChanges(eligible, "item", (change) => change.itemId),
456
+ ...groupSubjectChanges(eligible),
457
+ ].map((group) => {
458
+ const impacts = groupImpacts(group);
459
+ const code = group.kind === "source" ? "truth-source-collision" : group.kind === "item" ? "truth-item-collision" : "truth-subject-collision";
460
+ findings.push(finding(root, code, group.classification === "duplicate" ? "attention" : "conflict", impacts, {
461
+ blocksTask: isTaskRelated(impacts.scopes, input.task.paths), resolutionKinds: RESOLUTIONS[group.classification],
462
+ humanRoles: group.classification === "semantic-conflict" ? ["business-owner", "truth-maintainer"] : ["truth-maintainer"],
463
+ }));
464
+ return {
465
+ kind: group.kind,
466
+ key: group.key,
467
+ classification: group.classification,
468
+ candidateIds: uniqueSorted(group.changes.map((entry) => entry.candidateId)),
469
+ candidateDigests: uniqueSorted(group.changes.map((entry) => entry.change.candidateDigest)),
470
+ affectedSourceIds: uniqueSorted(impacts.sourceIds),
471
+ affectedItemIds: uniqueSorted(impacts.itemIds),
472
+ affectedSubjects: uniqueSorted(impacts.subjects),
473
+ affectedScopes: [...new Map(impacts.scopes.map((scope) => [scopeKey(scope), scope])).values()].sort((a, b) => scopeKey(a).localeCompare(scopeKey(b))),
474
+ resolutionKinds: [...RESOLUTIONS[group.classification]],
475
+ promoted: false,
476
+ };
477
+ }).sort((left, right) => `${left.kind}:${left.key}`.localeCompare(`${right.kind}:${right.key}`));
478
+
479
+ for (const signal of input.globalFindings) {
480
+ const related = isTaskRelated(signal.scopes, input.task.paths);
481
+ const mandatory = signal.itemKinds.some((kind) => ["policy", "validation-description"].includes(kind));
482
+ findings.push(finding(root, signal.code, signal.severity, {
483
+ sourceIds: signal.sourceIds, itemIds: signal.itemIds, subjects: signal.subjects, scopes: signal.scopes,
484
+ }, {
485
+ blocksTask: related && (mandatory || ["conflict", "blocked"].includes(signal.severity)),
486
+ resolutionKinds: related && mandatory ? ["fix-source-and-rerun", "defer-scoped-conflict"] : ["defer-scoped-conflict"],
487
+ humanRoles: mandatory ? ["subject-owner", "truth-maintainer"] : ["truth-maintainer"],
488
+ }));
489
+ }
490
+
491
+ const stableFindings = findings.sort((left, right) => {
492
+ const leftKey = `${left.code}:${left.affectedSourceIds.join(",")}:${left.affectedItemIds.join(",")}:${left.affectedSubjects.join(",")}`;
493
+ const rightKey = `${right.code}:${right.affectedSourceIds.join(",")}:${right.affectedItemIds.join(",")}:${right.affectedSubjects.join(",")}`;
494
+ return leftKey.localeCompare(rightKey);
495
+ });
496
+ const taskBlocked = stableFindings.some((entry) => entry.blocksTask);
497
+ const outcomes = resolutionOutcomes(input, previousReview, stableFindings);
498
+ return finalizeTruthReviewBundle({
499
+ schemaVersion: TRUTH_RECONCILIATION_REVIEW_BUNDLE_SCHEMA_VERSION,
500
+ kind: "truth-reconciliation-review-bundle",
501
+ project: { id: project.contract.project.id, name: project.contract.project.name },
502
+ current: { revision: input.current.revision, projectSnapshots: input.current.projectSnapshots },
503
+ task: input.task,
504
+ inputDigest: digestJson(input),
505
+ summary: {
506
+ candidates: input.candidates.length,
507
+ eligibleCandidates: eligible.length,
508
+ collisionGroups: collisionGroups.length,
509
+ findings: stableFindings.length,
510
+ taskHealth: taskBlocked ? "blocked" : "ready",
511
+ globalHealth: stableFindings.length > 0 ? "attention" : "clean",
512
+ existingDelivery: "not-blocked",
513
+ },
514
+ candidateOutcomes: candidateOutcomes.sort((left, right) => left.candidateId.localeCompare(right.candidateId)),
515
+ collisionGroups,
516
+ findings: stableFindings,
517
+ resolutionOutcomes: outcomes,
518
+ guarantees: {
519
+ projectContextVerified: ["schema", "canonical-digests", "project-snapshots", "scope-impact", "recovery-contract"],
520
+ hostAsserted: ["revision", "code-snapshot", "source-snapshot", "artifact", "verification-results", "content-equivalence"],
521
+ humanDecided: ["release", "long-term-semantics", "approval", "rollback"],
522
+ },
523
+ boundaries: {
524
+ readOnly: true, git: false, ciClient: false, network: false, provider: false, taskExecution: false,
525
+ automaticApproval: false, automaticPromotion: false, persistentQueue: false, blocksExistingDelivery: false,
526
+ },
527
+ });
528
+ }
529
+
530
+ export async function buildTruthReconciliationReviewFiles(root, inputPaths, options = {}) {
531
+ const project = await loadProject(root);
532
+ const values = [];
533
+ for (const inputPath of inputPaths) {
534
+ const resolved = await resolveExistingInside(root, inputPath);
535
+ values.push(await readJsonFile(resolved.absolute, "truth reconciliation input"));
536
+ }
537
+ let previousReview = null;
538
+ if (options.previousReviewPath) {
539
+ const resolved = await resolveExistingInside(root, options.previousReviewPath);
540
+ previousReview = validateTruthReviewBundle(await readJsonFile(resolved.absolute, "previous truth reconciliation review"));
541
+ }
542
+ return buildTruthReconciliationReview(root, project, values, { previousReview });
543
+ }