sparda-mcp 0.6.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "mcpName": "io.github.zyx77550/sparda-mcp",
5
5
  "description": "Turn any codebase into an MCP server with one command. Express & FastAPI → Claude-ready in 3 minutes.",
6
6
  "type": "module",
@@ -2,6 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { detectStack } from '../detect.js';
5
+ import { buildNegentropy, renderNegentropy } from './negentropy.js';
5
6
 
6
7
  // Returns { healthy } so the CLI can exit non-zero on any ✗ — scripts/CI can
7
8
  // gate on `sparda doctor` (E-012). Informational '·' lines never fail it.
@@ -26,9 +27,12 @@ export async function runDoctor(opts) {
26
27
  fail(` ✗ ${e.message}`);
27
28
  }
28
29
  const manifest = path.join(opts.cwd, 'sparda.json');
30
+ let liveStats = null; // captured for the negentropy pass
31
+ let parsedManifest = null;
29
32
  if (fs.existsSync(manifest)) {
30
33
  try {
31
34
  const m = JSON.parse(fs.readFileSync(manifest, 'utf8'));
35
+ parsedManifest = m;
32
36
  const tools = Object.values(m.tools ?? {});
33
37
  console.log(
34
38
  ` ✓ sparda.json valid (${tools.length} tools, ${tools.filter((t) => t.enabled).length} enabled)`,
@@ -67,6 +71,7 @@ export async function runDoctor(opts) {
67
71
  .then((x) => (x.ok ? x.json() : null))
68
72
  .catch(() => null);
69
73
  if (s) {
74
+ liveStats = s;
70
75
  const totals = Object.values(s.stats ?? {}).reduce(
71
76
  (a, t) => ({ calls: a.calls + t.calls, errors: a.errors + t.errors }),
72
77
  { calls: 0, errors: 0 },
@@ -102,5 +107,41 @@ export async function runDoctor(opts) {
102
107
  } else {
103
108
  console.log(' · sparda.json not found (run `npx sparda-mcp init`)');
104
109
  }
110
+
111
+ // ── negentropy pass (opt-in: doctor --app) — R3.1, deterministic ─────────
112
+ if (opts.app && parsedManifest) {
113
+ console.log('');
114
+ let currentRoutes = null;
115
+ try {
116
+ if (stack?.framework === 'express') {
117
+ const { parseExpressProject } = await import('../parser/express.js');
118
+ currentRoutes = parseExpressProject(opts.cwd, stack.entryFile).routes;
119
+ } else if (stack?.framework === 'nextjs') {
120
+ const { parseNextProject } = await import('../parser/nextjs.js');
121
+ currentRoutes = parseNextProject(opts.cwd, stack.entryFile).routes;
122
+ } else if (stack?.framework === 'fastapi') {
123
+ const { parseFastAPIProject } = await import('../parser/fastapi.js');
124
+ currentRoutes = parseFastAPIProject(
125
+ opts.cwd,
126
+ stack.entryFile,
127
+ stack.pythonCmd,
128
+ ).routes;
129
+ }
130
+ } catch {
131
+ currentRoutes = null; // drift becomes "not measurable", never a guess
132
+ }
133
+ const result = buildNegentropy({
134
+ manifest: parsedManifest,
135
+ currentRoutes,
136
+ live: liveStats,
137
+ detectedPort: stack?.port ?? null,
138
+ cwd: opts.cwd,
139
+ });
140
+ const { lines, failing } = renderNegentropy(result);
141
+ for (const l of lines) console.log(l);
142
+ if (failing) healthy = false;
143
+ } else if (opts.app) {
144
+ console.log('\n · negentropy scan skipped — no sparda.json to compare against');
145
+ }
105
146
  return { healthy };
106
147
  }
@@ -0,0 +1,234 @@
1
+ // commands/negentropy.js — the Maxwell's demon pass (`doctor --app`, R3.1).
2
+ // Detects rot, deterministically and with zero LLM: schema drift (the code
3
+ // moved, the manifest didn't), stale/unsynced tools, dead current, chronic
4
+ // antigens the immune system keeps diagnosing but nobody fixes, and zombie
5
+ // config. Every finding names its repair. Honest by construction: a gauge
6
+ // that cannot be measured says so instead of guessing.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import crypto from 'node:crypto';
10
+
11
+ // same fingerprint the generators stamp into sparding.toolFingerprints —
12
+ // method|path|params, sha256/8. Divergence = the route's shape changed.
13
+ export function fingerprintFor(tool) {
14
+ const raw = `${tool.method}|${tool.path}|${JSON.stringify(tool.params ?? [])}`;
15
+ return crypto.createHash('sha256').update(raw).digest('hex').slice(0, 8);
16
+ }
17
+
18
+ // routes (parser output) → the tool shape the generators build, keyed the
19
+ // same way, so fingerprints are comparable across init/sync/doctor.
20
+ export function toolShapeOf(route) {
21
+ return {
22
+ method: route.method.toUpperCase(),
23
+ path: route.path,
24
+ params: route.params ?? [],
25
+ };
26
+ }
27
+
28
+ // Pure core. inputs:
29
+ // manifest — parsed sparda.json (required)
30
+ // currentRoutes — parser output for the code as it is NOW, or null (parse failed)
31
+ // live — /mcp/stats payload, or null (host down)
32
+ // detectedPort — what detect.js sees now, or null
33
+ // cwd — for file existence checks (router present?)
34
+ export function buildNegentropy({ manifest, currentRoutes, live, detectedPort, cwd }) {
35
+ const findings = [];
36
+ const add = (kind, severity, title, detail, fix) =>
37
+ findings.push({ kind, severity, title, detail, fix });
38
+
39
+ const tools = manifest.tools ?? {};
40
+ const byKey = new Map(
41
+ Object.entries(tools).map(([name, t]) => [`${t.method} ${t.path}`, name]),
42
+ );
43
+
44
+ // ── drift: manifest vs the code as parsed right now ──────────────────────
45
+ if (currentRoutes) {
46
+ const currentKeys = new Map(
47
+ currentRoutes.map((r) => [`${r.method.toUpperCase()} ${r.path}`, r]),
48
+ );
49
+ for (const [key, name] of byKey) {
50
+ if (!currentKeys.has(key)) {
51
+ add(
52
+ 'drift',
53
+ 'high',
54
+ `stale tool: ${name}`,
55
+ `${key} is in sparda.json but no longer exists in the code — the AI sees a ghost route`,
56
+ 'npx sparda-mcp sync',
57
+ );
58
+ }
59
+ }
60
+ for (const [key] of currentKeys) {
61
+ if (!byKey.has(key)) {
62
+ add(
63
+ 'drift',
64
+ 'medium',
65
+ `unsynced route: ${key}`,
66
+ 'exists in the code but not in sparda.json — invisible to the AI',
67
+ 'npx sparda-mcp sync',
68
+ );
69
+ }
70
+ }
71
+ const storedFps = manifest.sparding?.toolFingerprints ?? {};
72
+ for (const [key, name] of byKey) {
73
+ const route = currentKeys.get(key);
74
+ if (!route || !storedFps[name]) continue;
75
+ const fp = fingerprintFor(toolShapeOf(route));
76
+ if (fp !== storedFps[name]) {
77
+ add(
78
+ 'drift',
79
+ 'medium',
80
+ `shape drift: ${name}`,
81
+ `route structure changed since the last sync (fingerprint ${storedFps[name]} → ${fp}) — params the AI knows may be wrong`,
82
+ 'npx sparda-mcp sync',
83
+ );
84
+ }
85
+ }
86
+ } else {
87
+ add(
88
+ 'drift',
89
+ 'info',
90
+ 'drift not measurable',
91
+ 'the current code could not be re-parsed — drift against the manifest is unknown',
92
+ 'fix the parse error reported above, then re-run',
93
+ );
94
+ }
95
+
96
+ // ── dead current: enabled tools nobody exercised this session ────────────
97
+ if (live) {
98
+ const stats = live.stats ?? {};
99
+ const totalCalls = Object.values(stats).reduce((n, s) => n + (s.calls ?? 0), 0);
100
+ const uptime = live.uptimeSec ?? 0;
101
+ if (totalCalls > 0 && uptime >= 60) {
102
+ for (const [name, t] of Object.entries(tools)) {
103
+ if (!t.enabled) continue;
104
+ if ((stats[name]?.calls ?? 0) === 0) {
105
+ add(
106
+ 'dead',
107
+ 'info',
108
+ `no current: ${name}`,
109
+ `${t.method} ${t.path} — zero calls since host start (${uptime}s, ${totalCalls} calls elsewhere). RAM gauge: this session only, not a lifetime verdict`,
110
+ 'if this route is dead in the code too, delete it; if it matters, tell the AI about it (semantic pass)',
111
+ );
112
+ }
113
+ }
114
+ } else {
115
+ add(
116
+ 'dead',
117
+ 'info',
118
+ 'current not measurable yet',
119
+ `host up ${uptime}s with ${totalCalls} total calls — not enough observation to call any route dead`,
120
+ 'let the app run under real usage, then re-run',
121
+ );
122
+ }
123
+ for (const [name, q] of Object.entries(live.quarantine ?? {})) {
124
+ add(
125
+ 'sick',
126
+ 'high',
127
+ `quarantined: ${name}`,
128
+ `the immune system is protecting this route (${q.reason ?? 'repeated 5xx'})`,
129
+ 'fix the underlying failure; quarantine lifts itself (half-open probe)',
130
+ );
131
+ }
132
+ } else {
133
+ add(
134
+ 'dead',
135
+ 'info',
136
+ 'live gauges unavailable',
137
+ 'host not running — dead-current and quarantine checks need the app up',
138
+ 'start the app, then re-run npx sparda-mcp doctor --app',
139
+ );
140
+ }
141
+
142
+ // ── chronic antigens: diagnosed again and again, never fixed ─────────────
143
+ const failures = Object.entries(manifest.sparding?.failures ?? {})
144
+ .map(([sig, f]) => ({ sig, count: f.count ?? 0, lesson: f.lesson ?? '' }))
145
+ .filter((f) => f.count >= 3)
146
+ .sort((a, b) => b.count - a.count)
147
+ .slice(0, 3);
148
+ for (const f of failures) {
149
+ add(
150
+ 'sick',
151
+ 'medium',
152
+ `recurring failure: ${f.sig}`,
153
+ `${f.count} occurrences${f.lesson ? ` — lesson on file: ${f.lesson}` : ''}`,
154
+ f.sig.includes('write_disabled')
155
+ ? 'the AI keeps hitting a write-safety wall: enable the tool deliberately, or leave it and the block keeps doing its job'
156
+ : 'the diagnosis exists in the journal; the fix does not — repair the route',
157
+ );
158
+ }
159
+ const antibodies = Object.entries(manifest.immune?.antibodies ?? {})
160
+ .map(([sig, a]) => ({ sig, hits: a.hits ?? 0, diagnosis: a.diagnosis ?? '' }))
161
+ .filter((a) => a.hits >= 3)
162
+ .sort((a, b) => b.hits - a.hits)
163
+ .slice(0, 3);
164
+ for (const a of antibodies) {
165
+ add(
166
+ 'sick',
167
+ 'medium',
168
+ `chronic antigen: ${a.sig}`,
169
+ `${a.hits} zero-token diagnoses served — the memory works, the wound stays open${a.diagnosis ? ` (${a.diagnosis.slice(0, 100)})` : ''}`,
170
+ 'apply the cached diagnosis for real; the antibody then goes quiet on its own',
171
+ );
172
+ }
173
+
174
+ // ── zombie config ─────────────────────────────────────────────────────────
175
+ if (detectedPort && manifest.port && detectedPort !== manifest.port) {
176
+ add(
177
+ 'zombie',
178
+ 'high',
179
+ 'port drift',
180
+ `sparda.json says :${manifest.port} but the app now runs on :${detectedPort} — the bridge and the proxy point at the wrong door`,
181
+ 'npx sparda-mcp sync (re-detects and regenerates)',
182
+ );
183
+ }
184
+ for (const f of manifest.generatedFiles ?? []) {
185
+ const abs = path.resolve(cwd ?? '.', f);
186
+ if (!fs.existsSync(abs)) {
187
+ add(
188
+ 'zombie',
189
+ 'high',
190
+ 'router file missing',
191
+ `${f} is recorded in sparda.json but absent on disk — the organism has a memory of a body it no longer has`,
192
+ 'npx sparda-mcp init --yes (regenerates; carry-over keeps your state)',
193
+ );
194
+ } else if (
195
+ manifest.localKey &&
196
+ !fs.readFileSync(abs, 'utf8').includes(manifest.localKey)
197
+ ) {
198
+ add(
199
+ 'zombie',
200
+ 'high',
201
+ 'router/manifest key mismatch',
202
+ `${f} does not carry the manifest's localKey — a stale router from an older init is still wired in`,
203
+ 'npx sparda-mcp init --yes',
204
+ );
205
+ }
206
+ }
207
+
208
+ const summary = { drift: 0, dead: 0, sick: 0, zombie: 0 };
209
+ for (const f of findings) summary[f.kind] += 1;
210
+ const actionable = findings.filter((f) => f.severity !== 'info');
211
+ return { findings, summary, actionable: actionable.length };
212
+ }
213
+
214
+ // terminal rendering, doctor-style: ✗ fails CI, ⚠ warns, · informs
215
+ export function renderNegentropy(result) {
216
+ const lines = [];
217
+ let failing = false;
218
+ lines.push(' Negentropy scan (doctor --app)');
219
+ if (result.findings.length === 0) {
220
+ lines.push(' ✓ no rot detected — manifest, code and gauges agree');
221
+ return { lines, failing };
222
+ }
223
+ for (const f of result.findings) {
224
+ const glyph = f.severity === 'high' ? '✗' : f.severity === 'medium' ? '⚠' : '·';
225
+ if (f.severity === 'high') failing = true;
226
+ lines.push(` ${glyph} [${f.kind}] ${f.title}`);
227
+ lines.push(` ${f.detail}`);
228
+ lines.push(` → ${f.fix}`);
229
+ }
230
+ lines.push(
231
+ ` ${result.actionable ? '⚠' : '·'} ${result.findings.length} finding(s) — drift ${result.summary.drift} · dead ${result.summary.dead} · sick ${result.summary.sick} · zombie ${result.summary.zombie}`,
232
+ );
233
+ return { lines, failing };
234
+ }
@@ -0,0 +1,282 @@
1
+ // commands/seed.js — the genome (R4.5 lite): everything the organism LEARNED,
2
+ // distilled into a file that can regerminate elsewhere (dev → prod, or a
3
+ // community seed for a popular stack) without re-paying the learning.
4
+ //
5
+ // The closed circle: app → usage → seed → next app. Structure and lessons
6
+ // only — never a value, never a secret, never a security decision:
7
+ // EXPORTED : semantic descriptions/workflows, immune antibodies, failure
8
+ // lessons, Labs circuit structure.
9
+ // NEVER : localKey, port, policies, per-tool `enabled`, events, paths.
10
+ // An imported seed is UNTRUSTED input: every text field is re-sanitized, the
11
+ // security fields are stripped even if present, and nothing it contains can
12
+ // enable a write or change a policy (hard rule #3 is not negotiable by file).
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { sanitizeDescription } from '../security/sanitize.js';
16
+ import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
17
+
18
+ const SEED_VERSION = 'sparda-seed/v1';
19
+ const MAX_ANTIBODIES = 50; // same cap as the bridge
20
+ const MAX_WORKFLOWS = 20;
21
+ const MAX_CIRCUITS = 30; // same cap as the condenser
22
+ const MAX_FAILURES = 50;
23
+
24
+ const clean = (raw, fallback = '') => sanitizeDescription(raw, fallback).text;
25
+
26
+ // ── pure: manifest → seed ──────────────────────────────────────────────────
27
+
28
+ export function buildSeed(manifest) {
29
+ const seed = {
30
+ version: SEED_VERSION,
31
+ framework: manifest.framework ?? 'unknown',
32
+ exportedAt: new Date().toISOString(),
33
+ semantic: { descriptions: {}, workflows: [] },
34
+ antibodies: {},
35
+ failures: {},
36
+ circuits: {},
37
+ };
38
+
39
+ for (const [tool, desc] of Object.entries(manifest.semantic?.descriptions ?? {})) {
40
+ seed.semantic.descriptions[tool] = clean(desc);
41
+ }
42
+ for (const wf of (manifest.semantic?.workflows ?? []).slice(0, MAX_WORKFLOWS)) {
43
+ seed.semantic.workflows.push({
44
+ name: clean(wf.name, 'workflow'),
45
+ description: clean(wf.description),
46
+ steps: Array.isArray(wf.steps) ? wf.steps.map((s) => clean(s)).slice(0, 20) : [],
47
+ });
48
+ }
49
+ for (const [sig, a] of Object.entries(manifest.immune?.antibodies ?? {}).slice(
50
+ 0,
51
+ MAX_ANTIBODIES,
52
+ )) {
53
+ seed.antibodies[sig] = { diagnosis: clean(a.diagnosis), hits: Number(a.hits) || 0 };
54
+ }
55
+ for (const [sig, f] of Object.entries(manifest.sparding?.failures ?? {}).slice(
56
+ 0,
57
+ MAX_FAILURES,
58
+ )) {
59
+ seed.failures[sig] = { count: Number(f.count) || 0, lesson: clean(f.lesson) };
60
+ }
61
+ for (const [key, cir] of Object.entries(manifest.labs?.circuits ?? {}).slice(
62
+ 0,
63
+ MAX_CIRCUITS,
64
+ )) {
65
+ // structure only, exactly what the condenser persists — never values
66
+ seed.circuits[key] = {
67
+ steps: Array.isArray(cir.steps) ? cir.steps : [],
68
+ links: Array.isArray(cir.links) ? cir.links : [],
69
+ seen: Number(cir.seen) || 0,
70
+ ...(cir.composite
71
+ ? {
72
+ composite: {
73
+ name: clean(cir.composite.name, 'composite'),
74
+ description: clean(cir.composite.description),
75
+ source: 'seed',
76
+ },
77
+ }
78
+ : {}),
79
+ };
80
+ }
81
+ return seed;
82
+ }
83
+
84
+ // ── pure: (manifest, seed) → merged manifest — local knowledge wins ────────
85
+
86
+ export function mergeSeed(manifest, seed) {
87
+ if (!seed || seed.version !== SEED_VERSION) {
88
+ throw Object.assign(new Error('not a SPARDA seed (missing/unknown version field).'), {
89
+ code: 'USER',
90
+ hint: `expected "version": "${SEED_VERSION}" — re-export with npx sparda-mcp seed export`,
91
+ });
92
+ }
93
+ const m = structuredClone(manifest);
94
+ const toolNames = new Set(Object.keys(m.tools ?? {}));
95
+ const sigToolExists = (sig) => {
96
+ // signatures are `source|tool|status` — a lesson about a tool this app
97
+ // does not have is noise, not knowledge
98
+ const tool = String(sig).split('|')[1];
99
+ return tool === 'null' || tool === 'undefined' || toolNames.has(tool);
100
+ };
101
+ const report = {
102
+ descriptions: 0,
103
+ workflows: 0,
104
+ antibodies: 0,
105
+ failures: 0,
106
+ circuits: 0,
107
+ skipped: 0,
108
+ };
109
+
110
+ m.semantic ??= { descriptions: {}, workflows: [] };
111
+ m.semantic.descriptions ??= {};
112
+ m.semantic.workflows ??= [];
113
+ for (const [tool, desc] of Object.entries(seed.semantic?.descriptions ?? {})) {
114
+ if (!toolNames.has(tool)) {
115
+ report.skipped++;
116
+ continue;
117
+ }
118
+ if (m.semantic.descriptions[tool]) continue; // local knowledge wins
119
+ m.semantic.descriptions[tool] = clean(desc);
120
+ report.descriptions++;
121
+ }
122
+ const wfNames = new Set(m.semantic.workflows.map((w) => w.name));
123
+ for (const wf of (seed.semantic?.workflows ?? []).slice(0, MAX_WORKFLOWS)) {
124
+ const name = clean(wf.name, 'workflow');
125
+ if (wfNames.has(name) || m.semantic.workflows.length >= MAX_WORKFLOWS) continue;
126
+ wfNames.add(name);
127
+ m.semantic.workflows.push({
128
+ name,
129
+ description: clean(wf.description),
130
+ steps: Array.isArray(wf.steps) ? wf.steps.map((s) => clean(s)).slice(0, 20) : [],
131
+ source: 'seed',
132
+ });
133
+ report.workflows++;
134
+ }
135
+
136
+ m.immune ??= { antibodies: {} };
137
+ m.immune.antibodies ??= {};
138
+ for (const [sig, a] of Object.entries(seed.antibodies ?? {})) {
139
+ if (!sigToolExists(sig)) {
140
+ report.skipped++;
141
+ continue;
142
+ }
143
+ if (
144
+ Object.keys(m.immune.antibodies).length >= MAX_ANTIBODIES &&
145
+ !m.immune.antibodies[sig]
146
+ )
147
+ continue;
148
+ const existing = m.immune.antibodies[sig];
149
+ if (existing) {
150
+ existing.hits = Math.max(Number(existing.hits) || 0, Number(a.hits) || 0);
151
+ } else {
152
+ m.immune.antibodies[sig] = {
153
+ diagnosis: clean(a.diagnosis),
154
+ hits: Number(a.hits) || 0,
155
+ firstSeen: new Date().toISOString(),
156
+ lastSeen: new Date().toISOString(),
157
+ source: 'seed',
158
+ };
159
+ report.antibodies++;
160
+ }
161
+ }
162
+
163
+ m.sparding ??= {};
164
+ m.sparding.failures ??= {};
165
+ for (const [sig, f] of Object.entries(seed.failures ?? {})) {
166
+ if (!sigToolExists(sig)) {
167
+ report.skipped++;
168
+ continue;
169
+ }
170
+ const existing = m.sparding.failures[sig];
171
+ if (existing) {
172
+ existing.count = Math.max(Number(existing.count) || 0, Number(f.count) || 0);
173
+ } else if (Object.keys(m.sparding.failures).length < MAX_FAILURES) {
174
+ m.sparding.failures[sig] = { count: Number(f.count) || 0, lesson: clean(f.lesson) };
175
+ report.failures++;
176
+ }
177
+ }
178
+
179
+ m.labs ??= {};
180
+ m.labs.circuits ??= {};
181
+ for (const [key, cir] of Object.entries(seed.circuits ?? {})) {
182
+ if (m.labs.circuits[key]) continue; // local observation wins
183
+ const stepTools = (Array.isArray(cir.steps) ? cir.steps : [])
184
+ .map((s) => (typeof s === 'string' ? s : s?.tool))
185
+ .filter(Boolean);
186
+ if (!stepTools.every((t) => toolNames.has(t))) {
187
+ report.skipped++;
188
+ continue;
189
+ }
190
+ if (Object.keys(m.labs.circuits).length >= MAX_CIRCUITS) continue;
191
+ m.labs.circuits[key] = {
192
+ steps: cir.steps,
193
+ links: Array.isArray(cir.links) ? cir.links : [],
194
+ seen: Number(cir.seen) || 0,
195
+ firstSeen: new Date().toISOString(),
196
+ lastSeen: new Date().toISOString(),
197
+ ...(cir.composite
198
+ ? {
199
+ composite: {
200
+ name: clean(cir.composite.name, 'composite'),
201
+ description: clean(cir.composite.description),
202
+ source: 'seed',
203
+ createdAt: new Date().toISOString(),
204
+ },
205
+ }
206
+ : {}),
207
+ };
208
+ report.circuits++;
209
+ }
210
+
211
+ // the file may claim anything — the organism's security is not up for
212
+ // negotiation: whatever a seed carries, these never cross (hard rule #3/#5)
213
+ // (localKey, port, policies and per-tool `enabled` were simply never read.)
214
+ return { manifest: m, report };
215
+ }
216
+
217
+ // ── the command: seed export [--out f] | seed import <file> ───────────────
218
+
219
+ export async function runSeed(opts, args) {
220
+ const sub = args[0];
221
+ const manifestPath = path.join(opts.cwd, 'sparda.json');
222
+ if (!fs.existsSync(manifestPath)) {
223
+ throw Object.assign(new Error('sparda.json not found.'), {
224
+ code: 'USER',
225
+ hint: 'run `npx sparda-mcp init` first',
226
+ });
227
+ }
228
+ let manifest;
229
+ try {
230
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
231
+ } catch {
232
+ throw Object.assign(new Error('sparda.json is not valid JSON.'), {
233
+ code: 'USER',
234
+ hint: 'restore it from git or re-run `npx sparda-mcp init`',
235
+ });
236
+ }
237
+
238
+ if (sub === 'export') {
239
+ const seed = buildSeed(manifest);
240
+ const out = opts.out ?? 'sparda-seed.json';
241
+ atomicWrite(path.resolve(opts.cwd, out), JSON.stringify(seed, null, 2) + '\n');
242
+ console.log(
243
+ `✓ Seed exported: ${out} — ${Object.keys(seed.antibodies).length} antibodies, ${Object.keys(seed.semantic.descriptions).length} descriptions, ${seed.semantic.workflows.length} workflows, ${Object.keys(seed.circuits).length} circuits, ${Object.keys(seed.failures).length} lessons`,
244
+ );
245
+ console.log(' Structure and lessons only. No key, no policy, no value ever leaves.');
246
+ return { seed };
247
+ }
248
+
249
+ if (sub === 'import') {
250
+ const file = args[1];
251
+ if (!file) {
252
+ throw Object.assign(new Error('missing seed file.'), {
253
+ code: 'USER',
254
+ hint: 'usage: npx sparda-mcp seed import <sparda-seed.json>',
255
+ });
256
+ }
257
+ const abs = path.resolve(opts.cwd, file);
258
+ if (!fs.existsSync(abs)) {
259
+ throw Object.assign(new Error(`seed file not found: ${file}`), { code: 'USER' });
260
+ }
261
+ let seed;
262
+ try {
263
+ seed = JSON.parse(fs.readFileSync(abs, 'utf8'));
264
+ } catch {
265
+ throw Object.assign(new Error('seed file is not valid JSON.'), { code: 'USER' });
266
+ }
267
+ const { manifest: merged, report } = mergeSeed(manifest, seed);
268
+ atomicWrite(manifestPath, JSON.stringify(merged, null, 2) + '\n');
269
+ console.log(
270
+ `✓ Seed germinated into sparda.json — +${report.antibodies} antibodies, +${report.descriptions} descriptions, +${report.workflows} workflows, +${report.circuits} circuits, +${report.failures} lessons (${report.skipped} entries skipped: unknown tools here)`,
271
+ );
272
+ console.log(
273
+ ' Local knowledge always wins; keys, policies and enabled flags were never read.',
274
+ );
275
+ return { report };
276
+ }
277
+
278
+ throw Object.assign(new Error(`unknown seed subcommand: ${sub ?? '(none)'}`), {
279
+ code: 'USER',
280
+ hint: 'usage: npx sparda-mcp seed export [--out file] | seed import <file>',
281
+ });
282
+ }
package/src/index.js CHANGED
@@ -20,7 +20,9 @@ const opts = {
20
20
  probe: flags.has('--probe'),
21
21
  html: flags.has('--html'),
22
22
  json: flags.has('--json'),
23
+ app: flags.has('--app'),
23
24
  port: getOpt('port', null),
25
+ out: getOpt('out', null),
24
26
  cwd: process.cwd(),
25
27
  };
26
28
 
@@ -67,6 +69,14 @@ try {
67
69
  await runReport(opts);
68
70
  break;
69
71
  }
72
+ case 'seed': {
73
+ const { runSeed } = await import('./commands/seed.js');
74
+ await runSeed(
75
+ opts,
76
+ rest.filter((a) => !a.startsWith('--')),
77
+ );
78
+ break;
79
+ }
70
80
  default:
71
81
  console.log(`SPARDA v${VERSION} — Turn any codebase into an MCP server.
72
82
 
@@ -77,8 +87,9 @@ Usage:
77
87
  npx sparda-mcp sync Re-sync the router after route changes (no prompts)
78
88
  npx sparda-mcp hook Install the git sentinel (auto-sync after commits)
79
89
  npx sparda-mcp remove Remove SPARDA from this project (clean git diff)
80
- npx sparda-mcp doctor Diagnose your setup
90
+ npx sparda-mcp doctor Diagnose your setup (--app: negentropy scan — drift, dead routes, rot)
81
91
  npx sparda-mcp report The black box: what AI agents did to this app
92
+ npx sparda-mcp seed Export/import the learned genome (export [--out f] | import <f>)
82
93
 
83
94
  Flags: --yes (skip prompts) --port <n> --quiet --verbose
84
95
  --probe (init: also run the app to discover dynamic routes the AST missed)