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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sdd-mcp-server",
3
- "version": "3.5.0",
3
+ "version": "4.0.0",
4
4
  "description": "MCP server for spec-driven development workflows across AI-agent CLIs and IDEs",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -17,6 +17,7 @@
17
17
  "agents/**/*",
18
18
  "hooks/**/*",
19
19
  "templates/**/*",
20
+ "scripts/context-usage-report.mjs",
20
21
  "sdd-entry.js",
21
22
  "mcp-server.js",
22
23
  "atomicWrite.js",
@@ -30,6 +31,7 @@
30
31
  "build": "tsc",
31
32
  "dev": "tsx watch src/index.ts",
32
33
  "start": "node dist/index.js",
34
+ "context:report": "node scripts/context-usage-report.mjs",
33
35
  "test": "jest",
34
36
  "test:unit": "jest --testPathPattern=__tests__.*\\.test\\.ts$ --testPathIgnorePattern=integration|e2e",
35
37
  "test:integration": "jest --testPathPattern=integration.*\\.test\\.ts$",
@@ -75,7 +77,8 @@
75
77
  "inversify": "^6.0.2",
76
78
  "os-locale": "^6.0.2",
77
79
  "reflect-metadata": "^0.2.2",
78
- "uuid": "^10.0.0",
80
+ "uuid": "^11.1.1",
81
+ "write-file-atomic": "^5.0.1",
79
82
  "zod": "^3.23.8"
80
83
  },
81
84
  "devDependencies": {
@@ -0,0 +1,602 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { mkdir, mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
+ import { InstallSkillsCLI } from '../dist/cli/install-skills.js';
8
+
9
+ export const V351_OMP_BASELINE_LABEL = 'v3.5.1 OMP compatibility baseline (Codex AGENTS.md + .agents/skills metadata; .codex/agents excluded)';
10
+ export const V351_CLAUDE_BASELINE_LABEL = 'v3.5.1 Claude Code full baseline';
11
+
12
+ export const PROVIDER_ADAPTERS = Object.freeze({
13
+ anthropic: Object.freeze({ inputIncludesCacheRead: false, inputIncludesCacheWrite: false, outputIncludesReasoning: true }),
14
+ openai: Object.freeze({ inputIncludesCacheRead: true, inputIncludesCacheWrite: false, outputIncludesReasoning: true }),
15
+ 'openai-codex': Object.freeze({ inputIncludesCacheRead: true, inputIncludesCacheWrite: false, outputIncludesReasoning: true }),
16
+ });
17
+
18
+ function bytes(value) {
19
+ return Buffer.byteLength(value, 'utf8');
20
+ }
21
+
22
+ async function filesBelow(root) {
23
+ const found = [];
24
+ async function visit(directory) {
25
+ const entries = await readdir(directory, { withFileTypes: true });
26
+ entries.sort((left, right) => left.name.localeCompare(right.name));
27
+ for (const entry of entries) {
28
+ const absolute = path.join(directory, entry.name);
29
+ if (entry.isSymbolicLink()) continue;
30
+ if (entry.isDirectory()) await visit(absolute);
31
+ else if (entry.isFile()) found.push(absolute);
32
+ }
33
+ }
34
+ await visit(root);
35
+ return found;
36
+ }
37
+
38
+ function frontmatter(content) {
39
+ if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) return { metadata: '', body: content };
40
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
41
+ if (!match) return { metadata: '', body: content };
42
+ return { metadata: `${match[1]}\n`, body: content.slice(match[0].length) };
43
+ }
44
+
45
+ function emptyCategory() {
46
+ return { bytes: 0, files: 0 };
47
+ }
48
+
49
+ function categoryFor(relative) {
50
+ const normalized = relative.replaceAll(path.sep, '/');
51
+ if (/(?:^|\/)skills\/[^/]+\/SKILL\.md$/.test(normalized)) return 'coreSkillBodies';
52
+ if (/(?:^|\/)skills\/[^/]+\/(?:REFERENCE\.md|references\/)/.test(normalized)) return 'references';
53
+ if (/(?:^|\/)agents\//.test(normalized)) return 'agentBodies';
54
+ if (/(?:^|\/)rules\//.test(normalized)) return 'rules';
55
+ if (/(?:^|\/)contexts\//.test(normalized)) return 'contexts';
56
+ if (/(?:^|\/)steering\//.test(normalized)) return 'steering';
57
+ if (/(?:^|\/)hooks\//.test(normalized)) return 'hooks';
58
+ return 'other';
59
+ }
60
+
61
+ function hostRules(target, relative, content) {
62
+ const normalized = relative.replaceAll(path.sep, '/');
63
+ const parsed = frontmatter(content);
64
+ const metadataBytes = bytes(parsed.metadata);
65
+ if (target === 'claude-code' && normalized.startsWith('.claude/rules/')) {
66
+ const scoped = /^paths\s*:/m.test(parsed.metadata);
67
+ return scoped
68
+ ? { metadata: metadataBytes, unconditional: 0 }
69
+ : { metadata: 0, unconditional: bytes(content) };
70
+ }
71
+ if (target === 'omp' && normalized.startsWith('.omp/rules/')) {
72
+ const always = /^alwaysApply\s*:\s*true\s*$/m.test(parsed.metadata);
73
+ return always
74
+ ? { metadata: 0, unconditional: bytes(content) }
75
+ : { metadata: metadataBytes, unconditional: 0 };
76
+ }
77
+ return { metadata: 0, unconditional: 0 };
78
+ }
79
+
80
+ function isRootGuidance(target, relative) {
81
+ if (target === 'claude-code') return relative === 'CLAUDE.md';
82
+ if (target === 'omp') return relative === '.omp/AGENTS.md';
83
+ return relative === 'AGENTS.md';
84
+ }
85
+
86
+ function isSkill(target, relative) {
87
+ const normalized = relative.replaceAll(path.sep, '/');
88
+ if (target === 'claude-code') return /^\.claude\/skills\/[^/]+\/SKILL\.md$/.test(normalized);
89
+ if (target === 'omp') return /^\.omp\/skills\/[^/]+\/SKILL\.md$/.test(normalized);
90
+ return /^\.agents\/skills\/[^/]+\/SKILL\.md$/.test(normalized);
91
+ }
92
+
93
+ function skillIndexBytes(target, content) {
94
+ const metadata = frontmatter(content).metadata;
95
+ if ((target === 'omp' || target === 'claude-code') && /^disable-model-invocation\s*:\s*true\s*$/m.test(metadata)) {
96
+ return 0;
97
+ }
98
+ const visibleFields = metadata
99
+ .split(/\r?\n/)
100
+ .filter(line => /^(?:name|description)\s*:/.test(line))
101
+ .join('\n');
102
+ return visibleFields ? bytes(`${visibleFields}\n`) : 0;
103
+ }
104
+
105
+ export async function collectStaticTree(root, options) {
106
+ const absoluteRoot = path.resolve(root);
107
+ const target = options.target;
108
+ const categories = Object.fromEntries(['coreSkillBodies', 'references', 'agentBodies', 'rules', 'contexts', 'steering', 'hooks', 'other'].map(name => [name, emptyCategory()]));
109
+ const hostVisible = {
110
+ rootGuidanceBytes: 0,
111
+ skillIndexBytes: 0,
112
+ rulebookMetadataBytes: 0,
113
+ unconditionalRuleBytes: 0,
114
+ totalBytes: 0,
115
+ };
116
+ let installedBytes = 0;
117
+ let installedFiles = 0;
118
+
119
+ for (const absolute of await filesBelow(absoluteRoot)) {
120
+ const relative = path.relative(absoluteRoot, absolute).replaceAll(path.sep, '/');
121
+ const content = await readFile(absolute, 'utf8');
122
+ const fileBytes = bytes(content);
123
+ installedBytes += fileBytes;
124
+ installedFiles += 1;
125
+ const category = categories[categoryFor(relative)];
126
+ category.bytes += fileBytes;
127
+ category.files += 1;
128
+ if (isRootGuidance(target, relative)) hostVisible.rootGuidanceBytes += fileBytes;
129
+ if (isSkill(target, relative)) hostVisible.skillIndexBytes += skillIndexBytes(target, content);
130
+ const rule = hostRules(target, relative, content);
131
+ hostVisible.rulebookMetadataBytes += rule.metadata;
132
+ hostVisible.unconditionalRuleBytes += rule.unconditional;
133
+ }
134
+
135
+ hostVisible.totalBytes = hostVisible.rootGuidanceBytes
136
+ + hostVisible.skillIndexBytes
137
+ + hostVisible.rulebookMetadataBytes
138
+ + hostVisible.unconditionalRuleBytes;
139
+ const baseline = target === 'omp-v3.5.1-baseline';
140
+ return {
141
+ label: baseline ? V351_OMP_BASELINE_LABEL : `${target} ${options.profile}`,
142
+ target,
143
+ profile: options.profile,
144
+ installedBytes,
145
+ installedFiles,
146
+ hostVisible,
147
+ nonHostLoadedInstalledBytes: Math.max(0, installedBytes - hostVisible.totalBytes),
148
+ categories,
149
+ notes: baseline
150
+ ? ['.codex/agents is installed but excluded because OMP does not discover Codex agent TOML']
151
+ : [],
152
+ };
153
+ }
154
+
155
+ const V351_OMP_BASELINE = Object.freeze({
156
+ label: V351_OMP_BASELINE_LABEL,
157
+ target: 'omp-v3.5.1-baseline',
158
+ profile: 'full',
159
+ installedBytes: 140625,
160
+ installedFiles: 35,
161
+ hostVisible: Object.freeze({
162
+ rootGuidanceBytes: 5491,
163
+ skillIndexBytes: 2526,
164
+ rulebookMetadataBytes: 0,
165
+ unconditionalRuleBytes: 0,
166
+ totalBytes: 8017,
167
+ }),
168
+ nonHostLoadedInstalledBytes: 132608,
169
+ categories: Object.freeze({
170
+ coreSkillBodies: Object.freeze({ bytes: 75358, files: 11 }),
171
+ references: Object.freeze({ bytes: 0, files: 0 }),
172
+ agentBodies: Object.freeze({ bytes: 26332, files: 6 }),
173
+ rules: Object.freeze({ bytes: 16055, files: 6 }),
174
+ contexts: Object.freeze({ bytes: 9914, files: 5 }),
175
+ steering: Object.freeze({ bytes: 4522, files: 3 }),
176
+ hooks: Object.freeze({ bytes: 2336, files: 1 }),
177
+ other: Object.freeze({ bytes: 6108, files: 3 }),
178
+ }),
179
+ notes: Object.freeze([
180
+ 'Measured from the v3.5.1 release installer output; retained as an immutable release baseline.',
181
+ '.codex/agents is installed but excluded because OMP does not discover Codex agent TOML',
182
+ ]),
183
+ });
184
+
185
+ const V351_CLAUDE_BASELINE = Object.freeze({
186
+ label: V351_CLAUDE_BASELINE_LABEL,
187
+ target: 'claude-code-v3.5.1-baseline',
188
+ profile: 'full',
189
+ installedBytes: 148037,
190
+ installedFiles: 40,
191
+ hostVisible: Object.freeze({
192
+ rootGuidanceBytes: 6616,
193
+ skillIndexBytes: 2526,
194
+ rulebookMetadataBytes: 0,
195
+ unconditionalRuleBytes: 16055,
196
+ totalBytes: 25197,
197
+ }),
198
+ nonHostLoadedInstalledBytes: 122840,
199
+ categories: Object.freeze({
200
+ coreSkillBodies: Object.freeze({ bytes: 75358, files: 11 }),
201
+ references: Object.freeze({ bytes: 0, files: 0 }),
202
+ agentBodies: Object.freeze({ bytes: 26332, files: 6 }),
203
+ rules: Object.freeze({ bytes: 16055, files: 6 }),
204
+ contexts: Object.freeze({ bytes: 9914, files: 5 }),
205
+ steering: Object.freeze({ bytes: 4522, files: 3 }),
206
+ hooks: Object.freeze({ bytes: 9981, files: 7 }),
207
+ other: Object.freeze({ bytes: 5875, files: 2 }),
208
+ }),
209
+ notes: Object.freeze([
210
+ 'Measured from a fresh v3.5.1 Claude Code full install generated from the release tag.',
211
+ 'Host-visible bytes include root guidance, skill metadata, and six unscoped rule bodies.',
212
+ ]),
213
+ });
214
+
215
+ export function collectV351ClaudeBaseline() {
216
+ return V351_CLAUDE_BASELINE;
217
+ }
218
+
219
+ export async function collectV351OmpBaseline() {
220
+ return V351_OMP_BASELINE;
221
+ }
222
+
223
+ function quietPromptIO() {
224
+ return {
225
+ isInteractive: () => false,
226
+ chooseTarget: async () => null,
227
+ writeNotice: () => {},
228
+ };
229
+ }
230
+
231
+ export async function generateFreshStaticTrees() {
232
+ const temporary = await mkdtemp(path.join(tmpdir(), 'sdd-context-static-'));
233
+ const results = [];
234
+ const originalCwd = process.cwd();
235
+ const originalLog = console.log;
236
+ const originalError = console.error;
237
+ try {
238
+ console.log = () => {};
239
+ console.error = () => {};
240
+ for (const target of ['codex', 'claude-code', 'omp']) {
241
+ for (const profile of ['lean', 'full']) {
242
+ const root = path.join(temporary, target, profile);
243
+ await mkdir(root, { recursive: true });
244
+ process.chdir(root);
245
+ const cli = new InstallSkillsCLI(undefined, undefined, quietPromptIO());
246
+ const options = cli.parseArgs(['--target', target, '--profile', profile]);
247
+ await cli.runUnified(options);
248
+ if (process.exitCode) throw new Error(`Static ${target}/${profile} installation failed`);
249
+ results.push(await collectStaticTree(root, { target, profile }));
250
+ }
251
+ }
252
+ } finally {
253
+ process.chdir(originalCwd);
254
+ console.log = originalLog;
255
+ console.error = originalError;
256
+ await rm(temporary, { recursive: true, force: true });
257
+ }
258
+ results.unshift(collectV351ClaudeBaseline(), await collectV351OmpBaseline());
259
+ return results;
260
+ }
261
+
262
+ function requireNumber(value, label) {
263
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
264
+ throw new Error(`${label} must be a finite non-negative number`);
265
+ }
266
+ return value;
267
+ }
268
+
269
+ function rawZero() {
270
+ return {
271
+ input: 0,
272
+ output: 0,
273
+ cacheRead: 0,
274
+ cacheWrite: 0,
275
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
276
+ };
277
+ }
278
+
279
+ function addRaw(target, usage, label) {
280
+ for (const name of ['input', 'output', 'cacheRead', 'cacheWrite']) {
281
+ target[name] += requireNumber(usage[name], `${label}.usage.${name}`);
282
+ }
283
+ if (!usage.cost || typeof usage.cost !== 'object') throw new Error(`${label}.usage.cost must be an object`);
284
+ for (const name of ['input', 'output', 'cacheRead', 'cacheWrite', 'total']) {
285
+ target.cost[name] += requireNumber(usage.cost[name], `${label}.usage.cost.${name}`);
286
+ }
287
+ }
288
+
289
+ function adapterFor(provider) {
290
+ return PROVIDER_ADAPTERS[String(provider).toLowerCase()];
291
+ }
292
+
293
+ function normalizeMessage(message, label) {
294
+ const adapter = adapterFor(message.provider);
295
+ const usage = message.usage;
296
+ if (!adapter) {
297
+ return { inputWorkTokens: 0, outputWorkTokens: 0, totalWorkTokens: 0, verified: false, reason: `No verified usage adapter for ${message.provider || 'missing provider'}` };
298
+ }
299
+ if (!adapter.outputIncludesReasoning) {
300
+ return { inputWorkTokens: 0, outputWorkTokens: 0, totalWorkTokens: 0, verified: false, reason: `${message.provider} output normalization does not include reasoning` };
301
+ }
302
+ const input = requireNumber(usage.input, `${label}.usage.input`)
303
+ + (adapter.inputIncludesCacheRead ? 0 : requireNumber(usage.cacheRead, `${label}.usage.cacheRead`))
304
+ + (adapter.inputIncludesCacheWrite ? 0 : requireNumber(usage.cacheWrite, `${label}.usage.cacheWrite`));
305
+ const output = requireNumber(usage.output, `${label}.usage.output`);
306
+ return { inputWorkTokens: input, outputWorkTokens: output, totalWorkTokens: input + output, verified: true };
307
+ }
308
+
309
+ async function parseSessionFile(file) {
310
+ const lines = (await readFile(file, 'utf8')).split(/\r?\n/);
311
+ if (lines.at(-1) === '') lines.pop();
312
+ if (lines.length === 0) throw new Error(`${path.basename(file)}: empty JSONL session`);
313
+ const entries = lines.map((line, index) => {
314
+ try {
315
+ return JSON.parse(line);
316
+ } catch (error) {
317
+ throw new Error(`${path.basename(file)}:${index + 1}: malformed JSON: ${error instanceof Error ? error.message : String(error)}`);
318
+ }
319
+ });
320
+ const headerIndex = entries.findIndex(entry => entry?.type === 'session');
321
+ const header = headerIndex >= 0 ? entries[headerIndex] : undefined;
322
+ if (!header || typeof header.id !== 'string') {
323
+ throw new Error(`${path.basename(file)}: invalid session header`);
324
+ }
325
+ const containingDirectory = path.dirname(file);
326
+ const possibleParentFile = path.join(
327
+ path.dirname(containingDirectory),
328
+ `${path.basename(containingDirectory)}.jsonl`
329
+ );
330
+ let nestedOmpSpecialist = false;
331
+ try {
332
+ nestedOmpSpecialist = (await stat(possibleParentFile)).isFile();
333
+ } catch {
334
+ nestedOmpSpecialist = false;
335
+ }
336
+ const raw = rawZero();
337
+ const normalized = { inputWorkTokens: 0, outputWorkTokens: 0, totalWorkTokens: 0, verified: true };
338
+ const normalizationErrors = [];
339
+ const providerModels = new Set();
340
+ let usageMessages = 0;
341
+ entries.forEach((entry, index) => {
342
+ if (entry.type !== 'message' || !entry.message || entry.message.usage === undefined) return;
343
+ const label = `${path.basename(file)}:${index + 1}`;
344
+ addRaw(raw, entry.message.usage, label);
345
+ usageMessages += 1;
346
+ const provider = typeof entry.message.provider === 'string' ? entry.message.provider : 'unknown';
347
+ const model = typeof entry.message.model === 'string' ? entry.message.model : 'unknown';
348
+ providerModels.add(`${provider}/${model}`);
349
+ const value = normalizeMessage(entry.message, label);
350
+ normalized.inputWorkTokens += value.inputWorkTokens;
351
+ normalized.outputWorkTokens += value.outputWorkTokens;
352
+ normalized.totalWorkTokens += value.totalWorkTokens;
353
+ if (!value.verified) {
354
+ normalized.verified = false;
355
+ normalizationErrors.push(value.reason);
356
+ }
357
+ });
358
+ return {
359
+ sessionId: header.id,
360
+ parentSession: typeof header.parentSession === 'string' ? header.parentSession : undefined,
361
+ kind: header.parentSession || nestedOmpSpecialist ? 'specialist' : 'parent',
362
+ usageMessages,
363
+ providerModels: [...providerModels].sort(),
364
+ raw,
365
+ normalized,
366
+ normalizationErrors: [...new Set(normalizationErrors)].sort(),
367
+ };
368
+ }
369
+
370
+ export async function parseSessionRoots(roots) {
371
+ if (!Array.isArray(roots) || roots.length === 0) throw new Error('At least one explicit session JSONL root is required');
372
+ const sessionFiles = new Set();
373
+ for (const supplied of roots) {
374
+ const absolute = path.resolve(supplied);
375
+ const info = await stat(absolute);
376
+ if (info.isFile()) {
377
+ if (!absolute.endsWith('.jsonl')) throw new Error(`${supplied} is not a JSONL file`);
378
+ sessionFiles.add(absolute);
379
+ } else if (info.isDirectory()) {
380
+ for (const file of await filesBelow(absolute)) if (file.endsWith('.jsonl')) sessionFiles.add(file);
381
+ } else throw new Error(`${supplied} is neither a file nor a directory`);
382
+ }
383
+ if (sessionFiles.size === 0) throw new Error('No JSONL session files found beneath the supplied roots');
384
+ const sessions = [];
385
+ for (const file of [...sessionFiles].sort()) sessions.push(await parseSessionFile(file));
386
+ sessions.sort((left, right) => left.kind.localeCompare(right.kind) || left.sessionId.localeCompare(right.sessionId));
387
+ const raw = rawZero();
388
+ const normalized = { inputWorkTokens: 0, outputWorkTokens: 0, totalWorkTokens: 0, verified: true };
389
+ const normalizationErrors = [];
390
+ const providerModels = new Set();
391
+ let peakInputWorkTokens = 0;
392
+ for (const session of sessions) {
393
+ addRaw(raw, { ...session.raw, cost: session.raw.cost }, `session ${session.sessionId}`);
394
+ normalized.inputWorkTokens += session.normalized.inputWorkTokens;
395
+ normalized.outputWorkTokens += session.normalized.outputWorkTokens;
396
+ normalized.totalWorkTokens += session.normalized.totalWorkTokens;
397
+ normalized.verified &&= session.normalized.verified;
398
+ normalizationErrors.push(...session.normalizationErrors);
399
+ session.providerModels.forEach(value => providerModels.add(value));
400
+ peakInputWorkTokens = Math.max(peakInputWorkTokens, session.normalized.inputWorkTokens);
401
+ }
402
+ return {
403
+ sessionCount: sessions.length,
404
+ specialistCount: sessions.filter(session => session.kind === 'specialist').length,
405
+ sessions,
406
+ raw,
407
+ normalized,
408
+ peakInputWorkTokens,
409
+ providerModels: [...providerModels].sort(),
410
+ normalizationErrors: [...new Set(normalizationErrors)].sort(),
411
+ };
412
+ }
413
+
414
+ function sameStrings(left, right) {
415
+ return left.length === right.length && left.every((value, index) => value === right[index]);
416
+ }
417
+
418
+ export function compareUsage(before, after) {
419
+ if (!before || !after) return { valid: false, reason: 'Both before and after usage are required for comparison' };
420
+ const sameProviderModels = sameStrings(before.providerModels, after.providerModels);
421
+ const positiveCost = before.raw.cost.total > 0 && after.raw.cost.total > 0;
422
+ if (positiveCost && sameProviderModels) {
423
+ return {
424
+ valid: true,
425
+ metric: 'cost',
426
+ before: before.raw.cost.total,
427
+ after: after.raw.cost.total,
428
+ changePercentage: ((after.raw.cost.total - before.raw.cost.total) / before.raw.cost.total) * 100,
429
+ };
430
+ }
431
+ if (before.normalized.verified && after.normalized.verified && before.normalized.totalWorkTokens > 0) {
432
+ return {
433
+ valid: true,
434
+ metric: 'normalizedTotalWorkTokens',
435
+ before: before.normalized.totalWorkTokens,
436
+ after: after.normalized.totalWorkTokens,
437
+ changePercentage: ((after.normalized.totalWorkTokens - before.normalized.totalWorkTokens) / before.normalized.totalWorkTokens) * 100,
438
+ costFallbackReason: positiveCost ? 'Positive costs are not comparable across different provider/model sets' : 'Comparable positive cost is unavailable',
439
+ };
440
+ }
441
+ return {
442
+ valid: false,
443
+ reason: `Comparable positive cost is unavailable and verified total-token normalization is unavailable${[...before.normalizationErrors, ...after.normalizationErrors].length ? `: ${[...new Set([...before.normalizationErrors, ...after.normalizationErrors])].join('; ')}` : ''}`,
444
+ };
445
+ }
446
+
447
+ const STATIC_REDUCTION_CONTRACTS = Object.freeze({
448
+ codex: Object.freeze({ baseline: V351_OMP_BASELINE, minimumReductionPercentage: 50 }),
449
+ omp: Object.freeze({ baseline: V351_OMP_BASELINE, minimumReductionPercentage: 50 }),
450
+ 'claude-code': Object.freeze({ baseline: V351_CLAUDE_BASELINE, minimumReductionPercentage: 60 }),
451
+ });
452
+
453
+ export function calculateStaticReductions(staticTrees) {
454
+ return Object.entries(STATIC_REDUCTION_CONTRACTS).map(([target, contract]) => {
455
+ const current = staticTrees.find(tree => tree.target === target && tree.profile === 'full');
456
+ if (!current) {
457
+ return {
458
+ target,
459
+ valid: false,
460
+ reason: `Missing fresh ${target} full tree`,
461
+ minimumReductionPercentage: contract.minimumReductionPercentage,
462
+ };
463
+ }
464
+ const baselineHostVisibleBytes = contract.baseline.hostVisible.totalBytes;
465
+ const currentHostVisibleBytes = current.hostVisible.totalBytes;
466
+ const reductionPercentage = ((baselineHostVisibleBytes - currentHostVisibleBytes) / baselineHostVisibleBytes) * 100;
467
+ return {
468
+ target,
469
+ valid: true,
470
+ baselineLabel: contract.baseline.label,
471
+ baselineHostVisibleBytes,
472
+ currentHostVisibleBytes,
473
+ reductionPercentage,
474
+ minimumReductionPercentage: contract.minimumReductionPercentage,
475
+ passes: reductionPercentage >= contract.minimumReductionPercentage,
476
+ };
477
+ });
478
+ }
479
+
480
+ export function createReport({ staticTrees = [], before, after } = {}) {
481
+ return {
482
+ schemaVersion: 1,
483
+ measurementModel: {
484
+ repositoryStaticPayload: 'measured exact UTF-8 bytes',
485
+ repositoryDynamicPayload: 'reported as installed core/reference/agent categories; invocation is not inferred',
486
+ observedHostUsage: 'provider-reported aggregate usage only',
487
+ unobservableHostPayload: 'unknown',
488
+ deterministicTokenEstimate: 'ceil(characters / 4), labeled estimatedTokens; not an actual tokenizer count',
489
+ providerUsageNormalization: PROVIDER_ADAPTERS,
490
+ },
491
+ static: staticTrees,
492
+ staticReductions: calculateStaticReductions(staticTrees),
493
+ observed: { before, after, comparison: before && after ? compareUsage(before, after) : undefined },
494
+ };
495
+ }
496
+
497
+ function sorted(value) {
498
+ if (Array.isArray(value)) return value.map(sorted);
499
+ if (value && typeof value === 'object') {
500
+ return Object.fromEntries(Object.keys(value).sort().filter(key => value[key] !== undefined).map(key => [key, sorted(value[key])]));
501
+ }
502
+ return value;
503
+ }
504
+
505
+ export function stableJson(value) {
506
+ return `${JSON.stringify(sorted(value), null, 2)}\n`;
507
+ }
508
+
509
+ function usageRow(label, usage) {
510
+ return [label, usage.raw.input, usage.raw.output, usage.raw.cacheRead, usage.raw.cacheWrite, usage.raw.cost.total, usage.normalized.inputWorkTokens, usage.normalized.totalWorkTokens, usage.normalized.verified ? 'yes' : 'no'];
511
+ }
512
+
513
+ function usageRows(label, usage) {
514
+ if (!usage) return [];
515
+ return [
516
+ usageRow(`${label}:total`, usage),
517
+ ...usage.sessions.map(session => usageRow(`${label}:${session.kind}:${session.sessionId}`, session)),
518
+ ];
519
+ }
520
+
521
+ function table(headers, rows) {
522
+ if (rows.length === 0) return '(none)';
523
+ const printable = [headers, ...rows].map(row => row.map(String));
524
+ const widths = headers.map((_, column) => Math.max(...printable.map(row => row[column].length)));
525
+ return printable.map((row, index) => {
526
+ const rendered = row.map((cell, column) => cell.padEnd(widths[column])).join(' | ');
527
+ if (index === 0) return `${rendered}\n${widths.map(width => '-'.repeat(width)).join('-|-')}`;
528
+ return rendered;
529
+ }).join('\n');
530
+ }
531
+
532
+ export function renderHumanReport(report) {
533
+ const staticRows = report.static.map(item => [item.label, item.installedBytes, item.hostVisible.totalBytes, item.nonHostLoadedInstalledBytes, item.hostVisible.rootGuidanceBytes, item.hostVisible.skillIndexBytes, item.hostVisible.unconditionalRuleBytes]);
534
+ const reductionRows = report.staticReductions.map(item => item.valid
535
+ ? [item.target, item.baselineHostVisibleBytes, item.currentHostVisibleBytes, item.reductionPercentage.toFixed(2), item.minimumReductionPercentage, item.passes ? 'pass' : 'fail']
536
+ : [item.target, 'n/a', 'n/a', 'n/a', item.minimumReductionPercentage, `invalid: ${item.reason}`]);
537
+ const usage = [...usageRows('before', report.observed.before), ...usageRows('after', report.observed.after)];
538
+ const comparison = report.observed.comparison
539
+ ? report.observed.comparison.valid
540
+ ? `${report.observed.comparison.metric}: ${report.observed.comparison.before} -> ${report.observed.comparison.after} (${report.observed.comparison.changePercentage.toFixed(2)}%)`
541
+ : `invalid: ${report.observed.comparison.reason}`
542
+ : 'not requested';
543
+ return [
544
+ 'Repository static payload (exact UTF-8 bytes)',
545
+ table(['tree', 'installed', 'host-visible', 'non-host-loaded', 'root', 'skill-index', 'unconditional-rules'], staticRows),
546
+ 'Static host-visible reduction gates',
547
+ table(['target', 'baseline', 'current', 'reduction %', 'required %', 'status'], reductionRows),
548
+ '',
549
+ '',
550
+ 'Repository dynamic payload',
551
+ 'Installed core skill, reference, context, and specialist-agent bodies are categorized in JSON; invocation is not inferred.',
552
+ '',
553
+ 'Observed host usage (provider-reported aggregates)',
554
+ table(['branch/session', 'input', 'output', 'cache-read', 'cache-write', 'cost', 'normalized-input', 'normalized-work', 'verified'], usage),
555
+ `Peak normalized input work: before=${report.observed.before?.peakInputWorkTokens ?? 'n/a'}, after=${report.observed.after?.peakInputWorkTokens ?? 'n/a'}`,
556
+ `Comparison gate: ${comparison}`,
557
+ '',
558
+ 'Unobservable host payload',
559
+ 'unknown (host system instructions, tool schemas, provider internals, and hidden orchestration are not inferred)',
560
+ '',
561
+ ].join('\n');
562
+ }
563
+
564
+ function parseArguments(argv) {
565
+ const options = { json: false };
566
+ for (let index = 0; index < argv.length; index += 1) {
567
+ const argument = argv[index];
568
+ if (argument === '--json') options.json = true;
569
+ else if (argument === '--before' || argument === '--after') {
570
+ const value = argv[++index];
571
+ if (!value) throw new Error(`${argument} requires a path`);
572
+ options[argument.slice(2)] = value;
573
+ } else if (argument === '--help' || argument === '-h') options.help = true;
574
+ else throw new Error(`Unknown option: ${argument}`);
575
+ }
576
+ return options;
577
+ }
578
+
579
+ const HELP = `Usage: sdd-mcp-server context-report [--before <session-root>] [--after <session-root>] [--json]\n\nReads only explicitly supplied OMP JSONL roots. Static target trees are generated in temporary directories.\n`;
580
+
581
+ export async function runCli(argv, io = process) {
582
+ const options = parseArguments(argv);
583
+ if (options.help) {
584
+ io.stdout.write(HELP);
585
+ return 0;
586
+ }
587
+ if (options.after && !options.before) throw new Error('--after requires --before');
588
+ const staticTrees = await generateFreshStaticTrees();
589
+ const before = options.before ? await parseSessionRoots([options.before]) : undefined;
590
+ const after = options.after ? await parseSessionRoots([options.after]) : undefined;
591
+ const report = createReport({ staticTrees, before, after });
592
+ io.stdout.write(options.json ? stableJson(report) : renderHumanReport(report));
593
+ return 0;
594
+ }
595
+
596
+ const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : '';
597
+ if (import.meta.url === invokedPath) {
598
+ runCli(process.argv.slice(2)).catch(error => {
599
+ process.stderr.write(`context-report: ${error instanceof Error ? error.message : String(error)}\n`);
600
+ process.exitCode = 1;
601
+ });
602
+ }
package/sdd-entry.js CHANGED
@@ -16,16 +16,27 @@ const CLI_COMMANDS = ['install', 'install-skills', 'migrate-kiro', 'migrate-stee
16
16
  const args = process.argv.slice(2);
17
17
  const command = args[0];
18
18
 
19
- if (command && (CLI_COMMANDS.includes(command) || command === '--help' || command === '-h')) {
19
+ if (command === 'context-report') {
20
+ import('./scripts/context-usage-report.mjs')
21
+ .then(({ runCli }) => runCli(args.slice(1)))
22
+ .then(exitCode => {
23
+ process.exitCode = exitCode;
24
+ })
25
+ .catch(err => {
26
+ console.error('Failed to run context report:', err.message);
27
+ process.exitCode = 1;
28
+ });
29
+ } else if (command && (CLI_COMMANDS.includes(command) || command === '--help' || command === '-h')) {
20
30
  // CLI mode - import and run CLI module
21
31
  import('./dist/cli/sdd-mcp-cli.js').catch(err => {
22
32
  console.error('Failed to load CLI:', err.message);
23
33
  process.exit(1);
24
34
  });
25
35
  } else {
26
- // MCP server mode - import and run the MCP server
27
- import('./mcp-server.js').catch(err => {
28
- console.error('Failed to start MCP server:', err.message);
29
- process.exit(1);
30
- });
36
+ import('./dist/index.js')
37
+ .then(({ startMCPServer }) => startMCPServer())
38
+ .catch(err => {
39
+ console.error('Failed to start MCP server:', err.message);
40
+ process.exitCode = 1;
41
+ });
31
42
  }