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.
- package/README.md +97 -671
- package/agents/architect.md +15 -93
- package/agents/implementer.md +16 -141
- package/agents/planner.md +16 -84
- package/agents/reviewer.md +16 -239
- package/agents/security-auditor.md +16 -114
- package/agents/tdd-guide.md +17 -228
- package/dist/adapters/cli/SDDToolAdapter.d.ts +14 -5
- package/dist/adapters/cli/SDDToolAdapter.js +189 -362
- package/dist/adapters/cli/SDDToolAdapter.js.map +1 -1
- package/dist/application/services/ContextCompactionService.d.ts +81 -16
- package/dist/application/services/ContextCompactionService.js +370 -187
- package/dist/application/services/ContextCompactionService.js.map +1 -1
- package/dist/application/services/SpecPathResolver.d.ts +24 -0
- package/dist/application/services/SpecPathResolver.js +70 -0
- package/dist/application/services/SpecPathResolver.js.map +1 -0
- package/dist/application/services/WorkflowEngineService.d.ts +100 -46
- package/dist/application/services/WorkflowEngineService.js +468 -288
- package/dist/application/services/WorkflowEngineService.js.map +1 -1
- package/dist/cli/install-skills.d.ts +3 -9
- package/dist/cli/install-skills.js +130 -175
- package/dist/cli/install-skills.js.map +1 -1
- package/dist/cli/install-target.d.ts +45 -14
- package/dist/cli/install-target.js +26 -12
- package/dist/cli/install-target.js.map +1 -1
- package/dist/cli/sdd-mcp-cli.d.ts +1 -1
- package/dist/cli/sdd-mcp-cli.js +7 -6
- package/dist/cli/sdd-mcp-cli.js.map +1 -1
- package/dist/cli/tool-support/claude-code.js +13 -34
- package/dist/cli/tool-support/claude-code.js.map +1 -1
- package/dist/cli/tool-support/codex.d.ts +0 -53
- package/dist/cli/tool-support/codex.js +6 -94
- package/dist/cli/tool-support/codex.js.map +1 -1
- package/dist/cli/tool-support/index.d.ts +3 -2
- package/dist/cli/tool-support/index.js +3 -1
- package/dist/cli/tool-support/index.js.map +1 -1
- package/dist/cli/tool-support/omp.d.ts +5 -0
- package/dist/cli/tool-support/omp.js +43 -0
- package/dist/cli/tool-support/omp.js.map +1 -0
- package/dist/cli/tool-support/root-guidance.d.ts +2 -9
- package/dist/cli/tool-support/root-guidance.js +44 -37
- package/dist/cli/tool-support/root-guidance.js.map +1 -1
- package/dist/cli/tool-support/target-agent-renderer.d.ts +1 -0
- package/dist/cli/tool-support/target-agent-renderer.js +37 -4
- package/dist/cli/tool-support/target-agent-renderer.js.map +1 -1
- package/dist/cli/tool-support/target-installer.d.ts +8 -2
- package/dist/cli/tool-support/target-installer.js +94 -26
- package/dist/cli/tool-support/target-installer.js.map +1 -1
- package/dist/cli/utils/preserving-writer.d.ts +22 -0
- package/dist/cli/utils/preserving-writer.js +233 -11
- package/dist/cli/utils/preserving-writer.js.map +1 -1
- package/dist/domain/ports.d.ts +4 -0
- package/dist/index.d.ts +13 -10
- package/dist/index.js +16 -1199
- package/dist/index.js.map +1 -1
- package/dist/infrastructure/adapters/NodeFileSystemAdapter.d.ts +3 -0
- package/dist/infrastructure/adapters/NodeFileSystemAdapter.js +10 -0
- package/dist/infrastructure/adapters/NodeFileSystemAdapter.js.map +1 -1
- package/dist/infrastructure/mcp/CapabilityNegotiator.js +3 -3
- package/dist/infrastructure/mcp/CapabilityNegotiator.js.map +1 -1
- package/dist/infrastructure/mcp/sddToolDefinitions.d.ts +6 -0
- package/dist/infrastructure/mcp/sddToolDefinitions.js +124 -0
- package/dist/infrastructure/mcp/sddToolDefinitions.js.map +1 -0
- package/dist/utils/atomicWrite.d.ts +8 -35
- package/dist/utils/atomicWrite.js +12 -60
- package/dist/utils/atomicWrite.js.map +1 -1
- package/mcp-server.js +5 -2883
- package/package.json +5 -2
- package/scripts/context-usage-report.mjs +602 -0
- package/sdd-entry.js +17 -6
- package/skills/sdd-commit/REFERENCE.md +31 -0
- package/skills/sdd-commit/SKILL.md +17 -273
- package/skills/sdd-design/REFERENCE.md +35 -0
- package/skills/sdd-design/SKILL.md +19 -265
- package/skills/sdd-implement/REFERENCE.md +26 -0
- package/skills/sdd-implement/SKILL.md +22 -283
- package/skills/sdd-requirements/REFERENCE.md +31 -0
- package/skills/sdd-requirements/SKILL.md +23 -135
- package/skills/sdd-review/REFERENCE.md +26 -0
- package/skills/sdd-review/SKILL.md +17 -181
- package/skills/sdd-security-check/REFERENCE.md +19 -0
- package/skills/sdd-security-check/SKILL.md +18 -184
- package/skills/sdd-steering/REFERENCE.md +25 -0
- package/skills/sdd-steering/SKILL.md +18 -216
- package/skills/sdd-steering-custom/REFERENCE.md +27 -0
- package/skills/sdd-steering-custom/SKILL.md +19 -203
- package/skills/sdd-tasks/REFERENCE.md +25 -0
- package/skills/sdd-tasks/SKILL.md +19 -248
- package/skills/sdd-test-gen/REFERENCE.md +15 -0
- package/skills/sdd-test-gen/SKILL.md +17 -287
- package/skills/simple-task/REFERENCE.md +22 -0
- package/skills/simple-task/SKILL.md +17 -138
- package/templates/CLAUDE.md +18 -30
- package/rules/git-workflow.md +0 -92
- 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
|
|
25
|
-
const
|
|
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:
|
|
80
|
+
path: path.join(project.path, '.spec', 'specs', project.name, 'context', 'handoff.md'),
|
|
50
81
|
content,
|
|
51
|
-
|
|
82
|
+
fingerprint: result.fingerprint,
|
|
83
|
+
estimate: this.estimateContextSize(result.sourceCharacters, result.payloadCharacters),
|
|
52
84
|
};
|
|
53
85
|
}
|
|
54
|
-
async
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 =
|
|
76
|
-
const compactTokens =
|
|
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
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
''
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
304
|
+
return {
|
|
305
|
+
name,
|
|
306
|
+
relativePath: name,
|
|
307
|
+
absolutePath,
|
|
308
|
+
content: await this.fileSystem.readFile(absolutePath),
|
|
309
|
+
};
|
|
174
310
|
}
|
|
175
311
|
catch {
|
|
176
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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 (
|
|
220
|
-
return
|
|
321
|
+
if (!selected.documents.length) {
|
|
322
|
+
return { content: this.embedEstimate(envelope), omittedSources: [] };
|
|
221
323
|
}
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
367
|
+
return candidates;
|
|
226
368
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
|
|
240
|
-
return
|
|
422
|
+
approvalLabel(state) {
|
|
423
|
+
return state.approved ? 'approved' : state.generated ? 'generated, unapproved' : 'not generated';
|
|
241
424
|
}
|
|
242
|
-
|
|
243
|
-
return
|
|
425
|
+
hash(content) {
|
|
426
|
+
return createHash('sha256').update(content).digest('hex');
|
|
244
427
|
}
|
|
245
428
|
};
|
|
246
429
|
ContextCompactionService = __decorate([
|