@msn-control/liftoff 0.9.9 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEVELOPER.md +142 -0
- package/README.md +26 -18
- package/assets/governance/single-maintainer-gitflow/policy.md +13 -11
- package/dist/args.js +23 -1
- 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/catalogs.d.ts +2 -0
- package/dist/catalogs.js +13 -1
- package/dist/catalogs.js.map +1 -1
- package/dist/commands.js +365 -17
- 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/interactive.js +4 -3
- package/dist/interactive.js.map +1 -1
- package/dist/planner.js +5 -5
- 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 +357 -51
- 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 +15 -7
- package/docs/cli-reference.md +61 -13
- package/docs/configuration-and-manifests.md +43 -16
- package/docs/existing-repositories.md +22 -11
- package/docs/getting-started.md +46 -10
- package/docs/project-structure.md +35 -6
- package/docs/repository-governance.md +200 -245
- package/docs/safety-and-consent.md +24 -2
- package/docs/troubleshooting.md +59 -4
- package/docs/workloads.md +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,1023 @@
|
|
|
1
|
+
import { access, readdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readBooleanFlag, readStringFlag } from '../args.js';
|
|
4
|
+
import { findProjectRoot, loadManifest, readProjectFile, resolveProjectPath, validateArtifactPathParts } from '../file-system.js';
|
|
5
|
+
import { governanceArtifactPaths, validateGovernancePolicy } from '../repository-governance.js';
|
|
6
|
+
import { loadActivationState } from './activation-state.js';
|
|
7
|
+
import { canonicalSha256 } from './canonical-json.js';
|
|
8
|
+
import { canonicalApprovalEnvelopeHash, evaluateApprovalForTransitionPlan, transitionPlanForPhase } from './approvals.js';
|
|
9
|
+
import { buildPatEnrollmentGuidance, canonicalCredentialRepository, credentialPolicyPathParts, detectCredentialLeaks, runnerPreflightPermissions, validateCredentialPolicyUsage } from './credentials.js';
|
|
10
|
+
import { evidenceContextForPhase, requiredLiveReadbackProviders, selectLatestPhaseEvidence } from './evidence.js';
|
|
11
|
+
import { canonicalPhaseGraph, canonicalPhaseGraphHash, currentActivationIdentity, phaseContractDigests } from './graph.js';
|
|
12
|
+
import { governanceActivationPolicyVersion } from './identity.js';
|
|
13
|
+
import { calculatePhaseReadiness } from './readiness.js';
|
|
14
|
+
import { projectOpenSpecTaskCheckboxes } from './task-projection.js';
|
|
15
|
+
import { inspectGovernanceSourceOfTruth } from './source-of-truth.js';
|
|
16
|
+
import { executeApplyNext, previewApplyNext } from './transitions.js';
|
|
17
|
+
import { phaseIds } from './types.js';
|
|
18
|
+
import { validateApprovalEnvelope, validateEvidenceHeader, validateLiveReadbackProof, validateManagedPhaseGraph, validateCredentialPolicy } from './validators.js';
|
|
19
|
+
const governanceSubcommands = new Set([
|
|
20
|
+
'status',
|
|
21
|
+
'plan',
|
|
22
|
+
'apply-next',
|
|
23
|
+
'resume',
|
|
24
|
+
'verify'
|
|
25
|
+
]);
|
|
26
|
+
const managedPhaseGraphPathParts = ['.liftoff', 'governance', 'phase-graph.json'];
|
|
27
|
+
const approvalDirectoryPathParts = ['governance', 'approvals'];
|
|
28
|
+
const evidenceDirectoryPathParts = ['governance', 'evidence'];
|
|
29
|
+
const terminalEvidenceStates = new Set([
|
|
30
|
+
'verified',
|
|
31
|
+
'failed',
|
|
32
|
+
'inapplicable',
|
|
33
|
+
'retained',
|
|
34
|
+
'disposed'
|
|
35
|
+
]);
|
|
36
|
+
function errorMessage(error) {
|
|
37
|
+
return error instanceof Error ? error.message : String(error);
|
|
38
|
+
}
|
|
39
|
+
function errorCode(error) {
|
|
40
|
+
if (typeof error !== 'object' || error === null || !('code' in error)) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const code = error.code;
|
|
44
|
+
return typeof code === 'string' ? code : undefined;
|
|
45
|
+
}
|
|
46
|
+
function json(presentation, value) {
|
|
47
|
+
presentation.rawStdout(`${JSON.stringify(value, null, 2)}\n`);
|
|
48
|
+
}
|
|
49
|
+
function parseGovernanceSubcommand(parsed) {
|
|
50
|
+
if (!parsed.subcommand || !governanceSubcommands.has(parsed.subcommand)) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
return parsed.subcommand;
|
|
54
|
+
}
|
|
55
|
+
async function resolveGovernanceProjectRoot(parsed, context) {
|
|
56
|
+
const positionalProject = parsed.positional[0];
|
|
57
|
+
const flagProject = readStringFlag(parsed.flags, 'project');
|
|
58
|
+
if (positionalProject && flagProject) {
|
|
59
|
+
throw new Error('Provide a project path either positionally or with --project, not both.');
|
|
60
|
+
}
|
|
61
|
+
const explicit = positionalProject ?? flagProject;
|
|
62
|
+
const start = explicit ? path.resolve(context.cwd, explicit) : context.cwd;
|
|
63
|
+
return await findProjectRoot(start);
|
|
64
|
+
}
|
|
65
|
+
function projectRootError(start) {
|
|
66
|
+
return {
|
|
67
|
+
message: `No liftoff.manifest.json found in ${start} or any parent directory.`,
|
|
68
|
+
remedy: 'Run this command inside a Liftoff project or provide its path explicitly.'
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function emptyPhaseState(now) {
|
|
72
|
+
const phases = {};
|
|
73
|
+
for (const phaseId of phaseIds) {
|
|
74
|
+
phases[phaseId] = {
|
|
75
|
+
state: 'pending',
|
|
76
|
+
updatedAt: now,
|
|
77
|
+
evidence: [],
|
|
78
|
+
approvals: [],
|
|
79
|
+
blockers: []
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return phases;
|
|
83
|
+
}
|
|
84
|
+
function notStartedState(manifest) {
|
|
85
|
+
const now = '1970-01-01T00:00:00.000Z';
|
|
86
|
+
return {
|
|
87
|
+
schemaVersion: currentActivationIdentity.activationStateSchemaVersion,
|
|
88
|
+
identity: currentActivationIdentity,
|
|
89
|
+
repository: {
|
|
90
|
+
id: `local:${manifest.project.name}`,
|
|
91
|
+
name: manifest.project.name,
|
|
92
|
+
defaultBranch: 'develop'
|
|
93
|
+
},
|
|
94
|
+
activeChange: null,
|
|
95
|
+
applicability: {
|
|
96
|
+
statePath: 'none',
|
|
97
|
+
privateStagingDast: false,
|
|
98
|
+
credentialRequired: false
|
|
99
|
+
},
|
|
100
|
+
phases: emptyPhaseState(now),
|
|
101
|
+
createdAt: now,
|
|
102
|
+
updatedAt: now
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
async function loadGovernanceGraph(projectRoot) {
|
|
106
|
+
const managed = await readProjectFile(projectRoot, [...managedPhaseGraphPathParts]);
|
|
107
|
+
if (managed === undefined) {
|
|
108
|
+
const graph = validateManagedPhaseGraph(canonicalPhaseGraph);
|
|
109
|
+
return { source: 'packaged', graph, hash: canonicalPhaseGraphHash };
|
|
110
|
+
}
|
|
111
|
+
let parsed;
|
|
112
|
+
try {
|
|
113
|
+
parsed = JSON.parse(managed.toString('utf8'));
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
throw new Error(`Unable to parse ${managedPhaseGraphPathParts.join('/')}: ${errorMessage(error)}`);
|
|
117
|
+
}
|
|
118
|
+
const graph = validateManagedPhaseGraph(parsed);
|
|
119
|
+
const hash = canonicalSha256(graph);
|
|
120
|
+
if (hash !== canonicalPhaseGraphHash || hash !== currentActivationIdentity.phaseGraphHash) {
|
|
121
|
+
throw new Error(`${managedPhaseGraphPathParts.join('/')} identity drift: expected graph hash ${canonicalPhaseGraphHash}, found ${hash}.`);
|
|
122
|
+
}
|
|
123
|
+
return { source: 'managed', graph, hash };
|
|
124
|
+
}
|
|
125
|
+
async function readJsonFiles(projectRoot, directoryPathParts, label) {
|
|
126
|
+
const directory = await resolveProjectPath(projectRoot, validateArtifactPathParts([...directoryPathParts], `${label} directory path`));
|
|
127
|
+
let entries;
|
|
128
|
+
try {
|
|
129
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (errorCode(error) === 'ENOENT') {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
throw new Error(`Unable to read ${directoryPathParts.join('/')}: ${errorMessage(error)}`);
|
|
136
|
+
}
|
|
137
|
+
const values = [];
|
|
138
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) {
|
|
139
|
+
if (!entry.name.endsWith('.json')) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (!entry.isFile()) {
|
|
143
|
+
throw new Error(`${directoryPathParts.join('/')}/${entry.name} must be a regular JSON file.`);
|
|
144
|
+
}
|
|
145
|
+
const pathParts = validateArtifactPathParts([...directoryPathParts, entry.name], `${label} file path`);
|
|
146
|
+
const bytes = await readProjectFile(projectRoot, pathParts);
|
|
147
|
+
if (bytes === undefined) {
|
|
148
|
+
throw new Error(`${pathParts.join('/')} disappeared during governance inspection.`);
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
values.push({ name: entry.name, value: JSON.parse(bytes.toString('utf8')) });
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
throw new Error(`Unable to parse ${pathParts.join('/')}: ${errorMessage(error)}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return values;
|
|
158
|
+
}
|
|
159
|
+
async function loadApprovals(projectRoot, identity) {
|
|
160
|
+
const entries = await readJsonFiles(projectRoot, approvalDirectoryPathParts, 'Approval');
|
|
161
|
+
return entries.map((entry) => {
|
|
162
|
+
try {
|
|
163
|
+
return validateApprovalEnvelope(entry.value, { expectedIdentity: identity });
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
throw new Error(`Invalid ${approvalDirectoryPathParts.join('/')}/${entry.name}: ${errorMessage(error)}`);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
function asRecord(value, pathLabel) {
|
|
171
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
172
|
+
throw new Error(`${pathLabel} must be a JSON object.`);
|
|
173
|
+
}
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
function validateEvidenceRecord(value, evidenceId, pathLabel) {
|
|
177
|
+
const record = asRecord(value, pathLabel);
|
|
178
|
+
if (Object.hasOwn(record, 'header')) {
|
|
179
|
+
const allowed = new Set(['evidenceId', 'header', 'liveReadback', 'payload']);
|
|
180
|
+
for (const key of Object.keys(record)) {
|
|
181
|
+
if (!allowed.has(key)) {
|
|
182
|
+
throw new Error(`${pathLabel}.${key} is not allowed.`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (typeof record.evidenceId !== 'string' || record.evidenceId.length === 0) {
|
|
186
|
+
throw new Error(`${pathLabel}.evidenceId must be a non-empty string.`);
|
|
187
|
+
}
|
|
188
|
+
const liveReadback = record.liveReadback === undefined
|
|
189
|
+
? undefined
|
|
190
|
+
: validateLiveReadbackArray(record.liveReadback, `${pathLabel}.liveReadback`);
|
|
191
|
+
return {
|
|
192
|
+
evidenceId: record.evidenceId,
|
|
193
|
+
header: validateEvidenceHeader(record.header),
|
|
194
|
+
...(liveReadback ? { liveReadback } : {}),
|
|
195
|
+
...(Object.hasOwn(record, 'payload') ? { payload: record.payload } : {})
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
evidenceId,
|
|
200
|
+
header: validateEvidenceHeader(value)
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function validateLiveReadbackArray(value, pathLabel) {
|
|
204
|
+
if (!Array.isArray(value)) {
|
|
205
|
+
throw new Error(`${pathLabel} must be an array.`);
|
|
206
|
+
}
|
|
207
|
+
return value.map((entry, index) => {
|
|
208
|
+
try {
|
|
209
|
+
return validateLiveReadbackProof(entry);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
throw new Error(`${pathLabel}[${index}]: ${errorMessage(error)}`);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
async function loadEvidence(projectRoot) {
|
|
217
|
+
const entries = await readJsonFiles(projectRoot, evidenceDirectoryPathParts, 'Evidence');
|
|
218
|
+
return entries.map((entry) => {
|
|
219
|
+
try {
|
|
220
|
+
const evidenceId = entry.name.replace(/\.json$/u, '');
|
|
221
|
+
return validateEvidenceRecord(entry.value, evidenceId, `${evidenceDirectoryPathParts.join('/')}/${entry.name}`);
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
throw new Error(`Invalid ${evidenceDirectoryPathParts.join('/')}/${entry.name}: ${errorMessage(error)}`);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
function repositoryFromState(state) {
|
|
229
|
+
const repositoryName = state.repository.name.includes('/')
|
|
230
|
+
? state.repository.name.split('/').at(-1)
|
|
231
|
+
: state.repository.name;
|
|
232
|
+
const owner = state.repository.name.includes('/')
|
|
233
|
+
? state.repository.name.split('/')[0]
|
|
234
|
+
: 'local';
|
|
235
|
+
return canonicalCredentialRepository({
|
|
236
|
+
id: state.repository.id,
|
|
237
|
+
owner,
|
|
238
|
+
name: repositoryName
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
async function inspectCredentialPolicy(projectRoot, state) {
|
|
242
|
+
const pathLabel = credentialPolicyPathParts.join('/');
|
|
243
|
+
if (!state.applicability.credentialRequired) {
|
|
244
|
+
return {
|
|
245
|
+
applicable: false,
|
|
246
|
+
readOnly: true,
|
|
247
|
+
path: pathLabel,
|
|
248
|
+
status: 'not-applicable',
|
|
249
|
+
ready: false,
|
|
250
|
+
guidance: null,
|
|
251
|
+
policy: null,
|
|
252
|
+
issues: []
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const repository = repositoryFromState(state);
|
|
256
|
+
const guidance = buildPatEnrollmentGuidance({ repository });
|
|
257
|
+
const bytes = await readProjectFile(projectRoot, [...credentialPolicyPathParts]);
|
|
258
|
+
if (bytes === undefined) {
|
|
259
|
+
return {
|
|
260
|
+
applicable: true,
|
|
261
|
+
readOnly: true,
|
|
262
|
+
path: pathLabel,
|
|
263
|
+
status: 'missing',
|
|
264
|
+
ready: false,
|
|
265
|
+
guidance,
|
|
266
|
+
policy: null,
|
|
267
|
+
issues: [`${pathLabel} is missing; deterministic PAT enrollment is required if no selected-repository App is available.`]
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
const text = bytes.toString('utf8');
|
|
271
|
+
const leaks = detectCredentialLeaks([{ source: 'imported-evidence', label: pathLabel, text }]);
|
|
272
|
+
if (leaks.status === 'compromised') {
|
|
273
|
+
return {
|
|
274
|
+
applicable: true,
|
|
275
|
+
readOnly: true,
|
|
276
|
+
path: pathLabel,
|
|
277
|
+
status: 'compromised',
|
|
278
|
+
ready: false,
|
|
279
|
+
guidance,
|
|
280
|
+
policy: null,
|
|
281
|
+
issues: leaks.guidance
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
let parsed;
|
|
285
|
+
try {
|
|
286
|
+
parsed = JSON.parse(text);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
return {
|
|
290
|
+
applicable: true,
|
|
291
|
+
readOnly: true,
|
|
292
|
+
path: pathLabel,
|
|
293
|
+
status: 'invalid',
|
|
294
|
+
ready: false,
|
|
295
|
+
guidance,
|
|
296
|
+
policy: null,
|
|
297
|
+
issues: [`Unable to parse ${pathLabel}: ${errorMessage(error)}`]
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
const policy = validateCredentialPolicy(parsed);
|
|
302
|
+
const usage = validateCredentialPolicyUsage(policy, {
|
|
303
|
+
repository: policy.repository,
|
|
304
|
+
permissions: runnerPreflightPermissions(),
|
|
305
|
+
references: policy.allowedWorkflows.flatMap((entry) => entry.jobs.map((job) => ({ workflowPath: entry.path, job }))),
|
|
306
|
+
forwardsCredential: false,
|
|
307
|
+
verifiedReadbackDigest: policy.proof.readbackDigest
|
|
308
|
+
});
|
|
309
|
+
return {
|
|
310
|
+
applicable: true,
|
|
311
|
+
readOnly: true,
|
|
312
|
+
path: pathLabel,
|
|
313
|
+
status: usage.ready ? 'valid' : 'not-ready',
|
|
314
|
+
ready: usage.ready,
|
|
315
|
+
guidance: usage.ready ? null : guidance,
|
|
316
|
+
policy,
|
|
317
|
+
issues: usage.issues
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
return {
|
|
322
|
+
applicable: true,
|
|
323
|
+
readOnly: true,
|
|
324
|
+
path: pathLabel,
|
|
325
|
+
status: 'invalid',
|
|
326
|
+
ready: false,
|
|
327
|
+
guidance,
|
|
328
|
+
policy: null,
|
|
329
|
+
issues: [errorMessage(error)]
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function phaseMap(graph) {
|
|
334
|
+
return Object.fromEntries(graph.phases.map((phase) => [phase.id, phase]));
|
|
335
|
+
}
|
|
336
|
+
function buildEvidenceContexts(graph, state, now) {
|
|
337
|
+
const digests = phaseContractDigests(graph);
|
|
338
|
+
return Object.fromEntries(graph.phases.map((phase) => {
|
|
339
|
+
const context = evidenceContextForPhase(phase.id, {
|
|
340
|
+
repositoryId: state.repository.id,
|
|
341
|
+
identity: state.identity,
|
|
342
|
+
phaseGraphHash: state.identity.phaseGraphHash,
|
|
343
|
+
now,
|
|
344
|
+
liveReadbackProviders: requiredLiveReadbackProviders(phase)
|
|
345
|
+
});
|
|
346
|
+
return [phase.id, {
|
|
347
|
+
...context,
|
|
348
|
+
phaseContractDigest: digests[phase.id]
|
|
349
|
+
}];
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
352
|
+
function freshnessEntry(phase, selection) {
|
|
353
|
+
return {
|
|
354
|
+
phaseId: phase.id,
|
|
355
|
+
status: selection.selected ? 'fresh' : selection.issues.length > 0 ? 'stale' : 'missing',
|
|
356
|
+
selectedEvidenceId: selection.selected?.evidenceId ?? null,
|
|
357
|
+
selectedResult: selection.selected?.header.result ?? null,
|
|
358
|
+
requiresLiveReadback: phase.evidence.liveReadbackProviders.length > 0,
|
|
359
|
+
liveReadbackProviders: phase.evidence.liveReadbackProviders,
|
|
360
|
+
issues: selection.issues.map((issue) => `${issue.evidenceId ? `${issue.evidenceId}: ` : ''}${issue.message}`)
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
function buildEvidenceFreshness(graph, evidence, contexts) {
|
|
364
|
+
return Object.fromEntries(graph.phases.map((phase) => {
|
|
365
|
+
const records = evidence.filter((record) => record.header.phaseId === phase.id);
|
|
366
|
+
const selection = selectLatestPhaseEvidence(records, contexts[phase.id]);
|
|
367
|
+
return [phase.id, freshnessEntry(phase, selection)];
|
|
368
|
+
}));
|
|
369
|
+
}
|
|
370
|
+
async function inspectGovernance(projectRoot) {
|
|
371
|
+
const manifest = await loadManifest(projectRoot);
|
|
372
|
+
const graph = await loadGovernanceGraph(projectRoot);
|
|
373
|
+
await assertPolicyIdentity(projectRoot, manifest);
|
|
374
|
+
const loadedState = await loadActivationState(projectRoot);
|
|
375
|
+
const state = loadedState?.state ?? notStartedState(manifest);
|
|
376
|
+
if (state.identity.phaseGraphHash !== graph.hash) {
|
|
377
|
+
throw new Error(`Activation state graph hash ${state.identity.phaseGraphHash} does not match loaded graph hash ${graph.hash}.`);
|
|
378
|
+
}
|
|
379
|
+
if (state.identity.policyVersion !== governanceActivationPolicyVersion) {
|
|
380
|
+
throw new Error(`Activation state policy version ${state.identity.policyVersion} does not match supported policy ${governanceActivationPolicyVersion}.`);
|
|
381
|
+
}
|
|
382
|
+
const approvals = await loadApprovals(projectRoot, state.identity);
|
|
383
|
+
const evidence = await loadEvidence(projectRoot);
|
|
384
|
+
const sourceOfTruth = await inspectGovernanceSourceOfTruth({
|
|
385
|
+
projectRoot,
|
|
386
|
+
manifest,
|
|
387
|
+
state,
|
|
388
|
+
evidence
|
|
389
|
+
});
|
|
390
|
+
const now = new Date();
|
|
391
|
+
const credential = await inspectCredentialPolicy(projectRoot, state);
|
|
392
|
+
const contexts = buildEvidenceContexts(graph.graph, state, now);
|
|
393
|
+
const evidenceFreshness = buildEvidenceFreshness(graph.graph, evidence, contexts);
|
|
394
|
+
const readiness = calculatePhaseReadiness({
|
|
395
|
+
graph: graph.graph,
|
|
396
|
+
state,
|
|
397
|
+
approvals,
|
|
398
|
+
evidence,
|
|
399
|
+
transitionContexts: contexts,
|
|
400
|
+
now
|
|
401
|
+
});
|
|
402
|
+
return {
|
|
403
|
+
projectRoot,
|
|
404
|
+
manifest,
|
|
405
|
+
graph,
|
|
406
|
+
loadedState,
|
|
407
|
+
state,
|
|
408
|
+
stateSource: loadedState ? 'user' : 'not-started',
|
|
409
|
+
approvals,
|
|
410
|
+
evidence,
|
|
411
|
+
contexts,
|
|
412
|
+
evidenceFreshness,
|
|
413
|
+
readiness,
|
|
414
|
+
sourceOfTruth,
|
|
415
|
+
credential
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
async function assertPolicyIdentity(projectRoot, manifest) {
|
|
419
|
+
if (manifest.governance.profile === 'none' || manifest.governance.profile === 'unspecified') {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (manifest.governance.policyVersion !== governanceActivationPolicyVersion) {
|
|
423
|
+
throw new Error(`Manifest governance policyVersion ${manifest.governance.policyVersion ?? 'missing'} does not match ${governanceActivationPolicyVersion}.`);
|
|
424
|
+
}
|
|
425
|
+
const bytes = await readProjectFile(projectRoot, [...governanceArtifactPaths.policy]);
|
|
426
|
+
if (bytes === undefined) {
|
|
427
|
+
throw new Error(`${governanceArtifactPaths.policy.join('/')} is missing.`);
|
|
428
|
+
}
|
|
429
|
+
validateGovernancePolicy(bytes.toString('utf8'));
|
|
430
|
+
}
|
|
431
|
+
function approvalStatus(approval, now = new Date()) {
|
|
432
|
+
return Date.parse(approval.expiresAt) > now.getTime() ? 'valid' : 'expired';
|
|
433
|
+
}
|
|
434
|
+
function approvalEvaluationForPhase(phase, inspection) {
|
|
435
|
+
const plan = transitionPlanForPhase(phase, inspection.state, inspection.contexts[phase.id].transition, inspection.projectRoot);
|
|
436
|
+
return evaluateApprovalForTransitionPlan(plan, inspection.approvals);
|
|
437
|
+
}
|
|
438
|
+
function approvalEvaluationJson(evaluation) {
|
|
439
|
+
return {
|
|
440
|
+
questionKind: evaluation.questionKind,
|
|
441
|
+
approvalRequired: evaluation.approvalRequired,
|
|
442
|
+
status: evaluation.status,
|
|
443
|
+
envelopeId: evaluation.envelopeId,
|
|
444
|
+
envelopeHash: evaluation.envelopeHash,
|
|
445
|
+
reasons: evaluation.reasons,
|
|
446
|
+
expansionReasons: evaluation.expansionReasons
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
function statusJson(inspection, command) {
|
|
450
|
+
const blockers = phaseIds.flatMap((phaseId) => inspection.readiness.phases[phaseId].blockers.map((message) => ({ phaseId, message })));
|
|
451
|
+
return {
|
|
452
|
+
schemaVersion: 1,
|
|
453
|
+
command: `governance ${command}`,
|
|
454
|
+
projectRoot: inspection.projectRoot,
|
|
455
|
+
readOnly: command !== 'apply-next',
|
|
456
|
+
stateSource: inspection.stateSource,
|
|
457
|
+
activationIdentity: inspection.state.identity,
|
|
458
|
+
graphHash: inspection.graph.hash,
|
|
459
|
+
graph: {
|
|
460
|
+
source: inspection.graph.source,
|
|
461
|
+
hash: inspection.graph.hash,
|
|
462
|
+
schemaVersion: inspection.graph.graph.schemaVersion
|
|
463
|
+
},
|
|
464
|
+
activeChange: inspection.state.activeChange,
|
|
465
|
+
activeSourceOfTruth: inspection.sourceOfTruth,
|
|
466
|
+
credential: inspection.credential,
|
|
467
|
+
phases: inspection.graph.graph.phases.map((phase) => ({
|
|
468
|
+
id: phase.id,
|
|
469
|
+
label: phase.label,
|
|
470
|
+
state: inspection.readiness.phases[phase.id].state,
|
|
471
|
+
storedState: inspection.state.phases[phase.id].state,
|
|
472
|
+
blockers: inspection.readiness.phases[phase.id].blockers,
|
|
473
|
+
evidence: {
|
|
474
|
+
schema: phase.evidence.schema,
|
|
475
|
+
required: phase.evidence.required,
|
|
476
|
+
freshness: inspection.evidenceFreshness[phase.id]
|
|
477
|
+
},
|
|
478
|
+
approvalGate: phase.approvalGate,
|
|
479
|
+
approval: approvalEvaluationJson(approvalEvaluationForPhase(phase, inspection)),
|
|
480
|
+
allowedMutations: phase.allowedMutations
|
|
481
|
+
})),
|
|
482
|
+
nextReadyPhase: inspection.readiness.nextReadyPhase,
|
|
483
|
+
blockers,
|
|
484
|
+
approvals: inspection.approvals.map((approval) => ({
|
|
485
|
+
id: approval.id,
|
|
486
|
+
phaseId: approval.phaseId,
|
|
487
|
+
gateKind: approval.gateKind,
|
|
488
|
+
status: approvalStatus(approval),
|
|
489
|
+
envelopeHash: canonicalApprovalEnvelopeHash(approval),
|
|
490
|
+
expiresAt: approval.expiresAt
|
|
491
|
+
})),
|
|
492
|
+
evidenceFreshness: phaseIds.map((phaseId) => inspection.evidenceFreshness[phaseId])
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
function renderStatusHuman(inspection, command) {
|
|
496
|
+
const presentation = inspectionPresentation(inspection);
|
|
497
|
+
presentation.commandIdentity(`governance ${command}`, 'Deterministic activation status');
|
|
498
|
+
presentation.definitions('Activation identity', [
|
|
499
|
+
{ label: 'Project', value: inspection.projectRoot },
|
|
500
|
+
{ label: 'State', value: inspection.stateSource },
|
|
501
|
+
{ label: 'Policy', value: inspection.state.identity.policyVersion },
|
|
502
|
+
{ label: 'Contract', value: String(inspection.state.identity.activationContractVersion) },
|
|
503
|
+
{ label: 'Graph hash', value: inspection.graph.hash },
|
|
504
|
+
{ label: 'Active change', value: inspection.state.activeChange?.id ?? 'none' },
|
|
505
|
+
{
|
|
506
|
+
label: 'Active source',
|
|
507
|
+
value: inspection.sourceOfTruth.status === 'selected'
|
|
508
|
+
? inspection.sourceOfTruth.selected.changeId
|
|
509
|
+
: inspection.sourceOfTruth.status
|
|
510
|
+
}
|
|
511
|
+
]);
|
|
512
|
+
if (inspection.sourceOfTruth.status === 'seed-blocked') {
|
|
513
|
+
presentation.status('error', 'Seed blocker', inspection.sourceOfTruth.blockers.join('; '));
|
|
514
|
+
}
|
|
515
|
+
else if (inspection.sourceOfTruth.status === 'ambiguous' || inspection.sourceOfTruth.status === 'incompatible') {
|
|
516
|
+
presentation.status('error', 'Active source blocked', inspection.sourceOfTruth.blockers.join('; '));
|
|
517
|
+
}
|
|
518
|
+
else if (inspection.sourceOfTruth.status === 'selected') {
|
|
519
|
+
presentation.status(inspection.sourceOfTruth.reconciliation.status === 'not-required' ? 'success' : 'pending', 'Source acknowledgment', inspection.sourceOfTruth.reconciliation.status);
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
presentation.status('pending', 'Governance change plan', inspection.sourceOfTruth.createPlan.reason);
|
|
523
|
+
}
|
|
524
|
+
if (inspection.credential.applicable) {
|
|
525
|
+
presentation.status(inspection.credential.ready ? 'success' : inspection.credential.status === 'compromised' ? 'error' : 'pending', 'Credential policy', inspection.credential.ready
|
|
526
|
+
? 'credential-ready metadata has verified payload-free readback'
|
|
527
|
+
: inspection.credential.issues[0] ?? 'deterministic credential enrollment required');
|
|
528
|
+
}
|
|
529
|
+
const next = inspection.readiness.nextReadyPhase ?? 'none';
|
|
530
|
+
presentation.status(next === 'none' ? 'info' : 'pending', 'Next ready phase', next);
|
|
531
|
+
if (inspection.readiness.nextReadyPhase) {
|
|
532
|
+
const phase = phaseMap(inspection.graph.graph)[inspection.readiness.nextReadyPhase];
|
|
533
|
+
const approval = approvalEvaluationForPhase(phase, inspection);
|
|
534
|
+
presentation.status(approval.approvalRequired ? 'pending' : 'success', 'Approval', `${approval.questionKind ?? 'none'}; ${approval.status}; ${approval.reasons.join('; ')}`);
|
|
535
|
+
}
|
|
536
|
+
const blockerRows = phaseIds
|
|
537
|
+
.filter((phaseId) => inspection.readiness.phases[phaseId].blockers.length > 0)
|
|
538
|
+
.slice(0, 6)
|
|
539
|
+
.map((phaseId) => [
|
|
540
|
+
phaseId,
|
|
541
|
+
inspection.readiness.phases[phaseId].state,
|
|
542
|
+
inspection.readiness.phases[phaseId].blockers[0] ?? ''
|
|
543
|
+
]);
|
|
544
|
+
if (blockerRows.length > 0) {
|
|
545
|
+
presentation.table('Current blockers', ['Phase', 'State', 'Reason'], blockerRows);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
function inspectionPresentation(inspection) {
|
|
549
|
+
return inspection.presentation;
|
|
550
|
+
}
|
|
551
|
+
function planJson(inspection) {
|
|
552
|
+
const ready = inspection.graph.graph.phases
|
|
553
|
+
.filter((phase) => inspection.readiness.phases[phase.id].state === 'ready')
|
|
554
|
+
.map((phase) => planPhase(phase, inspection));
|
|
555
|
+
const blocked = inspection.graph.graph.phases
|
|
556
|
+
.filter((phase) => inspection.readiness.phases[phase.id].state === 'blocked')
|
|
557
|
+
.map((phase) => ({
|
|
558
|
+
...planPhase(phase, inspection),
|
|
559
|
+
blockers: inspection.readiness.phases[phase.id].blockers
|
|
560
|
+
}));
|
|
561
|
+
return {
|
|
562
|
+
schemaVersion: 1,
|
|
563
|
+
command: 'governance plan',
|
|
564
|
+
projectRoot: inspection.projectRoot,
|
|
565
|
+
readOnly: true,
|
|
566
|
+
noWrites: true,
|
|
567
|
+
activationIdentity: inspection.state.identity,
|
|
568
|
+
graphHash: inspection.graph.hash,
|
|
569
|
+
activeChange: inspection.state.activeChange,
|
|
570
|
+
activeSourceOfTruth: inspection.sourceOfTruth,
|
|
571
|
+
credential: inspection.credential,
|
|
572
|
+
readyPhases: ready,
|
|
573
|
+
blockedPhases: blocked
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
function planPhase(phase, inspection) {
|
|
577
|
+
return {
|
|
578
|
+
id: phase.id,
|
|
579
|
+
label: phase.label,
|
|
580
|
+
requiredEvidence: {
|
|
581
|
+
schema: phase.evidence.schema,
|
|
582
|
+
required: phase.evidence.required,
|
|
583
|
+
liveReadbackProviders: phase.evidence.liveReadbackProviders
|
|
584
|
+
},
|
|
585
|
+
approvalGate: phase.approvalGate,
|
|
586
|
+
approval: approvalEvaluationJson(approvalEvaluationForPhase(phase, inspection)),
|
|
587
|
+
permittedMutations: phase.allowedMutations,
|
|
588
|
+
costEnvelope: costEnvelope(phase),
|
|
589
|
+
evidenceFreshness: inspection.evidenceFreshness[phase.id],
|
|
590
|
+
...(phase.id === 'credential-ready' && inspection.credential.applicable
|
|
591
|
+
? { credential: inspection.credential }
|
|
592
|
+
: {})
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
function costEnvelope(phase) {
|
|
596
|
+
const relevant = phase.approvalGate.kind === 'infrastructure-cost' ||
|
|
597
|
+
phase.allowedMutations.remote.some((entry) => entry.startsWith('azure-'));
|
|
598
|
+
return {
|
|
599
|
+
relevant,
|
|
600
|
+
gate: phase.approvalGate.kind,
|
|
601
|
+
reason: relevant
|
|
602
|
+
? 'Infrastructure approval may constrain resource classes, destinations, and cost ceilings.'
|
|
603
|
+
: 'No infrastructure cost envelope is required for this phase.'
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
function renderPlanHuman(inspection, presentation) {
|
|
607
|
+
presentation.commandIdentity('governance plan', 'Read-only activation transition plan');
|
|
608
|
+
const ready = inspection.graph.graph.phases.filter((phase) => inspection.readiness.phases[phase.id].state === 'ready');
|
|
609
|
+
const blocked = inspection.graph.graph.phases.filter((phase) => inspection.readiness.phases[phase.id].state === 'blocked');
|
|
610
|
+
presentation.status('info', 'Read-only', 'No files, remotes, credentials, or cloud resources are changed.');
|
|
611
|
+
presentation.table('Ready phases', ['Phase', 'Evidence', 'Question', 'Approval', 'Mutations'], ready.map((phase) => {
|
|
612
|
+
const approval = approvalEvaluationForPhase(phase, inspection);
|
|
613
|
+
return [
|
|
614
|
+
phase.id,
|
|
615
|
+
phase.evidence.schema,
|
|
616
|
+
approval.questionKind ?? 'none',
|
|
617
|
+
phase.approvalGate.required ? phase.approvalGate.kind : 'none',
|
|
618
|
+
`local=${phase.allowedMutations.local.join(',')} remote=${phase.allowedMutations.remote.join(',')}`
|
|
619
|
+
];
|
|
620
|
+
}));
|
|
621
|
+
presentation.table('Blocked phases', ['Phase', 'Reason'], blocked.slice(0, 12).map((phase) => [
|
|
622
|
+
phase.id,
|
|
623
|
+
inspection.readiness.phases[phase.id].blockers.join('; ')
|
|
624
|
+
]));
|
|
625
|
+
}
|
|
626
|
+
function validatePolicyIdentity(inspection) {
|
|
627
|
+
const issues = [];
|
|
628
|
+
if (inspection.manifest.governance.profile !== 'none' && inspection.manifest.governance.profile !== 'unspecified') {
|
|
629
|
+
if (inspection.manifest.governance.policyVersion !== governanceActivationPolicyVersion) {
|
|
630
|
+
issues.push(`Manifest governance policyVersion ${inspection.manifest.governance.policyVersion ?? 'missing'} does not match ${governanceActivationPolicyVersion}.`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return { id: 'manifest-policy-identity', status: issues.length === 0 ? 'passed' : 'failed', issues };
|
|
634
|
+
}
|
|
635
|
+
async function validateManagedPolicy(projectRoot, manifest) {
|
|
636
|
+
if (manifest.governance.profile === 'none' || manifest.governance.profile === 'unspecified') {
|
|
637
|
+
return { id: 'managed-policy', status: 'skipped', issues: [] };
|
|
638
|
+
}
|
|
639
|
+
const bytes = await readProjectFile(projectRoot, [...governanceArtifactPaths.policy]);
|
|
640
|
+
const issues = [];
|
|
641
|
+
if (bytes === undefined) {
|
|
642
|
+
issues.push(`${governanceArtifactPaths.policy.join('/')} is missing.`);
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
try {
|
|
646
|
+
validateGovernancePolicy(bytes.toString('utf8'));
|
|
647
|
+
}
|
|
648
|
+
catch (error) {
|
|
649
|
+
issues.push(errorMessage(error));
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return { id: 'managed-policy', status: issues.length === 0 ? 'passed' : 'failed', issues };
|
|
653
|
+
}
|
|
654
|
+
function validateStateEvidence(inspection) {
|
|
655
|
+
const issues = [];
|
|
656
|
+
if (inspection.stateSource === 'not-started') {
|
|
657
|
+
return { id: 'state-evidence', status: 'passed', issues };
|
|
658
|
+
}
|
|
659
|
+
for (const phaseId of phaseIds) {
|
|
660
|
+
const stored = inspection.state.phases[phaseId];
|
|
661
|
+
if (!terminalEvidenceStates.has(stored.state)) {
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
const records = inspection.evidence.filter((record) => record.header.phaseId === phaseId);
|
|
665
|
+
const current = selectLatestPhaseEvidence(records, inspection.contexts[phaseId]);
|
|
666
|
+
if (!current.selected || current.selected.header.result !== stored.state) {
|
|
667
|
+
issues.push(`Phase ${phaseId} is stored as ${stored.state} but has no current matching authoritative evidence.`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return { id: 'state-evidence', status: issues.length === 0 ? 'passed' : 'failed', issues };
|
|
671
|
+
}
|
|
672
|
+
function validateEvidenceFreshnessCheck(inspection) {
|
|
673
|
+
const issues = phaseIds.flatMap((phaseId) => {
|
|
674
|
+
const freshness = inspection.evidenceFreshness[phaseId];
|
|
675
|
+
return freshness.status === 'fresh'
|
|
676
|
+
? []
|
|
677
|
+
: freshness.issues.map((issue) => `${phaseId}: ${issue}`);
|
|
678
|
+
});
|
|
679
|
+
return { id: 'evidence-freshness', status: issues.length === 0 ? 'passed' : 'failed', issues };
|
|
680
|
+
}
|
|
681
|
+
function validateReadinessCheck(inspection) {
|
|
682
|
+
if (inspection.readiness.identityCompatible) {
|
|
683
|
+
return { id: 'readiness', status: 'passed', issues: [] };
|
|
684
|
+
}
|
|
685
|
+
return {
|
|
686
|
+
id: 'readiness',
|
|
687
|
+
status: 'failed',
|
|
688
|
+
issues: [inspection.readiness.identityBlocker ?? 'Activation identity is incompatible.']
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
async function activeTaskProjectionCheck(inspection) {
|
|
692
|
+
const activeChange = inspection.state.activeChange;
|
|
693
|
+
if (!activeChange || activeChange.kind !== 'openspec') {
|
|
694
|
+
return { id: 'task-projection', status: 'skipped', issues: [] };
|
|
695
|
+
}
|
|
696
|
+
const pathParts = validateArtifactPathParts(['openspec', 'changes', activeChange.id, 'tasks.md'], 'Active OpenSpec task path');
|
|
697
|
+
const bytes = await readProjectFile(inspection.projectRoot, pathParts);
|
|
698
|
+
if (bytes === undefined) {
|
|
699
|
+
return { id: 'task-projection', status: 'skipped', issues: [] };
|
|
700
|
+
}
|
|
701
|
+
const markdown = bytes.toString('utf8');
|
|
702
|
+
const mappings = extractPhaseTaskMappings(markdown);
|
|
703
|
+
if (mappings.length === 0) {
|
|
704
|
+
return { id: 'task-projection', status: 'skipped', issues: [] };
|
|
705
|
+
}
|
|
706
|
+
const projection = projectOpenSpecTaskCheckboxes(markdown, mappings, inspection.readiness.phases);
|
|
707
|
+
const issues = projection.changes.map((change) => `Task ${change.taskId} for ${change.phaseId} is ${change.fromChecked ? 'checked' : 'unchecked'} but authoritative phase state is ${change.state}.`);
|
|
708
|
+
return { id: 'task-projection', status: issues.length === 0 ? 'passed' : 'failed', issues };
|
|
709
|
+
}
|
|
710
|
+
function extractPhaseTaskMappings(markdown) {
|
|
711
|
+
const phaseIdSet = new Set(phaseIds);
|
|
712
|
+
const mappings = [];
|
|
713
|
+
const pattern = /^\s*[-*]\s+\[[ xX]\]\s+(\S+).*<!--\s*liftoff-phase:\s*([a-z0-9-]+)\s*-->/u;
|
|
714
|
+
for (const line of markdown.split(/\r?\n/u)) {
|
|
715
|
+
const match = line.match(pattern);
|
|
716
|
+
if (!match) {
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
const phaseId = match[2];
|
|
720
|
+
if (!phaseIdSet.has(phaseId)) {
|
|
721
|
+
throw new Error(`Task projection references unknown phase ${phaseId}.`);
|
|
722
|
+
}
|
|
723
|
+
mappings.push({ phaseId: phaseId, taskId: match[1] });
|
|
724
|
+
}
|
|
725
|
+
return mappings;
|
|
726
|
+
}
|
|
727
|
+
function activeChangeIdentityCheck(inspection) {
|
|
728
|
+
const activeChange = inspection.state.activeChange;
|
|
729
|
+
if (!activeChange) {
|
|
730
|
+
return { id: 'active-change-identity', status: 'skipped', issues: [] };
|
|
731
|
+
}
|
|
732
|
+
const issues = [];
|
|
733
|
+
try {
|
|
734
|
+
validateArtifactPathParts(activeChange.kind === 'openspec'
|
|
735
|
+
? ['openspec', 'changes', activeChange.id]
|
|
736
|
+
: ['specs', activeChange.id], 'Active change path');
|
|
737
|
+
}
|
|
738
|
+
catch (error) {
|
|
739
|
+
issues.push(errorMessage(error));
|
|
740
|
+
}
|
|
741
|
+
return { id: 'active-change-identity', status: issues.length === 0 ? 'passed' : 'failed', issues };
|
|
742
|
+
}
|
|
743
|
+
function liveReadbackCheck(inspection) {
|
|
744
|
+
const issues = inspection.graph.graph.phases.flatMap((phase) => {
|
|
745
|
+
if (phase.evidence.liveReadbackProviders.length === 0) {
|
|
746
|
+
return [];
|
|
747
|
+
}
|
|
748
|
+
const freshness = inspection.evidenceFreshness[phase.id];
|
|
749
|
+
return freshness.status === 'fresh'
|
|
750
|
+
? []
|
|
751
|
+
: freshness.issues.map((issue) => `${phase.id}: ${issue}`);
|
|
752
|
+
});
|
|
753
|
+
return { id: 'live-readback', status: issues.length === 0 ? 'passed' : 'failed', issues };
|
|
754
|
+
}
|
|
755
|
+
function credentialPolicyCheck(inspection) {
|
|
756
|
+
if (!inspection.credential.applicable) {
|
|
757
|
+
return { id: 'credential-policy', status: 'skipped', issues: [] };
|
|
758
|
+
}
|
|
759
|
+
return {
|
|
760
|
+
id: 'credential-policy',
|
|
761
|
+
status: inspection.credential.ready ? 'passed' : 'failed',
|
|
762
|
+
issues: inspection.credential.issues
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
function activeSourceOfTruthCheck(inspection) {
|
|
766
|
+
const source = inspection.sourceOfTruth;
|
|
767
|
+
if (source.status === 'selected' || source.status === 'none') {
|
|
768
|
+
if (source.status === 'selected' && source.reconciliation.status !== 'not-required') {
|
|
769
|
+
return {
|
|
770
|
+
id: 'active-source-of-truth',
|
|
771
|
+
status: 'failed',
|
|
772
|
+
issues: source.reconciliation.issues
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
return { id: 'active-source-of-truth', status: 'passed', issues: [] };
|
|
776
|
+
}
|
|
777
|
+
if (source.status === 'seed-blocked' || source.status === 'ambiguous' || source.status === 'incompatible') {
|
|
778
|
+
return {
|
|
779
|
+
id: 'active-source-of-truth',
|
|
780
|
+
status: 'failed',
|
|
781
|
+
issues: source.blockers
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
return { id: 'active-source-of-truth', status: 'failed', issues: ['Unknown active source-of-truth status.'] };
|
|
785
|
+
}
|
|
786
|
+
async function pathExists(filePath) {
|
|
787
|
+
try {
|
|
788
|
+
await access(filePath);
|
|
789
|
+
return true;
|
|
790
|
+
}
|
|
791
|
+
catch (error) {
|
|
792
|
+
if (errorCode(error) === 'ENOENT') {
|
|
793
|
+
return false;
|
|
794
|
+
}
|
|
795
|
+
throw error;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
async function verifyChecks(inspection) {
|
|
799
|
+
const managedGraphPresent = await pathExists(await resolveProjectPath(inspection.projectRoot, [...managedPhaseGraphPathParts]));
|
|
800
|
+
return [
|
|
801
|
+
{ id: 'phase-graph', status: 'passed', issues: [] },
|
|
802
|
+
{
|
|
803
|
+
id: 'graph-identity',
|
|
804
|
+
status: inspection.graph.hash === currentActivationIdentity.phaseGraphHash ? 'passed' : 'failed',
|
|
805
|
+
issues: inspection.graph.hash === currentActivationIdentity.phaseGraphHash
|
|
806
|
+
? []
|
|
807
|
+
: [`Graph hash ${inspection.graph.hash} does not match activation identity.`]
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
id: 'managed-graph-source',
|
|
811
|
+
status: managedGraphPresent || inspection.graph.source === 'packaged' ? 'passed' : 'failed',
|
|
812
|
+
issues: []
|
|
813
|
+
},
|
|
814
|
+
validatePolicyIdentity(inspection),
|
|
815
|
+
await validateManagedPolicy(inspection.projectRoot, inspection.manifest),
|
|
816
|
+
{
|
|
817
|
+
id: 'activation-state',
|
|
818
|
+
status: inspection.stateSource === 'not-started' ? 'skipped' : 'passed',
|
|
819
|
+
issues: inspection.stateSource === 'not-started'
|
|
820
|
+
? ['No user activation state exists; reporting deterministic not-started view.']
|
|
821
|
+
: []
|
|
822
|
+
},
|
|
823
|
+
activeChangeIdentityCheck(inspection),
|
|
824
|
+
activeSourceOfTruthCheck(inspection),
|
|
825
|
+
validateEvidenceFreshnessCheck(inspection),
|
|
826
|
+
validateStateEvidence(inspection),
|
|
827
|
+
credentialPolicyCheck(inspection),
|
|
828
|
+
liveReadbackCheck(inspection),
|
|
829
|
+
await activeTaskProjectionCheck(inspection),
|
|
830
|
+
validateReadinessCheck(inspection)
|
|
831
|
+
];
|
|
832
|
+
}
|
|
833
|
+
async function verifyJson(inspection) {
|
|
834
|
+
const checks = await verifyChecks(inspection);
|
|
835
|
+
return {
|
|
836
|
+
schemaVersion: 1,
|
|
837
|
+
command: 'governance verify',
|
|
838
|
+
projectRoot: inspection.projectRoot,
|
|
839
|
+
readOnly: true,
|
|
840
|
+
ok: checks.every((check) => check.status !== 'failed'),
|
|
841
|
+
activationIdentity: inspection.state.identity,
|
|
842
|
+
graphHash: inspection.graph.hash,
|
|
843
|
+
activeChange: inspection.state.activeChange,
|
|
844
|
+
activeSourceOfTruth: inspection.sourceOfTruth,
|
|
845
|
+
nextReadyPhase: inspection.readiness.nextReadyPhase,
|
|
846
|
+
checks
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
async function renderVerifyHuman(inspection, presentation) {
|
|
850
|
+
const result = await verifyJson(inspection);
|
|
851
|
+
const checks = result.checks;
|
|
852
|
+
presentation.commandIdentity('governance verify', 'Read-only activation verification');
|
|
853
|
+
for (const check of checks) {
|
|
854
|
+
presentation.status(check.status === 'failed' ? 'error' : check.status === 'skipped' ? 'info' : 'success', check.id, check.issues[0]);
|
|
855
|
+
}
|
|
856
|
+
return result.ok === true ? 0 : 1;
|
|
857
|
+
}
|
|
858
|
+
function renderInspectionFailure(subcommand, projectRoot, error, presentation, jsonMode) {
|
|
859
|
+
const result = {
|
|
860
|
+
schemaVersion: 1,
|
|
861
|
+
command: `governance ${subcommand}`,
|
|
862
|
+
projectRoot,
|
|
863
|
+
readOnly: true,
|
|
864
|
+
ok: false,
|
|
865
|
+
checks: [{
|
|
866
|
+
id: 'inspection',
|
|
867
|
+
status: 'failed',
|
|
868
|
+
issues: [errorMessage(error)]
|
|
869
|
+
}]
|
|
870
|
+
};
|
|
871
|
+
if (jsonMode) {
|
|
872
|
+
json(presentation, result);
|
|
873
|
+
}
|
|
874
|
+
else {
|
|
875
|
+
presentation.error(errorMessage(error), 'Fix the malformed governance file or restore it from version control, then rerun verification.');
|
|
876
|
+
}
|
|
877
|
+
return 1;
|
|
878
|
+
}
|
|
879
|
+
function attachPresentation(inspection, presentation) {
|
|
880
|
+
inspection.presentation = presentation;
|
|
881
|
+
return inspection;
|
|
882
|
+
}
|
|
883
|
+
function transitionInspection(inspection) {
|
|
884
|
+
return {
|
|
885
|
+
projectRoot: inspection.projectRoot,
|
|
886
|
+
manifest: inspection.manifest,
|
|
887
|
+
graph: inspection.graph.graph,
|
|
888
|
+
graphHash: inspection.graph.hash,
|
|
889
|
+
...(inspection.loadedState ? { loadedState: inspection.loadedState } : {}),
|
|
890
|
+
state: inspection.state,
|
|
891
|
+
approvals: inspection.approvals,
|
|
892
|
+
evidence: inspection.evidence,
|
|
893
|
+
contexts: inspection.contexts,
|
|
894
|
+
readiness: inspection.readiness,
|
|
895
|
+
sourceOfTruth: inspection.sourceOfTruth
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
function renderApplyNextHuman(result, presentation) {
|
|
899
|
+
presentation.commandIdentity('governance apply-next', 'Controlled activation transition');
|
|
900
|
+
presentation.status(result.applied ? 'success' : result.authorized ? 'pending' : 'error', result.reason, result.message);
|
|
901
|
+
presentation.table('Proposed operations', ['Adapter', 'Action', 'Mutation', 'Remote', 'Destructive'], result.proposedMutations.operations.map((op) => [
|
|
902
|
+
op.adapter,
|
|
903
|
+
op.actionId,
|
|
904
|
+
op.mutationClass,
|
|
905
|
+
String(op.remote),
|
|
906
|
+
String(op.destructive)
|
|
907
|
+
]));
|
|
908
|
+
if ('executedOperations' in result) {
|
|
909
|
+
presentation.table('Executed operations', ['Adapter', 'Action', 'Mutation'], result.executedOperations.map((op) => [
|
|
910
|
+
op.adapter,
|
|
911
|
+
op.actionId,
|
|
912
|
+
op.mutationClass
|
|
913
|
+
]));
|
|
914
|
+
if (result.savedPlan) {
|
|
915
|
+
presentation.status('info', 'Saved plan', `${result.savedPlan.pathParts.join('/')} (${result.savedPlan.digest})`);
|
|
916
|
+
}
|
|
917
|
+
if (result.evidence) {
|
|
918
|
+
presentation.status('info', 'Evidence', `${result.evidence.pathParts.join('/')} (${result.evidence.headerDigest})`);
|
|
919
|
+
}
|
|
920
|
+
if (result.stateHash) {
|
|
921
|
+
presentation.status('info', 'State hash', result.stateHash);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
else {
|
|
925
|
+
presentation.status('info', 'Preview only', 'No writes occurred; rerun with --execute to execute at most one phase.');
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
export async function governanceCommand(parsed, context) {
|
|
929
|
+
const presentation = context.presentation;
|
|
930
|
+
const jsonMode = readBooleanFlag(parsed.flags, 'json') ?? false;
|
|
931
|
+
const subcommand = parseGovernanceSubcommand(parsed);
|
|
932
|
+
if (!subcommand) {
|
|
933
|
+
presentation.error('Missing governance subcommand.', 'Run `liftoff governance --help` and choose one of: status, plan, apply-next, resume, verify.');
|
|
934
|
+
return 1;
|
|
935
|
+
}
|
|
936
|
+
let projectRoot;
|
|
937
|
+
try {
|
|
938
|
+
projectRoot = await resolveGovernanceProjectRoot(parsed, context);
|
|
939
|
+
}
|
|
940
|
+
catch (error) {
|
|
941
|
+
presentation.error(errorMessage(error), 'Run `liftoff governance --help` to review accepted project arguments.');
|
|
942
|
+
return 1;
|
|
943
|
+
}
|
|
944
|
+
if (!projectRoot) {
|
|
945
|
+
const start = parsed.positional[0] ?? readStringFlag(parsed.flags, 'project') ?? context.cwd;
|
|
946
|
+
const failure = projectRootError(path.resolve(context.cwd, start));
|
|
947
|
+
presentation.error(failure.message, failure.remedy);
|
|
948
|
+
return 1;
|
|
949
|
+
}
|
|
950
|
+
let inspection;
|
|
951
|
+
try {
|
|
952
|
+
inspection = attachPresentation(await inspectGovernance(projectRoot), presentation);
|
|
953
|
+
}
|
|
954
|
+
catch (error) {
|
|
955
|
+
if (subcommand === 'verify') {
|
|
956
|
+
return renderInspectionFailure(subcommand, projectRoot, error, presentation, jsonMode);
|
|
957
|
+
}
|
|
958
|
+
throw error;
|
|
959
|
+
}
|
|
960
|
+
if (subcommand === 'status') {
|
|
961
|
+
if (jsonMode) {
|
|
962
|
+
json(presentation, statusJson(inspection, 'status'));
|
|
963
|
+
}
|
|
964
|
+
else {
|
|
965
|
+
renderStatusHuman(inspection, 'status');
|
|
966
|
+
}
|
|
967
|
+
return 0;
|
|
968
|
+
}
|
|
969
|
+
if (subcommand === 'plan') {
|
|
970
|
+
if (jsonMode) {
|
|
971
|
+
json(presentation, planJson(inspection));
|
|
972
|
+
}
|
|
973
|
+
else {
|
|
974
|
+
renderPlanHuman(inspection, presentation);
|
|
975
|
+
}
|
|
976
|
+
return 0;
|
|
977
|
+
}
|
|
978
|
+
if (subcommand === 'resume') {
|
|
979
|
+
const result = {
|
|
980
|
+
...statusJson(inspection, 'resume'),
|
|
981
|
+
deterministicPreflights: ['phase-graph', 'activation-state', 'approvals', 'evidence-freshness', 'readiness'],
|
|
982
|
+
executedOperations: [],
|
|
983
|
+
noWrites: true
|
|
984
|
+
};
|
|
985
|
+
if (jsonMode) {
|
|
986
|
+
json(presentation, result);
|
|
987
|
+
}
|
|
988
|
+
else {
|
|
989
|
+
renderStatusHuman(inspection, 'resume');
|
|
990
|
+
presentation.status('info', 'Resume scope', 'Recalculated blockers and readiness only; no verified operation was rerun.');
|
|
991
|
+
}
|
|
992
|
+
return 0;
|
|
993
|
+
}
|
|
994
|
+
if (subcommand === 'verify') {
|
|
995
|
+
if (jsonMode) {
|
|
996
|
+
const result = await verifyJson(inspection);
|
|
997
|
+
json(presentation, result);
|
|
998
|
+
return result.ok === true ? 0 : 1;
|
|
999
|
+
}
|
|
1000
|
+
return await renderVerifyHuman(inspection, presentation);
|
|
1001
|
+
}
|
|
1002
|
+
const execute = readBooleanFlag(parsed.flags, 'execute') ?? false;
|
|
1003
|
+
const transitionInput = transitionInspection(inspection);
|
|
1004
|
+
const result = execute
|
|
1005
|
+
? await executeApplyNext({
|
|
1006
|
+
inspection: transitionInput,
|
|
1007
|
+
runner: context.runner,
|
|
1008
|
+
reinspect: async () => transitionInspection(attachPresentation(await inspectGovernance(projectRoot), presentation))
|
|
1009
|
+
})
|
|
1010
|
+
: await previewApplyNext({
|
|
1011
|
+
inspection: transitionInput,
|
|
1012
|
+
runner: context.runner,
|
|
1013
|
+
execute: false
|
|
1014
|
+
});
|
|
1015
|
+
if (jsonMode) {
|
|
1016
|
+
json(presentation, result);
|
|
1017
|
+
}
|
|
1018
|
+
else {
|
|
1019
|
+
renderApplyNextHuman(result, presentation);
|
|
1020
|
+
}
|
|
1021
|
+
return result.applied || result.reason === 'execute-required' ? 0 : 1;
|
|
1022
|
+
}
|
|
1023
|
+
//# sourceMappingURL=commands.js.map
|