sdd-mcp-server 4.0.0 → 5.0.0

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