sdd-mcp-server 3.5.0 → 4.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 (95) hide show
  1. package/README.md +97 -671
  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 -5
  9. package/dist/adapters/cli/SDDToolAdapter.js +189 -362
  10. package/dist/adapters/cli/SDDToolAdapter.js.map +1 -1
  11. package/dist/application/services/ContextCompactionService.d.ts +81 -16
  12. package/dist/application/services/ContextCompactionService.js +370 -187
  13. package/dist/application/services/ContextCompactionService.js.map +1 -1
  14. package/dist/application/services/SpecPathResolver.d.ts +24 -0
  15. package/dist/application/services/SpecPathResolver.js +70 -0
  16. package/dist/application/services/SpecPathResolver.js.map +1 -0
  17. package/dist/application/services/WorkflowEngineService.d.ts +100 -46
  18. package/dist/application/services/WorkflowEngineService.js +468 -288
  19. package/dist/application/services/WorkflowEngineService.js.map +1 -1
  20. package/dist/cli/install-skills.d.ts +3 -9
  21. package/dist/cli/install-skills.js +130 -175
  22. package/dist/cli/install-skills.js.map +1 -1
  23. package/dist/cli/install-target.d.ts +45 -14
  24. package/dist/cli/install-target.js +26 -12
  25. package/dist/cli/install-target.js.map +1 -1
  26. package/dist/cli/sdd-mcp-cli.d.ts +1 -1
  27. package/dist/cli/sdd-mcp-cli.js +7 -6
  28. package/dist/cli/sdd-mcp-cli.js.map +1 -1
  29. package/dist/cli/tool-support/claude-code.js +13 -34
  30. package/dist/cli/tool-support/claude-code.js.map +1 -1
  31. package/dist/cli/tool-support/codex.d.ts +0 -53
  32. package/dist/cli/tool-support/codex.js +6 -94
  33. package/dist/cli/tool-support/codex.js.map +1 -1
  34. package/dist/cli/tool-support/index.d.ts +3 -2
  35. package/dist/cli/tool-support/index.js +3 -1
  36. package/dist/cli/tool-support/index.js.map +1 -1
  37. package/dist/cli/tool-support/omp.d.ts +5 -0
  38. package/dist/cli/tool-support/omp.js +43 -0
  39. package/dist/cli/tool-support/omp.js.map +1 -0
  40. package/dist/cli/tool-support/root-guidance.d.ts +2 -9
  41. package/dist/cli/tool-support/root-guidance.js +44 -37
  42. package/dist/cli/tool-support/root-guidance.js.map +1 -1
  43. package/dist/cli/tool-support/target-agent-renderer.d.ts +1 -0
  44. package/dist/cli/tool-support/target-agent-renderer.js +37 -4
  45. package/dist/cli/tool-support/target-agent-renderer.js.map +1 -1
  46. package/dist/cli/tool-support/target-installer.d.ts +8 -2
  47. package/dist/cli/tool-support/target-installer.js +94 -26
  48. package/dist/cli/tool-support/target-installer.js.map +1 -1
  49. package/dist/cli/utils/preserving-writer.d.ts +22 -0
  50. package/dist/cli/utils/preserving-writer.js +233 -11
  51. package/dist/cli/utils/preserving-writer.js.map +1 -1
  52. package/dist/domain/ports.d.ts +4 -0
  53. package/dist/index.d.ts +13 -10
  54. package/dist/index.js +16 -1199
  55. package/dist/index.js.map +1 -1
  56. package/dist/infrastructure/adapters/NodeFileSystemAdapter.d.ts +3 -0
  57. package/dist/infrastructure/adapters/NodeFileSystemAdapter.js +10 -0
  58. package/dist/infrastructure/adapters/NodeFileSystemAdapter.js.map +1 -1
  59. package/dist/infrastructure/mcp/CapabilityNegotiator.js +3 -3
  60. package/dist/infrastructure/mcp/CapabilityNegotiator.js.map +1 -1
  61. package/dist/infrastructure/mcp/sddToolDefinitions.d.ts +6 -0
  62. package/dist/infrastructure/mcp/sddToolDefinitions.js +124 -0
  63. package/dist/infrastructure/mcp/sddToolDefinitions.js.map +1 -0
  64. package/dist/utils/atomicWrite.d.ts +8 -35
  65. package/dist/utils/atomicWrite.js +12 -60
  66. package/dist/utils/atomicWrite.js.map +1 -1
  67. package/mcp-server.js +5 -2883
  68. package/package.json +5 -2
  69. package/scripts/context-usage-report.mjs +602 -0
  70. package/sdd-entry.js +17 -6
  71. package/skills/sdd-commit/REFERENCE.md +31 -0
  72. package/skills/sdd-commit/SKILL.md +17 -273
  73. package/skills/sdd-design/REFERENCE.md +35 -0
  74. package/skills/sdd-design/SKILL.md +19 -265
  75. package/skills/sdd-implement/REFERENCE.md +26 -0
  76. package/skills/sdd-implement/SKILL.md +22 -283
  77. package/skills/sdd-requirements/REFERENCE.md +31 -0
  78. package/skills/sdd-requirements/SKILL.md +23 -135
  79. package/skills/sdd-review/REFERENCE.md +26 -0
  80. package/skills/sdd-review/SKILL.md +17 -181
  81. package/skills/sdd-security-check/REFERENCE.md +19 -0
  82. package/skills/sdd-security-check/SKILL.md +18 -184
  83. package/skills/sdd-steering/REFERENCE.md +25 -0
  84. package/skills/sdd-steering/SKILL.md +18 -216
  85. package/skills/sdd-steering-custom/REFERENCE.md +27 -0
  86. package/skills/sdd-steering-custom/SKILL.md +19 -203
  87. package/skills/sdd-tasks/REFERENCE.md +25 -0
  88. package/skills/sdd-tasks/SKILL.md +19 -248
  89. package/skills/sdd-test-gen/REFERENCE.md +15 -0
  90. package/skills/sdd-test-gen/SKILL.md +17 -287
  91. package/skills/simple-task/REFERENCE.md +22 -0
  92. package/skills/simple-task/SKILL.md +17 -138
  93. package/templates/CLAUDE.md +18 -30
  94. package/rules/git-workflow.md +0 -92
  95. package/rules/sdd-workflow.md +0 -116
@@ -10,9 +10,55 @@ 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 ContextSourceError extends Error {
50
+ sourcePath;
51
+ code = 'ContextSourceError';
52
+ constructor(sourcePath, message) {
53
+ super(`${message}: ${sourcePath}`);
54
+ this.sourcePath = sourcePath;
55
+ this.name = 'ContextSourceError';
56
+ }
57
+ }
58
+ const MODE_BUDGETS = { compact: 2048, standard: 4096, full: 16384 };
59
+ const PHASES = ['requirements', 'design', 'tasks'];
60
+ const HANDOFF_SCHEMA = 2;
61
+ const SELECTION_VERSION = 2;
16
62
  let ContextCompactionService = class ContextCompactionService {
17
63
  fileSystem;
18
64
  logger;
@@ -20,227 +66,364 @@ let ContextCompactionService = class ContextCompactionService {
20
66
  this.fileSystem = fileSystem;
21
67
  this.logger = logger;
22
68
  }
69
+ async loadContext(requestOrProject, legacyMode = 'compact') {
70
+ if (!('projectRoot' in requestOrProject)) {
71
+ const result = await this.loadContextRequest({ projectRoot: requestOrProject.path, featureName: requestOrProject.name, mode: legacyMode });
72
+ return result.content ?? '';
73
+ }
74
+ return this.loadContextRequest(requestOrProject);
75
+ }
23
76
  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
- });
77
+ const result = await this.loadContextRequest({ projectRoot: project.path, featureName: project.name, phase: approvedPhase, mode: 'compact' });
78
+ const content = result.content ?? '';
48
79
  return {
49
- path: handoffPath,
80
+ path: path.join(project.path, '.spec', 'specs', project.name, 'context', 'handoff.md'),
50
81
  content,
51
- estimate
82
+ fingerprint: result.fingerprint,
83
+ estimate: this.estimateContextSize(result.sourceCharacters, result.payloadCharacters),
52
84
  };
53
85
  }
54
- async loadContext(project, mode = 'compact') {
55
- if (mode === 'full') {
56
- return this.loadFullContext(project);
57
- }
58
- const handoffPath = `${this.getContextDir(project)}/handoff.md`;
86
+ async invalidateCanonicalHandoff(request) {
87
+ const resolved = await new SpecPathResolver(this.fileSystem).resolve(request.projectRoot, request.featureName);
88
+ const handoffPath = path.join(resolved.featureRoot, 'context', 'handoff.md');
89
+ await new SpecPathResolver(this.fileSystem).assertContained(resolved.featureRoot, handoffPath);
59
90
  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;
91
+ if (!this.fileSystem.unlink)
92
+ throw new Error('FileSystemPort.unlink is required to invalidate context');
93
+ await this.fileSystem.unlink(handoffPath);
69
94
  }
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
95
  }
74
96
  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));
97
+ const sourceTokens = Math.ceil(sourceCharacters / 4);
98
+ const compactTokens = Math.ceil(compactCharacters / 4);
80
99
  return {
81
100
  sourceCharacters,
82
101
  sourceTokens,
83
102
  compactCharacters,
84
103
  compactTokens,
85
- reductionPercentage
104
+ reductionPercentage: sourceTokens === 0 || compactTokens >= sourceTokens ? 0 : Math.round((1 - compactTokens / sourceTokens) * 100),
86
105
  };
87
106
  }
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'];
107
+ async loadContextRequest(request) {
108
+ const { mode, budget } = this.resolveLoadOptions(request);
109
+ const resolver = new SpecPathResolver(this.fileSystem);
110
+ const resolved = await resolver.resolve(request.projectRoot, request.featureName);
111
+ const specPath = path.join(resolved.featureRoot, 'spec.json');
112
+ await resolver.assertContained(resolved.featureRoot, specPath);
113
+ const spec = await this.readSpec(specPath, request.featureName);
114
+ const selected = await this.selectState(resolved.featureRoot, spec, request, resolver);
115
+ const sourceCharacters = selected.documents.reduce((total, document) => total + document.content.length, 0);
116
+ const sourceFingerprint = this.hash(JSON.stringify({
117
+ schema: HANDOFF_SCHEMA,
118
+ phase: selected.phase,
119
+ status: selected.status,
120
+ approvals: spec.approvals,
121
+ reviewRequired: spec.reviewRequired,
122
+ reviewCompleted: spec.reviewCompleted,
123
+ sources: selected.documents.map(({ relativePath, content }) => [relativePath, content]),
124
+ }));
125
+ const fingerprint = this.hash(JSON.stringify({
126
+ sourceFingerprint,
127
+ mode,
128
+ budget,
129
+ includeUnapproved: request.includeUnapproved === true,
130
+ selection: SELECTION_VERSION,
131
+ }));
132
+ const canonical = mode === 'compact'
133
+ && request.maxEstimatedTokens === undefined
134
+ && request.includeUnapproved !== true;
135
+ const handoffPath = path.join(resolved.featureRoot, 'context', 'handoff.md');
136
+ const cached = await this.readCanonicalCache(canonical, handoffPath, resolved.featureRoot, resolver, selected, sourceFingerprint, fingerprint);
137
+ if (cached !== undefined) {
138
+ const result = this.resultFor(cached, mode, selected, sourceFingerprint, fingerprint, sourceCharacters, [], 'hit');
139
+ return this.applyEtag(result, request.ifNoneMatch);
140
+ }
141
+ const built = mode === 'full'
142
+ ? this.buildFull(spec, selected, budget)
143
+ : this.buildBounded(spec, selected, budget, mode, sourceFingerprint, fingerprint, canonical);
144
+ await this.persistCanonical(canonical, handoffPath, built.content);
145
+ const result = this.resultFor(built.content, mode, selected, sourceFingerprint, fingerprint, sourceCharacters, built.omittedSources, 'regenerated');
146
+ this.logger.debug('Context payload prepared', {
147
+ featureName: request.featureName,
148
+ mode,
149
+ phase: selected.phase,
150
+ payloadEstimatedTokens: result.payloadEstimatedTokens,
151
+ });
152
+ return this.applyEtag(result, request.ifNoneMatch);
153
+ }
154
+ resolveLoadOptions(request) {
155
+ const mode = request.mode ?? 'compact';
156
+ const budget = request.maxEstimatedTokens ?? MODE_BUDGETS[mode];
157
+ if (!Number.isInteger(budget) || budget <= 0)
158
+ throw new ContextBudgetTooSmallError(budget, 1);
159
+ if (request.includeUnapproved && mode !== 'full') {
160
+ throw new Error('includeUnapproved is supported only in full mode');
161
+ }
162
+ return { mode, budget };
163
+ }
164
+ async readCanonicalCache(canonical, handoffPath, featureRoot, resolver, selected, sourceFingerprint, fingerprint) {
165
+ if (!canonical || !(await this.fileSystem.exists(handoffPath)))
166
+ return undefined;
167
+ await resolver.assertContained(featureRoot, handoffPath);
168
+ const cached = await this.fileSystem.readFile(handoffPath);
169
+ return this.cacheMatches(cached, selected.phase, sourceFingerprint, fingerprint)
170
+ ? cached
171
+ : undefined;
172
+ }
173
+ async persistCanonical(canonical, handoffPath, content) {
174
+ if (!canonical)
175
+ return;
176
+ await this.fileSystem.mkdir(path.dirname(handoffPath));
177
+ if (!this.fileSystem.writeFileAtomic) {
178
+ throw new Error('FileSystemPort.writeFileAtomic is required for context persistence');
179
+ }
180
+ await this.fileSystem.writeFileAtomic(handoffPath, content);
181
+ }
182
+ applyEtag(result, ifNoneMatch) {
183
+ if (ifNoneMatch !== result.fingerprint)
184
+ return result;
185
+ return {
186
+ ...result,
187
+ content: undefined,
188
+ cacheStatus: 'not-modified',
189
+ payloadCharacters: 0,
190
+ payloadEstimatedTokens: 0,
191
+ };
192
+ }
193
+ async readSpec(specPath, expectedFeature) {
194
+ if (!(await this.fileSystem.exists(specPath))) {
195
+ throw new ContextSourceError(specPath, 'Missing required workflow metadata');
196
+ }
197
+ const record = await this.readJsonRecord(specPath);
198
+ const approvalsRecord = this.requiredRecord(record.approvals, specPath, 'Missing approvals');
199
+ const options = this.optionalRecord(record.workflow_options ?? record.workflowOptions);
200
+ const checkpoints = this.optionalRecord(record.checkpoints);
201
+ const test = this.optionalRecord(checkpoints.test_cases ?? checkpoints.testCases);
202
+ return {
203
+ featureName: this.specFeatureName(record, expectedFeature, specPath),
204
+ approvals: this.parseApprovals(approvalsRecord),
205
+ reviewRequired: test.required === true
206
+ || options.review_test_cases === true
207
+ || options.reviewTestCases === true,
208
+ reviewCompleted: test.reviewed === true,
209
+ };
210
+ }
211
+ async readJsonRecord(specPath) {
212
+ let value;
213
+ try {
214
+ value = JSON.parse(await this.fileSystem.readFile(specPath));
215
+ }
216
+ catch {
217
+ throw new ContextSourceError(specPath, 'Malformed workflow metadata');
218
+ }
219
+ if (!value || typeof value !== 'object') {
220
+ throw new ContextSourceError(specPath, 'Invalid workflow metadata');
221
+ }
222
+ return value;
223
+ }
224
+ requiredRecord(value, specPath, message) {
225
+ if (!value || typeof value !== 'object')
226
+ throw new ContextSourceError(specPath, message);
227
+ return value;
228
+ }
229
+ optionalRecord(value) {
230
+ return value && typeof value === 'object' ? value : {};
231
+ }
232
+ parseApprovals(record) {
233
+ return Object.fromEntries(PHASES.map(phase => {
234
+ const state = this.optionalRecord(record[phase]);
235
+ return [phase, { generated: state.generated === true, approved: state.approved === true }];
236
+ }));
237
+ }
238
+ specFeatureName(record, expectedFeature, specPath) {
239
+ const persisted = typeof record.feature_name === 'string'
240
+ ? record.feature_name
241
+ : typeof record.name === 'string'
242
+ ? record.name
243
+ : undefined;
244
+ if (persisted !== undefined && persisted !== expectedFeature) {
245
+ throw new ContextSourceError(specPath, 'Feature metadata name does not match directory');
246
+ }
247
+ return expectedFeature;
248
+ }
249
+ async selectState(featureRoot, spec, request, resolver) {
250
+ const selection = this.resolveEffectivePhase(spec, request);
251
+ const documents = await this.readSelectedDocuments(featureRoot, spec, selection.phase, resolver);
252
+ return { ...selection, documents };
253
+ }
254
+ resolveEffectivePhase(spec, request) {
255
+ if (request.phase)
256
+ return this.resolveExplicitPhase(spec, request);
257
+ if (request.mode === 'full' && request.includeUnapproved) {
258
+ const latestGenerated = [...PHASES].reverse()
259
+ .find(candidate => spec.approvals[candidate].generated);
260
+ if (!latestGenerated)
261
+ return { phase: 'init', status: 'init' };
262
+ return {
263
+ phase: latestGenerated,
264
+ status: spec.approvals[latestGenerated].approved ? 'approved' : 'unapproved',
265
+ };
266
+ }
267
+ const latestApproved = [...PHASES].reverse()
268
+ .find(candidate => spec.approvals[candidate].approved);
269
+ return latestApproved
270
+ ? { phase: latestApproved, status: 'approved' }
271
+ : { phase: 'init', status: 'init' };
272
+ }
273
+ resolveExplicitPhase(spec, request) {
274
+ const phase = request.phase;
275
+ if (!phase)
276
+ return { phase: 'init', status: 'init' };
277
+ const state = spec.approvals[phase];
278
+ const draftAllowed = request.mode === 'full' && request.includeUnapproved && state.generated;
279
+ if (!state.approved && !draftAllowed)
280
+ throw new PhaseNotApprovedError(phase);
281
+ return { phase, status: state.approved ? 'approved' : 'unapproved' };
282
+ }
283
+ async readSelectedDocuments(featureRoot, spec, phase, resolver) {
284
+ if (phase === 'init')
285
+ return [];
158
286
  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 });
287
+ const last = PHASES.indexOf(phase);
288
+ for (const selectedPhase of PHASES.slice(0, last + 1)) {
289
+ if (!spec.approvals[selectedPhase].generated) {
290
+ throw new ContextSourceError(`${selectedPhase}.md`, 'Selected phase metadata is inconsistent');
164
291
  }
292
+ documents.push(await this.readPhaseDocument(featureRoot, selectedPhase, resolver));
165
293
  }
166
294
  return documents;
167
295
  }
168
- async readOptionalFile(path) {
296
+ async readPhaseDocument(featureRoot, selectedPhase, resolver) {
297
+ const name = `${selectedPhase}.md`;
298
+ const absolutePath = path.join(featureRoot, name);
299
+ await resolver.assertContained(featureRoot, absolutePath);
300
+ if (!(await this.fileSystem.exists(absolutePath))) {
301
+ throw new ContextSourceError(absolutePath, 'Missing required selected-phase document');
302
+ }
169
303
  try {
170
- if (!(await this.fileSystem.exists(path))) {
171
- return null;
172
- }
173
- return await this.fileSystem.readFile(path);
304
+ return {
305
+ name,
306
+ relativePath: name,
307
+ absolutePath,
308
+ content: await this.fileSystem.readFile(absolutePath),
309
+ };
174
310
  }
175
311
  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
- }
312
+ throw new ContextSourceError(absolutePath, 'Unable to read selected-phase document');
212
313
  }
213
- return result;
214
314
  }
215
- getNextSteps(approvedPhase, testReviewPending) {
216
- if (approvedPhase === 'requirements') {
217
- return ['Generate or review design using the approved requirements handoff.'];
315
+ buildBounded(spec, selected, budget, mode, sourceFingerprint, fingerprint, canonical) {
316
+ const envelope = this.buildEnvelope(spec, selected, sourceFingerprint, fingerprint, canonical);
317
+ const maximumCharacters = budget * 4;
318
+ if (envelope.length > maximumCharacters) {
319
+ throw new ContextBudgetTooSmallError(budget, Math.ceil(envelope.length / 4));
218
320
  }
219
- if (approvedPhase === 'design') {
220
- return ['Generate TDD task breakdown from the approved design handoff.'];
321
+ if (!selected.documents.length) {
322
+ return { content: this.embedEstimate(envelope), omittedSources: [] };
221
323
  }
222
- if (testReviewPending) {
223
- return ['Review TDD test cases, then run sdd-review-test-cases before approving tasks.'];
324
+ const direct = this.buildDirectPayload(envelope, selected, maximumCharacters);
325
+ if (direct)
326
+ return { content: direct, omittedSources: [] };
327
+ return this.selectCandidates(envelope, this.collectCandidates(selected.documents, mode), selected.documents, maximumCharacters);
328
+ }
329
+ buildEnvelope(spec, selected, sourceFingerprint, fingerprint, canonical) {
330
+ const metadata = canonical
331
+ ? `<!-- sdd-context schema=${HANDOFF_SCHEMA} phase=${selected.phase} source=${sourceFingerprint} payload=${fingerprint} -->\n`
332
+ : '';
333
+ const sourceReferences = selected.documents.length
334
+ ? selected.documents.map(document => `- ${document.relativePath}`).join('\n')
335
+ : '- spec.json';
336
+ const review = spec.reviewRequired
337
+ ? spec.reviewCompleted ? 'reviewed' : 'pending'
338
+ : 'not required';
339
+ 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}\n\n## Next Action\n${this.nextAction(spec, selected.phase)}\n\n## Source References\n${sourceReferences}\n\n## Payload Estimate\n- Payload estimated tokens: 00000`;
340
+ }
341
+ nextAction(spec, phase) {
342
+ if (phase === 'init')
343
+ return 'Generate requirements.';
344
+ if (phase === 'requirements')
345
+ return 'Generate design from approved requirements.';
346
+ if (phase === 'design')
347
+ return 'Generate the TDD task breakdown.';
348
+ if (spec.reviewRequired && !spec.reviewCompleted) {
349
+ return 'Review test cases before approving tasks.';
350
+ }
351
+ return 'Proceed with focused implementation.';
352
+ }
353
+ buildDirectPayload(envelope, selected, maximumCharacters) {
354
+ if (selected.documents.length !== 1 || selected.documents[0].content.length > 600) {
355
+ return undefined;
356
+ }
357
+ const direct = `${envelope}\n\n## Approved Source\n${selected.documents[0].content.trim()}`;
358
+ return direct.length <= maximumCharacters ? this.embedEstimate(direct) : undefined;
359
+ }
360
+ collectCandidates(documents, mode) {
361
+ const seen = new Set();
362
+ const candidates = [];
363
+ const perLineCap = mode === 'compact' ? 320 : 640;
364
+ for (const document of documents) {
365
+ this.collectDocumentCandidates(document, perLineCap, seen, candidates);
224
366
  }
225
- return ['Begin implementation with compact context loaded; open full docs only for ambiguous details.'];
367
+ return candidates;
226
368
  }
227
- formatApproval(status) {
228
- if (status.generated && status.approved) {
229
- return 'generated, approved';
369
+ collectDocumentCandidates(document, perLineCap, seen, candidates) {
370
+ for (const rawLine of document.content.split(/\r?\n/)) {
371
+ const normalized = rawLine.trim().replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, '');
372
+ if (!normalized || normalized.startsWith('```'))
373
+ continue;
374
+ const capped = normalized.slice(0, perLineCap);
375
+ const key = capped.toLocaleLowerCase();
376
+ if (seen.has(key))
377
+ continue;
378
+ seen.add(key);
379
+ candidates.push({ source: document.relativePath, text: capped });
230
380
  }
231
- if (status.generated) {
232
- return 'generated, pending approval';
381
+ }
382
+ selectCandidates(envelope, candidates, documents, maximumCharacters) {
383
+ let content = envelope;
384
+ const usedSources = new Set();
385
+ for (const candidate of candidates) {
386
+ const line = `\n- ${candidate.text}`;
387
+ if (content.length + '\n\n## Selected Context'.length + line.length > maximumCharacters) {
388
+ continue;
389
+ }
390
+ if (!content.includes('\n\n## Selected Context'))
391
+ content += '\n\n## Selected Context';
392
+ content += line;
393
+ usedSources.add(candidate.source);
233
394
  }
234
- return 'not generated';
395
+ return {
396
+ content: this.embedEstimate(content),
397
+ omittedSources: documents
398
+ .filter(document => !usedSources.has(document.relativePath))
399
+ .map(document => document.relativePath),
400
+ };
401
+ }
402
+ buildFull(spec, selected, budget) {
403
+ let content = [`# Full SDD Context: ${spec.featureName}`, `Effective phase: ${selected.phase} (${selected.status})`, ...selected.documents.map((document) => `## ${document.name}\n\n${document.content}`), '## Payload Estimate\n- Payload estimated tokens: 00000'].join('\n\n');
404
+ content = this.embedEstimate(content);
405
+ const required = Math.ceil(content.length / 4);
406
+ if (required > budget)
407
+ throw new ContextBudgetExceededError(budget, required);
408
+ return { content, omittedSources: [] };
409
+ }
410
+ embedEstimate(content) {
411
+ return content.replace('Payload estimated tokens: 00000', `Payload estimated tokens: ${String(Math.ceil(content.length / 4)).padStart(5, '0')}`);
412
+ }
413
+ resultFor(content, mode, selected, sourceFingerprint, fingerprint, sourceCharacters, omittedSources, cacheStatus) {
414
+ const payloadEstimatedTokens = Math.ceil(content.length / 4);
415
+ const sourceEstimatedTokens = Math.ceil(sourceCharacters / 4);
416
+ 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 };
235
417
  }
236
- estimateTokens(characters) {
237
- return Math.max(1, Math.ceil(characters / 4));
418
+ cacheMatches(content, phase, sourceFingerprint, fingerprint) {
419
+ const match = content.match(/^<!-- sdd-context schema=(\d+) phase=(\w+) source=([a-f0-9]{64}) payload=([a-f0-9]{64}) -->/);
420
+ return match?.[1] === String(HANDOFF_SCHEMA) && match[2] === phase && match[3] === sourceFingerprint && match[4] === fingerprint;
238
421
  }
239
- getSpecDir(project) {
240
- return `${project.path}/.spec/specs/${project.name}`;
422
+ approvalLabel(state) {
423
+ return state.approved ? 'approved' : state.generated ? 'generated, unapproved' : 'not generated';
241
424
  }
242
- getContextDir(project) {
243
- return `${this.getSpecDir(project)}/context`;
425
+ hash(content) {
426
+ return createHash('sha256').update(content).digest('hex');
244
427
  }
245
428
  };
246
429
  ContextCompactionService = __decorate([