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,9 +10,64 @@ 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 path from 'node:path';
13
15
  import { injectable, inject } from 'inversify';
14
- import { v4 as uuidv4 } from 'uuid';
15
16
  import { TYPES } from '../../infrastructure/di/types.js';
17
+ import { SpecPathResolver } from './SpecPathResolver.js';
18
+ export class ContextBudgetTooSmallError extends Error {
19
+ requested;
20
+ minimumRequired;
21
+ code = 'ContextBudgetTooSmall';
22
+ constructor(requested, minimumRequired) {
23
+ super(`Context budget ${requested} is below the mandatory minimum ${minimumRequired}`);
24
+ this.requested = requested;
25
+ this.minimumRequired = minimumRequired;
26
+ this.name = 'ContextBudgetTooSmallError';
27
+ }
28
+ }
29
+ export class ContextBudgetExceededError extends Error {
30
+ requested;
31
+ required;
32
+ code = 'ContextBudgetExceeded';
33
+ constructor(requested, required) {
34
+ super(`Full context requires ${required} estimated tokens; requested ${requested}`);
35
+ this.requested = requested;
36
+ this.required = required;
37
+ this.name = 'ContextBudgetExceededError';
38
+ }
39
+ }
40
+ export class PhaseNotApprovedError extends Error {
41
+ phase;
42
+ code = 'PhaseNotApproved';
43
+ constructor(phase) {
44
+ super(`Phase is not approved: ${phase}`);
45
+ this.phase = phase;
46
+ this.name = 'PhaseNotApprovedError';
47
+ }
48
+ }
49
+ export class ContextArtifactDriftError extends Error {
50
+ phase;
51
+ code = 'ArtifactDrift';
52
+ constructor(phase) {
53
+ super(`Approved artifact has drifted: ${phase}`);
54
+ this.phase = phase;
55
+ this.name = 'ContextArtifactDriftError';
56
+ }
57
+ }
58
+ export class ContextSourceError extends Error {
59
+ sourcePath;
60
+ code = 'ContextSourceError';
61
+ constructor(sourcePath, message) {
62
+ super(`${message}: ${sourcePath}`);
63
+ this.sourcePath = sourcePath;
64
+ this.name = 'ContextSourceError';
65
+ }
66
+ }
67
+ const MODE_BUDGETS = { compact: 2048, standard: 4096, full: 16384 };
68
+ const PHASES = ['requirements', 'design', 'tasks'];
69
+ const HANDOFF_SCHEMA = 2;
70
+ const SELECTION_VERSION = 2;
16
71
  let ContextCompactionService = class ContextCompactionService {
17
72
  fileSystem;
18
73
  logger;
@@ -20,227 +75,459 @@ let ContextCompactionService = class ContextCompactionService {
20
75
  this.fileSystem = fileSystem;
21
76
  this.logger = logger;
22
77
  }
78
+ async loadContext(requestOrProject, legacyMode = 'compact') {
79
+ if (!('projectRoot' in requestOrProject)) {
80
+ const result = await this.loadContextRequest({ projectRoot: requestOrProject.path, featureName: requestOrProject.name, mode: legacyMode });
81
+ return result.content ?? '';
82
+ }
83
+ return this.loadContextRequest(requestOrProject);
84
+ }
23
85
  async generatePhaseHandoff(project, approvedPhase) {
24
- const correlationId = uuidv4();
25
- const contextDir = this.getContextDir(project);
26
- const handoffPath = `${contextDir}/handoff.md`;
27
- const phaseHandoffPath = `${contextDir}/${approvedPhase}-handoff.md`;
28
- this.logger.info('Generating compact workflow handoff', {
29
- correlationId,
30
- projectId: project.id,
31
- approvedPhase
32
- });
33
- await this.fileSystem.mkdir(contextDir);
34
- const documents = await this.loadAvailableDocuments(project);
35
- const sourceCharacters = documents.reduce((sum, doc) => sum + doc.content.length, 0);
36
- const content = this.buildHandoff(project, approvedPhase, documents, sourceCharacters);
37
- await this.fileSystem.writeFile(handoffPath, content);
38
- await this.fileSystem.writeFile(phaseHandoffPath, content);
39
- const estimate = this.estimateContextSize(sourceCharacters, content.length);
40
- this.logger.info('Compact workflow handoff generated', {
41
- correlationId,
42
- projectId: project.id,
43
- approvedPhase,
44
- sourceTokens: estimate.sourceTokens,
45
- compactTokens: estimate.compactTokens,
46
- reductionPercentage: estimate.reductionPercentage
47
- });
86
+ const result = await this.loadContextRequest({ projectRoot: project.path, featureName: project.name, phase: approvedPhase, mode: 'compact' });
87
+ const content = result.content ?? '';
48
88
  return {
49
- path: handoffPath,
89
+ path: path.join(project.path, '.spec', 'specs', project.name, 'context', 'handoff.md'),
50
90
  content,
51
- estimate
91
+ fingerprint: result.fingerprint,
92
+ estimate: this.estimateContextSize(result.sourceCharacters, result.payloadCharacters),
52
93
  };
53
94
  }
54
- async loadContext(project, mode = 'compact') {
55
- if (mode === 'full') {
56
- return this.loadFullContext(project);
57
- }
58
- const handoffPath = `${this.getContextDir(project)}/handoff.md`;
95
+ async invalidateCanonicalHandoff(request) {
96
+ const resolved = await new SpecPathResolver(this.fileSystem).resolve(request.projectRoot, request.featureName);
97
+ const handoffPath = path.join(resolved.featureRoot, 'context', 'handoff.md');
98
+ const contextRoot = path.dirname(handoffPath);
99
+ const resolver = new SpecPathResolver(this.fileSystem);
100
+ await resolver.assertContained(resolved.featureRoot, contextRoot);
101
+ await resolver.assertContained(resolved.featureRoot, handoffPath);
59
102
  if (await this.fileSystem.exists(handoffPath)) {
60
- const handoff = await this.fileSystem.readFile(handoffPath);
61
- if (mode === 'standard') {
62
- const spec = await this.readOptionalFile(`${this.getSpecDir(project)}/spec.json`);
63
- return [
64
- handoff,
65
- spec ? '\n## Current Spec Metadata\n\n```json\n' + spec + '\n```' : ''
66
- ].filter(Boolean).join('\n');
67
- }
68
- return handoff;
103
+ if (!this.fileSystem.unlink)
104
+ throw new Error('FileSystemPort.unlink is required to invalidate context');
105
+ await this.fileSystem.unlink(handoffPath);
69
106
  }
70
- const documents = await this.loadAvailableDocuments(project);
71
- const sourceCharacters = documents.reduce((sum, doc) => sum + doc.content.length, 0);
72
- return this.buildHandoff(project, 'requirements', documents, sourceCharacters);
73
107
  }
74
108
  estimateContextSize(sourceCharacters, compactCharacters) {
75
- const sourceTokens = this.estimateTokens(sourceCharacters);
76
- const compactTokens = this.estimateTokens(compactCharacters);
77
- const reductionPercentage = sourceTokens === 0
78
- ? 0
79
- : Math.max(0, Math.round((1 - compactTokens / sourceTokens) * 100));
109
+ const sourceTokens = Math.ceil(sourceCharacters / 4);
110
+ const compactTokens = Math.ceil(compactCharacters / 4);
80
111
  return {
81
112
  sourceCharacters,
82
113
  sourceTokens,
83
114
  compactCharacters,
84
115
  compactTokens,
85
- reductionPercentage
116
+ reductionPercentage: sourceTokens === 0 || compactTokens >= sourceTokens ? 0 : Math.round((1 - compactTokens / sourceTokens) * 100),
117
+ };
118
+ }
119
+ async loadContextRequest(request) {
120
+ const { mode, budget } = this.resolveLoadOptions(request);
121
+ const resolver = new SpecPathResolver(this.fileSystem);
122
+ const resolved = await resolver.resolve(request.projectRoot, request.featureName);
123
+ const specPath = path.join(resolved.featureRoot, 'spec.json');
124
+ await resolver.assertContained(resolved.featureRoot, specPath);
125
+ const spec = await this.readSpec(specPath, request.featureName);
126
+ const selected = await this.selectState(resolved.featureRoot, spec, request, resolver);
127
+ const sourceCharacters = selected.documents.reduce((total, document) => total + document.content.length, 0);
128
+ const sourceFingerprint = this.hash(JSON.stringify({
129
+ schema: HANDOFF_SCHEMA,
130
+ phase: selected.phase,
131
+ status: selected.status,
132
+ approvals: spec.approvals,
133
+ reviewRequired: spec.reviewRequired,
134
+ reviewCompleted: spec.reviewCompleted,
135
+ implementation: spec.implementation,
136
+ sources: selected.documents.map(({ relativePath, content }) => [relativePath, content]),
137
+ }));
138
+ const fingerprint = this.hash(JSON.stringify({
139
+ sourceFingerprint,
140
+ mode,
141
+ budget,
142
+ includeUnapproved: request.includeUnapproved === true,
143
+ selection: SELECTION_VERSION,
144
+ }));
145
+ const canonical = mode === 'compact'
146
+ && request.maxEstimatedTokens === undefined
147
+ && request.includeUnapproved !== true;
148
+ const handoffPath = path.join(resolved.featureRoot, 'context', 'handoff.md');
149
+ const built = mode === 'full'
150
+ ? this.buildFull(spec, selected, budget)
151
+ : this.buildBounded(spec, selected, budget, mode, sourceFingerprint, fingerprint, canonical);
152
+ const cached = await this.readCanonicalCache(canonical, handoffPath, resolved.featureRoot, resolver, built.content);
153
+ if (cached !== undefined) {
154
+ const result = this.resultFor(cached, mode, selected, sourceFingerprint, fingerprint, sourceCharacters, [], 'hit');
155
+ return this.applyEtag(result, request.ifNoneMatch);
156
+ }
157
+ await this.persistCanonical(canonical, handoffPath, built.content, resolved.featureRoot, resolver);
158
+ const result = this.resultFor(built.content, mode, selected, sourceFingerprint, fingerprint, sourceCharacters, built.omittedSources, 'regenerated');
159
+ this.logger.debug('Context payload prepared', {
160
+ featureName: request.featureName,
161
+ mode,
162
+ phase: selected.phase,
163
+ payloadEstimatedTokens: result.payloadEstimatedTokens,
164
+ });
165
+ return this.applyEtag(result, request.ifNoneMatch);
166
+ }
167
+ resolveLoadOptions(request) {
168
+ const mode = request.mode ?? 'compact';
169
+ const budget = request.maxEstimatedTokens ?? MODE_BUDGETS[mode];
170
+ if (!Number.isInteger(budget) || budget <= 0)
171
+ throw new ContextBudgetTooSmallError(budget, 1);
172
+ if (request.includeUnapproved && mode !== 'full') {
173
+ throw new Error('includeUnapproved is supported only in full mode');
174
+ }
175
+ return { mode, budget };
176
+ }
177
+ async readCanonicalCache(canonical, handoffPath, featureRoot, resolver, expectedContent) {
178
+ if (!canonical || !(await this.fileSystem.exists(handoffPath)))
179
+ return undefined;
180
+ await resolver.assertContained(featureRoot, handoffPath);
181
+ const cached = await this.fileSystem.readFile(handoffPath);
182
+ return cached === expectedContent ? cached : undefined;
183
+ }
184
+ async persistCanonical(canonical, handoffPath, content, featureRoot, resolver) {
185
+ if (!canonical)
186
+ return;
187
+ const contextRoot = path.dirname(handoffPath);
188
+ await resolver.assertContained(featureRoot, contextRoot);
189
+ await this.fileSystem.mkdir(contextRoot);
190
+ await resolver.assertContained(featureRoot, contextRoot);
191
+ await resolver.assertContained(featureRoot, handoffPath);
192
+ if (!this.fileSystem.writeFileAtomic) {
193
+ throw new Error('FileSystemPort.writeFileAtomic is required for context persistence');
194
+ }
195
+ await this.fileSystem.writeFileAtomic(handoffPath, content);
196
+ }
197
+ applyEtag(result, ifNoneMatch) {
198
+ if (ifNoneMatch !== result.fingerprint)
199
+ return result;
200
+ return {
201
+ ...result,
202
+ content: undefined,
203
+ cacheStatus: 'not-modified',
204
+ payloadCharacters: 0,
205
+ payloadEstimatedTokens: 0,
206
+ };
207
+ }
208
+ async readSpec(specPath, expectedFeature) {
209
+ if (!(await this.fileSystem.exists(specPath))) {
210
+ throw new ContextSourceError(specPath, 'Missing required workflow metadata');
211
+ }
212
+ const record = await this.readJsonRecord(specPath);
213
+ const approvalsRecord = this.requiredRecord(record.approvals, specPath, 'Missing approvals');
214
+ const options = this.optionalRecord(record.workflow_options ?? record.workflowOptions);
215
+ const checkpoints = this.optionalRecord(record.checkpoints);
216
+ const test = this.optionalRecord(checkpoints.test_cases ?? checkpoints.testCases);
217
+ const implementationRecord = this.optionalRecord(record.implementation);
218
+ const implementationTasks = this.optionalRecord(implementationRecord.tasks);
219
+ return {
220
+ featureName: this.specFeatureName(record, expectedFeature, specPath),
221
+ phase: typeof record.phase === 'string' ? record.phase : 'init',
222
+ approvals: this.parseApprovals(approvalsRecord),
223
+ reviewRequired: test.required === true
224
+ || options.review_test_cases === true
225
+ || options.reviewTestCases === true,
226
+ reviewCompleted: test.reviewed === true,
227
+ implementation: Object.keys(implementationTasks).length > 0
228
+ ? {
229
+ revision: typeof implementationRecord.revision === 'number' ? implementationRecord.revision : 0,
230
+ tasks: Object.fromEntries(Object.entries(implementationTasks).map(([taskNumber, value]) => {
231
+ const task = this.optionalRecord(value);
232
+ const dependencies = Array.isArray(task.dependencies)
233
+ ? task.dependencies.filter((dependency) => typeof dependency === 'string')
234
+ : [];
235
+ return [taskNumber, {
236
+ status: typeof task.status === 'string' ? task.status : 'pending',
237
+ blocker: typeof task.blocker === 'string' ? task.blocker : undefined,
238
+ dependencies,
239
+ }];
240
+ })),
241
+ }
242
+ : undefined,
86
243
  };
87
244
  }
88
- async loadFullContext(project) {
89
- const documents = await this.loadAvailableDocuments(project);
90
- const sections = documents.map((doc) => `## ${doc.name}\n\n${doc.content}`);
91
- return [`# Full SDD Context: ${project.name}`, ...sections].join('\n\n');
92
- }
93
- buildHandoff(project, approvedPhase, documents, sourceCharacters) {
94
- const sourceTokens = this.estimateTokens(sourceCharacters);
95
- const sections = documents.map((doc) => this.summarizeDocument(doc));
96
- const approvals = project.metadata.approvals;
97
- const checkpoint = project.metadata.checkpoints?.testCases;
98
- const nextSteps = this.getNextSteps(approvedPhase, checkpoint?.required === true && !checkpoint.reviewed);
99
- const draft = [
100
- `# SDD Context Handoff: ${project.name}`,
101
- '',
102
- `Generated: ${new Date().toISOString()}`,
103
- `Approved phase: ${approvedPhase}`,
104
- '',
105
- '## Workflow State',
106
- '',
107
- `- Requirements: ${this.formatApproval(approvals.requirements)}`,
108
- `- Design: ${this.formatApproval(approvals.design)}`,
109
- `- Tasks: ${this.formatApproval(approvals.tasks)}`,
110
- checkpoint?.required
111
- ? `- TDD test-case review: ${checkpoint.reviewed ? 'reviewed' : 'pending'}`
112
- : '- TDD test-case review: not required',
113
- '',
114
- '## Compact Phase Summaries',
115
- '',
116
- sections.join('\n\n'),
117
- '',
118
- '## Next Actions',
119
- '',
120
- ...nextSteps.map((step) => `- ${step}`),
121
- '',
122
- '## Source References',
123
- '',
124
- ...documents.map((doc) => `- ${doc.path}`),
125
- '',
126
- '## Context Budget Estimate',
127
- '',
128
- `- Full source context: ~${sourceTokens} tokens`,
129
- `- Handoff context: ~${this.estimateTokens(sourceCharacters > 0 ? Math.min(sourceCharacters, 1) : 0)} tokens before final write estimate`,
130
- '- Use `sdd-context-load` default compact mode for routine continuation.',
131
- '- Use `sdd-context-load` with `mode: "full"` only for audits or ambiguous decisions.'
132
- ].join('\n');
133
- const estimate = this.estimateContextSize(sourceCharacters, draft.length);
134
- return draft.replace(/- Handoff context: ~\d+ tokens before final write estimate/, `- Handoff context: ~${estimate.compactTokens} tokens`);
135
- }
136
- summarizeDocument(doc) {
137
- const lines = this.normalizeLines(doc.content);
138
- const headings = this.extractHeadings(lines);
139
- const bullets = this.extractBullets(lines);
140
- const requirements = this.extractRequirementLikeLines(lines);
141
- return [
142
- `### ${doc.name}`,
143
- '',
144
- headings.length > 0 ? '**Key sections:**' : '',
145
- ...headings.slice(0, 8).map((line) => `- ${line}`),
146
- bullets.length > 0 ? '\n**Important points:**' : '',
147
- ...bullets.slice(0, 8).map((line) => `- ${line}`),
148
- requirements.length > 0 ? '\n**Constraints and acceptance signals:**' : '',
149
- ...requirements.slice(0, 8).map((line) => `- ${line}`),
150
- headings.length === 0 && bullets.length === 0 && requirements.length === 0
151
- ? '- No structured summary points found; open source document if this phase is active.'
152
- : ''
153
- ].filter(Boolean).join('\n');
154
- }
155
- async loadAvailableDocuments(project) {
156
- const specDir = this.getSpecDir(project);
157
- const names = ['requirements.md', 'design.md', 'tasks.md', 'spec.json'];
245
+ async readJsonRecord(specPath) {
246
+ let value;
247
+ try {
248
+ value = JSON.parse(await this.fileSystem.readFile(specPath));
249
+ }
250
+ catch {
251
+ throw new ContextSourceError(specPath, 'Malformed workflow metadata');
252
+ }
253
+ if (!value || typeof value !== 'object') {
254
+ throw new ContextSourceError(specPath, 'Invalid workflow metadata');
255
+ }
256
+ return value;
257
+ }
258
+ requiredRecord(value, specPath, message) {
259
+ if (!value || typeof value !== 'object')
260
+ throw new ContextSourceError(specPath, message);
261
+ return value;
262
+ }
263
+ optionalRecord(value) {
264
+ return value && typeof value === 'object' ? value : {};
265
+ }
266
+ parseApprovals(record) {
267
+ return Object.fromEntries(PHASES.map(phase => {
268
+ const state = this.optionalRecord(record[phase]);
269
+ return [phase, {
270
+ generated: state.generated === true,
271
+ approved: state.approved === true,
272
+ artifactSha256: typeof state.artifact_sha256 === 'string'
273
+ ? state.artifact_sha256
274
+ : typeof state.artifactSha256 === 'string'
275
+ ? state.artifactSha256
276
+ : undefined,
277
+ }];
278
+ }));
279
+ }
280
+ specFeatureName(record, expectedFeature, specPath) {
281
+ const persisted = typeof record.feature_name === 'string'
282
+ ? record.feature_name
283
+ : typeof record.name === 'string'
284
+ ? record.name
285
+ : undefined;
286
+ if (persisted !== undefined && persisted !== expectedFeature) {
287
+ throw new ContextSourceError(specPath, 'Feature metadata name does not match directory');
288
+ }
289
+ return expectedFeature;
290
+ }
291
+ async selectState(featureRoot, spec, request, resolver) {
292
+ const selection = this.resolveEffectivePhase(spec, request);
293
+ const documents = await this.readSelectedDocuments(featureRoot, spec, selection.phase, resolver);
294
+ return { ...selection, documents };
295
+ }
296
+ resolveEffectivePhase(spec, request) {
297
+ if (spec.implementation && !request.phase)
298
+ return { phase: 'implementation', status: 'approved' };
299
+ if (request.phase)
300
+ return this.resolveExplicitPhase(spec, request);
301
+ if (request.mode === 'full' && request.includeUnapproved) {
302
+ const latestGenerated = [...PHASES].reverse()
303
+ .find(candidate => spec.approvals[candidate].generated);
304
+ if (!latestGenerated)
305
+ return { phase: 'init', status: 'init' };
306
+ return {
307
+ phase: latestGenerated,
308
+ status: spec.approvals[latestGenerated].approved ? 'approved' : 'unapproved',
309
+ };
310
+ }
311
+ const latestApproved = [...PHASES].reverse()
312
+ .find(candidate => spec.approvals[candidate].approved);
313
+ return latestApproved
314
+ ? { phase: latestApproved, status: 'approved' }
315
+ : { phase: 'init', status: 'init' };
316
+ }
317
+ resolveExplicitPhase(spec, request) {
318
+ const phase = request.phase;
319
+ if (!phase)
320
+ return { phase: 'init', status: 'init' };
321
+ if (phase === 'implementation') {
322
+ if (!spec.approvals.tasks.approved || !spec.implementation)
323
+ throw new PhaseNotApprovedError('tasks');
324
+ return { phase, status: 'approved' };
325
+ }
326
+ const state = spec.approvals[phase];
327
+ const draftAllowed = request.mode === 'full' && request.includeUnapproved && state.generated;
328
+ if (!state.approved && !draftAllowed)
329
+ throw new PhaseNotApprovedError(phase);
330
+ return { phase, status: state.approved ? 'approved' : 'unapproved' };
331
+ }
332
+ async readSelectedDocuments(featureRoot, spec, phase, resolver) {
333
+ if (phase === 'init')
334
+ return [];
158
335
  const documents = [];
159
- for (const name of names) {
160
- const path = `${specDir}/${name}`;
161
- const content = await this.readOptionalFile(path);
162
- if (content) {
163
- documents.push({ name, path, content });
336
+ const last = phase === 'implementation' ? PHASES.length - 1 : PHASES.indexOf(phase);
337
+ for (const selectedPhase of PHASES.slice(0, last + 1)) {
338
+ if (!spec.approvals[selectedPhase].generated) {
339
+ throw new ContextSourceError(`${selectedPhase}.md`, 'Selected phase metadata is inconsistent');
164
340
  }
341
+ documents.push(await this.readPhaseDocument(featureRoot, selectedPhase, spec.approvals[selectedPhase], resolver));
165
342
  }
166
343
  return documents;
167
344
  }
168
- async readOptionalFile(path) {
345
+ async readPhaseDocument(featureRoot, selectedPhase, approval, resolver) {
346
+ const name = `${selectedPhase}.md`;
347
+ const absolutePath = path.join(featureRoot, name);
348
+ await resolver.assertContained(featureRoot, absolutePath);
349
+ if (!(await this.fileSystem.exists(absolutePath))) {
350
+ throw new ContextSourceError(absolutePath, 'Missing required selected-phase document');
351
+ }
352
+ let content;
169
353
  try {
170
- if (!(await this.fileSystem.exists(path))) {
171
- return null;
172
- }
173
- return await this.fileSystem.readFile(path);
354
+ content = await this.fileSystem.readFile(absolutePath);
174
355
  }
175
356
  catch {
176
- return null;
177
- }
178
- }
179
- normalizeLines(content) {
180
- return content
181
- .split(/\r?\n/)
182
- .map((line) => line.trim())
183
- .filter((line) => line.length > 0 && !line.startsWith('```'));
184
- }
185
- extractHeadings(lines) {
186
- return this.unique(lines
187
- .filter((line) => /^#{1,4}\s+/.test(line))
188
- .map((line) => line.replace(/^#{1,4}\s+/, '').trim()));
189
- }
190
- extractBullets(lines) {
191
- return this.unique(lines
192
- .filter((line) => /^[-*]\s+/.test(line) || /^\d+\.\s+/.test(line))
193
- .map((line) => line.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, '').trim())
194
- .filter((line) => line.length > 0 && line.length <= 220));
195
- }
196
- extractRequirementLikeLines(lines) {
197
- const keywords = /\b(SHALL|MUST|WHEN|IF|THEN|WHERE|constraint|risk|security|performance|error|edge case)\b/i;
198
- return this.unique(lines
199
- .filter((line) => keywords.test(line))
200
- .map((line) => line.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, '').trim())
201
- .filter((line) => line.length <= 240));
202
- }
203
- unique(lines) {
204
- const seen = new Set();
205
- const result = [];
206
- for (const line of lines) {
207
- const key = line.toLowerCase();
208
- if (!seen.has(key)) {
209
- seen.add(key);
210
- result.push(line);
211
- }
357
+ throw new ContextSourceError(absolutePath, 'Unable to read selected-phase document');
358
+ }
359
+ if (approval.approved && approval.artifactSha256 && this.hash(content) !== approval.artifactSha256) {
360
+ throw new ContextArtifactDriftError(selectedPhase);
361
+ }
362
+ return {
363
+ name,
364
+ relativePath: name,
365
+ absolutePath,
366
+ content,
367
+ };
368
+ }
369
+ buildBounded(spec, selected, budget, mode, sourceFingerprint, fingerprint, canonical) {
370
+ const envelope = this.buildEnvelope(spec, selected, sourceFingerprint, fingerprint, canonical);
371
+ const maximumCharacters = budget * 4;
372
+ if (envelope.length > maximumCharacters) {
373
+ throw new ContextBudgetTooSmallError(budget, Math.ceil(envelope.length / 4));
374
+ }
375
+ if (!selected.documents.length) {
376
+ return { content: this.embedEstimate(envelope), omittedSources: [] };
212
377
  }
213
- return result;
378
+ const direct = this.buildDirectPayload(envelope, selected, maximumCharacters);
379
+ if (direct)
380
+ return { content: direct, omittedSources: [] };
381
+ return this.selectCandidates(envelope, this.collectCandidates(selected.documents, mode), selected.documents, maximumCharacters);
382
+ }
383
+ buildEnvelope(spec, selected, sourceFingerprint, fingerprint, canonical) {
384
+ const metadata = canonical
385
+ ? `<!-- sdd-context schema=${HANDOFF_SCHEMA} phase=${selected.phase} source=${sourceFingerprint} payload=${fingerprint} -->\n`
386
+ : '';
387
+ const sourceReferences = selected.documents.length
388
+ ? selected.documents.map(document => `- ${document.relativePath}`).join('\n')
389
+ : '- spec.json';
390
+ const review = spec.reviewRequired
391
+ ? spec.reviewCompleted ? 'reviewed' : 'pending'
392
+ : 'not required';
393
+ const implementationProgress = spec.implementation
394
+ ? `\n\n## Implementation Progress\n${this.implementationProgress(spec)}`
395
+ : '';
396
+ return `${metadata}# SDD Context: ${spec.featureName}\n\n## Workflow State\n- Effective phase: ${selected.phase}\n- Phase status: ${selected.status}\n- Requirements: ${this.approvalLabel(spec.approvals.requirements)}\n- Design: ${this.approvalLabel(spec.approvals.design)}\n- Tasks: ${this.approvalLabel(spec.approvals.tasks)}\n- Test-case review: ${review}${implementationProgress}\n\n## Next Action\n${this.nextAction(spec, selected)}\n\n## Source References\n${sourceReferences}\n\n## Payload Estimate\n- Payload estimated tokens: 00000`;
214
397
  }
215
- getNextSteps(approvedPhase, testReviewPending) {
216
- if (approvedPhase === 'requirements') {
217
- return ['Generate or review design using the approved requirements handoff.'];
398
+ nextAction(spec, selected) {
399
+ const phase = selected.phase;
400
+ if (selected.status === 'unapproved') {
401
+ return `Continue the ${phase} Skill to revise, validate, and request approval for this draft.`;
218
402
  }
219
- if (approvedPhase === 'design') {
220
- return ['Generate TDD task breakdown from the approved design handoff.'];
403
+ if (phase === 'init')
404
+ return 'Generate requirements through the requirements Skill.';
405
+ if (phase === 'requirements')
406
+ return 'Continue with the design Skill.';
407
+ if (phase === 'design')
408
+ return 'Continue with the tasks Skill.';
409
+ if (phase === 'implementation' && spec.implementation) {
410
+ const tasks = Object.entries(spec.implementation.tasks);
411
+ const active = tasks.filter(([, task]) => ['in-progress', 'red-observed', 'green-observed', 'blocked'].includes(task.status));
412
+ if (active.length === 1)
413
+ return `Continue task ${active[0][0]} from ${active[0][1].status}.`;
414
+ if (active.length > 1) {
415
+ return `Select a resumable task: ${this.taskCandidateSummary(active, true)}.`;
416
+ }
417
+ const ready = tasks.filter(([, task]) => task.status === 'pending'
418
+ && task.dependencies.every((dependency) => spec.implementation.tasks[dependency]?.status === 'completed'));
419
+ if (ready.length === 1)
420
+ return `Start task ${ready[0][0]}.`;
421
+ if (ready.length > 1)
422
+ return `Select a ready task: ${this.taskCandidateSummary(ready, false)}.`;
423
+ return tasks.every(([, task]) => task.status === 'completed')
424
+ ? 'Implementation is complete.'
425
+ : 'No dependency-ready task is available; inspect persisted task blockers.';
221
426
  }
222
- if (testReviewPending) {
223
- return ['Review TDD test cases, then run sdd-review-test-cases before approving tasks.'];
427
+ if (spec.reviewRequired && !spec.reviewCompleted)
428
+ return 'Review test cases before approving tasks.';
429
+ return 'Start governed implementation.';
430
+ }
431
+ taskCandidateSummary(candidates, includeStatus) {
432
+ const visible = candidates.slice(0, 20).map(([id, task]) => includeStatus ? `${id} (${task.status})` : id);
433
+ const omitted = candidates.length - visible.length;
434
+ return `${visible.join(', ')}${omitted > 0 ? `, and ${omitted} more` : ''}`;
435
+ }
436
+ implementationProgress(spec) {
437
+ if (!spec.implementation)
438
+ return '';
439
+ const tasks = Object.entries(spec.implementation.tasks);
440
+ const completed = tasks.filter(([, task]) => task.status === 'completed').length;
441
+ const active = tasks.filter(([, task]) => ['in-progress', 'red-observed', 'green-observed'].includes(task.status));
442
+ const blocked = tasks.filter(([, task]) => task.status === 'blocked');
443
+ const details = [...active, ...blocked].slice(0, 20)
444
+ .map(([taskNumber, task]) => `- ${taskNumber}: ${task.status}${this.blockerSummary(task.blocker)}`)
445
+ .join('\n');
446
+ return `- Revision: ${spec.implementation.revision}\n- Completed: ${completed}/${tasks.length}\n- Active: ${active.length}\n- Blocked: ${blocked.length}${details ? `\n${details}` : ''}`;
447
+ }
448
+ blockerSummary(blocker) {
449
+ if (!blocker)
450
+ return '';
451
+ const normalized = blocker.replace(/\s+/g, ' ').trim();
452
+ const summary = normalized.length > 160 ? `${normalized.slice(0, 159)}…` : normalized;
453
+ return ` — ${summary}`;
454
+ }
455
+ buildDirectPayload(envelope, selected, maximumCharacters) {
456
+ if (selected.documents.length !== 1 || selected.documents[0].content.length > 600) {
457
+ return undefined;
224
458
  }
225
- return ['Begin implementation with compact context loaded; open full docs only for ambiguous details.'];
459
+ const direct = `${envelope}\n\n## Approved Source\n${selected.documents[0].content.trim()}`;
460
+ return direct.length <= maximumCharacters ? this.embedEstimate(direct) : undefined;
226
461
  }
227
- formatApproval(status) {
228
- if (status.generated && status.approved) {
229
- return 'generated, approved';
462
+ collectCandidates(documents, mode) {
463
+ const seen = new Set();
464
+ const candidates = [];
465
+ const perLineCap = mode === 'compact' ? 320 : 640;
466
+ for (const document of documents) {
467
+ this.collectDocumentCandidates(document, perLineCap, seen, candidates);
230
468
  }
231
- if (status.generated) {
232
- return 'generated, pending approval';
469
+ return candidates;
470
+ }
471
+ collectDocumentCandidates(document, perLineCap, seen, candidates) {
472
+ for (const rawLine of document.content.split(/\r?\n/)) {
473
+ const normalized = rawLine.trim().replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, '');
474
+ if (!normalized || normalized.startsWith('```'))
475
+ continue;
476
+ const capped = normalized.slice(0, perLineCap);
477
+ const key = capped.toLocaleLowerCase();
478
+ if (seen.has(key))
479
+ continue;
480
+ seen.add(key);
481
+ candidates.push({ source: document.relativePath, text: capped });
482
+ }
483
+ }
484
+ selectCandidates(envelope, candidates, documents, maximumCharacters) {
485
+ let content = envelope;
486
+ const usedSources = new Set();
487
+ for (const candidate of candidates) {
488
+ const line = `\n- ${candidate.text}`;
489
+ if (content.length + '\n\n## Selected Context'.length + line.length > maximumCharacters) {
490
+ continue;
491
+ }
492
+ if (!content.includes('\n\n## Selected Context'))
493
+ content += '\n\n## Selected Context';
494
+ content += line;
495
+ usedSources.add(candidate.source);
233
496
  }
234
- return 'not generated';
497
+ return {
498
+ content: this.embedEstimate(content),
499
+ omittedSources: documents
500
+ .filter(document => !usedSources.has(document.relativePath))
501
+ .map(document => document.relativePath),
502
+ };
503
+ }
504
+ buildFull(spec, selected, budget) {
505
+ let content = [
506
+ `# Full SDD Context: ${spec.featureName}`,
507
+ `Effective phase: ${selected.phase} (${selected.status})`,
508
+ `Next action: ${this.nextAction(spec, selected)}`,
509
+ ...selected.documents.map((document) => `## ${document.name}\n\n${document.content}`),
510
+ '## Payload Estimate\n- Payload estimated tokens: 00000',
511
+ ].join('\n\n');
512
+ content = this.embedEstimate(content);
513
+ const required = Math.ceil(content.length / 4);
514
+ if (required > budget)
515
+ throw new ContextBudgetExceededError(budget, required);
516
+ return { content, omittedSources: [] };
517
+ }
518
+ embedEstimate(content) {
519
+ return content.replace('Payload estimated tokens: 00000', `Payload estimated tokens: ${String(Math.ceil(content.length / 4)).padStart(5, '0')}`);
235
520
  }
236
- estimateTokens(characters) {
237
- return Math.max(1, Math.ceil(characters / 4));
521
+ resultFor(content, mode, selected, sourceFingerprint, fingerprint, sourceCharacters, omittedSources, cacheStatus) {
522
+ const payloadEstimatedTokens = Math.ceil(content.length / 4);
523
+ const sourceEstimatedTokens = Math.ceil(sourceCharacters / 4);
524
+ return { content, mode, effectivePhase: selected.phase, phaseStatus: selected.status, sourceFingerprint, fingerprint, cacheStatus, sourceCharacters, sourceEstimatedTokens, payloadCharacters: content.length, payloadEstimatedTokens, reductionPercentage: sourceEstimatedTokens === 0 || payloadEstimatedTokens >= sourceEstimatedTokens ? 0 : Math.round((1 - payloadEstimatedTokens / sourceEstimatedTokens) * 100), omittedSources };
238
525
  }
239
- getSpecDir(project) {
240
- return `${project.path}/.spec/specs/${project.name}`;
526
+ approvalLabel(state) {
527
+ return state.approved ? 'approved' : state.generated ? 'generated, unapproved' : 'not generated';
241
528
  }
242
- getContextDir(project) {
243
- return `${this.getSpecDir(project)}/context`;
529
+ hash(content) {
530
+ return createHash('sha256').update(content).digest('hex');
244
531
  }
245
532
  };
246
533
  ContextCompactionService = __decorate([