sdd-mcp-server 3.5.1 → 5.0.0

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