sparda-mcp 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -105,6 +105,8 @@ What we *don't* promise: the honest limits in [docs/SECURITY.md](./docs/SECURITY
105
105
  2. Tool calls run **inside your live app process** — warm DB pools, real auth chain, real data. SPARDA adds no infrastructure: compute comes from your host process, intelligence from your AI client's own model (MCP sampling), storage from `sparda.json` + git.
106
106
  3. Write tools (POST/PUT/DELETE) are **disabled by default**. You opt in per tool in `sparda.json` — your choices survive re-runs.
107
107
  4. Suspicious docstrings are sanitized before they ever reach the AI (prompt-injection defense).
108
+ 5. `npx sparda-mcp doctor --app` audits your codebase for drift: it detects stale tools (IA seeing ghosts), unsynced routes, schema drift via fingerprints, and zombie configurations. High severity issues trigger a non-zero exit code for your CI pipeline.
109
+ 6. `npx sparda-mcp seed export/import` lets you package and share your app's "genome" (semantic memory, workflows, antibodies) securely, transferring immune memory between environments or across similar stacks with zero data leak.
108
110
 
109
111
  ## What SPARDA gives your AI
110
112
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
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)
@@ -32,6 +32,112 @@ export function fallbackComposite(circuit) {
32
32
  };
33
33
  }
34
34
 
35
+ // ── R2.4: nothing disappears, x becomes y ──────────────────────────────────
36
+ // Tool names derive from method+path, so a renamed route means a vanished step
37
+ // name. Instead of letting the composite die silently at wake-up, we look for
38
+ // the step's UNIQUE deterministic successor: an enabled GET whose name keeps
39
+ // the old name's segments in order (api_users ⊂ api_v2_users) and ends on the
40
+ // same resource segment. One candidate → re-map; zero or many → dormant with a
41
+ // recorded reason. Never a guess: ambiguity is a reason, not a coin flip.
42
+
43
+ function nameSegments(toolName) {
44
+ const parts = String(toolName).split('_');
45
+ return ['get', 'post', 'put', 'patch', 'delete'].includes(parts[0])
46
+ ? parts.slice(1)
47
+ : parts;
48
+ }
49
+
50
+ function isSubsequence(small, big) {
51
+ let i = 0;
52
+ for (const seg of big) if (seg === small[i]) i++;
53
+ return i === small.length;
54
+ }
55
+
56
+ export function successorFor(oldName, toolSpecs, exclude = new Set()) {
57
+ const oldSegs = nameSegments(oldName);
58
+ if (!oldSegs.length) return null;
59
+ const last = oldSegs[oldSegs.length - 1];
60
+ const candidates = Object.entries(toolSpecs)
61
+ .filter(([name, t]) => {
62
+ if (exclude.has(name) || !t.enabled || t.method !== 'GET') return false;
63
+ const segs = nameSegments(name);
64
+ return segs[segs.length - 1] === last && isSubsequence(oldSegs, segs);
65
+ })
66
+ .map(([name]) => name);
67
+ return candidates.length === 1 ? candidates[0] : null;
68
+ }
69
+
70
+ // (circuits map, today's tools) → what survives, what becomes, what sleeps.
71
+ // Pure: never mutates the input; the caller decides what to persist.
72
+ export function remapComposites(circuits, toolSpecs) {
73
+ const remapped = [];
74
+ const dormant = [];
75
+ for (const [oldKey, circuit] of Object.entries(circuits ?? {})) {
76
+ if (!circuit?.composite?.name) continue;
77
+ if (eligibleForCrystallization(circuit, toolSpecs)) continue; // alive as-is
78
+
79
+ const disabled = (circuit.steps ?? []).filter(
80
+ (s) => toolSpecs[s] && !toolSpecs[s].enabled,
81
+ );
82
+ if (disabled.length) {
83
+ // the user disabled a step on purpose — respect it, do not re-route around it
84
+ dormant.push({
85
+ composite: circuit.composite.name,
86
+ key: oldKey,
87
+ reason: `step disabled by the user: ${disabled.join(', ')} — composite sleeps until re-enabled`,
88
+ });
89
+ continue;
90
+ }
91
+
92
+ const renames = {};
93
+ const newSteps = [];
94
+ let resolvable = true;
95
+ for (const step of circuit.steps ?? []) {
96
+ if (toolSpecs[step]?.enabled && toolSpecs[step].method === 'GET') {
97
+ newSteps.push(step);
98
+ continue;
99
+ }
100
+ const successor = successorFor(step, toolSpecs, new Set(newSteps));
101
+ if (!successor) {
102
+ resolvable = false;
103
+ dormant.push({
104
+ composite: circuit.composite.name,
105
+ key: oldKey,
106
+ reason: `step vanished with no unique successor: ${step} — structure kept, waiting for a sync that brings it back`,
107
+ });
108
+ break;
109
+ }
110
+ renames[step] = successor;
111
+ newSteps.push(successor);
112
+ }
113
+ if (!resolvable) continue;
114
+
115
+ const next = structuredClone(circuit);
116
+ next.steps = newSteps;
117
+ next.links = (next.links ?? []).map((l) => ({
118
+ ...l,
119
+ from: renames[l.from] ?? l.from,
120
+ to: renames[l.to] ?? l.to,
121
+ }));
122
+ if (!eligibleForCrystallization(next, toolSpecs)) {
123
+ dormant.push({
124
+ composite: circuit.composite.name,
125
+ key: oldKey,
126
+ reason:
127
+ 'successor found but the re-mapped circuit is not crystallizable — kept dormant',
128
+ });
129
+ continue;
130
+ }
131
+ remapped.push({
132
+ oldKey,
133
+ newKey: next.steps.join('>'),
134
+ circuit: next,
135
+ renames,
136
+ });
137
+ }
138
+ return { remapped, dormant };
139
+ }
140
+
35
141
  // a sampled name is untrusted input: normalize hard, reject anything shapeless
36
142
  export function normalizeCompositeName(raw) {
37
143
  const n = String(raw ?? '')
@@ -15,6 +15,7 @@ import { createIdleHarvester } from './idle.js';
15
15
  import { createSequenceRecorder, sequenceRecordingEnabled } from './condenser.js';
16
16
  import {
17
17
  eligibleForCrystallization,
18
+ remapComposites,
18
19
  fallbackComposite,
19
20
  normalizeCompositeName,
20
21
  compositeSchema,
@@ -228,11 +229,59 @@ export async function startStdioBridge({ cwd, portOverride }) {
228
229
  // composites born in past sessions wake up with the bridge — re-validated
229
230
  // against today's tools (a route may have changed or been disabled since)
230
231
  if (recorder) {
232
+ // R2.4 — nothing disappears, x becomes y: a step whose route was renamed is
233
+ // re-mapped to its UNIQUE deterministic successor instead of killing the
234
+ // composite silently; the unmappable go dormant WITH a recorded lesson.
235
+ const { remapped, dormant } = remapComposites(
236
+ manifest.labs?.circuits ?? {},
237
+ toolSpecs,
238
+ );
231
239
  for (const [sig, c] of Object.entries(manifest.labs?.circuits ?? {})) {
232
240
  if (c.composite?.name && eligibleForCrystallization(c, toolSpecs)) {
233
241
  composites.set(uniqueCompositeName(c.composite.name), { sig, circuit: c });
234
242
  }
235
243
  }
244
+ for (const r of remapped) {
245
+ delete manifest.labs.circuits[r.oldKey];
246
+ manifest.labs.circuits[r.newKey] = r.circuit;
247
+ composites.set(uniqueCompositeName(r.circuit.composite.name), {
248
+ sig: r.newKey,
249
+ circuit: r.circuit,
250
+ });
251
+ manifest.sparding ??= {};
252
+ manifest.sparding.events ??= [];
253
+ manifest.sparding.events.push({
254
+ ts: new Date().toISOString(),
255
+ tool: r.circuit.composite.name,
256
+ decision: 'audit',
257
+ risk: 'low',
258
+ reasons: [
259
+ `composite re-mapped (R2.4): ${Object.entries(r.renames)
260
+ .map(([a, b]) => `${a} → ${b}`)
261
+ .join(', ')}`,
262
+ ],
263
+ });
264
+ if (manifest.sparding.events.length > 100) manifest.sparding.events.shift();
265
+ console.error(
266
+ `[sparda] R2.4: composite '${r.circuit.composite.name}' re-mapped (${Object.entries(
267
+ r.renames,
268
+ )
269
+ .map(([a, b]) => `${a} → ${b}`)
270
+ .join(', ')})`,
271
+ );
272
+ }
273
+ for (const d of dormant) {
274
+ manifest.sparding ??= {};
275
+ manifest.sparding.failures ??= {};
276
+ const fsig = `composite|${d.composite}|dormant`;
277
+ const prev = manifest.sparding.failures[fsig] ?? { count: 0, lesson: '' };
278
+ manifest.sparding.failures[fsig] = { count: prev.count + 1, lesson: d.reason };
279
+ console.error(`[sparda] R2.4: composite '${d.composite}' dormant — ${d.reason}`);
280
+ }
281
+ if (remapped.length || dormant.length) {
282
+ mergeManifestKeySync(manifestPath, 'labs', manifest.labs);
283
+ mergeManifestKeySync(manifestPath, 'sparding', manifest.sparding);
284
+ }
236
285
  }
237
286
 
238
287
  const descFor = (name, t) => {