sdd-mcp-server 3.5.1 → 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 +92 -683
- package/agents/architect.md +15 -93
- package/agents/implementer.md +16 -141
- package/agents/planner.md +16 -84
- package/agents/reviewer.md +16 -239
- package/agents/security-auditor.md +16 -114
- package/agents/tdd-guide.md +17 -228
- package/dist/adapters/cli/SDDToolAdapter.d.ts +14 -8
- package/dist/adapters/cli/SDDToolAdapter.js +188 -405
- package/dist/adapters/cli/SDDToolAdapter.js.map +1 -1
- package/dist/application/services/ContextCompactionService.d.ts +88 -16
- package/dist/application/services/ContextCompactionService.js +474 -187
- 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.d.ts +24 -0
- package/dist/application/services/SpecPathResolver.js +70 -0
- package/dist/application/services/SpecPathResolver.js.map +1 -0
- package/dist/application/services/WorkflowEngineService.d.ts +214 -50
- package/dist/application/services/WorkflowEngineService.js +1447 -292
- 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.d.ts +3 -9
- package/dist/cli/install-skills.js +129 -174
- package/dist/cli/install-skills.js.map +1 -1
- package/dist/cli/install-target.d.ts +42 -8
- package/dist/cli/install-target.js +27 -9
- package/dist/cli/install-target.js.map +1 -1
- package/dist/cli/sdd-mcp-cli.d.ts +1 -1
- package/dist/cli/sdd-mcp-cli.js +7 -6
- package/dist/cli/sdd-mcp-cli.js.map +1 -1
- package/dist/cli/tool-support/claude-code.js +17 -34
- package/dist/cli/tool-support/claude-code.js.map +1 -1
- package/dist/cli/tool-support/codex.d.ts +0 -53
- package/dist/cli/tool-support/codex.js +10 -94
- package/dist/cli/tool-support/codex.js.map +1 -1
- package/dist/cli/tool-support/index.d.ts +3 -2
- package/dist/cli/tool-support/index.js +3 -1
- package/dist/cli/tool-support/index.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.d.ts +5 -0
- package/dist/cli/tool-support/omp.js +47 -0
- package/dist/cli/tool-support/omp.js.map +1 -0
- package/dist/cli/tool-support/root-guidance.d.ts +2 -9
- package/dist/cli/tool-support/root-guidance.js +44 -37
- package/dist/cli/tool-support/root-guidance.js.map +1 -1
- package/dist/cli/tool-support/target-agent-renderer.d.ts +1 -0
- package/dist/cli/tool-support/target-agent-renderer.js +37 -4
- package/dist/cli/tool-support/target-agent-renderer.js.map +1 -1
- package/dist/cli/tool-support/target-installer.d.ts +9 -3
- package/dist/cli/tool-support/target-installer.js +100 -26
- package/dist/cli/tool-support/target-installer.js.map +1 -1
- package/dist/cli/utils/preserving-writer.d.ts +56 -0
- package/dist/cli/utils/preserving-writer.js +603 -10
- package/dist/cli/utils/preserving-writer.js.map +1 -1
- package/dist/domain/ports.d.ts +4 -0
- 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/index.d.ts +13 -10
- package/dist/index.js +16 -1199
- package/dist/index.js.map +1 -1
- package/dist/infrastructure/adapters/NodeFileSystemAdapter.d.ts +3 -0
- package/dist/infrastructure/adapters/NodeFileSystemAdapter.js +10 -0
- package/dist/infrastructure/adapters/NodeFileSystemAdapter.js.map +1 -1
- package/dist/infrastructure/mcp/CapabilityNegotiator.js +3 -3
- package/dist/infrastructure/mcp/CapabilityNegotiator.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.d.ts +6 -0
- package/dist/infrastructure/mcp/sddToolDefinitions.js +110 -0
- package/dist/infrastructure/mcp/sddToolDefinitions.js.map +1 -0
- 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.d.ts +8 -35
- package/dist/utils/atomicWrite.js +24 -57
- 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/mcp-server.js +5 -2883
- package/package.json +8 -3
- package/scripts/context-usage-report.mjs +602 -0
- package/sdd-entry.js +17 -6
- package/skills/sdd-commit/REFERENCE.md +31 -0
- package/skills/sdd-commit/SKILL.md +17 -273
- package/skills/sdd-design/REFERENCE.md +51 -0
- package/skills/sdd-design/SKILL.md +25 -262
- package/skills/sdd-implement/REFERENCE.md +30 -0
- package/skills/sdd-implement/SKILL.md +27 -284
- package/skills/sdd-requirements/REFERENCE.md +39 -0
- package/skills/sdd-requirements/SKILL.md +28 -132
- package/skills/sdd-review/REFERENCE.md +26 -0
- package/skills/sdd-review/SKILL.md +17 -181
- package/skills/sdd-security-check/REFERENCE.md +19 -0
- package/skills/sdd-security-check/SKILL.md +18 -184
- package/skills/sdd-steering/REFERENCE.md +25 -0
- package/skills/sdd-steering/SKILL.md +18 -216
- package/skills/sdd-steering-custom/REFERENCE.md +27 -0
- package/skills/sdd-steering-custom/SKILL.md +19 -203
- package/skills/sdd-tasks/REFERENCE.md +25 -0
- package/skills/sdd-tasks/SKILL.md +27 -244
- package/skills/sdd-test-gen/REFERENCE.md +15 -0
- package/skills/sdd-test-gen/SKILL.md +17 -287
- package/skills/simple-task/REFERENCE.md +22 -0
- package/skills/simple-task/SKILL.md +17 -138
- package/templates/CLAUDE.md +13 -31
- package/templates/codex-AGENTS.md +7 -9
- package/rules/git-workflow.md +0 -92
- package/rules/sdd-workflow.md +0 -116
|
@@ -10,354 +10,1509 @@ 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
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { readFile, rmdir, stat, unlink } from 'node:fs/promises';
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
13
17
|
import { injectable, inject } from 'inversify';
|
|
14
|
-
import { v4 as uuidv4 } from 'uuid';
|
|
15
18
|
import { TYPES } from '../../infrastructure/di/types.js';
|
|
16
|
-
import { WorkflowPhase } from '../../domain/types.js';
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
19
|
+
import { WorkflowPhase, } from '../../domain/types.js';
|
|
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
|
+
]);
|
|
21
38
|
let WorkflowEngineService = class WorkflowEngineService {
|
|
22
39
|
projectRepository;
|
|
23
|
-
projectService;
|
|
24
|
-
templateService;
|
|
25
40
|
contextCompactionService;
|
|
26
41
|
logger;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
constructor(projectRepository,
|
|
42
|
+
fileSystem;
|
|
43
|
+
validator = new WorkflowValidationService();
|
|
44
|
+
constructor(projectRepository, contextCompactionService, logger, fileSystem) {
|
|
30
45
|
this.projectRepository = projectRepository;
|
|
31
|
-
this.projectService = projectService;
|
|
32
|
-
this.templateService = templateService;
|
|
33
46
|
this.contextCompactionService = contextCompactionService;
|
|
34
47
|
this.logger = logger;
|
|
35
|
-
this.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
this.fileSystem = fileSystem;
|
|
49
|
+
}
|
|
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 } },
|
|
50
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);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// Preserve a non-empty directory: another actor created content we do not own.
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
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');
|
|
51
122
|
}
|
|
52
|
-
|
|
53
|
-
|
|
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 });
|
|
136
|
+
}
|
|
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);
|
|
54
163
|
return {
|
|
55
|
-
|
|
56
|
-
|
|
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',
|
|
57
167
|
};
|
|
168
|
+
});
|
|
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
|
+
}
|
|
207
|
+
async reviewTestCases(request) {
|
|
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);
|
|
229
|
+
}
|
|
230
|
+
const handoff = await this.publishHandoff(request.projectRoot, request.featureName);
|
|
231
|
+
return { featureName: request.featureName, reviewed: true, tasksRevision: tasks.revision, artifactSha256: tasks.artifactSha256, canApproveTasks: true, handoff };
|
|
232
|
+
});
|
|
233
|
+
}
|
|
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
|
+
}
|
|
275
|
+
const handoff = await this.publishHandoff(request.projectRoot, request.featureName);
|
|
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 };
|
|
278
|
+
});
|
|
279
|
+
}
|
|
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);
|
|
305
|
+
}
|
|
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()) {
|
|
363
|
+
try {
|
|
364
|
+
validateFeatureName(name);
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
if (error instanceof InvalidFeatureNameError)
|
|
368
|
+
continue;
|
|
369
|
+
throw error;
|
|
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
|
+
}
|
|
58
375
|
}
|
|
59
|
-
return
|
|
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));
|
|
60
380
|
}
|
|
61
|
-
async
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
targetPhase,
|
|
67
|
-
triggeredBy
|
|
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);
|
|
68
386
|
});
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
387
|
+
}
|
|
388
|
+
async withLockedFeature(projectRoot, featureName, mutate, action) {
|
|
389
|
+
if (!this.fileSystem)
|
|
390
|
+
throw new GovernanceError('StateInvariantViolation', 'FileSystemPort is required');
|
|
391
|
+
const resolver = new SpecPathResolver(this.fileSystem);
|
|
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);
|
|
74
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)}`);
|
|
75
432
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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: [] },
|
|
81
454
|
};
|
|
82
455
|
}
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
456
|
+
for (const phase of PHASES) {
|
|
457
|
+
if (!approvals[phase].generated || approvals[phase].approved)
|
|
458
|
+
continue;
|
|
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
|
+
},
|
|
96
470
|
};
|
|
97
471
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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}`);
|
|
493
|
+
}
|
|
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;
|
|
520
|
+
}
|
|
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) {
|
|
111
582
|
return {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
message: `Project successfully transitioned to ${targetPhase} phase`,
|
|
115
|
-
auditEntry: result.auditEntry,
|
|
116
|
-
nextSteps
|
|
583
|
+
requirements: phase === 'requirements' ? undefined : await this.requireArtifact(featureRoot, 'requirements'),
|
|
584
|
+
design: phase === 'tasks' ? await this.requireArtifact(featureRoot, 'design') : undefined,
|
|
117
585
|
};
|
|
118
586
|
}
|
|
119
|
-
async
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
success: false,
|
|
130
|
-
message: `Project not found: ${projectId}`
|
|
131
|
-
};
|
|
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
|
+
}
|
|
132
597
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
598
|
+
}
|
|
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');
|
|
139
611
|
}
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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,
|
|
148
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);
|
|
149
777
|
return {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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,
|
|
153
786
|
};
|
|
154
|
-
}
|
|
155
|
-
// Save updated project
|
|
156
|
-
if (result.updatedProject) {
|
|
157
|
-
await this.projectRepository.save(result.updatedProject);
|
|
158
|
-
// Reset approvals for rolled back phase
|
|
159
|
-
await this.resetPhaseApprovals(result.updatedProject, project.phase);
|
|
160
|
-
}
|
|
161
|
-
this.logger.info('Phase rollback completed successfully', {
|
|
162
|
-
correlationId,
|
|
163
|
-
projectId,
|
|
164
|
-
fromPhase: project.phase,
|
|
165
|
-
targetPhase: previousPhase
|
|
166
787
|
});
|
|
167
|
-
return {
|
|
168
|
-
success: true,
|
|
169
|
-
updatedProject: result.updatedProject,
|
|
170
|
-
message: `Project rolled back to ${previousPhase} phase`,
|
|
171
|
-
auditEntry: result.auditEntry
|
|
172
|
-
};
|
|
173
788
|
}
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
// Check if project can progress to next phase
|
|
192
|
-
const nextPhase = this.stateMachine.getNextPhase(updatedProject.phase);
|
|
193
|
-
let canProgressToNext = false;
|
|
194
|
-
if (nextPhase) {
|
|
195
|
-
const validation = this.stateMachine.canTransition(updatedProject, nextPhase);
|
|
196
|
-
canProgressToNext = validation.allowed;
|
|
197
|
-
}
|
|
198
|
-
const message = approval.approved
|
|
199
|
-
? `${phase} phase approved and ready for progression`
|
|
200
|
-
: `${phase} phase approval status updated`;
|
|
201
|
-
const messageWithHandoff = handoffResult
|
|
202
|
-
? `${message}. Compact handoff: ${handoffResult.path} (${handoffResult.estimate.reductionPercentage}% estimated context reduction, ~${handoffResult.estimate.sourceTokens} -> ~${handoffResult.estimate.compactTokens} tokens)`
|
|
203
|
-
: message;
|
|
204
|
-
this.logger.info('Approval status updated successfully', {
|
|
205
|
-
correlationId,
|
|
206
|
-
projectId,
|
|
207
|
-
phase,
|
|
208
|
-
canProgressToNext
|
|
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 '';
|
|
209
805
|
});
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
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');
|
|
854
|
+
}
|
|
855
|
+
}
|
|
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');
|
|
874
|
+
}
|
|
875
|
+
return { ...value, observedAt: new Date().toISOString() };
|
|
876
|
+
}
|
|
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');
|
|
893
|
+
}
|
|
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
|
+
},
|
|
979
|
+
},
|
|
980
|
+
implementation: raw.implementation === undefined
|
|
981
|
+
? undefined
|
|
982
|
+
: this.normalizeV5Implementation(raw.implementation),
|
|
215
983
|
};
|
|
984
|
+
this.assertV5Invariants(spec);
|
|
985
|
+
return spec;
|
|
216
986
|
}
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
if (!
|
|
220
|
-
|
|
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
|
+
};
|
|
221
1049
|
}
|
|
222
1050
|
return {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
validTransitions: this.stateMachine.getValidTransitions(project.phase)
|
|
1051
|
+
revision: raw.revision,
|
|
1052
|
+
tasks_revision: raw.tasks_revision,
|
|
1053
|
+
legacy_imported: raw.legacy_imported,
|
|
1054
|
+
tasks,
|
|
228
1055
|
};
|
|
229
1056
|
}
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
if (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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`);
|
|
239
1068
|
}
|
|
240
|
-
const integrity = status.integrity;
|
|
241
|
-
const progress = status.progress;
|
|
242
1069
|
return {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
nextPhase: progress.nextPhase
|
|
1070
|
+
command: evidence.command,
|
|
1071
|
+
exitCode: evidence.exit_code,
|
|
1072
|
+
summary: evidence.summary,
|
|
1073
|
+
observedAt: evidence.observed_at,
|
|
248
1074
|
};
|
|
249
1075
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
case WorkflowPhase.REQUIREMENTS:
|
|
254
|
-
if (!project.metadata.approvals.requirements.generated) {
|
|
255
|
-
const content = await this.templateService.generateRequirementsTemplate(project);
|
|
256
|
-
await this.templateService.writeProjectFile(project, 'requirements.md', content);
|
|
257
|
-
await this.projectService.updateApprovalStatus(project.id, 'requirements', { generated: true });
|
|
258
|
-
}
|
|
259
|
-
break;
|
|
260
|
-
case WorkflowPhase.DESIGN:
|
|
261
|
-
if (!project.metadata.approvals.design.generated) {
|
|
262
|
-
const content = await this.templateService.generateDesignTemplate(project);
|
|
263
|
-
await this.templateService.writeProjectFile(project, 'design.md', content);
|
|
264
|
-
await this.projectService.updateApprovalStatus(project.id, 'design', { generated: true });
|
|
265
|
-
}
|
|
266
|
-
break;
|
|
267
|
-
case WorkflowPhase.TASKS:
|
|
268
|
-
if (!project.metadata.approvals.tasks.generated) {
|
|
269
|
-
const content = await this.templateService.generateTasksTemplate(project);
|
|
270
|
-
await this.templateService.writeProjectFile(project, 'tasks.md', content);
|
|
271
|
-
await this.projectService.updateApprovalStatus(project.id, 'tasks', { generated: true });
|
|
272
|
-
}
|
|
273
|
-
break;
|
|
274
|
-
}
|
|
1076
|
+
requireSpecRecord(value, reference) {
|
|
1077
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
1078
|
+
throw new GovernanceError('StateInvariantViolation', `${reference} must be an object`);
|
|
275
1079
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
1080
|
+
return value;
|
|
1081
|
+
}
|
|
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`);
|
|
281
1088
|
}
|
|
1089
|
+
return value;
|
|
282
1090
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
steps.push('Run sdd-requirements to generate requirements document');
|
|
309
|
-
break;
|
|
310
|
-
case WorkflowPhase.REQUIREMENTS:
|
|
311
|
-
if (!project.metadata.approvals.requirements.approved) {
|
|
312
|
-
steps.push('Review and approve requirements document');
|
|
313
|
-
steps.push('Run sdd-design after requirements approval');
|
|
314
|
-
}
|
|
315
|
-
else {
|
|
316
|
-
steps.push('Run sdd-design to generate technical design');
|
|
317
|
-
}
|
|
318
|
-
break;
|
|
319
|
-
case WorkflowPhase.DESIGN:
|
|
320
|
-
if (!project.metadata.approvals.design.approved) {
|
|
321
|
-
steps.push('Review and approve design document');
|
|
322
|
-
steps.push('Run sdd-tasks after design approval');
|
|
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`);
|
|
323
1116
|
}
|
|
324
|
-
|
|
325
|
-
|
|
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`);
|
|
326
1145
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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`);
|
|
335
1219
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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);
|
|
1280
|
+
};
|
|
1281
|
+
for (const taskNumber of ids)
|
|
1282
|
+
visit(taskNumber);
|
|
1283
|
+
}
|
|
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`);
|
|
1294
|
+
}
|
|
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
|
+
};
|
|
339
1327
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
return `${JSON.stringify(persisted, null, 2)}\n`;
|
|
1331
|
+
}
|
|
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) {
|
|
1339
|
+
let parsed;
|
|
1340
|
+
try {
|
|
1341
|
+
parsed = JSON.parse(bytes);
|
|
1342
|
+
}
|
|
1343
|
+
catch {
|
|
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;
|
|
1439
|
+
}
|
|
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);
|
|
1463
|
+
}
|
|
1464
|
+
async publishHandoff(projectRoot, featureName) {
|
|
1465
|
+
try {
|
|
1466
|
+
const result = await this.contextCompactionService.loadContext({
|
|
1467
|
+
projectRoot,
|
|
1468
|
+
featureName,
|
|
1469
|
+
mode: 'compact',
|
|
1470
|
+
});
|
|
1471
|
+
return {
|
|
1472
|
+
status: 'published',
|
|
1473
|
+
path: path.join(projectRoot, '.spec', 'specs', featureName, 'context', 'handoff.md'),
|
|
1474
|
+
fingerprint: result.fingerprint,
|
|
1475
|
+
payloadEstimatedTokens: result.payloadEstimatedTokens,
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
catch (error) {
|
|
1479
|
+
try {
|
|
1480
|
+
await this.contextCompactionService.invalidateCanonicalHandoff({ projectRoot, featureName });
|
|
1481
|
+
}
|
|
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
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1491
|
+
this.logger.warn('Workflow state committed but handoff publication failed', {
|
|
1492
|
+
featureName,
|
|
1493
|
+
message,
|
|
1494
|
+
});
|
|
1495
|
+
return {
|
|
1496
|
+
status: 'pending-regeneration',
|
|
1497
|
+
warning: { code: 'HandoffPublicationFailed', message },
|
|
1498
|
+
};
|
|
346
1499
|
}
|
|
347
|
-
return steps;
|
|
348
1500
|
}
|
|
349
1501
|
};
|
|
350
1502
|
WorkflowEngineService = __decorate([
|
|
351
1503
|
injectable(),
|
|
352
1504
|
__param(0, inject(TYPES.ProjectRepository)),
|
|
353
|
-
__param(1, inject(TYPES.
|
|
354
|
-
__param(2, inject(TYPES.
|
|
355
|
-
__param(3, inject(TYPES.
|
|
356
|
-
|
|
357
|
-
__param(5, inject(TYPES.ValidationPort)),
|
|
358
|
-
__metadata("design:paramtypes", [Object, ProjectService,
|
|
359
|
-
TemplateService,
|
|
360
|
-
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])
|
|
361
1509
|
], WorkflowEngineService);
|
|
362
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); }
|
|
363
1518
|
//# sourceMappingURL=WorkflowEngineService.js.map
|