@mlmcps/ml-specs 1.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 (86) hide show
  1. package/.claude-plugin/marketplace.json +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +106 -0
  4. package/ml-specs/.claude-plugin/plugin.json +11 -0
  5. package/ml-specs/.mcp.json +17 -0
  6. package/ml-specs/CHANGELOG.md +860 -0
  7. package/ml-specs/LICENSE +21 -0
  8. package/ml-specs/README.md +347 -0
  9. package/ml-specs/agents/coder.md +76 -0
  10. package/ml-specs/agents/developer.md +78 -0
  11. package/ml-specs/agents/pr-author.md +36 -0
  12. package/ml-specs/agents/reviewer.md +65 -0
  13. package/ml-specs/agents/scanner.md +66 -0
  14. package/ml-specs/agents/spec-author.md +91 -0
  15. package/ml-specs/agents/spec-reviewer.md +59 -0
  16. package/ml-specs/commands/code.md +29 -0
  17. package/ml-specs/commands/fix.md +67 -0
  18. package/ml-specs/commands/nfr.md +114 -0
  19. package/ml-specs/commands/pr.md +32 -0
  20. package/ml-specs/commands/repo-adopt.md +86 -0
  21. package/ml-specs/commands/repo-doctor.md +57 -0
  22. package/ml-specs/commands/repo-estate.md +79 -0
  23. package/ml-specs/commands/repo-impact.md +77 -0
  24. package/ml-specs/commands/repo-init.md +155 -0
  25. package/ml-specs/commands/repo-refresh.md +58 -0
  26. package/ml-specs/commands/repo-rollout.md +84 -0
  27. package/ml-specs/commands/repo-status.md +59 -0
  28. package/ml-specs/commands/spec-advance.md +81 -0
  29. package/ml-specs/commands/spec-build.md +66 -0
  30. package/ml-specs/commands/spec-fanout.md +64 -0
  31. package/ml-specs/commands/spec-review.md +24 -0
  32. package/ml-specs/commands/spec-verify.md +55 -0
  33. package/ml-specs/commands/spec.md +73 -0
  34. package/ml-specs/hooks/hooks.json +23 -0
  35. package/ml-specs/hooks/knowledge-drift.sh +43 -0
  36. package/ml-specs/hooks/secret-scan.sh +56 -0
  37. package/ml-specs/mcp/README.md +173 -0
  38. package/ml-specs/mcp/ml-specs-server.mjs +708 -0
  39. package/ml-specs/mcp/ml-specs-server.test.mjs +225 -0
  40. package/ml-specs/package.json +44 -0
  41. package/ml-specs/scripts/branch-policy.mjs +71 -0
  42. package/ml-specs/scripts/fix-specs.mjs +289 -0
  43. package/ml-specs/scripts/lib/cli.mjs +43 -0
  44. package/ml-specs/scripts/lib/estate.mjs +108 -0
  45. package/ml-specs/scripts/lib/http.mjs +73 -0
  46. package/ml-specs/scripts/lib/knowledge.mjs +91 -0
  47. package/ml-specs/scripts/lib/knowledge.test.mjs +70 -0
  48. package/ml-specs/scripts/lib/nfr.mjs +119 -0
  49. package/ml-specs/scripts/lib/nfr.test.mjs +89 -0
  50. package/ml-specs/scripts/lib/policy.mjs +114 -0
  51. package/ml-specs/scripts/lib/policy.test.mjs +139 -0
  52. package/ml-specs/scripts/lib/scm.mjs +189 -0
  53. package/ml-specs/scripts/lib/scm.test.mjs +155 -0
  54. package/ml-specs/scripts/lib/specs.mjs +192 -0
  55. package/ml-specs/scripts/lib/specs.test.mjs +69 -0
  56. package/ml-specs/scripts/lib/trace.mjs +90 -0
  57. package/ml-specs/scripts/lib/trace.test.mjs +110 -0
  58. package/ml-specs/scripts/lib/tracker.mjs +257 -0
  59. package/ml-specs/scripts/lib/tracker.test.mjs +177 -0
  60. package/ml-specs/scripts/nfr-compile.mjs +120 -0
  61. package/ml-specs/scripts/scripts.test.mjs +182 -0
  62. package/ml-specs/scripts/spec-brief.mjs +127 -0
  63. package/ml-specs/scripts/spec-dashboard.mjs +331 -0
  64. package/ml-specs/scripts/spec-fanout.mjs +120 -0
  65. package/ml-specs/scripts/spec-gate.mjs +329 -0
  66. package/ml-specs/scripts/spec-trace.mjs +91 -0
  67. package/ml-specs/scripts/survey-estate.mjs +230 -0
  68. package/ml-specs/scripts/tracker-sync.mjs +91 -0
  69. package/ml-specs/skills/knowledge-retrieval/SKILL.md +75 -0
  70. package/ml-specs/templates/CLAUDE.fragment.md +87 -0
  71. package/ml-specs/templates/ci/azure-pipelines-spec-gate.yml +64 -0
  72. package/ml-specs/templates/ci/knowledge-check.mjs +176 -0
  73. package/ml-specs/templates/ci/knowledge-layer.yml +35 -0
  74. package/ml-specs/templates/ci/spec-gate.yml +69 -0
  75. package/ml-specs/templates/docs/ARCHITECTURE.template.md +110 -0
  76. package/ml-specs/templates/docs/ESTATE.template.md +55 -0
  77. package/ml-specs/templates/docs/PATTERNS.template.md +80 -0
  78. package/ml-specs/templates/gitattributes +18 -0
  79. package/ml-specs/templates/hooks/settings.hooks.example.json +32 -0
  80. package/ml-specs/templates/mcp/.mcp.json +34 -0
  81. package/ml-specs/templates/settings.json +18 -0
  82. package/ml-specs/templates/specs/AGENTS.md +57 -0
  83. package/ml-specs/templates/specs/README.md +104 -0
  84. package/ml-specs/templates/specs/TEMPLATE.md +114 -0
  85. package/ml-specs/templates/standards/.mlskills.json +24 -0
  86. package/package.json +36 -0
@@ -0,0 +1,708 @@
1
+ #!/usr/bin/env node
2
+ // ml-specs MCP server — the DETERMINISTIC half of the toolkit, exposed to any MCP client.
3
+ //
4
+ // node ml-specs-server.mjs [--root <repo path>] (stdio transport; --root defaults to cwd)
5
+ //
6
+ // Why this exists alongside the plugin: the parts that are pure computation over a repo's files
7
+ // are useful to *any* agent in any tool, and shouldn't need a model to run. Those are the tools.
8
+ // The commands are exposed too, as MCP prompts (see below), with the agents they delegate to
9
+ // inlined into them. Skills and hooks have no MCP equivalent at all — those stay Claude Code,
10
+ // and so does the real subagent execution the plugin gets.
11
+ //
12
+ // Everything is READ-ONLY. No tool writes, moves, or deletes anything. Writing is the plugin's
13
+ // job, where a human is in the loop to approve it.
14
+ //
15
+ // No dependencies, by the same convention as scripts/validate-plugin.mjs — the MCP SDK would
16
+ // mean a package.json and node_modules in a repo that deliberately has neither. stdio MCP is
17
+ // newline-delimited JSON-RPC 2.0, which is short enough to implement honestly.
18
+
19
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
20
+ import { join, dirname, basename, resolve, isAbsolute } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { execFileSync, spawnSync } from 'node:child_process';
23
+ import { runChecks } from '../templates/ci/knowledge-check.mjs';
24
+ import { listSpecs as parseSpecs, analyze } from '../scripts/lib/specs.mjs';
25
+
26
+ const HERE = dirname(fileURLToPath(import.meta.url));
27
+ const PROTOCOL_VERSION = '2024-11-05';
28
+
29
+ const argv = process.argv.slice(2);
30
+ const rootFlag = argv.indexOf('--root');
31
+ // Absolute, always. ROOT is both the spawn cwd and the --root passed to the scripts; a relative
32
+ // value composes with itself and every script ends up looking inside <root>/<root>.
33
+ const ROOT = resolve(rootFlag !== -1 ? (argv[rootFlag + 1] ?? '.') : process.cwd());
34
+
35
+ // Version skew is the predictable failure of distributing this: one teammate on a stale npx
36
+ // cache, another on a fresh plugin update, both reporting different answers. Make "what am I
37
+ // actually running?" a one-liner rather than an archaeology exercise.
38
+ const VERSION = '1.0.0';
39
+ if (argv.includes('--version') || argv.includes('-v')) {
40
+ console.log(`ml-specs-mcp ${VERSION} (${fileURLToPath(import.meta.url)})`);
41
+ process.exit(0);
42
+ }
43
+
44
+ const abs = (p) => join(ROOT, p);
45
+ const git = (...a) => {
46
+ try {
47
+ return execFileSync('git', ['-C', ROOT, ...a], {
48
+ encoding: 'utf8',
49
+ stdio: ['ignore', 'pipe', 'ignore'],
50
+ }).trim();
51
+ } catch {
52
+ return '';
53
+ }
54
+ };
55
+
56
+ // --- docs/ESTATE.md parsing -------------------------------------------------
57
+ // The estate index is markdown tables under `### Synchronous` / `### Asynchronous` /
58
+ // `### Shared data` headings. Column meaning depends on which table a row is in.
59
+
60
+ const SECTION_SHAPES = {
61
+ sync: ['caller', 'callee', 'via', 'what', 'evidence'],
62
+ async: ['contract', 'producer', 'consumer', 'notes', 'evidence'],
63
+ shared: ['contract', 'owner', 'usedBy', 'notes'],
64
+ };
65
+
66
+ function parseEstate() {
67
+ if (!existsSync(abs('docs/ESTATE.md'))) return { present: false, edges: [], services: [] };
68
+
69
+ const lines = readFileSync(abs('docs/ESTATE.md'), 'utf8').split('\n');
70
+ const edges = [];
71
+ const services = [];
72
+ let section = null;
73
+
74
+ const cells = (line) =>
75
+ line.split('|').slice(1, -1).map((c) => c.trim().replace(/^`|`$/g, ''));
76
+ const isSeparator = (line) => /^\|[\s:|-]+\|$/.test(line.trim());
77
+ // A row is unresolved if it's a placeholder or was never confirmed against the peer's code.
78
+ const unresolved = (row) => /_TBD_|\(inferred\)/i.test(row);
79
+
80
+ for (const raw of lines) {
81
+ const line = raw.trim();
82
+ if (line.startsWith('#')) {
83
+ const h = line.toLowerCase();
84
+ // "asynchronous" contains "synchronous" — test the longer one first or every event
85
+ // table is parsed with the sync column shape.
86
+ if (h.includes('service registry')) section = 'registry';
87
+ else if (h.includes('asynchronous')) section = 'async';
88
+ else if (h.includes('synchronous')) section = 'sync';
89
+ else if (h.includes('shared')) section = 'shared';
90
+ else if (line.startsWith('## ')) section = null;
91
+ continue;
92
+ }
93
+ if (!section || !line.startsWith('|') || isSeparator(line)) continue;
94
+
95
+ const c = cells(line);
96
+ if (c.length < 2) continue;
97
+ // Skip header rows and the template's placeholder rows.
98
+ const first = c[0].toLowerCase();
99
+ if (['service', 'caller', 'event / queue / topic', 'shared thing', 'event / queue (fifo)'].includes(first)) continue;
100
+ if (/^<.*>$/.test(c[0])) continue;
101
+
102
+ if (section === 'registry') {
103
+ services.push({ service: c[0], owns: c[1] ?? '', stack: c[2] ?? '', doc: c[3] ?? '', unresolved: unresolved(line) });
104
+ continue;
105
+ }
106
+
107
+ const shape = SECTION_SHAPES[section];
108
+ const edge = { kind: section, unresolved: unresolved(line) };
109
+ shape.forEach((k, i) => { edge[k] = c[i] ?? ''; });
110
+ edges.push(edge);
111
+ }
112
+
113
+ return { present: true, edges, services };
114
+ }
115
+
116
+ // --- specs ------------------------------------------------------------------
117
+
118
+ // Parsing lives in scripts/lib/specs.mjs so the dashboard generator and this server agree on
119
+ // what a spec says. Mapped to this tool's field names, which are part of its published contract.
120
+ function listSpecs() {
121
+ return parseSpecs(ROOT).map((s) => ({
122
+ file: s.file,
123
+ number: s.id,
124
+ slug: s.slug,
125
+ title: s.title,
126
+ status: s.status, // the canonical lifecycle word, or null
127
+ rawStatus: s.rawStatus, // what the cell literally holds
128
+ statusIsCanonical: s.statusIsCanonical,
129
+ branch: s.branch,
130
+ ticket: s.ticket,
131
+ acceptanceCriteria: { total: s.acTotal, checked: s.acChecked },
132
+ archived: s.archived,
133
+ }));
134
+ }
135
+
136
+ function nextSpecNumber() {
137
+ // Every branch, not just the working tree — otherwise two people speccing in parallel
138
+ // both take the next number and collide at merge.
139
+ const used = new Set();
140
+ for (const s of listSpecs()) used.add(Number(s.number));
141
+
142
+ const hasRemote = git('remote') !== '';
143
+ let fetched = false;
144
+ if (hasRemote) {
145
+ try {
146
+ execFileSync('git', ['-C', ROOT, 'fetch', '--quiet'], { stdio: 'ignore' });
147
+ fetched = true;
148
+ } catch {
149
+ fetched = false; // offline, or no credentials — reported below, never silently assumed
150
+ }
151
+ }
152
+ const historical = git(
153
+ 'log', '--all', '--pretty=format:', '--name-only', '--diff-filter=A', '--', 'specs/[0-9]*',
154
+ );
155
+ for (const line of historical.split('\n')) {
156
+ const m = basename(line.trim()).match(/^(\d{4})-/);
157
+ if (m) used.add(Number(m[1]));
158
+ }
159
+
160
+ const max = used.size ? Math.max(...used) : 0;
161
+ return {
162
+ next: String(max + 1).padStart(4, '0'),
163
+ highestUsed: used.size ? String(max).padStart(4, '0') : null,
164
+ countUsed: used.size,
165
+ scannedAllBranches: historical !== '',
166
+ remoteChecked: fetched,
167
+ warning: fetched ? null
168
+ : hasRemote
169
+ ? 'Remote exists but fetch failed (offline or no credentials) — branches you have not pulled were not counted, so a collision is possible.'
170
+ : 'No git remote — only local branches were counted.',
171
+ };
172
+ }
173
+
174
+ // --- deterministic scripts as tools ------------------------------------------
175
+ // scripts/ holds the half of the toolkit that must not be a model call: the lifecycle gate, the
176
+ // ticket-to-test chain, the NFR compiler. Until now only Claude Code could reach them, which made
177
+ // the most load-bearing check in the toolkit — "does this spec have the evidence for Verified?" —
178
+ // unavailable to CI, to Cursor, and to any other agent. These expose them.
179
+ //
180
+ // They are INVOKED, not imported. The scripts parse their own arguments at module top level, so
181
+ // importing one would run its CLI; but more than that, invoking is the stronger no-drift
182
+ // guarantee. An imported function can diverge from the CLI in argument handling and default
183
+ // values, where a subprocess is the same execution path a human gets. The cost is one process
184
+ // spawn per call, which is nothing for a read-only tool.
185
+ //
186
+ // Read-only is preserved by construction: the write-capable flags (`--apply`, `--out`) are never
187
+ // passed, and there is no parameter that could smuggle one in.
188
+
189
+ const SCRIPTS_DIR = join(HERE, '..', 'scripts');
190
+
191
+ /**
192
+ * @param okExit exit codes that are a RESULT rather than a failure. Several of these scripts exit
193
+ * 1 to mean "the gate failed" — that is an answer, and treating it as an error would turn a
194
+ * legitimate FAIL into a tool crash the caller cannot read.
195
+ */
196
+ /**
197
+ * spec-gate.mjs takes a path; spec-trace.mjs and spec-brief.mjs take a path OR a bare id. A caller
198
+ * cannot be expected to know which is which, so accept both everywhere and resolve here.
199
+ */
200
+ function resolveSpecPath(spec) {
201
+ const given = String(spec);
202
+ if (given.includes('/') || given.endsWith('.md')) return given;
203
+ const match = parseSpecs(ROOT).find((s) => String(s.id ?? s.number ?? '').replace(/^0+/, '') === given.replace(/^0+/, ''));
204
+ if (!match) throw new Error(`no spec with id ${given} under ${join(ROOT, 'specs')}`);
205
+ return match.path ?? match.file ?? given;
206
+ }
207
+
208
+ function runScript(file, args, { json = true, okExit = [0, 1] } = {}) {
209
+ const path = join(SCRIPTS_DIR, file);
210
+ if (!existsSync(path)) throw new Error(`script not found: ${file} (expected at ${path})`);
211
+
212
+ const r = spawnSync(process.execPath, [path, ...args], {
213
+ cwd: ROOT, encoding: 'utf8', timeout: 120_000, maxBuffer: 32 * 1024 * 1024, shell: false,
214
+ });
215
+ if (r.error) throw new Error(`${file} could not be run: ${r.error.message}`);
216
+ if (r.signal) throw new Error(`${file} was killed by ${r.signal} — likely a timeout`);
217
+
218
+ const stdout = String(r.stdout ?? '');
219
+ const stderr = String(r.stderr ?? '').trim();
220
+ if (!okExit.includes(r.status)) {
221
+ throw new Error(`${file} exited ${r.status}${stderr ? `: ${stderr.split('\n').slice(0, 3).join(' ')}` : ''}`);
222
+ }
223
+ if (!json) return { output: stdout.trimEnd(), exitCode: r.status };
224
+
225
+ try {
226
+ const parsed = JSON.parse(stdout);
227
+ // exitCode is part of the answer, not noise: 1 means a gate failed, and a caller reading only
228
+ // the payload should not have to infer that from the fields.
229
+ return Array.isArray(parsed) ? { results: parsed, exitCode: r.status } : { ...parsed, exitCode: r.status };
230
+ } catch {
231
+ throw new Error(`${file} did not return JSON (exit ${r.status}). First line: ${stdout.split('\n')[0]?.slice(0, 160) ?? '(no output)'}`);
232
+ }
233
+ }
234
+
235
+ // --- tools ------------------------------------------------------------------
236
+
237
+ const TOOLS = [
238
+ {
239
+ name: 'estate_lookup',
240
+ description:
241
+ 'Who consumes or produces a cross-service contract? Reads docs/ESTATE.md and returns the ' +
242
+ 'matching edges (sync HTTP/RPC calls, async events, shared data) with producers, consumers, ' +
243
+ 'and whether the row was confirmed against real code or is unverified. Call with no ' +
244
+ 'contract to list the whole index. Returns present:false if the repo has no estate index — ' +
245
+ 'that means "unknown", NOT "no consumers".',
246
+ inputSchema: {
247
+ type: 'object',
248
+ properties: {
249
+ contract: {
250
+ type: 'string',
251
+ description: 'Event name, endpoint, table, client, or service to match (substring, case-insensitive). Omit for everything.',
252
+ },
253
+ },
254
+ },
255
+ },
256
+ {
257
+ name: 'knowledge_check',
258
+ description:
259
+ 'Verify the repo\'s knowledge layer (CLAUDE.md, docs/PATTERNS.md, docs/ARCHITECTURE.md, ' +
260
+ 'shards) still matches the code: every file:line reference resolves, doc links resolve, ' +
261
+ 'every shard is reachable from the router, docs are within budget. Mechanical only — no ' +
262
+ 'judgment about whether a documented pattern is still the right one.',
263
+ inputSchema: {
264
+ type: 'object',
265
+ properties: {
266
+ base: {
267
+ type: 'string',
268
+ description: 'Optional git ref. If given, also reports when source changed and no doc did.',
269
+ },
270
+ },
271
+ },
272
+ },
273
+ {
274
+ name: 'spec_list',
275
+ description:
276
+ 'List every spec under specs/ with its lifecycle status, acceptance-criteria progress, ' +
277
+ 'branch, and ticket. Includes specs/archive/ flagged as archived.',
278
+ inputSchema: {
279
+ type: 'object',
280
+ properties: {
281
+ status: {
282
+ type: 'string',
283
+ description: 'Optional filter: Draft | Approved | Implemented | Verified | Archived.',
284
+ },
285
+ summaryOnly: {
286
+ type: 'boolean',
287
+ description: 'Return only the summary counts, omitting the per-spec rows. Use on repos with hundreds of specs.',
288
+ },
289
+ },
290
+ },
291
+ },
292
+ {
293
+ name: 'spec_next_number',
294
+ description:
295
+ 'The next free spec number, computed across ALL git branches rather than the working ' +
296
+ 'tree, so parallel spec authoring does not collide. Fetches first when a remote exists ' +
297
+ 'and says so when it could not.',
298
+ inputSchema: { type: 'object', properties: {} },
299
+ },
300
+ {
301
+ name: 'spec_gate',
302
+ description:
303
+ 'The MECHANICAL half of a lifecycle transition, checked exactly: lifecycle ordering, ' +
304
+ 'leftover <placeholder> text, whether every acceptance criterion is ticked, whether every ' +
305
+ 'test file named in the §6 table ACTUALLY EXISTS ON DISK, and whether the recorded branch ' +
306
+ 'is merged. Returns PASS / FAIL / MANUAL per gate. ' +
307
+ 'MANUAL means a script cannot witness it — human approval, whether a §8 question is ' +
308
+ 'blocking, whether the suite ran green — and a MANUAL is NOT a pass; those still need ' +
309
+ 'judging. exitCode 1 means at least one gate FAILED, which is a result, not an error.',
310
+ inputSchema: {
311
+ type: 'object',
312
+ properties: {
313
+ spec: { type: 'string', description: 'Spec file path (specs/0001-foo.md) or bare id (0001).' },
314
+ to: { type: 'string', description: 'Target status: Approved | Implemented | Verified | Archived. Omit for the next one in the lifecycle.' },
315
+ },
316
+ required: ['spec'],
317
+ },
318
+ },
319
+ {
320
+ name: 'spec_trace',
321
+ description:
322
+ 'The id chain from ticket to test case, for one spec or every spec — the question an ' +
323
+ 'auditor asks and the one a repo usually cannot answer. ' +
324
+ 'Distinguishes BROKEN from UNVERIFIABLE on purpose: a ticket reference typed into a header ' +
325
+ 'table cannot be checked from inside the repo, and counting it as passing would make the ' +
326
+ 'report a lie. exitCode 1 means at least one chain is broken.',
327
+ inputSchema: {
328
+ type: 'object',
329
+ properties: {
330
+ spec: { type: 'string', description: 'Spec path or bare id. Omit for every spec in the repo.' },
331
+ },
332
+ },
333
+ },
334
+ {
335
+ name: 'spec_brief',
336
+ description:
337
+ 'Package an approved spec for whoever implements it: acceptance criteria paired with ' +
338
+ 'reserved test-case ids, the constraints in force, and the gates that will fail the build — ' +
339
+ 'assembled from the three files they are otherwise scattered across. ' +
340
+ 'Returns MARKDOWN, not JSON, and deliberately so: it is the same document whether a person ' +
341
+ 'or an agent implements the spec. Anything an agent would need that a new engineer would ' +
342
+ 'not is a sign the spec is underspecified. exitCode 1 means the spec is not past approval.',
343
+ inputSchema: {
344
+ type: 'object',
345
+ properties: {
346
+ spec: { type: 'string', description: 'Spec path or bare id (e.g. 0031).' },
347
+ repo: { type: 'string', description: 'Optional repo name, for a brief that spans an estate.' },
348
+ },
349
+ required: ['spec'],
350
+ },
351
+ },
352
+ {
353
+ name: 'nfr_check',
354
+ description:
355
+ 'Compile the project\'s non-functional requirements and report what does NOT route: an NFR ' +
356
+ 'with no machine-checkable threshold, one flattened into an acceptance criterion, or one a ' +
357
+ 'spec names that is defined nowhere. Reads docs/NFRS.md (falling back to nfrs.json). ' +
358
+ 'Dry-run ONLY — this tool never writes docs/CONSTRAINTS.md or a gates file; run /nfr in the ' +
359
+ 'plugin for that. exitCode 1 means at least one NFR could not be compiled, which is the ' +
360
+ 'point: an NFR nothing can fail is not being enforced, whatever the document says.',
361
+ inputSchema: { type: 'object', properties: {} },
362
+ },
363
+ {
364
+ name: 'estate_survey',
365
+ description:
366
+ 'Cheap, mechanical inventory of candidate repos before onboarding: stack, knowledge-layer ' +
367
+ 'state, spec count, 90-day activity, uncommitted changes, cross-service edge signals, and ' +
368
+ 'an init/adopt/refresh/review recommendation each. Costs no model calls and writes nothing. ' +
369
+ 'Every judgment is a labelled heuristic — it tells you where to look, not what to conclude.',
370
+ inputSchema: {
371
+ type: 'object',
372
+ properties: {
373
+ path: { type: 'string', description: 'Parent directory to survey (default: the parent of this repo), or a single repo path.' },
374
+ paths: { type: 'array', items: { type: 'string' }, description: 'Explicit repo paths instead of scanning a parent.' },
375
+ },
376
+ },
377
+ },
378
+ ];
379
+
380
+ function callTool(name, args = {}) {
381
+ switch (name) {
382
+ case 'estate_lookup': {
383
+ const estate = parseEstate();
384
+ if (!estate.present) {
385
+ return {
386
+ present: false,
387
+ edges: [],
388
+ note:
389
+ 'No docs/ESTATE.md in this repo. This means the cross-service contracts are UNKNOWN, ' +
390
+ 'not that there are none. Run /repo-estate (ml-specs) to build the index before ' +
391
+ 'concluding a change is safe.',
392
+ };
393
+ }
394
+ const q = (args.contract ?? '').toLowerCase();
395
+ const edges = q
396
+ ? estate.edges.filter((e) => Object.values(e).some((v) => String(v).toLowerCase().includes(q)))
397
+ : estate.edges;
398
+ return {
399
+ present: true,
400
+ query: args.contract ?? null,
401
+ matched: edges.length,
402
+ totalEdges: estate.edges.length,
403
+ unresolvedMatches: edges.filter((e) => e.unresolved).length,
404
+ edges,
405
+ services: estate.services,
406
+ note: edges.some((e) => e.unresolved)
407
+ ? 'Some matched rows are (inferred) or _TBD_ — never confirmed against the peer repo. Treat those as hypotheses.'
408
+ : null,
409
+ };
410
+ }
411
+
412
+ case 'knowledge_check': {
413
+ const r = runChecks({ root: ROOT, base: args.base ?? null });
414
+ if (r.empty) {
415
+ return { ok: true, empty: true, note: 'No knowledge layer found (CLAUDE.md / docs/). Run /repo-init.' };
416
+ }
417
+ return {
418
+ ok: r.errors.length === 0,
419
+ docsChecked: r.docs,
420
+ refsChecked: r.refsChecked,
421
+ errors: r.errors,
422
+ warnings: r.warnings,
423
+ };
424
+ }
425
+
426
+ case 'spec_list': {
427
+ const all = parseSpecs(ROOT);
428
+ const summary = analyze(all);
429
+ let specs = listSpecs();
430
+ if (args.status) {
431
+ const want = String(args.status).toLowerCase();
432
+ specs = specs.filter((s) => (s.status ?? '').toLowerCase() === want);
433
+ }
434
+ // The summary is what a caller usually needs on a repo with hundreds of specs — it lets
435
+ // a client answer "what's the state?" without rendering every row.
436
+ return {
437
+ count: specs.length,
438
+ summary: {
439
+ total: summary.total,
440
+ active: summary.active,
441
+ byStatus: summary.byStatus,
442
+ withoutLifecycleWord: summary.unknownStatus,
443
+ acceptanceCriteria: { total: summary.acTotal, checked: summary.acChecked },
444
+ duplicateIds: summary.duplicateIds.map((d) => d.id),
445
+ needsAttention: summary.attention.length,
446
+ },
447
+ specs: args.summaryOnly ? undefined : specs,
448
+ };
449
+ }
450
+
451
+ case 'spec_next_number':
452
+ return nextSpecNumber();
453
+
454
+ case 'spec_gate': {
455
+ if (!args.spec) throw new Error('spec_gate needs a spec: a path (specs/0001-foo.md) or a bare id (0001)');
456
+ const extra = args.to ? ['--to', String(args.to)] : [];
457
+ return runScript('spec-gate.mjs', [resolveSpecPath(args.spec), '--root', ROOT, '--json', ...extra]);
458
+ }
459
+
460
+ case 'spec_trace': {
461
+ const target = args.spec ? [String(args.spec)] : [];
462
+ return runScript('spec-trace.mjs', [...target, '--root', ROOT, '--json']);
463
+ }
464
+
465
+ case 'spec_brief': {
466
+ if (!args.spec) throw new Error('spec_brief needs a spec: a path or a bare id (e.g. 0031)');
467
+ // Markdown by design — see the tool description. Never pass --out; this server does not write.
468
+ const extra = args.repo ? ['--repo', String(args.repo)] : [];
469
+ return runScript('spec-brief.mjs', [String(args.spec), '--root', ROOT, ...extra], { json: false });
470
+ }
471
+
472
+ case 'nfr_check': {
473
+ // Same distinction estate_lookup makes, for the same reason: a repo with no NFR file has
474
+ // UNKNOWN non-functional requirements, not zero. Reporting "nothing failed to compile"
475
+ // would be a false all-clear on exactly the requirements most likely to be agreed and then
476
+ // lost. The CLI exits 1 with a message here, which is right for a CLI and wrong for a tool.
477
+ const nfrFile = ['docs/NFRS.md', 'nfrs.json'].find((f) => existsSync(abs(f)));
478
+ if (!nfrFile) {
479
+ return {
480
+ present: false,
481
+ nfrs: [],
482
+ note:
483
+ 'No docs/NFRS.md or nfrs.json in this repo. The non-functional requirements are ' +
484
+ 'UNKNOWN, not absent — nothing here says the project has none, only that none are ' +
485
+ 'written down where they can be enforced. Run /nfr (ml-specs) to route them.',
486
+ };
487
+ }
488
+ // No --apply and no --gates, ever: those write. The plugin's /nfr is where writing happens,
489
+ // with a human in the loop to approve it.
490
+ return { present: true, source: nfrFile, ...runScript('nfr-compile.mjs', ['--root', ROOT, '--json']) };
491
+ }
492
+
493
+ case 'estate_survey': {
494
+ const resolveAgainstRoot = (p) => (isAbsolute(p) ? p : resolve(ROOT, p));
495
+ const targets = Array.isArray(args.paths) && args.paths.length
496
+ ? args.paths.map((p) => resolveAgainstRoot(String(p)))
497
+ : [resolveAgainstRoot(String(args.path ?? '..'))];
498
+ return runScript('survey-estate.mjs', [...targets, '--json'], { okExit: [0] });
499
+ }
500
+
501
+ default:
502
+ throw new Error(`unknown tool: ${name}`);
503
+ }
504
+ }
505
+
506
+ // --- resources: the plugin's templates --------------------------------------
507
+
508
+ function listResources() {
509
+ const out = [];
510
+ const walk = (dir, prefix) => {
511
+ if (!existsSync(dir)) return;
512
+ for (const f of readdirSync(dir)) {
513
+ const full = join(dir, f);
514
+ if (statSync(full).isDirectory()) walk(full, `${prefix}${f}/`);
515
+ else if (f.endsWith('.md') || f.endsWith('.json') || f.endsWith('.yml')) {
516
+ out.push({
517
+ uri: `mlspec://templates/${prefix}${f}`,
518
+ name: `${prefix}${f}`,
519
+ description: 'ml-specs template',
520
+ mimeType: f.endsWith('.md') ? 'text/markdown' : 'text/plain',
521
+ });
522
+ }
523
+ }
524
+ };
525
+ walk(join(HERE, '..', 'templates'), '');
526
+ return out;
527
+ }
528
+
529
+ function readResource(uri) {
530
+ const rel = uri.replace(/^mlspec:\/\/templates\//, '');
531
+ if (rel.includes('..')) throw new Error('invalid resource uri');
532
+ const path = join(HERE, '..', 'templates', rel);
533
+ if (!existsSync(path)) throw new Error(`no such resource: ${uri}`);
534
+ return readFileSync(path, 'utf8');
535
+ }
536
+
537
+ // --- prompts: the plugin's slash commands ------------------------------------
538
+ // commands/ is the plugin's other half, and it is already the shape of an MCP prompt: YAML
539
+ // frontmatter (description, argument-hint) over a markdown body with a $ARGUMENTS placeholder.
540
+ // Exposing it here is what lets a non-Claude-Code client run /spec, /code and the rest.
541
+ //
542
+ // The client namespaces these, so `spec` arrives as `/mcp__ml-specs__spec`, not `/spec`.
543
+ // That is the client's doing and cannot be opted out of.
544
+ //
545
+ // Agents have NO MCP equivalent, and several commands delegate real work to one. Rather than
546
+ // let those steps silently no-op, any agent a command names in bold is appended to the prompt
547
+ // as an appendix — so a client without subagents still gets the instructions. It runs them
548
+ // inline, losing the isolated context, tool restrictions and parallelism the plugin gets.
549
+ // /spec-fanout degrades the most; it is parallel-by-design.
550
+
551
+ const COMMANDS_DIR = join(HERE, '..', 'commands');
552
+ const AGENTS_DIR = join(HERE, '..', 'agents');
553
+
554
+ // Deliberately not a YAML parser. Every key in commands/ and agents/ is a flat `key: scalar`
555
+ // on one line; anything richer would be a new convention, not a parsing problem.
556
+ function parseFrontmatter(src) {
557
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(src);
558
+ if (!m) return { meta: {}, body: src };
559
+ const meta = {};
560
+ for (const line of m[1].split(/\r?\n/)) {
561
+ const kv = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(line);
562
+ if (kv) meta[kv[1]] = kv[2].trim();
563
+ }
564
+ return { meta, body: src.slice(m[0].length) };
565
+ }
566
+
567
+ // Commands name an agent in bold prose: "Spawn the **spec-reviewer** agent on the file".
568
+ // Match that rather than inventing a machine-readable field the plugin does not use, so the
569
+ // two halves cannot drift apart.
570
+ function agentAppendix(body) {
571
+ if (!existsSync(AGENTS_DIR)) return '';
572
+ const parts = [];
573
+ for (const f of readdirSync(AGENTS_DIR).filter((n) => n.endsWith('.md')).sort()) {
574
+ const slug = basename(f, '.md');
575
+ if (!body.includes(`**${slug}**`)) continue;
576
+ const { body: agentBody } = parseFrontmatter(readFileSync(join(AGENTS_DIR, f), 'utf8'));
577
+ parts.push(`### Agent: ${slug}\n\n${agentBody.trim()}`);
578
+ }
579
+ if (!parts.length) return '';
580
+ return [
581
+ '\n\n---\n',
582
+ '## Inlined agent instructions',
583
+ '',
584
+ 'The steps above delegate to subagents. This client has no subagent mechanism, so each',
585
+ 'referenced agent is reproduced below — follow its instructions inline, in the same order',
586
+ 'the steps call for, keeping its stated scope and restrictions.',
587
+ '',
588
+ parts.join('\n\n'),
589
+ ].join('\n');
590
+ }
591
+
592
+ let PROMPTS = null;
593
+ function loadPrompts() {
594
+ if (PROMPTS) return PROMPTS;
595
+ PROMPTS = [];
596
+ if (!existsSync(COMMANDS_DIR)) return PROMPTS;
597
+ for (const f of readdirSync(COMMANDS_DIR).filter((n) => n.endsWith('.md')).sort()) {
598
+ const name = basename(f, '.md');
599
+ const { meta, body } = parseFrontmatter(readFileSync(join(COMMANDS_DIR, f), 'utf8'));
600
+ PROMPTS.push({
601
+ name,
602
+ description: meta.description ?? `ml-specs /${name}`,
603
+ hint: meta['argument-hint'] ?? '',
604
+ takesArgs: body.includes('$ARGUMENTS'),
605
+ body,
606
+ });
607
+ }
608
+ return PROMPTS;
609
+ }
610
+
611
+ // Never `required: true`. Four commands take no arguments at all, and a client that blocks on
612
+ // a required field it cannot fill turns a working prompt into a dead menu entry.
613
+ function listPrompts() {
614
+ return loadPrompts().map((p) => ({
615
+ name: p.name,
616
+ description: p.description,
617
+ arguments: p.takesArgs
618
+ ? [{ name: 'arguments', description: p.hint || 'arguments for this command', required: false }]
619
+ : [],
620
+ }));
621
+ }
622
+
623
+ function getPrompt(name, args) {
624
+ const p = loadPrompts().find((x) => x.name === name);
625
+ if (!p) throw new Error(`no such prompt: ${name}`);
626
+ const given = typeof args?.arguments === 'string' ? args.arguments.trim() : '';
627
+ const text = p.body.split('$ARGUMENTS').join(given) + agentAppendix(p.body);
628
+ return {
629
+ description: p.description,
630
+ messages: [{ role: 'user', content: { type: 'text', text } }],
631
+ };
632
+ }
633
+
634
+ // --- JSON-RPC over stdio ----------------------------------------------------
635
+
636
+ const send = (msg) => process.stdout.write(JSON.stringify(msg) + '\n');
637
+ const ok = (id, result) => send({ jsonrpc: '2.0', id, result });
638
+ const fail = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
639
+
640
+ function handle(msg) {
641
+ const { id, method, params } = msg;
642
+ // Notifications have no id and take no response.
643
+ if (id === undefined) return;
644
+
645
+ try {
646
+ switch (method) {
647
+ case 'initialize':
648
+ return ok(id, {
649
+ protocolVersion: typeof params?.protocolVersion === 'string' ? params.protocolVersion : PROTOCOL_VERSION,
650
+ capabilities: { tools: {}, resources: {}, prompts: {} },
651
+ serverInfo: { name: 'ml-specs', version: VERSION },
652
+ });
653
+
654
+ case 'ping':
655
+ return ok(id, {});
656
+
657
+ case 'tools/list':
658
+ return ok(id, { tools: TOOLS });
659
+
660
+ case 'tools/call': {
661
+ const result = callTool(params?.name, params?.arguments ?? {});
662
+ return ok(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] });
663
+ }
664
+
665
+ case 'resources/list':
666
+ return ok(id, { resources: listResources() });
667
+
668
+ case 'prompts/list':
669
+ return ok(id, { prompts: listPrompts() });
670
+
671
+ case 'prompts/get':
672
+ return ok(id, getPrompt(params?.name, params?.arguments ?? {}));
673
+
674
+ case 'resources/read':
675
+ return ok(id, {
676
+ contents: [{ uri: params?.uri, mimeType: 'text/markdown', text: readResource(params?.uri) }],
677
+ });
678
+
679
+ default:
680
+ return fail(id, -32601, `method not found: ${method}`);
681
+ }
682
+ } catch (e) {
683
+ // A tool that throws is a tool error, not a protocol error — report it as content so the
684
+ // client can show it, rather than killing the session.
685
+ if (method === 'tools/call') {
686
+ return ok(id, { content: [{ type: 'text', text: `error: ${e.message}` }], isError: true });
687
+ }
688
+ return fail(id, -32603, e.message);
689
+ }
690
+ }
691
+
692
+ let buffer = '';
693
+ process.stdin.setEncoding('utf8');
694
+ process.stdin.on('data', (chunk) => {
695
+ buffer += chunk;
696
+ let nl;
697
+ while ((nl = buffer.indexOf('\n')) !== -1) {
698
+ const line = buffer.slice(0, nl).trim();
699
+ buffer = buffer.slice(nl + 1);
700
+ if (!line) continue;
701
+ try {
702
+ handle(JSON.parse(line));
703
+ } catch {
704
+ fail(null, -32700, 'parse error');
705
+ }
706
+ }
707
+ });
708
+ process.stdin.on('end', () => process.exit(0));