sdd-mcp-server 4.0.0 → 5.0.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/README.md +18 -35
- package/dist/adapters/cli/SDDToolAdapter.d.ts +2 -5
- package/dist/adapters/cli/SDDToolAdapter.js +48 -92
- package/dist/adapters/cli/SDDToolAdapter.js.map +1 -1
- package/dist/application/services/ContextCompactionService.d.ts +10 -3
- package/dist/application/services/ContextCompactionService.js +139 -35
- package/dist/application/services/ContextCompactionService.js.map +1 -1
- package/dist/application/services/ProjectService.js +3 -3
- package/dist/application/services/ProjectService.js.map +1 -1
- package/dist/application/services/SpecPathResolver.js +1 -1
- package/dist/application/services/SpecPathResolver.js.map +1 -1
- package/dist/application/services/WorkflowEngineService.d.ts +160 -50
- package/dist/application/services/WorkflowEngineService.js +1404 -429
- package/dist/application/services/WorkflowEngineService.js.map +1 -1
- package/dist/application/services/WorkflowErrors.d.ts +16 -0
- package/dist/application/services/WorkflowErrors.js +53 -0
- package/dist/application/services/WorkflowErrors.js.map +1 -0
- package/dist/application/services/WorkflowValidationService.d.ts +25 -46
- package/dist/application/services/WorkflowValidationService.js +284 -627
- package/dist/application/services/WorkflowValidationService.js.map +1 -1
- package/dist/cli/install-skills.js.map +1 -1
- package/dist/cli/install-target.d.ts +4 -1
- package/dist/cli/install-target.js +4 -0
- package/dist/cli/install-target.js.map +1 -1
- package/dist/cli/tool-support/claude-code.js +7 -3
- package/dist/cli/tool-support/claude-code.js.map +1 -1
- package/dist/cli/tool-support/codex.js +6 -2
- package/dist/cli/tool-support/codex.js.map +1 -1
- package/dist/cli/tool-support/mcp-registration.d.ts +22 -0
- package/dist/cli/tool-support/mcp-registration.js +275 -0
- package/dist/cli/tool-support/mcp-registration.js.map +1 -0
- package/dist/cli/tool-support/omp.js +6 -2
- package/dist/cli/tool-support/omp.js.map +1 -1
- package/dist/cli/tool-support/root-guidance.js +2 -2
- package/dist/cli/tool-support/root-guidance.js.map +1 -1
- package/dist/cli/tool-support/target-installer.d.ts +2 -2
- package/dist/cli/tool-support/target-installer.js +9 -3
- package/dist/cli/tool-support/target-installer.js.map +1 -1
- package/dist/cli/utils/preserving-writer.d.ts +35 -1
- package/dist/cli/utils/preserving-writer.js +479 -108
- package/dist/cli/utils/preserving-writer.js.map +1 -1
- package/dist/domain/types.d.ts +52 -7
- package/dist/domain/types.js +5 -4
- package/dist/domain/types.js.map +1 -1
- package/dist/infrastructure/mcp/MCPServer.js +13 -13
- package/dist/infrastructure/mcp/MCPServer.js.map +1 -1
- package/dist/infrastructure/mcp/ToolRegistry.d.ts +5 -1
- package/dist/infrastructure/mcp/ToolRegistry.js +11 -4
- package/dist/infrastructure/mcp/ToolRegistry.js.map +1 -1
- package/dist/infrastructure/mcp/sddToolDefinitions.js +76 -90
- package/dist/infrastructure/mcp/sddToolDefinitions.js.map +1 -1
- package/dist/infrastructure/schemas/project.schema.d.ts +2 -2
- package/dist/infrastructure/schemas/project.schema.js +2 -2
- package/dist/infrastructure/schemas/project.schema.js.map +1 -1
- package/dist/shared/version.d.ts +3 -0
- package/dist/shared/version.js +4 -0
- package/dist/shared/version.js.map +1 -0
- package/dist/utils/atomicWrite.js +20 -5
- package/dist/utils/atomicWrite.js.map +1 -1
- package/dist/utils/withFilesystemLock.d.ts +22 -0
- package/dist/utils/withFilesystemLock.js +219 -0
- package/dist/utils/withFilesystemLock.js.map +1 -0
- package/package.json +4 -2
- package/skills/sdd-design/REFERENCE.md +16 -0
- package/skills/sdd-design/SKILL.md +22 -13
- package/skills/sdd-implement/REFERENCE.md +4 -0
- package/skills/sdd-implement/SKILL.md +20 -16
- package/skills/sdd-requirements/REFERENCE.md +15 -7
- package/skills/sdd-requirements/SKILL.md +30 -22
- package/skills/sdd-tasks/REFERENCE.md +9 -9
- package/skills/sdd-tasks/SKILL.md +27 -15
- package/templates/CLAUDE.md +6 -12
- package/templates/codex-AGENTS.md +7 -9
|
@@ -10,486 +10,1464 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
10
10
|
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
11
11
|
return function (target, key) { decorator(target, key, paramIndex); }
|
|
12
12
|
};
|
|
13
|
-
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { readFile, rmdir, stat, unlink } from 'node:fs/promises';
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
14
16
|
import path from 'node:path';
|
|
15
17
|
import { injectable, inject } from 'inversify';
|
|
16
18
|
import { TYPES } from '../../infrastructure/di/types.js';
|
|
17
19
|
import { WorkflowPhase, } from '../../domain/types.js';
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
20
|
+
import { ContextCompactionService, } from './ContextCompactionService.js';
|
|
21
|
+
import { InvalidFeatureNameError, SpecPathResolver, validateFeatureName } from './SpecPathResolver.js';
|
|
22
|
+
import { WorkflowValidationService } from './WorkflowValidationService.js';
|
|
23
|
+
import { GovernanceError } from './WorkflowErrors.js';
|
|
24
|
+
import { FilesystemLockCompromisedError, withFilesystemLock } from '../../utils/withFilesystemLock.js';
|
|
25
|
+
const PHASES = ['requirements', 'design', 'tasks'];
|
|
26
|
+
const JOURNAL_LIMIT = 4 * 1024 * 1024;
|
|
27
|
+
const JOURNAL_FILE_LIMIT = 24 * 1024 * 1024;
|
|
28
|
+
const PHASE_CONTENT_CHARACTER_LIMIT = 1_048_576;
|
|
29
|
+
const VALIDATION_STATUSES = new Set(['not-run', 'passed', 'failed', 'legacy-accepted']);
|
|
30
|
+
const TASK_STATUSES = new Set([
|
|
31
|
+
'pending',
|
|
32
|
+
'in-progress',
|
|
33
|
+
'red-observed',
|
|
34
|
+
'green-observed',
|
|
35
|
+
'blocked',
|
|
36
|
+
'completed',
|
|
37
|
+
]);
|
|
22
38
|
let WorkflowEngineService = class WorkflowEngineService {
|
|
23
|
-
static { WorkflowEngineService_1 = this; }
|
|
24
39
|
projectRepository;
|
|
25
|
-
projectService;
|
|
26
|
-
templateService;
|
|
27
40
|
contextCompactionService;
|
|
28
41
|
logger;
|
|
29
42
|
fileSystem;
|
|
30
|
-
|
|
31
|
-
constructor(projectRepository,
|
|
43
|
+
validator = new WorkflowValidationService();
|
|
44
|
+
constructor(projectRepository, contextCompactionService, logger, fileSystem) {
|
|
32
45
|
this.projectRepository = projectRepository;
|
|
33
|
-
this.projectService = projectService;
|
|
34
|
-
this.templateService = templateService;
|
|
35
46
|
this.contextCompactionService = contextCompactionService;
|
|
36
47
|
this.logger = logger;
|
|
37
48
|
this.fileSystem = fileSystem;
|
|
38
49
|
}
|
|
39
|
-
async
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
async initializeFeature(request) {
|
|
51
|
+
validateFeatureName(request.featureName);
|
|
52
|
+
if (!request.description || request.description.length > 20_000) {
|
|
53
|
+
throw new GovernanceError('InvalidParams', 'description must contain 1-20000 characters');
|
|
54
|
+
}
|
|
55
|
+
if (!this.fileSystem)
|
|
56
|
+
throw new GovernanceError('StateInvariantViolation', 'FileSystemPort is required');
|
|
57
|
+
const absoluteProjectRoot = path.resolve(request.projectRoot);
|
|
58
|
+
const metadataRoot = path.join(absoluteProjectRoot, '.spec');
|
|
59
|
+
const specsRoot = path.join(metadataRoot, 'specs');
|
|
60
|
+
const resolver = new SpecPathResolver(this.fileSystem);
|
|
61
|
+
if (await this.fileSystem.exists(metadataRoot)) {
|
|
62
|
+
await resolver.assertContained(absoluteProjectRoot, metadataRoot);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
await this.fileSystem.mkdir(metadataRoot);
|
|
66
|
+
await resolver.assertContained(absoluteProjectRoot, metadataRoot);
|
|
67
|
+
}
|
|
68
|
+
if (await this.fileSystem.exists(specsRoot)) {
|
|
69
|
+
await resolver.assertContained(absoluteProjectRoot, specsRoot);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
await this.fileSystem.mkdir(specsRoot);
|
|
73
|
+
await resolver.assertContained(absoluteProjectRoot, specsRoot);
|
|
74
|
+
}
|
|
75
|
+
const resolved = await resolver.resolve(absoluteProjectRoot, request.featureName);
|
|
76
|
+
const initializationLock = path.join(resolved.specsRoot, `.${request.featureName}.init.lock`);
|
|
77
|
+
await resolver.assertContained(resolved.specsRoot, initializationLock);
|
|
78
|
+
return withFilesystemLock(initializationLock, async (lease) => {
|
|
79
|
+
if (await this.fileSystem.exists(resolved.featureRoot)) {
|
|
80
|
+
throw new GovernanceError('StateInvariantViolation', `Feature already exists: ${request.featureName}`);
|
|
81
|
+
}
|
|
82
|
+
await this.fileSystem.mkdir(resolved.featureRoot);
|
|
83
|
+
const now = new Date().toISOString();
|
|
84
|
+
const empty = () => ({
|
|
85
|
+
generated: false,
|
|
86
|
+
approved: false,
|
|
87
|
+
revision: 0,
|
|
88
|
+
validation: { status: 'not-run', blockers: [] },
|
|
89
|
+
});
|
|
90
|
+
const spec = {
|
|
91
|
+
schema_version: 5,
|
|
92
|
+
feature_name: request.featureName,
|
|
93
|
+
description: request.description,
|
|
94
|
+
language: request.language ?? 'en',
|
|
95
|
+
phase: WorkflowPhase.INIT,
|
|
96
|
+
created_at: now,
|
|
97
|
+
updated_at: now,
|
|
98
|
+
approvals: { requirements: empty(), design: empty(), tasks: empty() },
|
|
99
|
+
workflow_options: { review_test_cases: null },
|
|
100
|
+
checkpoints: { test_cases: { required: false, reviewed: false } },
|
|
101
|
+
};
|
|
102
|
+
const specPath = path.join(resolved.featureRoot, 'spec.json');
|
|
103
|
+
try {
|
|
104
|
+
await resolver.assertContained(resolved.featureRoot, specPath);
|
|
105
|
+
await this.persistSpecCas(specPath, null, spec, lease.assertHeld);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
try {
|
|
109
|
+
await rmdir(resolved.featureRoot);
|
|
60
110
|
}
|
|
111
|
+
catch {
|
|
112
|
+
// Preserve a non-empty directory: another actor created content we do not own.
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
61
115
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
|
|
116
|
+
return this.hydrateProject(resolved.projectRoot, request.featureName, spec);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async submitPhaseArtifact(request) {
|
|
120
|
+
if (!request.content || request.content.length > PHASE_CONTENT_CHARACTER_LIMIT) {
|
|
121
|
+
throw new GovernanceError('InvalidParams', 'content must contain 1-1048576 characters');
|
|
122
|
+
}
|
|
123
|
+
return this.withLockedFeature(request.projectRoot, request.featureName, true, async ({ featureRoot, specPath, spec, assertHeld, currentSpecBytes }) => {
|
|
124
|
+
await this.assertNoApprovedArtifactDrift(featureRoot, spec);
|
|
125
|
+
const record = spec.approvals[request.phase];
|
|
126
|
+
this.assertPriorApproved(request.phase, spec.approvals);
|
|
127
|
+
if (record.approved)
|
|
128
|
+
throw new GovernanceError('PhaseNotApproved', `${request.phase} is already approved`);
|
|
129
|
+
if (record.revision !== request.expectedRevision)
|
|
130
|
+
throw new GovernanceError('RevisionConflict', `Expected revision ${request.expectedRevision}, found ${record.revision}`);
|
|
131
|
+
const artifactPath = path.join(featureRoot, `${request.phase}.md`);
|
|
132
|
+
const priorArtifact = await this.readOptional(artifactPath);
|
|
133
|
+
const observedHash = priorArtifact === null ? null : sha256(priorArtifact);
|
|
134
|
+
if (observedHash !== request.expectedArtifactSha256) {
|
|
135
|
+
throw new GovernanceError('ArtifactDrift', `${request.phase}.md changed since it was observed`, { observedArtifactSha256: observedHash });
|
|
77
136
|
}
|
|
78
|
-
|
|
137
|
+
if (request.phase === 'tasks') {
|
|
138
|
+
if (typeof request.reviewTestCases !== 'boolean')
|
|
139
|
+
throw new GovernanceError('InvalidParams', 'reviewTestCases is required for tasks');
|
|
140
|
+
const persisted = spec.workflow_options.review_test_cases;
|
|
141
|
+
if (persisted !== null && persisted !== request.reviewTestCases)
|
|
142
|
+
throw new GovernanceError('StateInvariantViolation', 'reviewTestCases cannot be changed for this feature');
|
|
143
|
+
}
|
|
144
|
+
const upstream = await this.readUpstream(featureRoot, request.phase);
|
|
145
|
+
const validation = this.validate(request.phase, request.content, upstream.requirements, upstream.design);
|
|
146
|
+
const artifactSha256 = sha256(request.content);
|
|
147
|
+
const nextSpec = structuredClone(spec);
|
|
148
|
+
nextSpec.phase = this.phaseValue(request.phase);
|
|
149
|
+
nextSpec.updated_at = new Date().toISOString();
|
|
150
|
+
nextSpec.approvals[request.phase] = {
|
|
151
|
+
generated: true, approved: false, revision: record.revision + 1, artifactSha256,
|
|
152
|
+
validation: { status: validation.status, checkedAt: new Date().toISOString(), blockers: validation.blockers },
|
|
153
|
+
};
|
|
154
|
+
for (const later of PHASES.slice(PHASES.indexOf(request.phase) + 1)) {
|
|
155
|
+
if (nextSpec.approvals[later].generated)
|
|
156
|
+
throw new GovernanceError('StateInvariantViolation', `Cannot revise ${request.phase} after ${later} was generated`);
|
|
157
|
+
}
|
|
158
|
+
if (request.phase === 'tasks') {
|
|
159
|
+
nextSpec.workflow_options.review_test_cases = request.reviewTestCases;
|
|
160
|
+
nextSpec.checkpoints.test_cases = { required: request.reviewTestCases, reviewed: false };
|
|
161
|
+
}
|
|
162
|
+
await this.commitPhaseSubmission(specPath, artifactPath, currentSpecBytes(), nextSpec, priorArtifact, request.content, assertHeld);
|
|
79
163
|
return {
|
|
80
|
-
featureName: request.featureName,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
canProgressToNext: request.phase !== 'tasks' || !review.required || review.reviewed,
|
|
84
|
-
handoff,
|
|
164
|
+
featureName: request.featureName, phase: request.phase, revision: record.revision + 1,
|
|
165
|
+
artifact: { path: path.relative(request.projectRoot, artifactPath), sha256: artifactSha256 },
|
|
166
|
+
validation, approvalRequired: validation.status === 'passed',
|
|
85
167
|
};
|
|
86
168
|
});
|
|
87
169
|
}
|
|
170
|
+
async approve(request) {
|
|
171
|
+
return this.withLockedFeature(request.projectRoot, request.featureName, true, async ({ featureRoot, spec, commitSpec }) => {
|
|
172
|
+
await this.assertNoApprovedArtifactDrift(featureRoot, spec);
|
|
173
|
+
const record = spec.approvals[request.phase];
|
|
174
|
+
if (!record.generated)
|
|
175
|
+
throw new GovernanceError('PhaseNotApproved', `${request.phase} has not been submitted`);
|
|
176
|
+
if (record.revision !== request.expectedRevision)
|
|
177
|
+
throw new GovernanceError('RevisionConflict', 'The reviewed revision is stale');
|
|
178
|
+
if (record.artifactSha256 !== request.expectedArtifactSha256)
|
|
179
|
+
throw new GovernanceError('ArtifactDrift', 'The reviewed artifact hash is stale');
|
|
180
|
+
const artifact = await this.requireArtifact(featureRoot, request.phase);
|
|
181
|
+
if (sha256(artifact) !== record.artifactSha256)
|
|
182
|
+
throw new GovernanceError('ArtifactDrift', `${request.phase}.md differs from the submitted artifact`);
|
|
183
|
+
this.assertPriorApproved(request.phase, spec.approvals);
|
|
184
|
+
if (record.validation.status !== 'passed' && record.validation.status !== 'legacy-accepted')
|
|
185
|
+
throw new GovernanceError('PhaseValidationFailed', `${request.phase} did not pass validation`, { blockers: record.validation.blockers });
|
|
186
|
+
if (record.validation.status === 'passed') {
|
|
187
|
+
const upstream = await this.readUpstream(featureRoot, request.phase);
|
|
188
|
+
const validation = this.validate(request.phase, artifact, upstream.requirements, upstream.design);
|
|
189
|
+
if (validation.status !== 'passed')
|
|
190
|
+
throw new GovernanceError('PhaseValidationFailed', `${request.phase} no longer validates`, { blockers: validation.blockers });
|
|
191
|
+
}
|
|
192
|
+
const checkpoint = spec.checkpoints.test_cases;
|
|
193
|
+
if (request.phase === 'tasks' && spec.workflow_options.review_test_cases === true &&
|
|
194
|
+
(!checkpoint.reviewed || checkpoint.reviewed_revision !== record.revision || checkpoint.reviewed_artifact_sha256 !== record.artifactSha256)) {
|
|
195
|
+
throw new GovernanceError('PhaseNotApproved', 'The current tasks revision requires explicit test-case review');
|
|
196
|
+
}
|
|
197
|
+
if (!record.approved) {
|
|
198
|
+
spec.approvals[request.phase] = { ...record, approved: true };
|
|
199
|
+
spec.phase = this.phaseValue(request.phase);
|
|
200
|
+
spec.updated_at = new Date().toISOString();
|
|
201
|
+
await commitSpec(spec);
|
|
202
|
+
}
|
|
203
|
+
const handoff = await this.publishHandoff(request.projectRoot, request.featureName);
|
|
204
|
+
return { featureName: request.featureName, phase: request.phase, approved: true, canProgressToNext: true, handoff };
|
|
205
|
+
});
|
|
206
|
+
}
|
|
88
207
|
async reviewTestCases(request) {
|
|
89
|
-
return this.
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
[key]: { ...test, required: true, reviewed: true, reviewed_at: new Date().toISOString() },
|
|
111
|
-
},
|
|
112
|
-
};
|
|
113
|
-
await this.writeSpecAtomic(specPath, prospective);
|
|
208
|
+
return this.withLockedFeature(request.projectRoot, request.featureName, true, async ({ featureRoot, spec, commitSpec }) => {
|
|
209
|
+
await this.assertNoApprovedArtifactDrift(featureRoot, spec);
|
|
210
|
+
const tasks = spec.approvals.tasks;
|
|
211
|
+
if (spec.workflow_options.review_test_cases !== true)
|
|
212
|
+
throw new GovernanceError('StateInvariantViolation', 'Test-case review is not required');
|
|
213
|
+
if (!tasks.generated)
|
|
214
|
+
throw new GovernanceError('PhaseNotApproved', 'Tasks are not ready for test-case review');
|
|
215
|
+
if (tasks.revision !== request.expectedTasksRevision)
|
|
216
|
+
throw new GovernanceError('RevisionConflict', 'The tasks review revision is stale');
|
|
217
|
+
if (tasks.artifactSha256 !== request.expectedArtifactSha256 || sha256(await this.requireArtifact(featureRoot, 'tasks')) !== request.expectedArtifactSha256)
|
|
218
|
+
throw new GovernanceError('ArtifactDrift', 'The tasks artifact changed before review');
|
|
219
|
+
const checkpoint = spec.checkpoints.test_cases;
|
|
220
|
+
const alreadyReviewed = checkpoint.reviewed
|
|
221
|
+
&& checkpoint.reviewed_revision === tasks.revision
|
|
222
|
+
&& checkpoint.reviewed_artifact_sha256 === tasks.artifactSha256;
|
|
223
|
+
if (!alreadyReviewed) {
|
|
224
|
+
if (tasks.validation.status !== 'passed' || !spec.approvals.design.approved || tasks.approved)
|
|
225
|
+
throw new GovernanceError('PhaseNotApproved', 'Tasks are not ready for test-case review');
|
|
226
|
+
spec.checkpoints.test_cases = { required: true, reviewed: true, reviewed_at: new Date().toISOString(), reviewed_revision: tasks.revision, reviewed_artifact_sha256: tasks.artifactSha256 };
|
|
227
|
+
spec.updated_at = new Date().toISOString();
|
|
228
|
+
await commitSpec(spec);
|
|
114
229
|
}
|
|
115
230
|
const handoff = await this.publishHandoff(request.projectRoot, request.featureName);
|
|
116
|
-
return { featureName: request.featureName, reviewed: true, canApproveTasks: true, handoff };
|
|
231
|
+
return { featureName: request.featureName, reviewed: true, tasksRevision: tasks.revision, artifactSha256: tasks.artifactSha256, canApproveTasks: true, handoff };
|
|
117
232
|
});
|
|
118
233
|
}
|
|
119
|
-
async
|
|
120
|
-
return this.
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
throw new
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
234
|
+
async beginImplementation(request) {
|
|
235
|
+
return this.withLockedFeature(request.projectRoot, request.featureName, true, async ({ featureRoot, spec, commitSpec }) => {
|
|
236
|
+
await this.assertNoApprovedArtifactDrift(featureRoot, spec);
|
|
237
|
+
const tasksRecord = spec.approvals.tasks;
|
|
238
|
+
if (!tasksRecord.approved)
|
|
239
|
+
throw new GovernanceError('PhaseNotApproved', 'Tasks are not approved');
|
|
240
|
+
if (sha256(await this.requireArtifact(featureRoot, 'tasks')) !== tasksRecord.artifactSha256)
|
|
241
|
+
throw new GovernanceError('ArtifactDrift', 'Approved tasks artifact has drifted');
|
|
242
|
+
if (spec.workflow_options.review_test_cases === true) {
|
|
243
|
+
const cp = spec.checkpoints.test_cases;
|
|
244
|
+
if (!cp.reviewed || cp.reviewed_revision !== tasksRecord.revision || cp.reviewed_artifact_sha256 !== tasksRecord.artifactSha256)
|
|
245
|
+
throw new GovernanceError('PhaseNotApproved', 'Tasks review checkpoint is stale');
|
|
246
|
+
}
|
|
247
|
+
if (!spec.implementation) {
|
|
248
|
+
const content = await this.requireArtifact(featureRoot, 'tasks');
|
|
249
|
+
const parsed = (() => {
|
|
250
|
+
if (tasksRecord.validation.status === 'legacy-accepted')
|
|
251
|
+
return this.parseLegacyTasks(content);
|
|
252
|
+
const current = this.validator.parseTasks(content);
|
|
253
|
+
if (current.status !== 'passed') {
|
|
254
|
+
throw new GovernanceError('PhaseValidationFailed', 'Approved tasks no longer satisfy the executable task contract', { blockers: current.blockers });
|
|
255
|
+
}
|
|
256
|
+
return Object.entries(current.tasks).map(([id, task]) => ({ id, ...task }));
|
|
257
|
+
})();
|
|
258
|
+
if (parsed.length === 0)
|
|
259
|
+
throw new GovernanceError('LegacyTaskConflict', 'Tasks artifact contains no executable leaf tasks');
|
|
260
|
+
const taskStates = {};
|
|
261
|
+
for (const task of parsed) {
|
|
262
|
+
taskStates[task.id] = {
|
|
263
|
+
title: task.title, tdd_required: task.tddRequired, dependencies: [...task.dependencies],
|
|
264
|
+
status: 'completed' in task && task.completed ? 'completed' : 'pending',
|
|
265
|
+
evidence: {},
|
|
266
|
+
planned_artifacts: [...task.plannedArtifacts],
|
|
267
|
+
observed_artifacts: [],
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
spec.implementation = { revision: tasksRecord.validation.status === 'legacy-accepted' ? 1 : 0, tasks_revision: tasksRecord.revision, legacy_imported: tasksRecord.validation.status === 'legacy-accepted', tasks: taskStates };
|
|
271
|
+
spec.phase = this.allComplete(spec.implementation) ? WorkflowPhase.IMPLEMENTATION_COMPLETED : WorkflowPhase.IMPLEMENTATION;
|
|
272
|
+
spec.updated_at = new Date().toISOString();
|
|
273
|
+
await commitSpec(spec);
|
|
274
|
+
}
|
|
141
275
|
const handoff = await this.publishHandoff(request.projectRoot, request.featureName);
|
|
142
|
-
|
|
276
|
+
const counts = this.implementationCounts(spec.implementation);
|
|
277
|
+
return { featureName: request.featureName, phase: spec.phase, ready: true, revision: spec.implementation.revision, tasks: counts, nextAction: this.resolveNextAction(spec), handoff };
|
|
143
278
|
});
|
|
144
279
|
}
|
|
145
|
-
async
|
|
146
|
-
return this.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
280
|
+
async recordTaskProgress(request) {
|
|
281
|
+
return this.withLockedFeature(request.projectRoot, request.featureName, true, async ({ featureRoot, spec, commitSpec }) => {
|
|
282
|
+
await this.assertNoApprovedArtifactDrift(featureRoot, spec);
|
|
283
|
+
const implementation = spec.implementation;
|
|
284
|
+
if (!implementation)
|
|
285
|
+
throw new GovernanceError('TaskTransitionInvalid', 'Implementation has not started');
|
|
286
|
+
if (implementation.revision !== request.expectedRevision)
|
|
287
|
+
throw new GovernanceError('RevisionConflict', 'Implementation revision is stale');
|
|
288
|
+
const task = implementation.tasks[request.taskNumber];
|
|
289
|
+
if (!task)
|
|
290
|
+
throw new GovernanceError('TaskTransitionInvalid', `Unknown task ${request.taskNumber}`);
|
|
291
|
+
const incomplete = task.dependencies.filter((dependency) => implementation.tasks[dependency]?.status !== 'completed');
|
|
292
|
+
if (request.action === 'start' && incomplete.length > 0)
|
|
293
|
+
throw new GovernanceError('TaskDependencyIncomplete', 'Task dependencies are incomplete', { dependencies: incomplete });
|
|
294
|
+
const prior = task.status;
|
|
295
|
+
if (request.action === 'start') {
|
|
296
|
+
if (prior === 'pending')
|
|
297
|
+
task.status = 'in-progress';
|
|
298
|
+
else if (prior === 'blocked' && task.blocked_from) {
|
|
299
|
+
task.status = task.blocked_from;
|
|
300
|
+
delete task.blocker;
|
|
301
|
+
delete task.blocked_from;
|
|
302
|
+
}
|
|
303
|
+
else
|
|
304
|
+
this.invalidTransition(request.action, prior);
|
|
160
305
|
}
|
|
161
|
-
|
|
306
|
+
else if (request.action === 'record-red') {
|
|
307
|
+
if (!task.tdd_required || prior !== 'in-progress' || !request.evidence || request.evidence.exitCode === 0)
|
|
308
|
+
this.invalidTransition(request.action, prior);
|
|
309
|
+
task.status = 'red-observed';
|
|
310
|
+
task.evidence.red = this.evidence(request.evidence);
|
|
311
|
+
}
|
|
312
|
+
else if (request.action === 'record-green') {
|
|
313
|
+
if (!task.tdd_required || prior !== 'red-observed' || !request.evidence || request.evidence.exitCode !== 0)
|
|
314
|
+
this.invalidTransition(request.action, prior);
|
|
315
|
+
task.status = 'green-observed';
|
|
316
|
+
task.evidence.green = this.evidence(request.evidence);
|
|
317
|
+
}
|
|
318
|
+
else if (request.action === 'complete') {
|
|
319
|
+
const expected = task.tdd_required ? 'green-observed' : 'in-progress';
|
|
320
|
+
if (prior !== expected || !request.evidence || request.evidence.exitCode !== 0)
|
|
321
|
+
this.invalidTransition(request.action, prior);
|
|
322
|
+
const artifacts = this.normalizeArtifacts(request.affectedArtifacts ?? []);
|
|
323
|
+
if (task.planned_artifacts.length > 0 && artifacts.length === 0)
|
|
324
|
+
throw new GovernanceError('TaskTransitionInvalid', 'At least one observed artifact is required');
|
|
325
|
+
task.status = 'completed';
|
|
326
|
+
task.evidence.verification = this.evidence(request.evidence);
|
|
327
|
+
task.observed_artifacts = artifacts;
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
if (!['in-progress', 'red-observed', 'green-observed'].includes(prior) || !request.blocker?.trim())
|
|
331
|
+
this.invalidTransition(request.action, prior);
|
|
332
|
+
if (request.blocker.length > 2_000)
|
|
333
|
+
throw new GovernanceError('InvalidParams', 'Task blocker exceeds 2000 characters');
|
|
334
|
+
task.status = 'blocked';
|
|
335
|
+
task.blocked_from = prior;
|
|
336
|
+
task.blocker = request.blocker.trim();
|
|
337
|
+
}
|
|
338
|
+
implementation.revision += 1;
|
|
339
|
+
spec.phase = this.allComplete(implementation) ? WorkflowPhase.IMPLEMENTATION_COMPLETED : WorkflowPhase.IMPLEMENTATION;
|
|
340
|
+
spec.updated_at = new Date().toISOString();
|
|
341
|
+
await commitSpec(spec);
|
|
342
|
+
const handoff = await this.publishHandoff(request.projectRoot, request.featureName);
|
|
343
|
+
return { featureName: request.featureName, taskNumber: request.taskNumber, taskState: task.status, revision: implementation.revision, nextAction: this.resolveNextAction(spec), handoff };
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
async getFeatureStatus(request) {
|
|
347
|
+
return this.withLockedFeature(request.projectRoot, request.featureName, false, async ({ featureRoot, spec }) => this.buildStatus(request.featureName, featureRoot, spec));
|
|
348
|
+
}
|
|
349
|
+
async loadFeatureContext(request) {
|
|
350
|
+
return this.withLockedFeature(request.projectRoot, request.featureName, false, async ({ featureRoot, spec }) => {
|
|
351
|
+
await this.assertNoApprovedArtifactDrift(featureRoot, spec);
|
|
352
|
+
return this.contextCompactionService.loadContext(request);
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
async listFeatureStatuses(request) {
|
|
356
|
+
if (!this.fileSystem)
|
|
357
|
+
throw new GovernanceError('StateInvariantViolation', 'FileSystemPort is required');
|
|
358
|
+
const root = path.join(path.resolve(request.projectRoot), '.spec', 'specs');
|
|
359
|
+
if (!(await this.fileSystem.exists(root)))
|
|
360
|
+
return [];
|
|
361
|
+
const statuses = [];
|
|
362
|
+
for (const name of (await this.fileSystem.readdir(root)).sort()) {
|
|
162
363
|
try {
|
|
163
|
-
|
|
164
|
-
const parsed = JSON.parse(rendered);
|
|
165
|
-
await this.writeSpecAtomic(path.join(resolved.featureRoot, 'spec.json'), parsed);
|
|
166
|
-
return project;
|
|
364
|
+
validateFeatureName(name);
|
|
167
365
|
}
|
|
168
366
|
catch (error) {
|
|
169
|
-
|
|
367
|
+
if (error instanceof InvalidFeatureNameError)
|
|
368
|
+
continue;
|
|
170
369
|
throw error;
|
|
171
370
|
}
|
|
371
|
+
const candidate = path.join(root, name);
|
|
372
|
+
if ((await this.fileSystem.stat(candidate)).isDirectory()) {
|
|
373
|
+
statuses.push(await this.getFeatureStatus({ projectRoot: request.projectRoot, featureName: name }));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return statuses;
|
|
377
|
+
}
|
|
378
|
+
async loadProject(request) {
|
|
379
|
+
return this.withLockedFeature(request.projectRoot, request.featureName, false, async ({ projectRoot, spec }) => this.hydrateProject(projectRoot, request.featureName, spec));
|
|
380
|
+
}
|
|
381
|
+
async validateDesignArtifact(request) {
|
|
382
|
+
return this.withLockedFeature(request.projectRoot, request.featureName, false, async ({ featureRoot }) => {
|
|
383
|
+
const requirements = await this.requireArtifact(featureRoot, 'requirements');
|
|
384
|
+
const design = await this.requireArtifact(featureRoot, 'design');
|
|
385
|
+
return this.validator.validateDesign(design, requirements);
|
|
172
386
|
});
|
|
173
387
|
}
|
|
174
|
-
async
|
|
388
|
+
async withLockedFeature(projectRoot, featureName, mutate, action) {
|
|
175
389
|
if (!this.fileSystem)
|
|
176
|
-
throw new
|
|
177
|
-
const absoluteProject = path.resolve(request.projectRoot);
|
|
178
|
-
const specsRoot = path.join(absoluteProject, '.spec', 'specs');
|
|
179
|
-
if (!(await this.fileSystem.exists(specsRoot)))
|
|
180
|
-
return [];
|
|
390
|
+
throw new GovernanceError('StateInvariantViolation', 'FileSystemPort is required');
|
|
181
391
|
const resolver = new SpecPathResolver(this.fileSystem);
|
|
182
|
-
await resolver.
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
392
|
+
const resolved = await resolver.resolve(projectRoot, featureName);
|
|
393
|
+
const lockPath = path.join(resolved.featureRoot, '.workflow.lock');
|
|
394
|
+
await resolver.assertContained(resolved.featureRoot, lockPath);
|
|
395
|
+
return withFilesystemLock(lockPath, async (lease) => {
|
|
396
|
+
await this.assertGovernedFilesContained(resolved.featureRoot);
|
|
397
|
+
await this.recoverSubmission(resolved.featureRoot, lease.assertHeld);
|
|
398
|
+
const specPath = path.join(resolved.featureRoot, 'spec.json');
|
|
399
|
+
const raw = await this.readRequired(specPath, `Feature metadata not found: ${featureName}`);
|
|
400
|
+
const rawSpec = this.parseSpecBytes(raw);
|
|
401
|
+
const spec = this.normalizeSpec(rawSpec, resolved.featureRoot);
|
|
402
|
+
if (spec.feature_name !== featureName) {
|
|
403
|
+
throw new GovernanceError('StateInvariantViolation', `Feature metadata name does not match directory: ${featureName}`);
|
|
404
|
+
}
|
|
405
|
+
let expectedSpecBytes = raw;
|
|
406
|
+
const commitSpec = async (next) => {
|
|
407
|
+
expectedSpecBytes = await this.persistSpecCas(specPath, expectedSpecBytes, next, lease.assertHeld);
|
|
408
|
+
};
|
|
409
|
+
const currentSpecBytes = () => expectedSpecBytes;
|
|
410
|
+
if (mutate && rawSpec.schema_version !== 5) {
|
|
411
|
+
await commitSpec(spec);
|
|
412
|
+
}
|
|
413
|
+
return action({
|
|
414
|
+
projectRoot: resolved.projectRoot,
|
|
415
|
+
featureRoot: resolved.featureRoot,
|
|
416
|
+
specPath,
|
|
417
|
+
spec,
|
|
418
|
+
assertHeld: lease.assertHeld,
|
|
419
|
+
commitSpec,
|
|
420
|
+
currentSpecBytes,
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
normalizeSpec(raw, featureRoot) {
|
|
425
|
+
if (raw.schema_version === 5)
|
|
426
|
+
return this.normalizeV5(raw);
|
|
427
|
+
if (raw.schema_version !== undefined
|
|
428
|
+
&& (!Number.isInteger(raw.schema_version)
|
|
429
|
+
|| raw.schema_version < 1
|
|
430
|
+
|| raw.schema_version > 4)) {
|
|
431
|
+
throw new GovernanceError('LegacyStateConflict', `Unsupported workflow schema: ${String(raw.schema_version)}`);
|
|
432
|
+
}
|
|
433
|
+
const accepted = new Set(['init', 'requirements-generated', 'requirements-approved', 'design-generated', 'design-approved', 'tasks-generated', 'tasks-approved', 'implementation-ready', 'implementation', 'completed', 'implementation-completed']);
|
|
434
|
+
const legacyPhase = typeof raw.phase === 'string' ? raw.phase : 'init';
|
|
435
|
+
if (!accepted.has(legacyPhase))
|
|
436
|
+
throw new GovernanceError('LegacyStateConflict', `Unknown legacy phase: ${legacyPhase}`);
|
|
437
|
+
const legacyApprovals = raw.approvals && typeof raw.approvals === 'object' ? raw.approvals : {};
|
|
438
|
+
const approvals = {};
|
|
439
|
+
for (const phase of PHASES) {
|
|
440
|
+
const value = legacyApprovals[phase] && typeof legacyApprovals[phase] === 'object' ? legacyApprovals[phase] : {};
|
|
441
|
+
const generated = value.generated === true;
|
|
442
|
+
const approved = value.approved === true;
|
|
443
|
+
if (approved && !generated)
|
|
444
|
+
throw new GovernanceError('LegacyStateConflict', `${phase} is approved but not generated`);
|
|
445
|
+
const artifact = this.readOptionalSync(path.join(featureRoot, `${phase}.md`));
|
|
446
|
+
if (generated && artifact === null)
|
|
447
|
+
throw new GovernanceError('LegacyStateConflict', `Generated ${phase} artifact is missing`);
|
|
448
|
+
approvals[phase] = {
|
|
449
|
+
generated,
|
|
450
|
+
approved,
|
|
451
|
+
revision: generated ? 1 : 0,
|
|
452
|
+
artifactSha256: generated && artifact !== null ? sha256(artifact) : undefined,
|
|
453
|
+
validation: { status: approved ? 'legacy-accepted' : 'not-run', blockers: [] },
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
for (const phase of PHASES) {
|
|
457
|
+
if (!approvals[phase].generated || approvals[phase].approved)
|
|
189
458
|
continue;
|
|
190
|
-
const
|
|
191
|
-
|
|
459
|
+
const content = this.readOptionalSync(path.join(featureRoot, `${phase}.md`)) ?? '';
|
|
460
|
+
const requirementsContent = this.readOptionalSync(path.join(featureRoot, 'requirements.md')) ?? '';
|
|
461
|
+
const designContent = this.readOptionalSync(path.join(featureRoot, 'design.md')) ?? '';
|
|
462
|
+
const validation = this.validate(phase, content, requirementsContent, designContent);
|
|
463
|
+
approvals[phase] = {
|
|
464
|
+
...approvals[phase],
|
|
465
|
+
validation: {
|
|
466
|
+
status: validation.status,
|
|
467
|
+
checkedAt: typeof raw.updated_at === 'string' ? raw.updated_at : undefined,
|
|
468
|
+
blockers: validation.blockers,
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
this.assertLegacyOrdering(approvals);
|
|
473
|
+
this.assertLegacyPhaseRank(legacyPhase, approvals, raw);
|
|
474
|
+
const optionsRaw = raw.workflow_options && typeof raw.workflow_options === 'object' ? raw.workflow_options : {};
|
|
475
|
+
const checkpointRaw = raw.checkpoints && typeof raw.checkpoints === 'object' ? raw.checkpoints : {};
|
|
476
|
+
const testRaw = (checkpointRaw.test_cases ?? checkpointRaw.testCases);
|
|
477
|
+
const legacyRequired = typeof testRaw?.required === 'boolean' ? testRaw.required : undefined;
|
|
478
|
+
const choice = typeof optionsRaw.review_test_cases === 'boolean'
|
|
479
|
+
? optionsRaw.review_test_cases
|
|
480
|
+
: legacyRequired ?? (approvals.tasks.approved ? false : null);
|
|
481
|
+
const reviewed = choice === true && testRaw?.reviewed === true;
|
|
482
|
+
const completedRank = legacyPhase === 'completed' || legacyPhase === 'implementation-completed';
|
|
483
|
+
const implementationRank = ['implementation-ready', 'implementation', 'completed', 'implementation-completed'].includes(legacyPhase);
|
|
484
|
+
if (implementationRank && (!approvals.tasks.approved || (choice === true && !reviewed)))
|
|
485
|
+
throw new GovernanceError('LegacyStateConflict', 'Legacy implementation rank does not satisfy tasks governance');
|
|
486
|
+
const description = typeof raw.description === 'string' ? raw.description : '';
|
|
487
|
+
const language = typeof raw.language === 'string' ? raw.language : 'en';
|
|
488
|
+
if (description.length > 20_000) {
|
|
489
|
+
throw new GovernanceError('LegacyStateConflict', 'Legacy description exceeds the schema-v5 limit');
|
|
490
|
+
}
|
|
491
|
+
if (!['en', 'ja', 'zh-TW'].includes(language)) {
|
|
492
|
+
throw new GovernanceError('LegacyStateConflict', `Unsupported legacy language: ${language}`);
|
|
192
493
|
}
|
|
193
|
-
|
|
494
|
+
const updatedAt = typeof raw.updated_at === 'string'
|
|
495
|
+
? raw.updated_at
|
|
496
|
+
: typeof raw.updatedAt === 'string'
|
|
497
|
+
? raw.updatedAt
|
|
498
|
+
: new Date(0).toISOString();
|
|
499
|
+
const createdAt = typeof raw.created_at === 'string'
|
|
500
|
+
? raw.created_at
|
|
501
|
+
: typeof raw.createdAt === 'string'
|
|
502
|
+
? raw.createdAt
|
|
503
|
+
: updatedAt;
|
|
504
|
+
const spec = {
|
|
505
|
+
schema_version: 5, feature_name: String(raw.feature_name ?? raw.name ?? path.basename(featureRoot)),
|
|
506
|
+
description, language, created_at: createdAt, updated_at: updatedAt,
|
|
507
|
+
phase: completedRank ? WorkflowPhase.IMPLEMENTATION_COMPLETED : implementationRank ? WorkflowPhase.IMPLEMENTATION : this.legacyPhaseValue(legacyPhase),
|
|
508
|
+
approvals, workflow_options: { review_test_cases: choice },
|
|
509
|
+
checkpoints: { test_cases: { required: choice === true, reviewed, reviewed_revision: reviewed ? approvals.tasks.revision : undefined, reviewed_artifact_sha256: reviewed ? approvals.tasks.artifactSha256 : undefined } },
|
|
510
|
+
};
|
|
511
|
+
if (implementationRank) {
|
|
512
|
+
const tasks = this.parseLegacyTasks(this.readOptionalSync(path.join(featureRoot, 'tasks.md')) ?? '');
|
|
513
|
+
if (tasks.length === 0)
|
|
514
|
+
throw new GovernanceError('LegacyTaskConflict', 'Legacy tasks contain no leaves');
|
|
515
|
+
spec.implementation = { revision: 1, tasks_revision: approvals.tasks.revision, legacy_imported: true, tasks: Object.fromEntries(tasks.map((task) => [task.id, { title: task.title, tdd_required: task.tddRequired, dependencies: [], status: task.completed ? 'completed' : 'pending', evidence: {}, planned_artifacts: [], observed_artifacts: [] }])) };
|
|
516
|
+
if (completedRank && !this.allComplete(spec.implementation))
|
|
517
|
+
throw new GovernanceError('LegacyStateConflict', 'Completed legacy phase has unchecked tasks');
|
|
518
|
+
}
|
|
519
|
+
return spec;
|
|
194
520
|
}
|
|
195
|
-
async
|
|
196
|
-
const
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
|
|
521
|
+
async buildStatus(featureName, featureRoot, spec) {
|
|
522
|
+
const phases = {};
|
|
523
|
+
const blockers = [];
|
|
524
|
+
const driftedPhases = new Set();
|
|
525
|
+
for (const phase of PHASES) {
|
|
526
|
+
const record = spec.approvals[phase];
|
|
527
|
+
const current = await this.readOptional(path.join(featureRoot, `${phase}.md`));
|
|
528
|
+
const observed = current === null ? undefined : sha256(current);
|
|
529
|
+
const drift = record.artifactSha256 !== observed;
|
|
530
|
+
if (drift)
|
|
531
|
+
driftedPhases.add(phase);
|
|
532
|
+
phases[phase] = { ...record, observedArtifactSha256: drift ? observed : undefined };
|
|
533
|
+
if (record.approved && drift)
|
|
534
|
+
blockers.push(`${phase} artifact drift`);
|
|
535
|
+
}
|
|
536
|
+
const cp = spec.checkpoints.test_cases;
|
|
537
|
+
const implementation = spec.implementation ? { revision: spec.implementation.revision, ...this.implementationCounts(spec.implementation) } : undefined;
|
|
538
|
+
return { featureName, phase: spec.phase, currentPhase: spec.phase, nextAction: this.resolveNextAction(spec, driftedPhases), blockers, phases, checkpoint: { required: cp.required, reviewed: cp.reviewed, reviewedRevision: cp.reviewed_revision, reviewedArtifactSha256: cp.reviewed_artifact_sha256 }, implementation };
|
|
539
|
+
}
|
|
540
|
+
resolveNextAction(spec, driftedPhases = new Set()) {
|
|
541
|
+
for (const phase of PHASES)
|
|
542
|
+
if (spec.approvals[phase].approved && driftedPhases.has(phase))
|
|
543
|
+
return { kind: 'blocked', code: 'ArtifactDrift', phase };
|
|
544
|
+
for (const phase of ['requirements', 'design']) {
|
|
545
|
+
const record = spec.approvals[phase];
|
|
546
|
+
if (!record.generated)
|
|
547
|
+
return { kind: 'submit-phase', phase };
|
|
548
|
+
if (!record.approved)
|
|
549
|
+
return record.validation.status === 'passed' && !driftedPhases.has(phase) ? { kind: 'request-approval', phase } : { kind: 'revise-phase', phase };
|
|
550
|
+
}
|
|
551
|
+
const tasks = spec.approvals.tasks;
|
|
552
|
+
if (!tasks.generated)
|
|
553
|
+
return { kind: 'submit-phase', phase: 'tasks' };
|
|
554
|
+
if (!tasks.approved) {
|
|
555
|
+
if (tasks.validation.status !== 'passed' || spec.workflow_options.review_test_cases === null || driftedPhases.has('tasks'))
|
|
556
|
+
return { kind: 'revise-phase', phase: 'tasks' };
|
|
557
|
+
const cp = spec.checkpoints.test_cases;
|
|
558
|
+
if (spec.workflow_options.review_test_cases && (!cp.reviewed || cp.reviewed_revision !== tasks.revision || cp.reviewed_artifact_sha256 !== tasks.artifactSha256))
|
|
559
|
+
return { kind: 'review-test-cases', phase: 'tasks' };
|
|
560
|
+
return { kind: 'request-approval', phase: 'tasks' };
|
|
561
|
+
}
|
|
562
|
+
if (!spec.implementation)
|
|
563
|
+
return { kind: 'start-implementation' };
|
|
564
|
+
if (this.allComplete(spec.implementation))
|
|
565
|
+
return { kind: 'complete' };
|
|
566
|
+
const active = Object.entries(spec.implementation.tasks).filter(([, task]) => ['in-progress', 'red-observed', 'green-observed', 'blocked'].includes(task.status));
|
|
567
|
+
if (active.length === 1)
|
|
568
|
+
return { kind: 'continue-task', taskNumber: active[0][0], taskState: active[0][1].status };
|
|
569
|
+
if (active.length > 1)
|
|
570
|
+
return { kind: 'select-task', candidates: active.map(([taskNumber, task]) => ({ taskNumber, taskState: task.status })) };
|
|
571
|
+
const ready = Object.entries(spec.implementation.tasks).filter(([, task]) => task.status === 'pending' && task.dependencies.every((id) => spec.implementation.tasks[id]?.status === 'completed'));
|
|
572
|
+
return { kind: 'select-task', candidates: ready.map(([taskNumber, task]) => ({ taskNumber, taskState: task.status })) };
|
|
573
|
+
}
|
|
574
|
+
validate(phase, content, requirements, design) {
|
|
575
|
+
if (phase === 'requirements')
|
|
576
|
+
return this.validator.validateRequirements(content);
|
|
577
|
+
if (phase === 'design')
|
|
578
|
+
return this.validator.validateDesign(content, requirements ?? '');
|
|
579
|
+
return this.validator.validateTasks(content, requirements ?? '', design ?? '');
|
|
580
|
+
}
|
|
581
|
+
async readUpstream(featureRoot, phase) {
|
|
201
582
|
return {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
currentPhase: project.phase,
|
|
205
|
-
nextPhase: progression.nextPhase,
|
|
206
|
-
canProgress: progression.nextPhase !== undefined && progression.blockers.length === 0,
|
|
207
|
-
blockers: progression.blockers,
|
|
208
|
-
testCases: review,
|
|
583
|
+
requirements: phase === 'requirements' ? undefined : await this.requireArtifact(featureRoot, 'requirements'),
|
|
584
|
+
design: phase === 'tasks' ? await this.requireArtifact(featureRoot, 'design') : undefined,
|
|
209
585
|
};
|
|
210
586
|
}
|
|
211
|
-
async
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const content = await this.renderPhase(project, request.phase);
|
|
223
|
-
await this.persistPhaseDocument(fileSystem, featureRoot, request.phase, content, approvals[request.phase].generated);
|
|
224
|
-
const prospective = this.buildGeneratedSpec(spec, request);
|
|
225
|
-
await this.writeSpecAtomic(specPath, prospective);
|
|
226
|
-
await this.synchronizeRepository(featureRoot, this.readApprovals(prospective));
|
|
227
|
-
return `${this.phaseTitle(request.phase)} document generated for project "${request.featureName}"`;
|
|
228
|
-
});
|
|
587
|
+
async assertNoApprovedArtifactDrift(featureRoot, spec) {
|
|
588
|
+
for (const phase of PHASES) {
|
|
589
|
+
const record = spec.approvals[phase];
|
|
590
|
+
if (!record.approved)
|
|
591
|
+
continue;
|
|
592
|
+
const content = await this.readOptional(path.join(featureRoot, `${phase}.md`));
|
|
593
|
+
const observedArtifactSha256 = content === null ? undefined : sha256(content);
|
|
594
|
+
if (observedArtifactSha256 !== record.artifactSha256) {
|
|
595
|
+
throw new GovernanceError('ArtifactDrift', `Approved ${phase}.md differs from revision ${record.revision}`, { phase, observedArtifactSha256 });
|
|
596
|
+
}
|
|
597
|
+
}
|
|
229
598
|
}
|
|
230
|
-
async
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
599
|
+
async commitPhaseSubmission(specPath, artifactPath, expectedSpecBytes, nextSpec, priorArtifact, nextArtifact, assertHeld) {
|
|
600
|
+
this.assertV5Invariants(nextSpec);
|
|
601
|
+
const currentSpecBytes = expectedSpecBytes;
|
|
602
|
+
const nextSpecBytes = this.specBytes(nextSpec);
|
|
603
|
+
const endpoints = [
|
|
604
|
+
currentSpecBytes,
|
|
605
|
+
nextSpecBytes,
|
|
606
|
+
...(priorArtifact === null ? [] : [priorArtifact]),
|
|
607
|
+
nextArtifact,
|
|
608
|
+
];
|
|
609
|
+
if (endpoints.some((value) => Buffer.byteLength(value, 'utf8') > JOURNAL_LIMIT)) {
|
|
610
|
+
throw new GovernanceError('RecoveryConflict', 'Submission endpoint exceeds the journal size limit');
|
|
611
|
+
}
|
|
612
|
+
const journal = {
|
|
613
|
+
schema_version: 1,
|
|
614
|
+
spec: { prior: encode(currentSpecBytes), next: encode(nextSpecBytes) },
|
|
615
|
+
artifact: { prior: priorArtifact === null ? null : encode(priorArtifact), next: encode(nextArtifact) },
|
|
616
|
+
};
|
|
617
|
+
const journalBytes = `${JSON.stringify(journal)}\n`;
|
|
618
|
+
const journalPath = path.join(path.dirname(specPath), '.phase-submit.json');
|
|
619
|
+
await this.replaceCas(journalPath, null, journalBytes, assertHeld);
|
|
620
|
+
try {
|
|
621
|
+
await this.replaceCas(artifactPath, priorArtifact, nextArtifact, assertHeld);
|
|
622
|
+
await this.replaceCas(specPath, currentSpecBytes, nextSpecBytes, assertHeld);
|
|
623
|
+
await this.replaceCas(journalPath, journalBytes, null, assertHeld);
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
if (error instanceof FilesystemLockCompromisedError)
|
|
627
|
+
throw error;
|
|
628
|
+
try {
|
|
629
|
+
await this.replaceCas(artifactPath, nextArtifact, priorArtifact, assertHeld);
|
|
630
|
+
await this.replaceCas(specPath, nextSpecBytes, currentSpecBytes, assertHeld);
|
|
631
|
+
await this.replaceCas(journalPath, journalBytes, null, assertHeld);
|
|
632
|
+
}
|
|
633
|
+
catch (rollbackError) {
|
|
634
|
+
throw new GovernanceError('RecoveryConflict', 'Submission failed and CAS rollback could not safely restore prior bytes', { cause: errorSummary(error), rollback: errorSummary(rollbackError) });
|
|
635
|
+
}
|
|
636
|
+
throw error;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
async recoverSubmission(featureRoot, assertHeld) {
|
|
640
|
+
const journalPath = path.join(featureRoot, '.phase-submit.json');
|
|
641
|
+
if (!this.fileSystem)
|
|
642
|
+
throw new GovernanceError('StateInvariantViolation', 'FileSystemPort is required');
|
|
643
|
+
if (await this.fileSystem.exists(journalPath)) {
|
|
644
|
+
const journalStats = await stat(journalPath);
|
|
645
|
+
if (!journalStats.isFile() || journalStats.size > JOURNAL_FILE_LIMIT) {
|
|
646
|
+
throw new GovernanceError('RecoveryConflict', 'Submission journal exceeds its raw size limit');
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
const raw = await this.readOptional(journalPath);
|
|
650
|
+
if (raw === null)
|
|
651
|
+
return;
|
|
652
|
+
let journal;
|
|
653
|
+
try {
|
|
654
|
+
journal = JSON.parse(raw);
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
throw new GovernanceError('RecoveryConflict', 'Submission journal is malformed');
|
|
658
|
+
}
|
|
659
|
+
if (journal.schema_version !== 1) {
|
|
660
|
+
throw new GovernanceError('RecoveryConflict', 'Submission journal schema is unsupported');
|
|
661
|
+
}
|
|
662
|
+
const priorSpec = decode(journal.spec?.prior);
|
|
663
|
+
const nextSpec = decode(journal.spec?.next);
|
|
664
|
+
const priorArtifact = journal.artifact?.prior === null ? null : decode(journal.artifact?.prior);
|
|
665
|
+
const nextArtifact = decode(journal.artifact?.next);
|
|
666
|
+
const specPath = path.join(featureRoot, 'spec.json');
|
|
667
|
+
const journalPhase = this.artifactPhaseFromJournal(priorSpec, nextSpec, priorArtifact, nextArtifact);
|
|
668
|
+
const artifactPath = path.join(featureRoot, `${journalPhase}.md`);
|
|
669
|
+
const currentSpec = await this.readOptional(specPath);
|
|
670
|
+
const currentArtifact = await this.readOptional(artifactPath);
|
|
671
|
+
const convergeNext = currentSpec === nextSpec;
|
|
672
|
+
if (currentSpec !== priorSpec && !convergeNext) {
|
|
673
|
+
throw new GovernanceError('RecoveryConflict', 'Current spec bytes match neither journal endpoint');
|
|
674
|
+
}
|
|
675
|
+
if (currentArtifact !== priorArtifact && currentArtifact !== nextArtifact) {
|
|
676
|
+
throw new GovernanceError('RecoveryConflict', 'Current artifact bytes match neither journal endpoint');
|
|
677
|
+
}
|
|
678
|
+
await this.assertJournalValidation(featureRoot, journalPhase, nextSpec, nextArtifact);
|
|
679
|
+
await this.replaceCas(artifactPath, currentArtifact, convergeNext ? nextArtifact : priorArtifact, assertHeld);
|
|
680
|
+
await this.replaceCas(specPath, currentSpec, convergeNext ? nextSpec : priorSpec, assertHeld);
|
|
681
|
+
await this.replaceCas(journalPath, raw, null, assertHeld);
|
|
682
|
+
}
|
|
683
|
+
async replaceCas(filePath, expected, desired, assertHeld) {
|
|
684
|
+
await assertHeld();
|
|
685
|
+
const current = await this.readOptional(filePath);
|
|
686
|
+
if (current !== expected) {
|
|
687
|
+
if (current === desired) {
|
|
688
|
+
await assertHeld();
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
throw new GovernanceError('RecoveryConflict', `Concurrent byte change at ${path.basename(filePath)}`);
|
|
692
|
+
}
|
|
693
|
+
if (desired === null) {
|
|
694
|
+
if (current !== null)
|
|
695
|
+
await unlink(filePath);
|
|
696
|
+
if (await this.readOptional(filePath) !== null) {
|
|
697
|
+
throw new GovernanceError('RecoveryConflict', `Delete verification failed for ${path.basename(filePath)}`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
await this.requireAtomicFileSystem().writeFileAtomic(filePath, desired);
|
|
702
|
+
if (await this.readOptional(filePath) !== desired) {
|
|
703
|
+
throw new GovernanceError('RecoveryConflict', `Write verification failed for ${path.basename(filePath)}`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
await assertHeld();
|
|
707
|
+
}
|
|
708
|
+
parseLegacyTasks(content) {
|
|
709
|
+
const lines = this.visibleLegacyLines(content);
|
|
710
|
+
const headingPattern = /^#{3,4}\s+(\d+(?:\.\d+)*)\s+(.+?)\s*$/;
|
|
711
|
+
const checklistPattern = /^(\s*)-\s+\[([ xX])\]\s+(\d+(?:\.\d+)*)\s+(.+?)\s*$/;
|
|
712
|
+
const headings = new Map();
|
|
713
|
+
const checklist = new Map();
|
|
714
|
+
for (const [index, line] of lines.entries()) {
|
|
715
|
+
const heading = headingPattern.exec(line);
|
|
716
|
+
if (heading) {
|
|
717
|
+
const id = heading[1];
|
|
718
|
+
if (headings.has(id)) {
|
|
719
|
+
throw new GovernanceError('LegacyTaskConflict', `Duplicate legacy task ${id}`);
|
|
720
|
+
}
|
|
721
|
+
const body = [];
|
|
722
|
+
for (let cursor = index + 1; cursor < lines.length && !headingPattern.test(lines[cursor]); cursor += 1) {
|
|
723
|
+
body.push(lines[cursor]);
|
|
724
|
+
}
|
|
725
|
+
headings.set(id, { title: heading[2].trim(), body });
|
|
726
|
+
}
|
|
727
|
+
const item = checklistPattern.exec(line);
|
|
728
|
+
if (!item || checklist.has(item[3]))
|
|
729
|
+
continue;
|
|
730
|
+
const indentation = item[1].length;
|
|
731
|
+
const body = [];
|
|
732
|
+
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
733
|
+
if (headingPattern.test(lines[cursor]))
|
|
734
|
+
break;
|
|
735
|
+
const nextItem = checklistPattern.exec(lines[cursor]);
|
|
736
|
+
if (nextItem && nextItem[1].length <= indentation)
|
|
737
|
+
break;
|
|
738
|
+
body.push(lines[cursor]);
|
|
739
|
+
}
|
|
740
|
+
checklist.set(item[3], {
|
|
741
|
+
title: item[4].trim(),
|
|
742
|
+
checked: item[2].toLowerCase() === 'x',
|
|
743
|
+
body,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
const ids = new Set([...headings.keys(), ...checklist.keys()]);
|
|
747
|
+
if (ids.size > 5_000) {
|
|
748
|
+
throw new GovernanceError('LegacyTaskConflict', 'Legacy tasks contain too many task identifiers');
|
|
749
|
+
}
|
|
750
|
+
for (const id of ids) {
|
|
751
|
+
const title = headings.get(id)?.title ?? checklist.get(id)?.title ?? id;
|
|
752
|
+
if (id.length > 50 || title.trim().length === 0 || title.length > 500) {
|
|
753
|
+
throw new GovernanceError('LegacyTaskConflict', `Legacy task ${id.slice(0, 50)} exceeds schema-v5 field limits`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
const parents = new Set();
|
|
757
|
+
for (const id of ids) {
|
|
758
|
+
const segments = id.split('.');
|
|
759
|
+
while (segments.length > 1) {
|
|
760
|
+
segments.pop();
|
|
761
|
+
parents.add(segments.join('.'));
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
const leaves = [...ids].filter((id) => !parents.has(id));
|
|
765
|
+
if (leaves.length > 1_000) {
|
|
766
|
+
throw new GovernanceError('LegacyTaskConflict', 'Legacy tasks contain more than 1000 executable leaves');
|
|
767
|
+
}
|
|
768
|
+
return leaves.map((id) => {
|
|
769
|
+
const heading = headings.get(id);
|
|
770
|
+
const item = checklist.get(id);
|
|
771
|
+
const ownedBody = heading?.body ?? item?.body ?? [];
|
|
772
|
+
const headingStatus = ownedBody
|
|
773
|
+
.map((line) => /^\s*\*\*Status:\*\*\s*\[([ xX])\]\s*$/.exec(line)?.[1])
|
|
774
|
+
.find((value) => value !== undefined);
|
|
775
|
+
const body = ownedBody.join('\n');
|
|
776
|
+
const hasMarker = (marker) => new RegExp(`(^|[^A-Za-z0-9_])${marker}([^A-Za-z0-9_]|$)`, 'i').test(body);
|
|
777
|
+
return {
|
|
778
|
+
id,
|
|
779
|
+
title: heading?.title ?? item?.title ?? id,
|
|
780
|
+
tddRequired: hasMarker('RED') && hasMarker('GREEN') && hasMarker('REFACTOR'),
|
|
781
|
+
dependencies: [],
|
|
782
|
+
plannedArtifacts: [],
|
|
783
|
+
completed: headingStatus !== undefined
|
|
784
|
+
? headingStatus.toLowerCase() === 'x'
|
|
785
|
+
: item?.checked ?? false,
|
|
248
786
|
};
|
|
249
|
-
await this.writeSpecAtomic(specPath, prospective);
|
|
250
|
-
await this.synchronizeRepository(featureRoot, approvals);
|
|
251
|
-
return { featureName: request.featureName, phase: WorkflowPhase.IMPLEMENTATION, ready: true };
|
|
252
787
|
});
|
|
253
788
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
789
|
+
visibleLegacyLines(content) {
|
|
790
|
+
const lines = content.replace(/\r\n?/g, '\n').split('\n');
|
|
791
|
+
let fence;
|
|
792
|
+
return lines.map((line) => {
|
|
793
|
+
const opening = /^\s*(`{3,}|~{3,})/.exec(line);
|
|
794
|
+
if (!fence) {
|
|
795
|
+
if (opening) {
|
|
796
|
+
fence = { marker: opening[1][0], length: opening[1].length };
|
|
797
|
+
return '';
|
|
798
|
+
}
|
|
799
|
+
return line;
|
|
800
|
+
}
|
|
801
|
+
const closing = new RegExp(`^\\s*${fence.marker}{${fence.length},}\\s*$`);
|
|
802
|
+
if (closing.test(line))
|
|
803
|
+
fence = undefined;
|
|
804
|
+
return '';
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
async hydrateProject(projectRoot, featureName, spec) {
|
|
808
|
+
const approvals = spec.approvals;
|
|
809
|
+
const project = { id: `disk:${featureName}`, name: featureName, path: projectRoot, phase: spec.phase, metadata: { createdAt: new Date(String(spec.created_at ?? 0)), updatedAt: new Date(String(spec.updated_at ?? 0)), language: spec.language, approvals, workflowOptions: { reviewTestCases: spec.workflow_options.review_test_cases }, checkpoints: { testCases: { required: spec.checkpoints.test_cases.required, reviewed: spec.checkpoints.test_cases.reviewed, reviewedAt: spec.checkpoints.test_cases.reviewed_at ? new Date(spec.checkpoints.test_cases.reviewed_at) : undefined, reviewedRevision: spec.checkpoints.test_cases.reviewed_revision, reviewedArtifactSha256: spec.checkpoints.test_cases.reviewed_artifact_sha256 } } } };
|
|
810
|
+
await this.projectRepository.save(project);
|
|
811
|
+
return project;
|
|
812
|
+
}
|
|
813
|
+
assertPriorApproved(phase, approvals) {
|
|
814
|
+
for (const prior of PHASES.slice(0, PHASES.indexOf(phase)))
|
|
815
|
+
if (!approvals[prior].approved)
|
|
816
|
+
throw new GovernanceError('PhaseNotApproved', `${prior} is not approved`);
|
|
817
|
+
}
|
|
818
|
+
assertLegacyOrdering(approvals) {
|
|
819
|
+
for (const [index, phase] of PHASES.entries()) {
|
|
820
|
+
if (!approvals[phase].generated && !approvals[phase].approved)
|
|
821
|
+
continue;
|
|
822
|
+
for (const prior of PHASES.slice(0, index)) {
|
|
823
|
+
if (!approvals[prior].approved) {
|
|
824
|
+
throw new GovernanceError('LegacyStateConflict', `${phase} exists before ${prior} approval`);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
257
827
|
}
|
|
258
|
-
return this.fileSystem;
|
|
259
828
|
}
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
829
|
+
assertLegacyPhaseRank(legacyPhase, approvals, raw) {
|
|
830
|
+
const implementationRank = ['implementation-ready', 'implementation', 'completed', 'implementation-completed'].includes(legacyPhase);
|
|
831
|
+
const expected = approvals.tasks.approved
|
|
832
|
+
? 'tasks-approved'
|
|
833
|
+
: approvals.tasks.generated
|
|
834
|
+
? 'tasks-generated'
|
|
835
|
+
: approvals.design.approved
|
|
836
|
+
? 'design-approved'
|
|
837
|
+
: approvals.design.generated
|
|
838
|
+
? 'design-generated'
|
|
839
|
+
: approvals.requirements.approved
|
|
840
|
+
? 'requirements-approved'
|
|
841
|
+
: approvals.requirements.generated
|
|
842
|
+
? 'requirements-generated'
|
|
843
|
+
: 'init';
|
|
844
|
+
if (!implementationRank && legacyPhase !== expected) {
|
|
845
|
+
throw new GovernanceError('LegacyStateConflict', `Legacy phase ${legacyPhase} disagrees with approval records (${expected})`);
|
|
846
|
+
}
|
|
847
|
+
if (typeof raw.ready_for_implementation === 'boolean' && raw.ready_for_implementation !== implementationRank) {
|
|
848
|
+
throw new GovernanceError('LegacyStateConflict', 'ready_for_implementation disagrees with legacy phase rank');
|
|
849
|
+
}
|
|
850
|
+
if (typeof raw.implementation_completed === 'boolean') {
|
|
851
|
+
const completedRank = legacyPhase === 'completed' || legacyPhase === 'implementation-completed';
|
|
852
|
+
if (raw.implementation_completed !== completedRank) {
|
|
853
|
+
throw new GovernanceError('LegacyStateConflict', 'implementation_completed disagrees with legacy phase rank');
|
|
265
854
|
}
|
|
266
855
|
}
|
|
267
|
-
|
|
268
|
-
|
|
856
|
+
}
|
|
857
|
+
phaseValue(phase) { return phase === 'requirements' ? WorkflowPhase.REQUIREMENTS : phase === 'design' ? WorkflowPhase.DESIGN : WorkflowPhase.TASKS; }
|
|
858
|
+
legacyPhaseValue(value) { if (value.startsWith('tasks'))
|
|
859
|
+
return WorkflowPhase.TASKS; if (value.startsWith('design'))
|
|
860
|
+
return WorkflowPhase.DESIGN; if (value.startsWith('requirements'))
|
|
861
|
+
return WorkflowPhase.REQUIREMENTS; return WorkflowPhase.INIT; }
|
|
862
|
+
implementationCounts(value) { const tasks = Object.values(value.tasks); return { completed: tasks.filter((t) => t.status === 'completed').length, total: tasks.length, active: tasks.filter((t) => ['in-progress', 'red-observed', 'green-observed'].includes(t.status)).length, blocked: tasks.filter((t) => t.status === 'blocked').length }; }
|
|
863
|
+
allComplete(value) { const tasks = Object.values(value.tasks); return tasks.length > 0 && tasks.every((task) => task.status === 'completed'); }
|
|
864
|
+
invalidTransition(action, status) { throw new GovernanceError('TaskTransitionInvalid', `Cannot ${action} from ${status}`); }
|
|
865
|
+
evidence(value) {
|
|
866
|
+
if (typeof value.command !== 'string'
|
|
867
|
+
|| value.command.trim().length === 0
|
|
868
|
+
|| value.command.length > 500
|
|
869
|
+
|| !Number.isInteger(value.exitCode)
|
|
870
|
+
|| typeof value.summary !== 'string'
|
|
871
|
+
|| value.summary.trim().length === 0
|
|
872
|
+
|| value.summary.length > 2_000) {
|
|
873
|
+
throw new GovernanceError('InvalidParams', 'Execution evidence is invalid');
|
|
269
874
|
}
|
|
875
|
+
return { ...value, observedAt: new Date().toISOString() };
|
|
270
876
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
877
|
+
normalizeArtifacts(values) { const output = [...new Set(values.map((value) => value.trim()).filter(Boolean))]; for (const value of output)
|
|
878
|
+
if (!this.isProjectRelativePath(value))
|
|
879
|
+
throw new GovernanceError('InvalidParams', `Invalid affected artifact path: ${value}`); return output; }
|
|
880
|
+
requireAtomicFileSystem() { if (!this.fileSystem?.writeFileAtomic)
|
|
881
|
+
throw new GovernanceError('StateInvariantViolation', 'Atomic filesystem writes are required'); return this.fileSystem; }
|
|
882
|
+
normalizeV5(raw) {
|
|
883
|
+
if (typeof raw.feature_name !== 'string' || raw.feature_name.length === 0) {
|
|
884
|
+
throw new GovernanceError('StateInvariantViolation', 'schema-v5 feature_name is missing');
|
|
885
|
+
}
|
|
886
|
+
if (typeof raw.description !== 'string'
|
|
887
|
+
|| raw.description.length > 20_000
|
|
888
|
+
|| typeof raw.language !== 'string'
|
|
889
|
+
|| !['en', 'ja', 'zh-TW'].includes(raw.language)
|
|
890
|
+
|| typeof raw.created_at !== 'string'
|
|
891
|
+
|| typeof raw.updated_at !== 'string') {
|
|
892
|
+
throw new GovernanceError('StateInvariantViolation', 'schema-v5 description or language is invalid');
|
|
278
893
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
894
|
+
if (!Object.values(WorkflowPhase).includes(raw.phase)) {
|
|
895
|
+
throw new GovernanceError('StateInvariantViolation', `Unknown schema-v5 phase: ${String(raw.phase)}`);
|
|
896
|
+
}
|
|
897
|
+
const approvalsRaw = this.requireSpecRecord(raw.approvals, 'approvals');
|
|
898
|
+
const approvals = {};
|
|
899
|
+
for (const phase of PHASES) {
|
|
900
|
+
const record = this.requireSpecRecord(approvalsRaw[phase], `approvals.${phase}`);
|
|
901
|
+
const validation = this.requireSpecRecord(record.validation, `approvals.${phase}.validation`);
|
|
902
|
+
if (record.artifact_sha256 !== undefined && typeof record.artifact_sha256 !== 'string') {
|
|
903
|
+
throw new GovernanceError('StateInvariantViolation', `${phase} artifact hash must be a string`);
|
|
904
|
+
}
|
|
905
|
+
if (validation.checked_at !== undefined && typeof validation.checked_at !== 'string') {
|
|
906
|
+
throw new GovernanceError('StateInvariantViolation', `${phase} validation timestamp must be a string`);
|
|
907
|
+
}
|
|
908
|
+
if (typeof record.generated !== 'boolean' || typeof record.approved !== 'boolean') {
|
|
909
|
+
throw new GovernanceError('StateInvariantViolation', `${phase} approval flags must be booleans`);
|
|
910
|
+
}
|
|
911
|
+
if (!Number.isInteger(record.revision) || record.revision < 0) {
|
|
912
|
+
throw new GovernanceError('StateInvariantViolation', `${phase} revision must be a non-negative integer`);
|
|
913
|
+
}
|
|
914
|
+
if (!VALIDATION_STATUSES.has(validation.status)) {
|
|
915
|
+
throw new GovernanceError('StateInvariantViolation', `${phase} validation status is invalid`);
|
|
916
|
+
}
|
|
917
|
+
if (!Array.isArray(validation.blockers)) {
|
|
918
|
+
throw new GovernanceError('StateInvariantViolation', `${phase} validation blockers must be an array`);
|
|
919
|
+
}
|
|
920
|
+
const blockers = validation.blockers.map((value, index) => {
|
|
921
|
+
const item = this.requireSpecRecord(value, `approvals.${phase}.validation.blockers[${index}]`);
|
|
922
|
+
if (typeof item.code !== 'string'
|
|
923
|
+
|| typeof item.message !== 'string'
|
|
924
|
+
|| (item.reference !== undefined && typeof item.reference !== 'string')) {
|
|
925
|
+
throw new GovernanceError('StateInvariantViolation', `${phase} validation blocker is invalid`);
|
|
926
|
+
}
|
|
927
|
+
return {
|
|
928
|
+
code: item.code,
|
|
929
|
+
message: item.message,
|
|
930
|
+
...(typeof item.reference === 'string' ? { reference: item.reference } : {}),
|
|
931
|
+
};
|
|
932
|
+
});
|
|
933
|
+
approvals[phase] = {
|
|
934
|
+
generated: record.generated,
|
|
935
|
+
approved: record.approved,
|
|
936
|
+
revision: record.revision,
|
|
937
|
+
artifactSha256: typeof record.artifact_sha256 === 'string' ? record.artifact_sha256 : undefined,
|
|
938
|
+
validation: {
|
|
939
|
+
status: validation.status,
|
|
940
|
+
checkedAt: typeof validation.checked_at === 'string' ? validation.checked_at : undefined,
|
|
941
|
+
blockers,
|
|
942
|
+
},
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
const options = this.requireSpecRecord(raw.workflow_options, 'workflow_options');
|
|
946
|
+
if (options.review_test_cases !== null && typeof options.review_test_cases !== 'boolean') {
|
|
947
|
+
throw new GovernanceError('StateInvariantViolation', 'workflow_options.review_test_cases is invalid');
|
|
948
|
+
}
|
|
949
|
+
const checkpoints = this.requireSpecRecord(raw.checkpoints, 'checkpoints');
|
|
950
|
+
const testCases = this.requireSpecRecord(checkpoints.test_cases, 'checkpoints.test_cases');
|
|
951
|
+
if (typeof testCases.required !== 'boolean' || typeof testCases.reviewed !== 'boolean') {
|
|
952
|
+
throw new GovernanceError('StateInvariantViolation', 'test-case checkpoint flags must be booleans');
|
|
953
|
+
}
|
|
954
|
+
if ((testCases.reviewed_at !== undefined && typeof testCases.reviewed_at !== 'string')
|
|
955
|
+
|| (testCases.reviewed_revision !== undefined && !Number.isInteger(testCases.reviewed_revision))
|
|
956
|
+
|| (testCases.reviewed_artifact_sha256 !== undefined
|
|
957
|
+
&& typeof testCases.reviewed_artifact_sha256 !== 'string')) {
|
|
958
|
+
throw new GovernanceError('StateInvariantViolation', 'Test-case checkpoint binding is invalid');
|
|
959
|
+
}
|
|
960
|
+
const spec = {
|
|
961
|
+
...structuredClone(raw),
|
|
962
|
+
schema_version: 5,
|
|
963
|
+
feature_name: raw.feature_name,
|
|
964
|
+
description: raw.description,
|
|
965
|
+
language: raw.language,
|
|
966
|
+
phase: raw.phase,
|
|
967
|
+
approvals,
|
|
968
|
+
workflow_options: { review_test_cases: options.review_test_cases },
|
|
969
|
+
checkpoints: {
|
|
970
|
+
test_cases: {
|
|
971
|
+
required: testCases.required,
|
|
972
|
+
reviewed: testCases.reviewed,
|
|
973
|
+
reviewed_at: typeof testCases.reviewed_at === 'string' ? testCases.reviewed_at : undefined,
|
|
974
|
+
reviewed_revision: typeof testCases.reviewed_revision === 'number' ? testCases.reviewed_revision : undefined,
|
|
975
|
+
reviewed_artifact_sha256: typeof testCases.reviewed_artifact_sha256 === 'string'
|
|
976
|
+
? testCases.reviewed_artifact_sha256
|
|
977
|
+
: undefined,
|
|
978
|
+
},
|
|
295
979
|
},
|
|
296
|
-
|
|
980
|
+
implementation: raw.implementation === undefined
|
|
981
|
+
? undefined
|
|
982
|
+
: this.normalizeV5Implementation(raw.implementation),
|
|
297
983
|
};
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
984
|
+
this.assertV5Invariants(spec);
|
|
985
|
+
return spec;
|
|
986
|
+
}
|
|
987
|
+
normalizeV5Implementation(value) {
|
|
988
|
+
const raw = this.requireSpecRecord(value, 'implementation');
|
|
989
|
+
if (!Number.isInteger(raw.revision)
|
|
990
|
+
|| raw.revision < 0
|
|
991
|
+
|| !Number.isInteger(raw.tasks_revision)
|
|
992
|
+
|| raw.tasks_revision < 1
|
|
993
|
+
|| typeof raw.legacy_imported !== 'boolean') {
|
|
994
|
+
throw new GovernanceError('StateInvariantViolation', 'Implementation metadata is invalid');
|
|
995
|
+
}
|
|
996
|
+
const tasksRaw = this.requireSpecRecord(raw.tasks, 'implementation.tasks');
|
|
997
|
+
const tasks = {};
|
|
998
|
+
if (Object.keys(tasksRaw).length > 1_000) {
|
|
999
|
+
throw new GovernanceError('StateInvariantViolation', 'Implementation contains too many tasks');
|
|
1000
|
+
}
|
|
1001
|
+
for (const [taskNumber, value] of Object.entries(tasksRaw)) {
|
|
1002
|
+
const task = this.requireSpecRecord(value, `implementation.tasks.${taskNumber}`);
|
|
1003
|
+
if (typeof task.title !== 'string'
|
|
1004
|
+
|| typeof task.tdd_required !== 'boolean'
|
|
1005
|
+
|| !TASK_STATUSES.has(task.status)) {
|
|
1006
|
+
throw new GovernanceError('StateInvariantViolation', `Implementation task ${taskNumber} is invalid`);
|
|
1007
|
+
}
|
|
1008
|
+
if (!/^\d+(?:\.\d+)*$/.test(taskNumber)
|
|
1009
|
+
|| taskNumber.length > 50
|
|
1010
|
+
|| task.title.trim().length === 0
|
|
1011
|
+
|| task.title.length > 500) {
|
|
1012
|
+
throw new GovernanceError('StateInvariantViolation', `Implementation task identifier ${taskNumber} is invalid`);
|
|
1013
|
+
}
|
|
1014
|
+
if (task.blocker !== undefined && typeof task.blocker !== 'string') {
|
|
1015
|
+
throw new GovernanceError('StateInvariantViolation', `Implementation task ${taskNumber} blocker is invalid`);
|
|
1016
|
+
}
|
|
1017
|
+
if (task.blocked_from !== undefined
|
|
1018
|
+
&& !['in-progress', 'red-observed', 'green-observed'].includes(String(task.blocked_from))) {
|
|
1019
|
+
throw new GovernanceError('StateInvariantViolation', `Implementation task ${taskNumber} blocked_from is invalid`);
|
|
1020
|
+
}
|
|
1021
|
+
const dependencies = this.requireStringArray(task.dependencies, `${taskNumber}.dependencies`);
|
|
1022
|
+
const plannedArtifacts = this.requireStringArray(task.planned_artifacts, `${taskNumber}.planned_artifacts`);
|
|
1023
|
+
const observedArtifacts = this.requireStringArray(task.observed_artifacts, `${taskNumber}.observed_artifacts`);
|
|
1024
|
+
this.assertPersistedArtifactPaths(plannedArtifacts, `${taskNumber}.planned_artifacts`);
|
|
1025
|
+
this.assertPersistedArtifactPaths(observedArtifacts, `${taskNumber}.observed_artifacts`);
|
|
1026
|
+
const evidenceRaw = this.requireSpecRecord(task.evidence, `${taskNumber}.evidence`);
|
|
1027
|
+
if (Object.keys(evidenceRaw).some((key) => !['red', 'green', 'verification'].includes(key))) {
|
|
1028
|
+
throw new GovernanceError('StateInvariantViolation', `${taskNumber}.evidence contains an unknown entry`);
|
|
1029
|
+
}
|
|
1030
|
+
const evidence = {};
|
|
1031
|
+
for (const key of ['red', 'green', 'verification']) {
|
|
1032
|
+
if (evidenceRaw[key] !== undefined) {
|
|
1033
|
+
evidence[key] = this.normalizeV5Evidence(evidenceRaw[key], `${taskNumber}.evidence.${key}`);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
tasks[taskNumber] = {
|
|
1037
|
+
title: task.title,
|
|
1038
|
+
tdd_required: task.tdd_required,
|
|
1039
|
+
dependencies,
|
|
1040
|
+
status: task.status,
|
|
1041
|
+
blocker: typeof task.blocker === 'string' ? task.blocker : undefined,
|
|
1042
|
+
blocked_from: ['in-progress', 'red-observed', 'green-observed'].includes(String(task.blocked_from))
|
|
1043
|
+
? task.blocked_from
|
|
1044
|
+
: undefined,
|
|
1045
|
+
evidence,
|
|
1046
|
+
planned_artifacts: plannedArtifacts,
|
|
1047
|
+
observed_artifacts: observedArtifacts,
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
return {
|
|
1051
|
+
revision: raw.revision,
|
|
1052
|
+
tasks_revision: raw.tasks_revision,
|
|
1053
|
+
legacy_imported: raw.legacy_imported,
|
|
1054
|
+
tasks,
|
|
314
1055
|
};
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
1056
|
+
}
|
|
1057
|
+
normalizeV5Evidence(value, reference) {
|
|
1058
|
+
const evidence = this.requireSpecRecord(value, reference);
|
|
1059
|
+
if (typeof evidence.command !== 'string'
|
|
1060
|
+
|| evidence.command.length === 0
|
|
1061
|
+
|| evidence.command.length > 500
|
|
1062
|
+
|| !Number.isInteger(evidence.exit_code)
|
|
1063
|
+
|| typeof evidence.summary !== 'string'
|
|
1064
|
+
|| evidence.summary.length === 0
|
|
1065
|
+
|| evidence.summary.length > 2_000
|
|
1066
|
+
|| typeof evidence.observed_at !== 'string') {
|
|
1067
|
+
throw new GovernanceError('StateInvariantViolation', `${reference} is invalid`);
|
|
1068
|
+
}
|
|
1069
|
+
return {
|
|
1070
|
+
command: evidence.command,
|
|
1071
|
+
exitCode: evidence.exit_code,
|
|
1072
|
+
summary: evidence.summary,
|
|
1073
|
+
observedAt: evidence.observed_at,
|
|
322
1074
|
};
|
|
323
|
-
return prospective;
|
|
324
1075
|
}
|
|
325
|
-
|
|
326
|
-
if (
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
return this.templateService.generateTasksTemplate(project);
|
|
1076
|
+
requireSpecRecord(value, reference) {
|
|
1077
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
1078
|
+
throw new GovernanceError('StateInvariantViolation', `${reference} must be an object`);
|
|
1079
|
+
}
|
|
1080
|
+
return value;
|
|
331
1081
|
}
|
|
332
|
-
|
|
333
|
-
if (
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
1082
|
+
requireStringArray(value, reference) {
|
|
1083
|
+
if (!Array.isArray(value)
|
|
1084
|
+
|| value.length > 100
|
|
1085
|
+
|| value.some((item) => typeof item !== 'string' || item.length === 0)
|
|
1086
|
+
|| new Set(value).size !== value.length) {
|
|
1087
|
+
throw new GovernanceError('StateInvariantViolation', `${reference} must be a unique string array`);
|
|
1088
|
+
}
|
|
1089
|
+
return value;
|
|
338
1090
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
1091
|
+
assertPersistedArtifactPaths(values, reference) {
|
|
1092
|
+
if (values.length > 100 || new Set(values).size !== values.length || values.some((value) => !this.isProjectRelativePath(value))) {
|
|
1093
|
+
throw new GovernanceError('StateInvariantViolation', `${reference} contains an invalid project-relative path`);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
isProjectRelativePath(value) {
|
|
1097
|
+
return value.length > 0
|
|
1098
|
+
&& value.length <= 500
|
|
1099
|
+
&& !/^[/\\]/.test(value)
|
|
1100
|
+
&& !path.isAbsolute(value)
|
|
1101
|
+
&& !/^[A-Za-z]:/.test(value)
|
|
1102
|
+
&& !value.includes('\0')
|
|
1103
|
+
&& !value.split(/[\\/]/).includes('..')
|
|
1104
|
+
&& value === value.trim();
|
|
1105
|
+
}
|
|
1106
|
+
assertV5Invariants(spec) {
|
|
1107
|
+
for (const [index, phase] of PHASES.entries()) {
|
|
1108
|
+
const record = spec.approvals[phase];
|
|
1109
|
+
if (!record.generated) {
|
|
1110
|
+
if (record.approved
|
|
1111
|
+
|| record.revision !== 0
|
|
1112
|
+
|| record.artifactSha256 !== undefined
|
|
1113
|
+
|| record.validation.status !== 'not-run'
|
|
1114
|
+
|| record.validation.blockers.length > 0) {
|
|
1115
|
+
throw new GovernanceError('StateInvariantViolation', `${phase} has state without a generated artifact`);
|
|
1116
|
+
}
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
if (record.revision < 1
|
|
1120
|
+
|| !record.artifactSha256
|
|
1121
|
+
|| !/^[a-f0-9]{64}$/.test(record.artifactSha256)
|
|
1122
|
+
|| record.validation.status === 'not-run') {
|
|
1123
|
+
throw new GovernanceError('StateInvariantViolation', `${phase} generated state is incomplete`);
|
|
1124
|
+
}
|
|
1125
|
+
if (record.approved && !['passed', 'legacy-accepted'].includes(record.validation.status)) {
|
|
1126
|
+
throw new GovernanceError('StateInvariantViolation', `${phase} approval lacks successful validation`);
|
|
1127
|
+
}
|
|
1128
|
+
if (record.validation.status === 'legacy-accepted' && !record.approved) {
|
|
1129
|
+
throw new GovernanceError('StateInvariantViolation', `${phase} has unapproved legacy validation`);
|
|
1130
|
+
}
|
|
1131
|
+
if (record.validation.blockers.length > 100
|
|
1132
|
+
|| record.validation.blockers.some((blocker) => blocker.code.trim().length === 0
|
|
1133
|
+
|| blocker.code.length > 100
|
|
1134
|
+
|| blocker.message.trim().length === 0
|
|
1135
|
+
|| blocker.message.length > 2_000
|
|
1136
|
+
|| (blocker.reference !== undefined && blocker.reference.trim().length === 0)
|
|
1137
|
+
|| (blocker.reference?.length ?? 0) > 200)
|
|
1138
|
+
|| (record.validation.status === 'failed' && record.validation.blockers.length === 0)
|
|
1139
|
+
|| (record.validation.status !== 'failed' && record.validation.blockers.length > 0)) {
|
|
1140
|
+
throw new GovernanceError('StateInvariantViolation', `${phase} validation blockers disagree with validation status`);
|
|
1141
|
+
}
|
|
1142
|
+
for (const prior of PHASES.slice(0, index)) {
|
|
1143
|
+
if (!spec.approvals[prior].approved) {
|
|
1144
|
+
throw new GovernanceError('StateInvariantViolation', `${phase} exists before ${prior} approval`);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
const checkpoint = spec.checkpoints.test_cases;
|
|
1149
|
+
const reviewRequired = spec.workflow_options.review_test_cases === true;
|
|
1150
|
+
if (checkpoint.required !== reviewRequired) {
|
|
1151
|
+
throw new GovernanceError('StateInvariantViolation', 'Test-case checkpoint disagrees with workflow option');
|
|
1152
|
+
}
|
|
1153
|
+
if (spec.approvals.tasks.generated && spec.workflow_options.review_test_cases === null) {
|
|
1154
|
+
throw new GovernanceError('StateInvariantViolation', 'Generated tasks are missing the review choice');
|
|
1155
|
+
}
|
|
1156
|
+
if (checkpoint.reviewed) {
|
|
1157
|
+
if (!reviewRequired
|
|
1158
|
+
|| checkpoint.reviewed_revision !== spec.approvals.tasks.revision
|
|
1159
|
+
|| checkpoint.reviewed_artifact_sha256 !== spec.approvals.tasks.artifactSha256
|
|
1160
|
+
|| (!checkpoint.reviewed_at && spec.approvals.tasks.validation.status !== 'legacy-accepted')) {
|
|
1161
|
+
throw new GovernanceError('StateInvariantViolation', 'Test-case review checkpoint is stale');
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
else if (checkpoint.reviewed_revision !== undefined
|
|
1165
|
+
|| checkpoint.reviewed_artifact_sha256 !== undefined
|
|
1166
|
+
|| checkpoint.reviewed_at !== undefined) {
|
|
1167
|
+
throw new GovernanceError('StateInvariantViolation', 'Unreviewed checkpoint contains a review binding');
|
|
1168
|
+
}
|
|
1169
|
+
if (spec.implementation) {
|
|
1170
|
+
if (!spec.approvals.tasks.approved
|
|
1171
|
+
|| spec.implementation.tasks_revision !== spec.approvals.tasks.revision
|
|
1172
|
+
|| Object.keys(spec.implementation.tasks).length === 0) {
|
|
1173
|
+
throw new GovernanceError('StateInvariantViolation', 'Implementation is not bound to executable approved tasks');
|
|
1174
|
+
}
|
|
1175
|
+
if (spec.implementation.legacy_imported
|
|
1176
|
+
!== (spec.approvals.tasks.validation.status === 'legacy-accepted')) {
|
|
1177
|
+
throw new GovernanceError('StateInvariantViolation', 'Implementation legacy marker disagrees with the approved tasks record');
|
|
1178
|
+
}
|
|
1179
|
+
if (reviewRequired && !checkpoint.reviewed) {
|
|
1180
|
+
throw new GovernanceError('StateInvariantViolation', 'Implementation review checkpoint is stale');
|
|
1181
|
+
}
|
|
1182
|
+
this.assertImplementationTasks(spec.implementation);
|
|
1183
|
+
}
|
|
1184
|
+
const expectedPhase = spec.implementation
|
|
1185
|
+
? this.allComplete(spec.implementation)
|
|
1186
|
+
? WorkflowPhase.IMPLEMENTATION_COMPLETED
|
|
1187
|
+
: WorkflowPhase.IMPLEMENTATION
|
|
1188
|
+
: spec.approvals.tasks.generated
|
|
1189
|
+
? WorkflowPhase.TASKS
|
|
1190
|
+
: spec.approvals.design.generated
|
|
1191
|
+
? WorkflowPhase.DESIGN
|
|
1192
|
+
: spec.approvals.requirements.generated
|
|
1193
|
+
? WorkflowPhase.REQUIREMENTS
|
|
1194
|
+
: WorkflowPhase.INIT;
|
|
1195
|
+
if (spec.phase !== expectedPhase) {
|
|
1196
|
+
throw new GovernanceError('StateInvariantViolation', `Phase ${spec.phase} disagrees with durable records (${expectedPhase})`);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
assertImplementationTasks(implementation) {
|
|
1200
|
+
const ids = new Set(Object.keys(implementation.tasks));
|
|
1201
|
+
for (const [taskNumber, task] of Object.entries(implementation.tasks)) {
|
|
1202
|
+
if (task.dependencies.length > 100 || new Set(task.dependencies).size !== task.dependencies.length) {
|
|
1203
|
+
throw new GovernanceError('StateInvariantViolation', `${taskNumber} dependencies must be a unique bounded list`);
|
|
1204
|
+
}
|
|
1205
|
+
if (task.dependencies.some((dependency) => !ids.has(dependency))) {
|
|
1206
|
+
throw new GovernanceError('StateInvariantViolation', `${taskNumber} has an unknown dependency`);
|
|
1207
|
+
}
|
|
1208
|
+
this.assertPersistedArtifactPaths(task.planned_artifacts, `${taskNumber}.planned_artifacts`);
|
|
1209
|
+
this.assertPersistedArtifactPaths(task.observed_artifacts, `${taskNumber}.observed_artifacts`);
|
|
1210
|
+
for (const [evidenceType, evidence] of Object.entries(task.evidence)) {
|
|
1211
|
+
this.assertExecutionEvidence(evidence, `${taskNumber}.evidence.${evidenceType}`);
|
|
1212
|
+
}
|
|
1213
|
+
if (task.status === 'blocked') {
|
|
1214
|
+
if (!task.blocker
|
|
1215
|
+
|| task.blocker.trim().length === 0
|
|
1216
|
+
|| task.blocker.length > 2_000
|
|
1217
|
+
|| !task.blocked_from) {
|
|
1218
|
+
throw new GovernanceError('StateInvariantViolation', `${taskNumber} has incomplete blocked state`);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
else if (task.blocker !== undefined || task.blocked_from !== undefined) {
|
|
1222
|
+
throw new GovernanceError('StateInvariantViolation', `${taskNumber} has stale blocker state`);
|
|
1223
|
+
}
|
|
1224
|
+
const acceptedLegacyCompletion = implementation.legacy_imported
|
|
1225
|
+
&& task.status === 'completed'
|
|
1226
|
+
&& Object.keys(task.evidence).length === 0;
|
|
1227
|
+
const effectiveState = task.status === 'blocked' ? task.blocked_from : task.status;
|
|
1228
|
+
if (effectiveState !== 'pending'
|
|
1229
|
+
&& task.dependencies.some((dependency) => implementation.tasks[dependency].status !== 'completed')) {
|
|
1230
|
+
throw new GovernanceError('StateInvariantViolation', `${taskNumber} advanced before its dependencies`);
|
|
1231
|
+
}
|
|
1232
|
+
if ((task.evidence.red && !['red-observed', 'green-observed', 'completed'].includes(effectiveState))
|
|
1233
|
+
|| (task.evidence.green && !['green-observed', 'completed'].includes(effectiveState))
|
|
1234
|
+
|| (task.evidence.verification && task.status !== 'completed')) {
|
|
1235
|
+
throw new GovernanceError('StateInvariantViolation', `${taskNumber} contains evidence ahead of its state`);
|
|
1236
|
+
}
|
|
1237
|
+
if (!task.tdd_required && ['red-observed', 'green-observed'].includes(effectiveState)) {
|
|
1238
|
+
throw new GovernanceError('StateInvariantViolation', `${taskNumber} has TDD evidence for a non-TDD task`);
|
|
1239
|
+
}
|
|
1240
|
+
if (task.tdd_required
|
|
1241
|
+
&& !acceptedLegacyCompletion
|
|
1242
|
+
&& ['red-observed', 'green-observed', 'completed'].includes(effectiveState)
|
|
1243
|
+
&& (!task.evidence.red || task.evidence.red.exitCode === 0)) {
|
|
1244
|
+
throw new GovernanceError('StateInvariantViolation', `${taskNumber} lacks failing RED evidence`);
|
|
1245
|
+
}
|
|
1246
|
+
if (task.tdd_required
|
|
1247
|
+
&& !acceptedLegacyCompletion
|
|
1248
|
+
&& ['green-observed', 'completed'].includes(effectiveState)
|
|
1249
|
+
&& (!task.evidence.green || task.evidence.green.exitCode !== 0)) {
|
|
1250
|
+
throw new GovernanceError('StateInvariantViolation', `${taskNumber} lacks passing GREEN evidence`);
|
|
1251
|
+
}
|
|
1252
|
+
if (task.status === 'completed'
|
|
1253
|
+
&& !acceptedLegacyCompletion
|
|
1254
|
+
&& (!task.evidence.verification || task.evidence.verification.exitCode !== 0)) {
|
|
1255
|
+
throw new GovernanceError('StateInvariantViolation', `${taskNumber} lacks final verification`);
|
|
1256
|
+
}
|
|
1257
|
+
if (task.status !== 'completed' && task.observed_artifacts.length > 0) {
|
|
1258
|
+
throw new GovernanceError('StateInvariantViolation', `${taskNumber} records artifacts before completion`);
|
|
1259
|
+
}
|
|
1260
|
+
if (task.status === 'completed'
|
|
1261
|
+
&& !acceptedLegacyCompletion
|
|
1262
|
+
&& task.planned_artifacts.length > 0
|
|
1263
|
+
&& task.observed_artifacts.length === 0) {
|
|
1264
|
+
throw new GovernanceError('StateInvariantViolation', `${taskNumber} completion lacks observed artifacts`);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
const visiting = new Set();
|
|
1268
|
+
const visited = new Set();
|
|
1269
|
+
const visit = (taskNumber) => {
|
|
1270
|
+
if (visiting.has(taskNumber)) {
|
|
1271
|
+
throw new GovernanceError('StateInvariantViolation', 'Implementation task dependencies contain a cycle');
|
|
1272
|
+
}
|
|
1273
|
+
if (visited.has(taskNumber))
|
|
1274
|
+
return;
|
|
1275
|
+
visiting.add(taskNumber);
|
|
1276
|
+
for (const dependency of implementation.tasks[taskNumber].dependencies)
|
|
1277
|
+
visit(dependency);
|
|
1278
|
+
visiting.delete(taskNumber);
|
|
1279
|
+
visited.add(taskNumber);
|
|
364
1280
|
};
|
|
365
|
-
|
|
366
|
-
|
|
1281
|
+
for (const taskNumber of ids)
|
|
1282
|
+
visit(taskNumber);
|
|
367
1283
|
}
|
|
368
|
-
|
|
369
|
-
if (typeof value
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
if (phase.startsWith('implementation'))
|
|
379
|
-
return WorkflowPhase.IMPLEMENTATION;
|
|
380
|
-
if (phase.startsWith('tasks') || approvals.tasks.generated)
|
|
381
|
-
return WorkflowPhase.TASKS;
|
|
382
|
-
if (phase.startsWith('design') || approvals.design.generated)
|
|
383
|
-
return WorkflowPhase.DESIGN;
|
|
384
|
-
if (phase.startsWith('requirements') || approvals.requirements.generated)
|
|
385
|
-
return WorkflowPhase.REQUIREMENTS;
|
|
386
|
-
return WorkflowPhase.INIT;
|
|
387
|
-
}
|
|
388
|
-
resolveProgression(approvals, review, currentPhase) {
|
|
389
|
-
if (currentPhase === WorkflowPhase.IMPLEMENTATION)
|
|
390
|
-
return { blockers: [] };
|
|
391
|
-
if (!approvals.requirements.generated)
|
|
392
|
-
return { nextPhase: WorkflowPhase.REQUIREMENTS, blockers: [] };
|
|
393
|
-
if (!approvals.requirements.approved) {
|
|
394
|
-
return { nextPhase: WorkflowPhase.DESIGN, blockers: ['Requirements must be approved'] };
|
|
395
|
-
}
|
|
396
|
-
if (!approvals.design.generated)
|
|
397
|
-
return { nextPhase: WorkflowPhase.DESIGN, blockers: [] };
|
|
398
|
-
if (!approvals.design.approved) {
|
|
399
|
-
return { nextPhase: WorkflowPhase.TASKS, blockers: ['Design must be approved'] };
|
|
400
|
-
}
|
|
401
|
-
if (!approvals.tasks.generated)
|
|
402
|
-
return { nextPhase: WorkflowPhase.TASKS, blockers: [] };
|
|
403
|
-
if (!approvals.tasks.approved) {
|
|
404
|
-
return { nextPhase: WorkflowPhase.IMPLEMENTATION, blockers: ['Tasks must be approved'] };
|
|
405
|
-
}
|
|
406
|
-
if (review.required && !review.reviewed) {
|
|
407
|
-
return { nextPhase: WorkflowPhase.IMPLEMENTATION, blockers: ['Test cases must be reviewed'] };
|
|
408
|
-
}
|
|
409
|
-
return { nextPhase: WorkflowPhase.IMPLEMENTATION, blockers: [] };
|
|
410
|
-
}
|
|
411
|
-
async withFeatureLock(projectRoot, featureName, operation) {
|
|
412
|
-
const key = `${path.resolve(projectRoot)}\0${featureName}`;
|
|
413
|
-
const previous = WorkflowEngineService_1.featureLocks.get(key) ?? Promise.resolve();
|
|
414
|
-
let release = () => undefined;
|
|
415
|
-
const gate = new Promise((resolve) => { release = resolve; });
|
|
416
|
-
const current = previous.then(() => gate);
|
|
417
|
-
WorkflowEngineService_1.featureLocks.set(key, current);
|
|
418
|
-
await previous;
|
|
419
|
-
try {
|
|
420
|
-
return await operation();
|
|
1284
|
+
assertExecutionEvidence(value, reference) {
|
|
1285
|
+
if (typeof value.command !== 'string'
|
|
1286
|
+
|| value.command.trim().length === 0
|
|
1287
|
+
|| value.command.length > 500
|
|
1288
|
+
|| !Number.isInteger(value.exitCode)
|
|
1289
|
+
|| typeof value.summary !== 'string'
|
|
1290
|
+
|| value.summary.trim().length === 0
|
|
1291
|
+
|| value.summary.length > 2_000
|
|
1292
|
+
|| typeof value.observedAt !== 'string') {
|
|
1293
|
+
throw new GovernanceError('StateInvariantViolation', `${reference} is invalid`);
|
|
421
1294
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
1295
|
+
}
|
|
1296
|
+
specBytes(spec) {
|
|
1297
|
+
const persisted = structuredClone(spec);
|
|
1298
|
+
delete persisted.ready_for_implementation;
|
|
1299
|
+
delete persisted.implementation_completed;
|
|
1300
|
+
persisted.approvals = Object.fromEntries(PHASES.map((phase) => {
|
|
1301
|
+
const record = spec.approvals[phase];
|
|
1302
|
+
return [phase, {
|
|
1303
|
+
generated: record.generated,
|
|
1304
|
+
approved: record.approved,
|
|
1305
|
+
revision: record.revision,
|
|
1306
|
+
...(record.artifactSha256 ? { artifact_sha256: record.artifactSha256 } : {}),
|
|
1307
|
+
validation: {
|
|
1308
|
+
status: record.validation.status,
|
|
1309
|
+
...(record.validation.checkedAt ? { checked_at: record.validation.checkedAt } : {}),
|
|
1310
|
+
blockers: record.validation.blockers,
|
|
1311
|
+
},
|
|
1312
|
+
}];
|
|
1313
|
+
}));
|
|
1314
|
+
const implementation = persisted.implementation;
|
|
1315
|
+
if (implementation) {
|
|
1316
|
+
for (const task of Object.values(implementation.tasks)) {
|
|
1317
|
+
for (const key of ['red', 'green', 'verification']) {
|
|
1318
|
+
const value = task.evidence[key];
|
|
1319
|
+
if (!value)
|
|
1320
|
+
continue;
|
|
1321
|
+
task.evidence[key] = {
|
|
1322
|
+
command: value.command,
|
|
1323
|
+
exit_code: value.exitCode,
|
|
1324
|
+
summary: value.summary,
|
|
1325
|
+
observed_at: value.observedAt,
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
426
1328
|
}
|
|
427
1329
|
}
|
|
1330
|
+
return `${JSON.stringify(persisted, null, 2)}\n`;
|
|
428
1331
|
}
|
|
429
|
-
async
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
if (!(await this.fileSystem.exists(specPath)))
|
|
437
|
-
throw new Error(`Feature metadata not found: ${featureName}`);
|
|
1332
|
+
async persistSpecCas(specPath, expectedBytes, spec, assertHeld) {
|
|
1333
|
+
this.assertV5Invariants(spec);
|
|
1334
|
+
const bytes = this.specBytes(spec);
|
|
1335
|
+
await this.replaceCas(specPath, expectedBytes, bytes, assertHeld);
|
|
1336
|
+
return bytes;
|
|
1337
|
+
}
|
|
1338
|
+
parseSpecBytes(bytes) {
|
|
438
1339
|
let parsed;
|
|
439
1340
|
try {
|
|
440
|
-
parsed = JSON.parse(
|
|
1341
|
+
parsed = JSON.parse(bytes);
|
|
441
1342
|
}
|
|
442
1343
|
catch {
|
|
443
|
-
throw new
|
|
444
|
-
}
|
|
445
|
-
if (!parsed || typeof parsed !== 'object')
|
|
446
|
-
throw new
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
1344
|
+
throw new GovernanceError('StateInvariantViolation', 'Feature metadata is malformed');
|
|
1345
|
+
}
|
|
1346
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1347
|
+
throw new GovernanceError('StateInvariantViolation', 'Feature metadata must be a JSON object');
|
|
1348
|
+
}
|
|
1349
|
+
return parsed;
|
|
1350
|
+
}
|
|
1351
|
+
async requireArtifact(root, phase) { return this.readRequired(path.join(root, `${phase}.md`), `${phase}.md is missing`); }
|
|
1352
|
+
async readRequired(filePath, message) { const value = await this.readOptional(filePath); if (value === null)
|
|
1353
|
+
throw new GovernanceError('StateInvariantViolation', message); return value; }
|
|
1354
|
+
async assertGovernedFilesContained(featureRoot) {
|
|
1355
|
+
if (!this.fileSystem)
|
|
1356
|
+
throw new GovernanceError('StateInvariantViolation', 'FileSystemPort is required');
|
|
1357
|
+
const resolver = new SpecPathResolver(this.fileSystem);
|
|
1358
|
+
await Promise.all([
|
|
1359
|
+
'spec.json',
|
|
1360
|
+
'.phase-submit.json',
|
|
1361
|
+
'.workflow.lock',
|
|
1362
|
+
...PHASES.map((phase) => `${phase}.md`),
|
|
1363
|
+
].map((name) => resolver.assertContained(featureRoot, path.join(featureRoot, name))));
|
|
1364
|
+
}
|
|
1365
|
+
async readOptional(filePath) { try {
|
|
1366
|
+
return await readFile(filePath, 'utf8');
|
|
1367
|
+
}
|
|
1368
|
+
catch (error) {
|
|
1369
|
+
if (error.code === 'ENOENT')
|
|
1370
|
+
return null;
|
|
1371
|
+
throw error;
|
|
1372
|
+
} }
|
|
1373
|
+
readOptionalSync(filePath) { try {
|
|
1374
|
+
return readFileSync(filePath, 'utf8');
|
|
1375
|
+
}
|
|
1376
|
+
catch (error) {
|
|
1377
|
+
if (error.code === 'ENOENT')
|
|
1378
|
+
return null;
|
|
1379
|
+
throw error;
|
|
1380
|
+
} }
|
|
1381
|
+
artifactPhaseFromJournal(priorBytes, nextBytes, priorArtifact, nextArtifact) {
|
|
1382
|
+
const priorRaw = this.parseSpecBytes(priorBytes);
|
|
1383
|
+
const nextRaw = this.parseSpecBytes(nextBytes);
|
|
1384
|
+
if (priorRaw.schema_version !== 5 || nextRaw.schema_version !== 5) {
|
|
1385
|
+
throw new GovernanceError('RecoveryConflict', 'Submission journal endpoints must use schema-v5');
|
|
1386
|
+
}
|
|
1387
|
+
let prior;
|
|
1388
|
+
let next;
|
|
1389
|
+
try {
|
|
1390
|
+
prior = this.normalizeV5(priorRaw);
|
|
1391
|
+
next = this.normalizeV5(nextRaw);
|
|
1392
|
+
}
|
|
1393
|
+
catch (error) {
|
|
1394
|
+
throw new GovernanceError('RecoveryConflict', 'Submission journal contains invalid workflow state', { cause: errorSummary(error) });
|
|
1395
|
+
}
|
|
1396
|
+
const changed = PHASES.filter((phase) => next.approvals[phase].revision === prior.approvals[phase].revision + 1);
|
|
1397
|
+
if (changed.length !== 1
|
|
1398
|
+
|| prior.feature_name !== next.feature_name
|
|
1399
|
+
|| PHASES.some((phase) => phase !== changed[0]
|
|
1400
|
+
&& next.approvals[phase].revision !== prior.approvals[phase].revision)) {
|
|
1401
|
+
throw new GovernanceError('RecoveryConflict', 'Submission journal does not describe one phase revision');
|
|
1402
|
+
}
|
|
1403
|
+
const changedPhase = changed[0];
|
|
1404
|
+
const priorRecord = prior.approvals[changedPhase];
|
|
1405
|
+
const nextRecord = next.approvals[changedPhase];
|
|
1406
|
+
const otherApprovalsUnchanged = PHASES
|
|
1407
|
+
.filter((phase) => phase !== changedPhase)
|
|
1408
|
+
.every((phase) => JSON.stringify(prior.approvals[phase]) === JSON.stringify(next.approvals[phase]));
|
|
1409
|
+
const ancillaryUnchanged = this.journalAncillaryState(prior) === this.journalAncillaryState(next);
|
|
1410
|
+
const submissionRecordValid = !priorRecord.approved
|
|
1411
|
+
&& nextRecord.generated
|
|
1412
|
+
&& !nextRecord.approved
|
|
1413
|
+
&& nextArtifact.length > 0
|
|
1414
|
+
&& nextArtifact.length <= PHASE_CONTENT_CHARACTER_LIMIT
|
|
1415
|
+
&& (nextRecord.validation.status === 'passed' || nextRecord.validation.status === 'failed')
|
|
1416
|
+
&& nextRecord.artifactSha256 === sha256(nextArtifact);
|
|
1417
|
+
const priorArtifactValid = !priorRecord.generated
|
|
1418
|
+
|| (priorArtifact !== null && sha256(priorArtifact) === priorRecord.artifactSha256);
|
|
1419
|
+
const workflowMetadataValid = changedPhase === 'tasks'
|
|
1420
|
+
? (typeof next.workflow_options.review_test_cases === 'boolean'
|
|
1421
|
+
&& (prior.workflow_options.review_test_cases === null
|
|
1422
|
+
|| prior.workflow_options.review_test_cases === next.workflow_options.review_test_cases)
|
|
1423
|
+
&& next.checkpoints.test_cases.required === next.workflow_options.review_test_cases
|
|
1424
|
+
&& !next.checkpoints.test_cases.reviewed
|
|
1425
|
+
&& next.checkpoints.test_cases.reviewed_at === undefined
|
|
1426
|
+
&& next.checkpoints.test_cases.reviewed_revision === undefined
|
|
1427
|
+
&& next.checkpoints.test_cases.reviewed_artifact_sha256 === undefined)
|
|
1428
|
+
: (JSON.stringify(prior.workflow_options) === JSON.stringify(next.workflow_options)
|
|
1429
|
+
&& JSON.stringify(prior.checkpoints) === JSON.stringify(next.checkpoints));
|
|
1430
|
+
if (!ancillaryUnchanged
|
|
1431
|
+
|| next.phase !== this.phaseValue(changedPhase)
|
|
1432
|
+
|| !otherApprovalsUnchanged
|
|
1433
|
+
|| !priorArtifactValid
|
|
1434
|
+
|| !submissionRecordValid
|
|
1435
|
+
|| !workflowMetadataValid) {
|
|
1436
|
+
throw new GovernanceError('RecoveryConflict', 'Submission journal does not describe a valid phase submission');
|
|
1437
|
+
}
|
|
1438
|
+
return changedPhase;
|
|
484
1439
|
}
|
|
485
|
-
async
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
1440
|
+
async assertJournalValidation(featureRoot, phase, nextSpecBytes, nextArtifact) {
|
|
1441
|
+
try {
|
|
1442
|
+
const next = this.normalizeV5(this.parseSpecBytes(nextSpecBytes));
|
|
1443
|
+
const upstream = await this.readUpstream(featureRoot, phase);
|
|
1444
|
+
const observed = this.validate(phase, nextArtifact, upstream.requirements, upstream.design);
|
|
1445
|
+
const recorded = next.approvals[phase].validation;
|
|
1446
|
+
if (observed.status !== recorded.status
|
|
1447
|
+
|| JSON.stringify(observed.blockers) !== JSON.stringify(recorded.blockers)) {
|
|
1448
|
+
throw new Error('recorded validation does not match deterministic validation');
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
catch (error) {
|
|
1452
|
+
throw new GovernanceError('RecoveryConflict', 'Submission journal validation cannot be reproduced', { cause: errorSummary(error) });
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
journalAncillaryState(spec) {
|
|
1456
|
+
const stable = structuredClone(spec);
|
|
1457
|
+
delete stable.phase;
|
|
1458
|
+
delete stable.updated_at;
|
|
1459
|
+
delete stable.approvals;
|
|
1460
|
+
delete stable.workflow_options;
|
|
1461
|
+
delete stable.checkpoints;
|
|
1462
|
+
return JSON.stringify(stable);
|
|
489
1463
|
}
|
|
490
1464
|
async publishHandoff(projectRoot, featureName) {
|
|
491
1465
|
try {
|
|
492
|
-
const result = await this.contextCompactionService.loadContext({
|
|
1466
|
+
const result = await this.contextCompactionService.loadContext({
|
|
1467
|
+
projectRoot,
|
|
1468
|
+
featureName,
|
|
1469
|
+
mode: 'compact',
|
|
1470
|
+
});
|
|
493
1471
|
return {
|
|
494
1472
|
status: 'published',
|
|
495
1473
|
path: path.join(projectRoot, '.spec', 'specs', featureName, 'context', 'handoff.md'),
|
|
@@ -501,43 +1479,40 @@ let WorkflowEngineService = class WorkflowEngineService {
|
|
|
501
1479
|
try {
|
|
502
1480
|
await this.contextCompactionService.invalidateCanonicalHandoff({ projectRoot, featureName });
|
|
503
1481
|
}
|
|
504
|
-
catch {
|
|
505
|
-
|
|
1482
|
+
catch (invalidationError) {
|
|
1483
|
+
this.logger.warn('Failed to invalidate stale workflow handoff', {
|
|
1484
|
+
featureName,
|
|
1485
|
+
message: invalidationError instanceof Error
|
|
1486
|
+
? invalidationError.message
|
|
1487
|
+
: String(invalidationError),
|
|
1488
|
+
});
|
|
506
1489
|
}
|
|
507
1490
|
const message = error instanceof Error ? error.message : String(error);
|
|
508
|
-
this.logger.warn('Workflow state committed but handoff publication failed', {
|
|
1491
|
+
this.logger.warn('Workflow state committed but handoff publication failed', {
|
|
1492
|
+
featureName,
|
|
1493
|
+
message,
|
|
1494
|
+
});
|
|
509
1495
|
return {
|
|
510
1496
|
status: 'pending-regeneration',
|
|
511
1497
|
warning: { code: 'HandoffPublicationFailed', message },
|
|
512
1498
|
};
|
|
513
1499
|
}
|
|
514
1500
|
}
|
|
515
|
-
async synchronizeRepository(featureRoot, approvals) {
|
|
516
|
-
const projects = await this.projectRepository.list();
|
|
517
|
-
const existing = projects.find((project) => project.name === path.basename(featureRoot));
|
|
518
|
-
if (!existing)
|
|
519
|
-
return;
|
|
520
|
-
await this.projectRepository.save({
|
|
521
|
-
...existing,
|
|
522
|
-
metadata: {
|
|
523
|
-
...existing.metadata,
|
|
524
|
-
updatedAt: new Date(),
|
|
525
|
-
approvals,
|
|
526
|
-
},
|
|
527
|
-
});
|
|
528
|
-
}
|
|
529
1501
|
};
|
|
530
|
-
WorkflowEngineService =
|
|
1502
|
+
WorkflowEngineService = __decorate([
|
|
531
1503
|
injectable(),
|
|
532
1504
|
__param(0, inject(TYPES.ProjectRepository)),
|
|
533
|
-
__param(1, inject(TYPES.
|
|
534
|
-
__param(2, inject(TYPES.
|
|
535
|
-
__param(3, inject(TYPES.
|
|
536
|
-
|
|
537
|
-
__param(5, inject(TYPES.FileSystemPort)),
|
|
538
|
-
__metadata("design:paramtypes", [Object, ProjectService,
|
|
539
|
-
TemplateService,
|
|
540
|
-
ContextCompactionService, Object, Object])
|
|
1505
|
+
__param(1, inject(TYPES.ContextCompactionService)),
|
|
1506
|
+
__param(2, inject(TYPES.LoggerPort)),
|
|
1507
|
+
__param(3, inject(TYPES.FileSystemPort)),
|
|
1508
|
+
__metadata("design:paramtypes", [Object, ContextCompactionService, Object, Object])
|
|
541
1509
|
], WorkflowEngineService);
|
|
542
1510
|
export { WorkflowEngineService };
|
|
1511
|
+
function sha256(content) { return createHash('sha256').update(content).digest('hex'); }
|
|
1512
|
+
function encode(content) { return { base64: Buffer.from(content).toString('base64'), sha256: sha256(content) }; }
|
|
1513
|
+
function decode(value) { if (!value || typeof value.base64 !== 'string' || typeof value.sha256 !== 'string')
|
|
1514
|
+
throw new GovernanceError('RecoveryConflict', 'Journal byte record is malformed'); const bytes = Buffer.from(value.base64, 'base64'); if (bytes.length > JOURNAL_LIMIT)
|
|
1515
|
+
throw new GovernanceError('RecoveryConflict', 'Journal byte record exceeds size limit'); const content = bytes.toString('utf8'); if (sha256(content) !== value.sha256 || Buffer.from(content).toString('base64') !== value.base64)
|
|
1516
|
+
throw new GovernanceError('RecoveryConflict', 'Journal byte hash or encoding is invalid'); return content; }
|
|
1517
|
+
function errorSummary(error) { return String(error).slice(0, 2_000); }
|
|
543
1518
|
//# sourceMappingURL=WorkflowEngineService.js.map
|