@msn-control/liftoff 0.9.8 → 0.10.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/DEVELOPER.md +142 -0
- package/README.md +26 -18
- package/assets/governance/single-maintainer-gitflow/policy.md +62 -13
- package/dist/args.js +21 -0
- package/dist/args.js.map +1 -1
- package/dist/artifact-lifecycle.d.ts +1 -1
- package/dist/artifact-lifecycle.js +5 -0
- package/dist/artifact-lifecycle.js.map +1 -1
- package/dist/commands.js +355 -13
- package/dist/commands.js.map +1 -1
- package/dist/file-system.js +100 -13
- package/dist/file-system.js.map +1 -1
- package/dist/governance-activation/activation-state.d.ts +32 -0
- package/dist/governance-activation/activation-state.js +165 -0
- package/dist/governance-activation/activation-state.js.map +1 -0
- package/dist/governance-activation/approvals.d.ts +188 -0
- package/dist/governance-activation/approvals.js +508 -0
- package/dist/governance-activation/approvals.js.map +1 -0
- package/dist/governance-activation/canonical-json.d.ts +3 -0
- package/dist/governance-activation/canonical-json.js +36 -0
- package/dist/governance-activation/canonical-json.js.map +1 -0
- package/dist/governance-activation/commands.d.ts +10 -0
- package/dist/governance-activation/commands.js +1023 -0
- package/dist/governance-activation/commands.js.map +1 -0
- package/dist/governance-activation/compatibility.d.ts +68 -0
- package/dist/governance-activation/compatibility.js +447 -0
- package/dist/governance-activation/compatibility.js.map +1 -0
- package/dist/governance-activation/credentials.d.ts +160 -0
- package/dist/governance-activation/credentials.js +403 -0
- package/dist/governance-activation/credentials.js.map +1 -0
- package/dist/governance-activation/doctor.d.ts +10 -0
- package/dist/governance-activation/doctor.js +379 -0
- package/dist/governance-activation/doctor.js.map +1 -0
- package/dist/governance-activation/evidence.d.ts +49 -0
- package/dist/governance-activation/evidence.js +246 -0
- package/dist/governance-activation/evidence.js.map +1 -0
- package/dist/governance-activation/graph.d.ts +397 -0
- package/dist/governance-activation/graph.js +441 -0
- package/dist/governance-activation/graph.js.map +1 -0
- package/dist/governance-activation/identity.d.ts +35 -0
- package/dist/governance-activation/identity.js +89 -0
- package/dist/governance-activation/identity.js.map +1 -0
- package/dist/governance-activation/index.d.ts +18 -0
- package/dist/governance-activation/index.js +19 -0
- package/dist/governance-activation/index.js.map +1 -0
- package/dist/governance-activation/migration.d.ts +44 -0
- package/dist/governance-activation/migration.js +268 -0
- package/dist/governance-activation/migration.js.map +1 -0
- package/dist/governance-activation/readiness.d.ts +23 -0
- package/dist/governance-activation/readiness.js +158 -0
- package/dist/governance-activation/readiness.js.map +1 -0
- package/dist/governance-activation/reconciliation.d.ts +11 -0
- package/dist/governance-activation/reconciliation.js +58 -0
- package/dist/governance-activation/reconciliation.js.map +1 -0
- package/dist/governance-activation/release-integrity.d.ts +11 -0
- package/dist/governance-activation/release-integrity.js +49 -0
- package/dist/governance-activation/release-integrity.js.map +1 -0
- package/dist/governance-activation/seed-lifecycle.d.ts +116 -0
- package/dist/governance-activation/seed-lifecycle.js +609 -0
- package/dist/governance-activation/seed-lifecycle.js.map +1 -0
- package/dist/governance-activation/source-of-truth.d.ts +164 -0
- package/dist/governance-activation/source-of-truth.js +986 -0
- package/dist/governance-activation/source-of-truth.js.map +1 -0
- package/dist/governance-activation/task-projection.d.ts +21 -0
- package/dist/governance-activation/task-projection.js +75 -0
- package/dist/governance-activation/task-projection.js.map +1 -0
- package/dist/governance-activation/transitions.d.ts +163 -0
- package/dist/governance-activation/transitions.js +2128 -0
- package/dist/governance-activation/transitions.js.map +1 -0
- package/dist/governance-activation/type-contract.d.ts +1 -0
- package/dist/governance-activation/type-contract.js +27 -0
- package/dist/governance-activation/type-contract.js.map +1 -0
- package/dist/governance-activation/types.d.ts +390 -0
- package/dist/governance-activation/types.js +108 -0
- package/dist/governance-activation/types.js.map +1 -0
- package/dist/governance-activation/validators.d.ts +17 -0
- package/dist/governance-activation/validators.js +1305 -0
- package/dist/governance-activation/validators.js.map +1 -0
- package/dist/init-filesystem.js +5 -1
- package/dist/init-filesystem.js.map +1 -1
- package/dist/planner.js +3 -3
- package/dist/planner.js.map +1 -1
- package/dist/power-apps-templates.js +14 -1
- package/dist/power-apps-templates.js.map +1 -1
- package/dist/process-runner.d.ts +3 -0
- package/dist/process-runner.js +68 -10
- package/dist/process-runner.js.map +1 -1
- package/dist/repository-governance.d.ts +13 -3
- package/dist/repository-governance.js +385 -52
- package/dist/repository-governance.js.map +1 -1
- package/dist/telemetry/contract.d.ts +1 -1
- package/dist/telemetry/contract.js +6 -0
- package/dist/telemetry/contract.js.map +1 -1
- package/dist/templates.js +320 -25
- package/dist/templates.js.map +1 -1
- package/dist/types.d.ts +14 -6
- package/docs/cli-reference.md +60 -12
- package/docs/configuration-and-manifests.md +40 -16
- package/docs/existing-repositories.md +22 -11
- package/docs/getting-started.md +46 -10
- package/docs/prerequisites.md +5 -0
- package/docs/project-structure.md +34 -5
- package/docs/repository-governance.md +200 -203
- package/docs/safety-and-consent.md +29 -2
- package/docs/troubleshooting.md +74 -4
- package/docs/workloads.md +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,986 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { applyProjectFileTransaction, readProjectFile, resolveProjectPath, validateArtifactPathParts } from '../file-system.js';
|
|
3
|
+
import { generatedSeedChangeName } from './seed-lifecycle.js';
|
|
4
|
+
import { canonicalJson, canonicalSha256 } from './canonical-json.js';
|
|
5
|
+
import { activationContractVersion, activationStateSchemaVersion, approvalEnvelopeSchemaVersion, credentialPolicySchemaVersion, evidenceHeaderSchemaVersion, liftoffManifestArtifactVersion, phaseGraphSchemaVersion, supersessionSchemaVersion } from './identity.js';
|
|
6
|
+
import { calculateGraphReconciliation } from './reconciliation.js';
|
|
7
|
+
import { canonicalPhaseGraph, canonicalPhaseGraphHash, currentActivationIdentity, phaseContractDigests } from './graph.js';
|
|
8
|
+
import { evidenceContextForPhase, validateEvidenceFreshness } from './evidence.js';
|
|
9
|
+
import { phaseIds } from './types.js';
|
|
10
|
+
import { validateSupersessionRecord } from './validators.js';
|
|
11
|
+
import { toSafeProjectName } from '../planner.js';
|
|
12
|
+
export const governanceChangeMetadataFileName = 'liftoff-governance.json';
|
|
13
|
+
export const governanceSupersessionPathParts = ['governance', 'supersessions'];
|
|
14
|
+
export const governanceReconciliationPathParts = ['governance', 'reconciliation'];
|
|
15
|
+
export const governanceSourceOfTruthMarker = 'liftoff-governance-source-of-truth';
|
|
16
|
+
export const governanceChangeMetadataSchemaVersion = 1;
|
|
17
|
+
const hex64Pattern = /^[a-f0-9]{64}$/u;
|
|
18
|
+
const isoLikePattern = /^\d{4}-\d{2}-\d{2}T/u;
|
|
19
|
+
const workflowKinds = new Set(['openspec', 'spec-kit']);
|
|
20
|
+
function errorCode(error) {
|
|
21
|
+
if (typeof error !== 'object' || error === null || !('code' in error)) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const code = error.code;
|
|
25
|
+
return typeof code === 'string' ? code : undefined;
|
|
26
|
+
}
|
|
27
|
+
function errorMessage(error) {
|
|
28
|
+
return error instanceof Error ? error.message : String(error);
|
|
29
|
+
}
|
|
30
|
+
function record(value, label) {
|
|
31
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
32
|
+
throw new Error(`${label} must be an object.`);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
function exact(value, keys, label) {
|
|
37
|
+
const item = record(value, label);
|
|
38
|
+
for (const key of keys) {
|
|
39
|
+
if (!Object.hasOwn(item, key)) {
|
|
40
|
+
throw new Error(`${label}.${key} is required.`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const allowed = new Set(keys);
|
|
44
|
+
for (const key of Object.keys(item)) {
|
|
45
|
+
if (!allowed.has(key)) {
|
|
46
|
+
throw new Error(`${label}.${key} is not allowed.`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return item;
|
|
50
|
+
}
|
|
51
|
+
function stringField(item, key, label) {
|
|
52
|
+
const value = item[key];
|
|
53
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
54
|
+
throw new Error(`${label}.${key} must be a non-empty string.`);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
function numberField(item, key, expected, label) {
|
|
59
|
+
const value = item[key];
|
|
60
|
+
if (value !== expected) {
|
|
61
|
+
throw new Error(`${label}.${key} must be ${expected}.`);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
function stringArray(value, label) {
|
|
66
|
+
if (!Array.isArray(value)) {
|
|
67
|
+
throw new Error(`${label} must be an array.`);
|
|
68
|
+
}
|
|
69
|
+
return value.map((entry, index) => {
|
|
70
|
+
if (typeof entry !== 'string' || entry.length === 0) {
|
|
71
|
+
throw new Error(`${label}[${index}] must be a non-empty string.`);
|
|
72
|
+
}
|
|
73
|
+
return entry;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function strictActivationIdentity(value, label) {
|
|
77
|
+
const identity = exact(value, [
|
|
78
|
+
'liftoffVersion',
|
|
79
|
+
'manifestArtifactVersion',
|
|
80
|
+
'policyVersion',
|
|
81
|
+
'activationContractVersion',
|
|
82
|
+
'phaseGraphSchemaVersion',
|
|
83
|
+
'phaseGraphHash',
|
|
84
|
+
'activationStateSchemaVersion',
|
|
85
|
+
'evidenceHeaderSchemaVersion',
|
|
86
|
+
'approvalEnvelopeSchemaVersion',
|
|
87
|
+
'supersessionSchemaVersion',
|
|
88
|
+
'credentialPolicySchemaVersion'
|
|
89
|
+
], label);
|
|
90
|
+
const phaseGraphHash = stringField(identity, 'phaseGraphHash', label);
|
|
91
|
+
if (!hex64Pattern.test(phaseGraphHash)) {
|
|
92
|
+
throw new Error(`${label}.phaseGraphHash must be a SHA-256 hex digest.`);
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
liftoffVersion: stringField(identity, 'liftoffVersion', label),
|
|
96
|
+
manifestArtifactVersion: numberField(identity, 'manifestArtifactVersion', liftoffManifestArtifactVersion, label),
|
|
97
|
+
policyVersion: stringField(identity, 'policyVersion', label),
|
|
98
|
+
activationContractVersion: numberField(identity, 'activationContractVersion', activationContractVersion, label),
|
|
99
|
+
phaseGraphSchemaVersion: numberField(identity, 'phaseGraphSchemaVersion', phaseGraphSchemaVersion, label),
|
|
100
|
+
phaseGraphHash,
|
|
101
|
+
activationStateSchemaVersion: numberField(identity, 'activationStateSchemaVersion', activationStateSchemaVersion, label),
|
|
102
|
+
evidenceHeaderSchemaVersion: numberField(identity, 'evidenceHeaderSchemaVersion', evidenceHeaderSchemaVersion, label),
|
|
103
|
+
approvalEnvelopeSchemaVersion: numberField(identity, 'approvalEnvelopeSchemaVersion', approvalEnvelopeSchemaVersion, label),
|
|
104
|
+
supersessionSchemaVersion: numberField(identity, 'supersessionSchemaVersion', supersessionSchemaVersion, label),
|
|
105
|
+
credentialPolicySchemaVersion: numberField(identity, 'credentialPolicySchemaVersion', credentialPolicySchemaVersion, label)
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function validateCurrentIdentity(identity, label) {
|
|
109
|
+
const issues = [];
|
|
110
|
+
const expected = currentActivationIdentity;
|
|
111
|
+
for (const key of Object.keys(expected)) {
|
|
112
|
+
if (identity[key] !== expected[key]) {
|
|
113
|
+
issues.push(`${label}.${key} expected ${JSON.stringify(expected[key])}, found ${JSON.stringify(identity[key])}.`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return issues;
|
|
117
|
+
}
|
|
118
|
+
function strictPhaseTaskMapping(value, label) {
|
|
119
|
+
const mapping = exact(value, ['phaseId', 'taskId', 'marker', 'policy'], label);
|
|
120
|
+
const phaseId = stringField(mapping, 'phaseId', label);
|
|
121
|
+
if (!phaseIds.includes(phaseId)) {
|
|
122
|
+
throw new Error(`${label}.phaseId contains unsupported phase ${phaseId}.`);
|
|
123
|
+
}
|
|
124
|
+
const taskId = stringField(mapping, 'taskId', label);
|
|
125
|
+
const marker = stringField(mapping, 'marker', label);
|
|
126
|
+
if (marker !== `<!-- liftoff-phase: ${phaseId} -->`) {
|
|
127
|
+
throw new Error(`${label}.marker must match its phaseId.`);
|
|
128
|
+
}
|
|
129
|
+
if (mapping.policy !== 'evidence-projection-v1') {
|
|
130
|
+
throw new Error(`${label}.policy must be evidence-projection-v1.`);
|
|
131
|
+
}
|
|
132
|
+
return { phaseId: phaseId, taskId, marker: marker, policy: 'evidence-projection-v1' };
|
|
133
|
+
}
|
|
134
|
+
export function validateGovernanceChangeMetadata(value) {
|
|
135
|
+
const metadata = exact(value, [
|
|
136
|
+
'schemaVersion',
|
|
137
|
+
'marker',
|
|
138
|
+
'changeId',
|
|
139
|
+
'workflowKind',
|
|
140
|
+
'activationIdentity',
|
|
141
|
+
'phaseGraphHash',
|
|
142
|
+
'baselineSha',
|
|
143
|
+
'phaseTaskMapping',
|
|
144
|
+
'currentPolicy',
|
|
145
|
+
'createdFrom',
|
|
146
|
+
'acknowledgedAt',
|
|
147
|
+
'owner'
|
|
148
|
+
], 'governanceChange');
|
|
149
|
+
numberField(metadata, 'schemaVersion', governanceChangeMetadataSchemaVersion, 'governanceChange');
|
|
150
|
+
if (metadata.marker !== governanceSourceOfTruthMarker) {
|
|
151
|
+
throw new Error(`governanceChange.marker must be ${governanceSourceOfTruthMarker}.`);
|
|
152
|
+
}
|
|
153
|
+
const changeId = stringField(metadata, 'changeId', 'governanceChange');
|
|
154
|
+
const workflowKind = stringField(metadata, 'workflowKind', 'governanceChange');
|
|
155
|
+
if (!workflowKinds.has(workflowKind)) {
|
|
156
|
+
throw new Error(`governanceChange.workflowKind contains unsupported workflow ${workflowKind}.`);
|
|
157
|
+
}
|
|
158
|
+
const activationIdentity = strictActivationIdentity(metadata.activationIdentity, 'governanceChange.activationIdentity');
|
|
159
|
+
const phaseGraphHash = stringField(metadata, 'phaseGraphHash', 'governanceChange');
|
|
160
|
+
if (phaseGraphHash !== activationIdentity.phaseGraphHash) {
|
|
161
|
+
throw new Error('governanceChange.phaseGraphHash must match activationIdentity.phaseGraphHash.');
|
|
162
|
+
}
|
|
163
|
+
const baselineSha = stringField(metadata, 'baselineSha', 'governanceChange');
|
|
164
|
+
if (!hex64Pattern.test(baselineSha)) {
|
|
165
|
+
throw new Error('governanceChange.baselineSha must be a SHA-256 hex digest.');
|
|
166
|
+
}
|
|
167
|
+
if (!Array.isArray(metadata.phaseTaskMapping)) {
|
|
168
|
+
throw new Error('governanceChange.phaseTaskMapping must be an array.');
|
|
169
|
+
}
|
|
170
|
+
const seenPhases = new Set();
|
|
171
|
+
const seenTasks = new Set();
|
|
172
|
+
const phaseTaskMapping = metadata.phaseTaskMapping.map((entry, index) => {
|
|
173
|
+
const mapping = strictPhaseTaskMapping(entry, `governanceChange.phaseTaskMapping[${index}]`);
|
|
174
|
+
if (seenPhases.has(mapping.phaseId)) {
|
|
175
|
+
throw new Error(`governanceChange.phaseTaskMapping contains duplicate phase ${mapping.phaseId}.`);
|
|
176
|
+
}
|
|
177
|
+
if (seenTasks.has(mapping.taskId)) {
|
|
178
|
+
throw new Error(`governanceChange.phaseTaskMapping contains duplicate task ${mapping.taskId}.`);
|
|
179
|
+
}
|
|
180
|
+
seenPhases.add(mapping.phaseId);
|
|
181
|
+
seenTasks.add(mapping.taskId);
|
|
182
|
+
return mapping;
|
|
183
|
+
});
|
|
184
|
+
for (const phaseId of phaseIds) {
|
|
185
|
+
if (!seenPhases.has(phaseId)) {
|
|
186
|
+
throw new Error(`governanceChange.phaseTaskMapping.${phaseId} is required.`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const policy = exact(metadata.currentPolicy, [
|
|
190
|
+
'phaseAuthority',
|
|
191
|
+
'taskCompletion',
|
|
192
|
+
'approvalPolicy'
|
|
193
|
+
], 'governanceChange.currentPolicy');
|
|
194
|
+
if (policy.phaseAuthority !== 'managed-phase-graph') {
|
|
195
|
+
throw new Error('governanceChange.currentPolicy.phaseAuthority must be managed-phase-graph.');
|
|
196
|
+
}
|
|
197
|
+
if (policy.taskCompletion !== 'authoritative-evidence-projection') {
|
|
198
|
+
throw new Error('governanceChange.currentPolicy.taskCompletion must be authoritative-evidence-projection.');
|
|
199
|
+
}
|
|
200
|
+
if (policy.approvalPolicy !== 'approval-envelope-required-for-gated-phases') {
|
|
201
|
+
throw new Error('governanceChange.currentPolicy.approvalPolicy must be approval-envelope-required-for-gated-phases.');
|
|
202
|
+
}
|
|
203
|
+
const createdFrom = exact(metadata.createdFrom, [
|
|
204
|
+
'kind',
|
|
205
|
+
'approvedFactDigest',
|
|
206
|
+
'evidenceIds'
|
|
207
|
+
], 'governanceChange.createdFrom');
|
|
208
|
+
if (createdFrom.kind !== 'approved-phase-0-facts') {
|
|
209
|
+
throw new Error('governanceChange.createdFrom.kind must be approved-phase-0-facts.');
|
|
210
|
+
}
|
|
211
|
+
const approvedFactDigest = stringField(createdFrom, 'approvedFactDigest', 'governanceChange.createdFrom');
|
|
212
|
+
if (!hex64Pattern.test(approvedFactDigest)) {
|
|
213
|
+
throw new Error('governanceChange.createdFrom.approvedFactDigest must be a SHA-256 hex digest.');
|
|
214
|
+
}
|
|
215
|
+
const acknowledgedAt = stringField(metadata, 'acknowledgedAt', 'governanceChange');
|
|
216
|
+
if (!isoLikePattern.test(acknowledgedAt) || Number.isNaN(Date.parse(acknowledgedAt))) {
|
|
217
|
+
throw new Error('governanceChange.acknowledgedAt must be a valid ISO timestamp.');
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
schemaVersion: governanceChangeMetadataSchemaVersion,
|
|
221
|
+
marker: governanceSourceOfTruthMarker,
|
|
222
|
+
changeId,
|
|
223
|
+
workflowKind: workflowKind,
|
|
224
|
+
activationIdentity,
|
|
225
|
+
phaseGraphHash,
|
|
226
|
+
baselineSha,
|
|
227
|
+
phaseTaskMapping,
|
|
228
|
+
currentPolicy: {
|
|
229
|
+
phaseAuthority: 'managed-phase-graph',
|
|
230
|
+
taskCompletion: 'authoritative-evidence-projection',
|
|
231
|
+
approvalPolicy: 'approval-envelope-required-for-gated-phases'
|
|
232
|
+
},
|
|
233
|
+
createdFrom: {
|
|
234
|
+
kind: 'approved-phase-0-facts',
|
|
235
|
+
approvedFactDigest,
|
|
236
|
+
evidenceIds: stringArray(createdFrom.evidenceIds, 'governanceChange.createdFrom.evidenceIds')
|
|
237
|
+
},
|
|
238
|
+
acknowledgedAt,
|
|
239
|
+
owner: stringField(metadata, 'owner', 'governanceChange')
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
export function governancePhaseTaskMappings() {
|
|
243
|
+
return phaseIds.map((phaseId, index) => ({
|
|
244
|
+
phaseId,
|
|
245
|
+
taskId: `${index + 1}.1`,
|
|
246
|
+
marker: `<!-- liftoff-phase: ${phaseId} -->`,
|
|
247
|
+
policy: 'evidence-projection-v1'
|
|
248
|
+
}));
|
|
249
|
+
}
|
|
250
|
+
export function approvedPhase0FactDigest(facts) {
|
|
251
|
+
return canonicalSha256({
|
|
252
|
+
approvedFacts: facts.approvedFacts,
|
|
253
|
+
baselineSha: facts.baselineSha,
|
|
254
|
+
defaultBranch: facts.defaultBranch,
|
|
255
|
+
evidenceIds: facts.evidenceIds,
|
|
256
|
+
repositoryId: facts.repositoryId,
|
|
257
|
+
repositoryName: facts.repositoryName,
|
|
258
|
+
workflowKind: facts.workflowKind
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
export function deterministicGovernanceChangeId(facts) {
|
|
262
|
+
const safeProject = toSafeProjectName(facts.projectName);
|
|
263
|
+
const suffix = facts.baselineSha.slice(0, 12);
|
|
264
|
+
return facts.workflowKind === 'openspec'
|
|
265
|
+
? `governance-${safeProject}-${suffix}`
|
|
266
|
+
: `001-liftoff-governance-${safeProject}-${suffix}`;
|
|
267
|
+
}
|
|
268
|
+
function metadataForFacts(facts, changeId) {
|
|
269
|
+
return validateGovernanceChangeMetadata({
|
|
270
|
+
schemaVersion: governanceChangeMetadataSchemaVersion,
|
|
271
|
+
marker: governanceSourceOfTruthMarker,
|
|
272
|
+
changeId,
|
|
273
|
+
workflowKind: facts.workflowKind,
|
|
274
|
+
activationIdentity: currentActivationIdentity,
|
|
275
|
+
phaseGraphHash: canonicalPhaseGraphHash,
|
|
276
|
+
baselineSha: facts.baselineSha,
|
|
277
|
+
phaseTaskMapping: governancePhaseTaskMappings(),
|
|
278
|
+
currentPolicy: {
|
|
279
|
+
phaseAuthority: 'managed-phase-graph',
|
|
280
|
+
taskCompletion: 'authoritative-evidence-projection',
|
|
281
|
+
approvalPolicy: 'approval-envelope-required-for-gated-phases'
|
|
282
|
+
},
|
|
283
|
+
createdFrom: {
|
|
284
|
+
kind: 'approved-phase-0-facts',
|
|
285
|
+
approvedFactDigest: approvedPhase0FactDigest(facts),
|
|
286
|
+
evidenceIds: facts.evidenceIds
|
|
287
|
+
},
|
|
288
|
+
acknowledgedAt: facts.approvedAt,
|
|
289
|
+
owner: facts.approver
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
function approvedFactsMarkdown(facts) {
|
|
293
|
+
if (facts.approvedFacts.length === 0) {
|
|
294
|
+
return '- No additional Phase 0 facts were discovered.\n';
|
|
295
|
+
}
|
|
296
|
+
return facts.approvedFacts
|
|
297
|
+
.map((fact) => `- ${fact.id}: ${fact.value === null ? 'null' : String(fact.value)}`)
|
|
298
|
+
.join('\n') + '\n';
|
|
299
|
+
}
|
|
300
|
+
function openSpecGovernanceSpec(metadata, facts) {
|
|
301
|
+
return `## ADDED Requirements
|
|
302
|
+
|
|
303
|
+
### Requirement: Liftoff activation follows the installed phase graph
|
|
304
|
+
The project SHALL use the Liftoff governance activation identity acknowledged by
|
|
305
|
+
\`${metadata.changeId}\` and SHALL treat the managed phase graph as the sole
|
|
306
|
+
source of execution order.
|
|
307
|
+
|
|
308
|
+
#### Scenario: Phase evidence is authoritative
|
|
309
|
+
- **WHEN** a phase task is checked or unchecked
|
|
310
|
+
- **THEN** Liftoff reconciles it from current validated evidence for the same repository, baseline SHA, activation identity, graph hash, and phase mapping
|
|
311
|
+
- **AND** prose or checkbox state alone does not authorize a transition
|
|
312
|
+
|
|
313
|
+
#### Scenario: Phase 0 facts are the only creation input
|
|
314
|
+
- **WHEN** this governance change is inspected
|
|
315
|
+
- **THEN** only these approved Phase 0 facts are in scope:
|
|
316
|
+
${approvedFactsMarkdown(facts).split('\n').filter(Boolean).map((line) => ` ${line}`).join('\n')}
|
|
317
|
+
`;
|
|
318
|
+
}
|
|
319
|
+
function renderOpenSpecFiles(metadata, facts) {
|
|
320
|
+
const idLines = [
|
|
321
|
+
`- Liftoff version: ${metadata.activationIdentity.liftoffVersion}`,
|
|
322
|
+
`- Manifest artifact version: ${metadata.activationIdentity.manifestArtifactVersion}`,
|
|
323
|
+
`- Policy version: ${metadata.activationIdentity.policyVersion}`,
|
|
324
|
+
`- Activation contract version: ${metadata.activationIdentity.activationContractVersion}`,
|
|
325
|
+
`- Phase-graph schema version: ${metadata.activationIdentity.phaseGraphSchemaVersion}`,
|
|
326
|
+
`- Phase-graph hash: ${metadata.phaseGraphHash}`,
|
|
327
|
+
`- Activation-state schema version: ${metadata.activationIdentity.activationStateSchemaVersion}`,
|
|
328
|
+
`- Evidence-header schema version: ${metadata.activationIdentity.evidenceHeaderSchemaVersion}`,
|
|
329
|
+
`- Approval-envelope schema version: ${metadata.activationIdentity.approvalEnvelopeSchemaVersion}`,
|
|
330
|
+
`- Supersession schema version: ${metadata.activationIdentity.supersessionSchemaVersion}`,
|
|
331
|
+
`- Credential-policy schema version: ${metadata.activationIdentity.credentialPolicySchemaVersion}`,
|
|
332
|
+
`- Baseline SHA: ${metadata.baselineSha}`,
|
|
333
|
+
`- Approved fact digest: ${metadata.createdFrom.approvedFactDigest}`
|
|
334
|
+
].join('\n');
|
|
335
|
+
const taskSections = metadata.phaseTaskMapping.map((mapping) => `## ${mapping.taskId.split('.')[0]}. ${mapping.phaseId}
|
|
336
|
+
|
|
337
|
+
- [ ] ${mapping.taskId} Reconcile \`${mapping.phaseId}\` from validated phase evidence. ${mapping.marker}`).join('\n\n');
|
|
338
|
+
const mappingLines = metadata.phaseTaskMapping.map((mapping) => `- \`${mapping.phaseId}\` -> task \`${mapping.taskId}\` (${mapping.policy})`).join('\n');
|
|
339
|
+
return [
|
|
340
|
+
{
|
|
341
|
+
pathParts: ['openspec', 'changes', metadata.changeId, '.openspec.yaml'],
|
|
342
|
+
content: 'schema: spec-driven\n'
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
pathParts: ['openspec', 'changes', metadata.changeId, governanceChangeMetadataFileName],
|
|
346
|
+
content: `${canonicalJson(metadata)}\n`
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
pathParts: ['openspec', 'changes', metadata.changeId, 'proposal.md'],
|
|
350
|
+
content: `# Proposal: ${metadata.changeId}
|
|
351
|
+
|
|
352
|
+
## Why
|
|
353
|
+
|
|
354
|
+
Activate repository governance using one deterministic Liftoff source of truth
|
|
355
|
+
created from approved Phase 0 facts.
|
|
356
|
+
|
|
357
|
+
## What Changes
|
|
358
|
+
|
|
359
|
+
- Acknowledge the complete compatible activation identity and phase graph hash.
|
|
360
|
+
- Map every Liftoff phase to a task marker that is projected from evidence.
|
|
361
|
+
- Keep implementation, credentials, remote resources, and approvals outside this change until their phase evidence and approval envelopes authorize them.
|
|
362
|
+
|
|
363
|
+
## Capabilities
|
|
364
|
+
|
|
365
|
+
### New Capabilities
|
|
366
|
+
|
|
367
|
+
- \`liftoff-governance-activation\`: Evidence-backed repository governance activation for ${facts.repositoryName}.
|
|
368
|
+
|
|
369
|
+
### Modified Capabilities
|
|
370
|
+
|
|
371
|
+
- None.
|
|
372
|
+
|
|
373
|
+
## Impact
|
|
374
|
+
|
|
375
|
+
- User-owned governance activation artifacts only.
|
|
376
|
+
- No product behavior, Git, GitHub, Azure, credential, or infrastructure mutation.
|
|
377
|
+
`
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
pathParts: ['openspec', 'changes', metadata.changeId, 'design.md'],
|
|
381
|
+
content: `# Design: ${metadata.changeId}
|
|
382
|
+
|
|
383
|
+
## Context
|
|
384
|
+
|
|
385
|
+
Repository: ${facts.repositoryName}
|
|
386
|
+
Baseline SHA: ${metadata.baselineSha}
|
|
387
|
+
Workflow: ${metadata.workflowKind}
|
|
388
|
+
|
|
389
|
+
## Activation identity
|
|
390
|
+
|
|
391
|
+
${idLines}
|
|
392
|
+
|
|
393
|
+
## Phase mapping and policy
|
|
394
|
+
|
|
395
|
+
The managed phase graph is the execution authority. Task completion is a
|
|
396
|
+
projection of authoritative evidence; approval-gated phases require a validated
|
|
397
|
+
approval envelope.
|
|
398
|
+
|
|
399
|
+
${mappingLines}
|
|
400
|
+
|
|
401
|
+
## Approved Phase 0 facts
|
|
402
|
+
|
|
403
|
+
${approvedFactsMarkdown(facts)}
|
|
404
|
+
`
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
pathParts: ['openspec', 'changes', metadata.changeId, 'specs', 'liftoff-governance-activation', 'spec.md'],
|
|
408
|
+
content: openSpecGovernanceSpec(metadata, facts)
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
pathParts: ['openspec', 'changes', metadata.changeId, 'tasks.md'],
|
|
412
|
+
content: `${taskSections}
|
|
413
|
+
|
|
414
|
+
`
|
|
415
|
+
}
|
|
416
|
+
];
|
|
417
|
+
}
|
|
418
|
+
function renderSpecKitFiles(metadata, facts) {
|
|
419
|
+
const base = ['specs', metadata.changeId];
|
|
420
|
+
const taskSections = metadata.phaseTaskMapping.map((mapping) => `## ${mapping.taskId.split('.')[0]}. ${mapping.phaseId}
|
|
421
|
+
|
|
422
|
+
- [ ] ${mapping.taskId} Reconcile \`${mapping.phaseId}\` from validated phase evidence. ${mapping.marker}`).join('\n\n');
|
|
423
|
+
return [
|
|
424
|
+
{
|
|
425
|
+
pathParts: [...base, governanceChangeMetadataFileName],
|
|
426
|
+
content: `${canonicalJson(metadata)}\n`
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
pathParts: [...base, 'spec.md'],
|
|
430
|
+
content: `# Feature Specification: Liftoff Governance Activation
|
|
431
|
+
|
|
432
|
+
**Feature Branch**: \`${metadata.changeId}\`
|
|
433
|
+
**Created**: ${metadata.acknowledgedAt}
|
|
434
|
+
**Status**: Draft
|
|
435
|
+
**Input**: Approved Phase 0 facts for ${facts.repositoryName}
|
|
436
|
+
|
|
437
|
+
## User Scenarios & Testing
|
|
438
|
+
|
|
439
|
+
### Primary User Story
|
|
440
|
+
|
|
441
|
+
As the repository owner, I need governance activation to resume from one
|
|
442
|
+
deterministic Liftoff source of truth using validated evidence rather than
|
|
443
|
+
checkboxes or prose.
|
|
444
|
+
|
|
445
|
+
## Requirements
|
|
446
|
+
|
|
447
|
+
- **REQ-001**: Acknowledge activation identity \`${metadata.createdFrom.approvedFactDigest}\`, graph hash \`${metadata.phaseGraphHash}\`, and baseline \`${metadata.baselineSha}\`.
|
|
448
|
+
- **REQ-002**: Use the managed phase graph as the only execution-order authority.
|
|
449
|
+
- **REQ-003**: Project task completion from authoritative phase evidence and validated approval envelopes only.
|
|
450
|
+
|
|
451
|
+
## Approved Phase 0 Facts
|
|
452
|
+
|
|
453
|
+
${approvedFactsMarkdown(facts)}
|
|
454
|
+
`
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
pathParts: [...base, 'plan.md'],
|
|
458
|
+
content: `# Implementation Plan: Liftoff Governance Activation
|
|
459
|
+
|
|
460
|
+
**Branch**: \`${metadata.changeId}\` | **Date**: ${metadata.acknowledgedAt} | **Spec**: ./spec.md
|
|
461
|
+
**Input**: Approved Phase 0 facts from Liftoff governance status.
|
|
462
|
+
|
|
463
|
+
## Technical Context
|
|
464
|
+
|
|
465
|
+
Activation identity: \`${metadata.activationIdentity.liftoffVersion}/${metadata.activationIdentity.policyVersion}/${metadata.activationIdentity.activationContractVersion}\`
|
|
466
|
+
Phase graph hash: \`${metadata.phaseGraphHash}\`
|
|
467
|
+
Baseline SHA: \`${metadata.baselineSha}\`
|
|
468
|
+
|
|
469
|
+
## Constitution Check
|
|
470
|
+
|
|
471
|
+
No Git, GitHub, Azure, credential, product, or infrastructure mutation is
|
|
472
|
+
authorized by this plan. Later transition adapters must consume validated
|
|
473
|
+
approval envelopes and evidence.
|
|
474
|
+
`
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
pathParts: [...base, 'tasks.md'],
|
|
478
|
+
content: `# Tasks: Liftoff Governance Activation
|
|
479
|
+
|
|
480
|
+
${taskSections}
|
|
481
|
+
`
|
|
482
|
+
}
|
|
483
|
+
];
|
|
484
|
+
}
|
|
485
|
+
export function renderGovernanceChangeWritePlan(facts) {
|
|
486
|
+
if (!hex64Pattern.test(facts.baselineSha)) {
|
|
487
|
+
throw new Error('Approved Phase 0 baselineSha must be a SHA-256 hex digest.');
|
|
488
|
+
}
|
|
489
|
+
if (!workflowKinds.has(facts.workflowKind)) {
|
|
490
|
+
throw new Error(`Unsupported governance workflow kind ${facts.workflowKind}.`);
|
|
491
|
+
}
|
|
492
|
+
if (!isoLikePattern.test(facts.approvedAt) || Number.isNaN(Date.parse(facts.approvedAt))) {
|
|
493
|
+
throw new Error('Approved Phase 0 approvedAt must be a valid ISO timestamp.');
|
|
494
|
+
}
|
|
495
|
+
const changeId = deterministicGovernanceChangeId(facts);
|
|
496
|
+
const metadata = metadataForFacts(facts, changeId);
|
|
497
|
+
const files = facts.workflowKind === 'openspec'
|
|
498
|
+
? renderOpenSpecFiles(metadata, facts)
|
|
499
|
+
: renderSpecKitFiles(metadata, facts);
|
|
500
|
+
return {
|
|
501
|
+
changeId,
|
|
502
|
+
workflowKind: facts.workflowKind,
|
|
503
|
+
metadata,
|
|
504
|
+
files,
|
|
505
|
+
transactionalWrite: {
|
|
506
|
+
functionName: 'writeGovernanceChangeArtifacts',
|
|
507
|
+
writesAllFilesOrThrows: true
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
export async function writeGovernanceChangeArtifacts(projectRoot, plan) {
|
|
512
|
+
const validated = validateGovernanceChangeMetadata(plan.metadata);
|
|
513
|
+
if (validated.changeId !== plan.changeId || validated.workflowKind !== plan.workflowKind) {
|
|
514
|
+
throw new Error('Governance change write plan metadata does not match plan identity.');
|
|
515
|
+
}
|
|
516
|
+
for (const file of plan.files) {
|
|
517
|
+
validateArtifactPathParts([...file.pathParts], 'Governance change artifact path');
|
|
518
|
+
}
|
|
519
|
+
for (const file of plan.files) {
|
|
520
|
+
const existing = await readProjectFile(projectRoot, [...file.pathParts]);
|
|
521
|
+
if (existing !== undefined) {
|
|
522
|
+
throw new Error(`Refusing to overwrite existing governance change artifact ${file.pathParts.join('/')}.`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
try {
|
|
526
|
+
await applyProjectFileTransaction(projectRoot, plan.files.map((file) => ({
|
|
527
|
+
type: 'write',
|
|
528
|
+
pathParts: [...file.pathParts],
|
|
529
|
+
content: file.content
|
|
530
|
+
})));
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
throw new Error(`Unable to write governance change artifacts transactionally: ${errorMessage(error)}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
async function directoryEntries(projectRoot, pathParts) {
|
|
537
|
+
const directory = await resolveProjectPath(projectRoot, validateArtifactPathParts([...pathParts], 'Governance directory path'));
|
|
538
|
+
try {
|
|
539
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
540
|
+
return entries
|
|
541
|
+
.filter((entry) => entry.isDirectory())
|
|
542
|
+
.map((entry) => entry.name)
|
|
543
|
+
.sort((left, right) => left.localeCompare(right, 'en'));
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
if (errorCode(error) === 'ENOENT') {
|
|
547
|
+
return [];
|
|
548
|
+
}
|
|
549
|
+
throw new Error(`Unable to inspect ${pathParts.join('/')}: ${errorMessage(error)}.`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
async function activeSeedBlockers(projectRoot, manifest) {
|
|
553
|
+
if (manifest.project.specWorkflow !== 'openspec') {
|
|
554
|
+
return [];
|
|
555
|
+
}
|
|
556
|
+
const expectedSeed = generatedSeedChangeName(manifest);
|
|
557
|
+
const active = await directoryEntries(projectRoot, ['openspec', 'changes']);
|
|
558
|
+
const activeSeeds = active.filter((entry) => entry.startsWith('bootstrap-') && entry !== 'archive');
|
|
559
|
+
if (activeSeeds.length === 0) {
|
|
560
|
+
return [];
|
|
561
|
+
}
|
|
562
|
+
if (activeSeeds.includes(expectedSeed)) {
|
|
563
|
+
return [`Generated OpenSpec seed ${expectedSeed} is still active; archive it before governance creation.`];
|
|
564
|
+
}
|
|
565
|
+
return [`Active bootstrap seed changes overlap governance setup: ${activeSeeds.join(', ')}.`];
|
|
566
|
+
}
|
|
567
|
+
async function readMetadataCandidate(projectRoot, workflowKind, changeId, pathParts, requireMetadata) {
|
|
568
|
+
const metadataBytes = await readProjectFile(projectRoot, [...pathParts, governanceChangeMetadataFileName]);
|
|
569
|
+
if (metadataBytes === undefined) {
|
|
570
|
+
return {
|
|
571
|
+
changeId,
|
|
572
|
+
workflowKind,
|
|
573
|
+
pathParts,
|
|
574
|
+
status: requireMetadata ? 'missing-metadata' : 'invalid-metadata',
|
|
575
|
+
issues: [`${[...pathParts, governanceChangeMetadataFileName].join('/')} is missing.`]
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
let parsed;
|
|
579
|
+
try {
|
|
580
|
+
parsed = JSON.parse(metadataBytes.toString('utf8'));
|
|
581
|
+
}
|
|
582
|
+
catch (error) {
|
|
583
|
+
return {
|
|
584
|
+
changeId,
|
|
585
|
+
workflowKind,
|
|
586
|
+
pathParts,
|
|
587
|
+
status: 'invalid-metadata',
|
|
588
|
+
issues: [`Unable to parse governance metadata: ${errorMessage(error)}`]
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
try {
|
|
592
|
+
const metadata = validateGovernanceChangeMetadata(parsed);
|
|
593
|
+
const issues = [];
|
|
594
|
+
if (metadata.changeId !== changeId) {
|
|
595
|
+
issues.push(`metadata changeId ${metadata.changeId} does not match path ${changeId}.`);
|
|
596
|
+
}
|
|
597
|
+
if (metadata.workflowKind !== workflowKind) {
|
|
598
|
+
issues.push(`metadata workflowKind ${metadata.workflowKind} does not match ${workflowKind}.`);
|
|
599
|
+
}
|
|
600
|
+
issues.push(...validateCurrentIdentity(metadata.activationIdentity, 'activationIdentity'));
|
|
601
|
+
if (metadata.phaseGraphHash !== canonicalPhaseGraphHash) {
|
|
602
|
+
issues.push(`phaseGraphHash expected ${canonicalPhaseGraphHash}, found ${metadata.phaseGraphHash}.`);
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
changeId,
|
|
606
|
+
workflowKind,
|
|
607
|
+
pathParts,
|
|
608
|
+
status: issues.length === 0 ? 'compatible' : 'identity-mismatch',
|
|
609
|
+
metadata,
|
|
610
|
+
issues
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
catch (error) {
|
|
614
|
+
return {
|
|
615
|
+
changeId,
|
|
616
|
+
workflowKind,
|
|
617
|
+
pathParts,
|
|
618
|
+
status: 'invalid-metadata',
|
|
619
|
+
issues: [errorMessage(error)]
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
async function activeGovernanceCandidates(projectRoot, manifest, state) {
|
|
624
|
+
const candidates = [];
|
|
625
|
+
if (manifest.project.specWorkflow === 'openspec') {
|
|
626
|
+
for (const changeId of await directoryEntries(projectRoot, ['openspec', 'changes'])) {
|
|
627
|
+
if (changeId === 'archive' || changeId.startsWith('bootstrap-')) {
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
const pathParts = ['openspec', 'changes', changeId];
|
|
631
|
+
const metadataBytes = await readProjectFile(projectRoot, [...pathParts, governanceChangeMetadataFileName]);
|
|
632
|
+
if (metadataBytes !== undefined || state.activeChange?.id === changeId) {
|
|
633
|
+
candidates.push(await readMetadataCandidate(projectRoot, 'openspec', changeId, pathParts, state.activeChange?.id === changeId));
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
else {
|
|
638
|
+
for (const changeId of await directoryEntries(projectRoot, ['specs'])) {
|
|
639
|
+
const pathParts = ['specs', changeId];
|
|
640
|
+
const metadataBytes = await readProjectFile(projectRoot, [...pathParts, governanceChangeMetadataFileName]);
|
|
641
|
+
if (metadataBytes !== undefined || state.activeChange?.id === changeId) {
|
|
642
|
+
candidates.push(await readMetadataCandidate(projectRoot, 'spec-kit', changeId, pathParts, state.activeChange?.id === changeId));
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (state.activeChange && !candidates.some((candidate) => candidate.changeId === state.activeChange?.id)) {
|
|
647
|
+
const pathParts = state.activeChange.kind === 'openspec'
|
|
648
|
+
? ['openspec', 'changes', state.activeChange.id]
|
|
649
|
+
: ['specs', state.activeChange.id];
|
|
650
|
+
candidates.push({
|
|
651
|
+
changeId: state.activeChange.id,
|
|
652
|
+
workflowKind: state.activeChange.kind === 'openspec' ? 'openspec' : 'spec-kit',
|
|
653
|
+
pathParts,
|
|
654
|
+
status: 'missing-metadata',
|
|
655
|
+
issues: [`Activation state activeChange ${state.activeChange.id} has no inspectable governance metadata.`]
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
return candidates.sort((left, right) => left.changeId.localeCompare(right.changeId, 'en'));
|
|
659
|
+
}
|
|
660
|
+
async function readSupersessionRecords(projectRoot, candidates) {
|
|
661
|
+
const directory = await resolveProjectPath(projectRoot, [...governanceSupersessionPathParts]);
|
|
662
|
+
let entries;
|
|
663
|
+
try {
|
|
664
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
665
|
+
}
|
|
666
|
+
catch (error) {
|
|
667
|
+
if (errorCode(error) === 'ENOENT') {
|
|
668
|
+
return { records: [], invalidRecords: [], selectedChangeId: null, issues: [] };
|
|
669
|
+
}
|
|
670
|
+
throw new Error(`Unable to inspect ${governanceSupersessionPathParts.join('/')}: ${errorMessage(error)}.`);
|
|
671
|
+
}
|
|
672
|
+
const byId = new Map(candidates.map((candidate) => [candidate.changeId, candidate]));
|
|
673
|
+
const records = [];
|
|
674
|
+
const invalidRecords = [];
|
|
675
|
+
const issues = [];
|
|
676
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) {
|
|
677
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) {
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
const pathParts = [...governanceSupersessionPathParts, entry.name];
|
|
681
|
+
const bytes = await readProjectFile(projectRoot, pathParts);
|
|
682
|
+
if (bytes === undefined) {
|
|
683
|
+
throw new Error(`${pathParts.join('/')} disappeared during inspection.`);
|
|
684
|
+
}
|
|
685
|
+
try {
|
|
686
|
+
const parsed = JSON.parse(bytes.toString('utf8'));
|
|
687
|
+
const record = validateSupersessionRecord(parsed);
|
|
688
|
+
const superseded = byId.get(record.supersededChangeId);
|
|
689
|
+
const superseding = byId.get(record.supersedingChangeId);
|
|
690
|
+
if (!superseded || !superseding) {
|
|
691
|
+
issues.push(`${pathParts.join('/')} references a change that is not active and valid in this inspection.`);
|
|
692
|
+
}
|
|
693
|
+
else if (superseded.status !== 'compatible' || superseding.status !== 'compatible') {
|
|
694
|
+
issues.push(`${pathParts.join('/')} references a change without a compatible identity.`);
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
records.push(record);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
catch (error) {
|
|
701
|
+
invalidRecords.push(`${pathParts.join('/')}: ${errorMessage(error)}`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
const compatible = candidates.filter((candidate) => candidate.status === 'compatible');
|
|
705
|
+
if (invalidRecords.length > 0 || issues.length > 0) {
|
|
706
|
+
return {
|
|
707
|
+
records,
|
|
708
|
+
invalidRecords,
|
|
709
|
+
selectedChangeId: null,
|
|
710
|
+
issues
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
const selectable = compatible.filter((candidate) => compatible
|
|
714
|
+
.filter((other) => other.changeId !== candidate.changeId)
|
|
715
|
+
.every((other) => records.some((record) => record.supersededChangeId === other.changeId &&
|
|
716
|
+
record.supersedingChangeId === candidate.changeId)));
|
|
717
|
+
return {
|
|
718
|
+
records,
|
|
719
|
+
invalidRecords,
|
|
720
|
+
selectedChangeId: selectable.length === 1 ? selectable[0].changeId : null,
|
|
721
|
+
issues
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
export function buildApprovedPhase0FactsFromState(manifest, state, evidence) {
|
|
725
|
+
const validPhase0 = evidence
|
|
726
|
+
.filter((record) => record.header.phaseId === 'phase-0-complete')
|
|
727
|
+
.flatMap((record) => {
|
|
728
|
+
const context = evidenceContextForPhase('phase-0-complete', {
|
|
729
|
+
repositoryId: state.repository.id,
|
|
730
|
+
identity: state.identity,
|
|
731
|
+
phaseGraphHash: state.identity.phaseGraphHash,
|
|
732
|
+
baselineSha: record.header.baselineSha,
|
|
733
|
+
inputDigest: record.header.inputDigest
|
|
734
|
+
});
|
|
735
|
+
const validation = validateEvidenceFreshness(record, context);
|
|
736
|
+
return validation.valid ? [validation.record] : [];
|
|
737
|
+
})
|
|
738
|
+
.sort((left, right) => {
|
|
739
|
+
const time = right.producedAtEpochMs - left.producedAtEpochMs;
|
|
740
|
+
if (time !== 0) {
|
|
741
|
+
return time;
|
|
742
|
+
}
|
|
743
|
+
const evidenceId = right.evidenceId.localeCompare(left.evidenceId, 'en');
|
|
744
|
+
return evidenceId === 0 ? right.headerDigest.localeCompare(left.headerDigest, 'en') : evidenceId;
|
|
745
|
+
});
|
|
746
|
+
const phase0 = validPhase0[0];
|
|
747
|
+
if (!phase0) {
|
|
748
|
+
return undefined;
|
|
749
|
+
}
|
|
750
|
+
const latestResults = new Set(validPhase0
|
|
751
|
+
.filter((record) => record.producedAtEpochMs === phase0.producedAtEpochMs)
|
|
752
|
+
.map((record) => record.header.result));
|
|
753
|
+
if (latestResults.size !== 1 || phase0.header.result !== 'verified') {
|
|
754
|
+
return undefined;
|
|
755
|
+
}
|
|
756
|
+
return {
|
|
757
|
+
projectName: manifest.project.name,
|
|
758
|
+
repositoryId: state.repository.id,
|
|
759
|
+
repositoryName: state.repository.name,
|
|
760
|
+
defaultBranch: state.repository.defaultBranch,
|
|
761
|
+
workflowKind: manifest.project.specWorkflow,
|
|
762
|
+
baselineSha: phase0.header.baselineSha,
|
|
763
|
+
evidenceIds: [phase0.evidenceId],
|
|
764
|
+
approvedFacts: [
|
|
765
|
+
{ id: 'repositoryId', value: state.repository.id },
|
|
766
|
+
{ id: 'repositoryName', value: state.repository.name },
|
|
767
|
+
{ id: 'defaultBranch', value: state.repository.defaultBranch },
|
|
768
|
+
{ id: 'specWorkflow', value: manifest.project.specWorkflow }
|
|
769
|
+
],
|
|
770
|
+
approvedAt: phase0.header.producedAt,
|
|
771
|
+
approver: phase0.header.producer
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
function createPreview(manifest, state, evidence) {
|
|
775
|
+
const facts = buildApprovedPhase0FactsFromState(manifest, state, evidence);
|
|
776
|
+
if (facts) {
|
|
777
|
+
return {
|
|
778
|
+
status: 'ready',
|
|
779
|
+
reason: 'Verified Phase 0 evidence is available for deterministic governance change rendering.',
|
|
780
|
+
changeId: deterministicGovernanceChangeId(facts),
|
|
781
|
+
workflowKind: facts.workflowKind,
|
|
782
|
+
requiredFacts: []
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
const fallback = {
|
|
786
|
+
projectName: manifest.project.name,
|
|
787
|
+
baselineSha: '0'.repeat(64),
|
|
788
|
+
workflowKind: manifest.project.specWorkflow
|
|
789
|
+
};
|
|
790
|
+
return {
|
|
791
|
+
status: 'blocked',
|
|
792
|
+
reason: 'Verified Phase 0 evidence is required before rendering user-owned governance artifacts.',
|
|
793
|
+
changeId: deterministicGovernanceChangeId(fallback),
|
|
794
|
+
workflowKind: manifest.project.specWorkflow,
|
|
795
|
+
requiredFacts: ['phase-0-complete verified evidence', 'repository id', 'repository name', 'default branch', 'baseline SHA']
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
export function reconcileActiveGovernanceChange(input) {
|
|
799
|
+
const toGraph = input.toGraph ?? canonicalPhaseGraph;
|
|
800
|
+
const fromGraph = input.fromGraph ?? toGraph;
|
|
801
|
+
const toIdentity = input.toIdentity ?? currentActivationIdentity;
|
|
802
|
+
if (canonicalJson(input.metadata.activationIdentity) === canonicalJson(toIdentity)) {
|
|
803
|
+
return {
|
|
804
|
+
status: 'not-required',
|
|
805
|
+
approvalRequired: false,
|
|
806
|
+
preservedPhaseIds: phaseIds,
|
|
807
|
+
invalidPhaseIds: [],
|
|
808
|
+
issues: []
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
if (input.metadata.activationIdentity.policyVersion !== toIdentity.policyVersion ||
|
|
812
|
+
input.metadata.activationIdentity.activationContractVersion !== toIdentity.activationContractVersion ||
|
|
813
|
+
input.metadata.activationIdentity.phaseGraphSchemaVersion !== toIdentity.phaseGraphSchemaVersion) {
|
|
814
|
+
return {
|
|
815
|
+
status: 'blocked',
|
|
816
|
+
approvalRequired: false,
|
|
817
|
+
issues: ['Active governance change identity is incompatible with the installed policy, contract, or graph schema.'],
|
|
818
|
+
preservedPhaseIds: [],
|
|
819
|
+
invalidPhaseIds: phaseIds
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
const fromHash = input.metadata.activationIdentity.phaseGraphHash;
|
|
823
|
+
const toHash = toIdentity.phaseGraphHash;
|
|
824
|
+
if (fromHash !== toHash && input.fromGraph === undefined) {
|
|
825
|
+
return {
|
|
826
|
+
status: 'blocked',
|
|
827
|
+
approvalRequired: false,
|
|
828
|
+
issues: [
|
|
829
|
+
'Active governance change records a different phase-graph hash, but no recognized source graph or explicit mapping was provided for phase contract reconciliation.'
|
|
830
|
+
],
|
|
831
|
+
preservedPhaseIds: [],
|
|
832
|
+
invalidPhaseIds: phaseIds
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
const fromDigests = phaseContractDigests(fromGraph);
|
|
836
|
+
const toDigests = phaseContractDigests(toGraph);
|
|
837
|
+
const record = {
|
|
838
|
+
schemaVersion: currentActivationIdentity.activationStateSchemaVersion,
|
|
839
|
+
fromGraphHash: fromHash,
|
|
840
|
+
toGraphHash: toHash,
|
|
841
|
+
fromIdentity: input.metadata.activationIdentity,
|
|
842
|
+
toIdentity,
|
|
843
|
+
phaseMappings: phaseIds.map((phaseId) => ({
|
|
844
|
+
phaseId,
|
|
845
|
+
fromContractDigest: fromDigests[phaseId],
|
|
846
|
+
toContractDigest: toDigests[phaseId],
|
|
847
|
+
preserveEvidence: fromDigests[phaseId] === toDigests[phaseId]
|
|
848
|
+
})),
|
|
849
|
+
reconciledAt: input.reconciledAt ?? new Date().toISOString(),
|
|
850
|
+
producer: 'liftoff-governance-source-of-truth'
|
|
851
|
+
};
|
|
852
|
+
try {
|
|
853
|
+
const reconciliation = calculateGraphReconciliation(record, {
|
|
854
|
+
fromGraph,
|
|
855
|
+
toGraph,
|
|
856
|
+
recognizedGraphHashes: input.recognizedGraphHashes ?? new Set([toHash])
|
|
857
|
+
});
|
|
858
|
+
const invalid = new Set(reconciliation.invalidPhaseIds);
|
|
859
|
+
return {
|
|
860
|
+
status: 'required',
|
|
861
|
+
approvalRequired: true,
|
|
862
|
+
fromIdentity: input.metadata.activationIdentity,
|
|
863
|
+
toIdentity,
|
|
864
|
+
record: reconciliation.record,
|
|
865
|
+
preservedPhaseIds: reconciliation.preservedPhaseIds,
|
|
866
|
+
invalidPhaseIds: reconciliation.invalidPhaseIds,
|
|
867
|
+
evidenceToReview: input.evidence
|
|
868
|
+
.filter((entry) => invalid.has(entry.header.phaseId))
|
|
869
|
+
.map((entry) => entry.evidenceId),
|
|
870
|
+
issues: ['Active governance change must acknowledge the installed activation identity before execution can continue.']
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
catch (error) {
|
|
874
|
+
return {
|
|
875
|
+
status: 'blocked',
|
|
876
|
+
approvalRequired: false,
|
|
877
|
+
issues: [errorMessage(error)],
|
|
878
|
+
preservedPhaseIds: [],
|
|
879
|
+
invalidPhaseIds: phaseIds
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
export function stateWithSelectedActiveChange(state, selected) {
|
|
884
|
+
if (selected.status !== 'compatible') {
|
|
885
|
+
throw new Error(`Cannot record incompatible active change ${selected.changeId}.`);
|
|
886
|
+
}
|
|
887
|
+
return {
|
|
888
|
+
...state,
|
|
889
|
+
activeChange: {
|
|
890
|
+
id: selected.changeId,
|
|
891
|
+
kind: selected.workflowKind === 'openspec' ? 'openspec' : 'spec-kit'
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
export async function inspectGovernanceSourceOfTruth(input) {
|
|
896
|
+
const seedBlockers = await activeSeedBlockers(input.projectRoot, input.manifest);
|
|
897
|
+
const candidates = await activeGovernanceCandidates(input.projectRoot, input.manifest, input.state);
|
|
898
|
+
if (seedBlockers.length > 0) {
|
|
899
|
+
return {
|
|
900
|
+
status: 'seed-blocked',
|
|
901
|
+
seedChangeId: generatedSeedChangeName(input.manifest),
|
|
902
|
+
blockers: seedBlockers,
|
|
903
|
+
selected: null,
|
|
904
|
+
candidates
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
const invalid = candidates.filter((candidate) => candidate.status === 'invalid-metadata' || candidate.status === 'missing-metadata');
|
|
908
|
+
if (invalid.length > 0) {
|
|
909
|
+
return {
|
|
910
|
+
status: 'incompatible',
|
|
911
|
+
selected: null,
|
|
912
|
+
candidates,
|
|
913
|
+
blockers: invalid.flatMap((candidate) => candidate.issues.map((issue) => `${candidate.changeId}: ${issue}`)),
|
|
914
|
+
reconciliation: {
|
|
915
|
+
status: 'blocked',
|
|
916
|
+
approvalRequired: false,
|
|
917
|
+
issues: invalid.flatMap((candidate) => candidate.issues),
|
|
918
|
+
preservedPhaseIds: [],
|
|
919
|
+
invalidPhaseIds: phaseIds
|
|
920
|
+
}
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
const mismatched = candidates.filter((candidate) => candidate.status === 'identity-mismatch');
|
|
924
|
+
if (mismatched.length > 0) {
|
|
925
|
+
return {
|
|
926
|
+
status: 'incompatible',
|
|
927
|
+
selected: null,
|
|
928
|
+
candidates,
|
|
929
|
+
blockers: mismatched.flatMap((candidate) => candidate.issues.map((issue) => `${candidate.changeId}: ${issue}`)),
|
|
930
|
+
reconciliation: mismatched[0]?.metadata
|
|
931
|
+
? reconcileActiveGovernanceChange({ metadata: mismatched[0].metadata, evidence: input.evidence })
|
|
932
|
+
: {
|
|
933
|
+
status: 'blocked',
|
|
934
|
+
approvalRequired: false,
|
|
935
|
+
issues: ['Active governance change lacks valid metadata.'],
|
|
936
|
+
preservedPhaseIds: [],
|
|
937
|
+
invalidPhaseIds: phaseIds
|
|
938
|
+
}
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
const compatible = candidates.filter((candidate) => candidate.status === 'compatible');
|
|
942
|
+
if (compatible.length === 0) {
|
|
943
|
+
return {
|
|
944
|
+
status: 'none',
|
|
945
|
+
selected: null,
|
|
946
|
+
candidates,
|
|
947
|
+
createPlan: createPreview(input.manifest, input.state, input.evidence)
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
const supersession = await readSupersessionRecords(input.projectRoot, compatible);
|
|
951
|
+
if (compatible.length > 1) {
|
|
952
|
+
if (supersession.selectedChangeId) {
|
|
953
|
+
const selected = compatible.find((candidate) => candidate.changeId === supersession.selectedChangeId);
|
|
954
|
+
return {
|
|
955
|
+
status: 'selected',
|
|
956
|
+
selected,
|
|
957
|
+
candidates,
|
|
958
|
+
recordActiveChangeOnNextMutation: input.state.activeChange?.id !== selected.changeId,
|
|
959
|
+
reconciliation: reconcileActiveGovernanceChange({ metadata: selected.metadata, evidence: input.evidence }),
|
|
960
|
+
supersession
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
return {
|
|
964
|
+
status: 'ambiguous',
|
|
965
|
+
selected: null,
|
|
966
|
+
candidates,
|
|
967
|
+
blockers: [
|
|
968
|
+
'Multiple compatible governance changes overlap activation scope.',
|
|
969
|
+
'Add explicit schema-valid supersession records; Liftoff never selects by timestamp or name.',
|
|
970
|
+
...supersession.invalidRecords,
|
|
971
|
+
...supersession.issues
|
|
972
|
+
],
|
|
973
|
+
supersession
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
const selected = compatible[0];
|
|
977
|
+
return {
|
|
978
|
+
status: 'selected',
|
|
979
|
+
selected,
|
|
980
|
+
candidates,
|
|
981
|
+
recordActiveChangeOnNextMutation: input.state.activeChange?.id !== selected.changeId,
|
|
982
|
+
reconciliation: reconcileActiveGovernanceChange({ metadata: selected.metadata, evidence: input.evidence }),
|
|
983
|
+
supersession
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
//# sourceMappingURL=source-of-truth.js.map
|