sparda-mcp 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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.7.0",
3
+ "version": "0.8.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",
@@ -59,14 +59,14 @@ export async function runDoctor(opts) {
59
59
  try {
60
60
  const r = await fetch(`http://127.0.0.1:${m.port}/mcp/tools`, {
61
61
  headers,
62
- signal: AbortSignal.timeout(1500),
62
+ signal: AbortSignal.timeout(5000),
63
63
  });
64
64
  if (!r.ok) {
65
65
  fail(` ✗ Host app on :${m.port} answered ${r.status}`);
66
66
  } else {
67
67
  const s = await fetch(`http://127.0.0.1:${m.port}/mcp/stats`, {
68
68
  headers,
69
- signal: AbortSignal.timeout(1500),
69
+ signal: AbortSignal.timeout(5000),
70
70
  })
71
71
  .then((x) => (x.ok ? x.json() : null))
72
72
  .catch(() => null);
@@ -0,0 +1,149 @@
1
+ // commands/evolve.js — R3.4, Darwin/Baldwin: mutate candidate chains from the
2
+ // grammar's hypotheses, trial them against the TWIN (never the host — ADR-021
3
+ // §5), and let the survivors land as SUGGESTIONS: `labs.circuits` entries with
4
+ // `seen: 0` and `evolved: true`. They are not composites and never become one
5
+ // here — crystallization still demands the real observation threshold. An
6
+ // emergent capability is a suggestion until reality confirms it (survival §1).
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { buildGrammar } from './grammar.js';
10
+ import { createTwinServer, twinFilePath } from './twin.js';
11
+ import { findByKey } from '../server/crystallize.js';
12
+ import { mergeManifestKeySync } from '../server/persistence.js';
13
+
14
+ const MAX_CANDIDATES_PER_RUN = 10;
15
+
16
+ // pure: hypothesis edges that no observed circuit already covers → candidates
17
+ export function candidateChains(grammar, circuits = {}) {
18
+ const out = [];
19
+ for (const e of grammar.edges ?? []) {
20
+ if (e.source !== 'hypothesis') continue;
21
+ const key = `${e.from}>${e.to}`;
22
+ if (circuits[key]) continue; // reality (or a past run) got there first
23
+ if (out.some((c) => c.key === key)) continue;
24
+ out.push({
25
+ key,
26
+ steps: [e.from, e.to],
27
+ links: [{ from: e.from, to: e.to, arg: e.arg, fromKey: e.fromKey }],
28
+ });
29
+ if (out.length >= MAX_CANDIDATES_PER_RUN) break;
30
+ }
31
+ return out;
32
+ }
33
+
34
+ // trial one candidate against a running twin: A answers, the linked key is
35
+ // really there, B accepts the fed value. All against the ghost, never the host.
36
+ export async function trialCandidate(candidate, { port, localKey }) {
37
+ const invoke = async (tool, args) => {
38
+ const res = await fetch(`http://127.0.0.1:${port}/mcp/invoke`, {
39
+ method: 'POST',
40
+ headers: { 'content-type': 'application/json', 'x-sparda-key': localKey },
41
+ body: JSON.stringify({ tool, args }),
42
+ signal: AbortSignal.timeout(5000),
43
+ });
44
+ return res.json();
45
+ };
46
+ const [a, b] = candidate.steps;
47
+ const link = candidate.links[0];
48
+ const first = await invoke(a, {});
49
+ if (first?.upstreamStatus !== 200)
50
+ return { ok: false, why: `${a} answered ${first?.upstreamStatus}` };
51
+ const fed = findByKey(first.data, link.fromKey);
52
+ if (fed === undefined)
53
+ return { ok: false, why: `'${link.fromKey}' not found in ${a}'s exemplar` };
54
+ if (typeof fed === 'object')
55
+ return { ok: false, why: `'${link.fromKey}' is not a scalar` };
56
+ const second = await invoke(b, { [link.arg]: fed });
57
+ if (second?.upstreamStatus !== 200)
58
+ return { ok: false, why: `${b} answered ${second?.upstreamStatus}` };
59
+ return { ok: true, fedValueType: typeof fed };
60
+ }
61
+
62
+ export async function runEvolve(opts) {
63
+ const manifestPath = path.join(opts.cwd, 'sparda.json');
64
+ if (!fs.existsSync(manifestPath)) {
65
+ throw Object.assign(new Error('sparda.json not found.'), {
66
+ code: 'USER',
67
+ hint: 'run `npx sparda-mcp init` first',
68
+ });
69
+ }
70
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
71
+
72
+ const twinPath = twinFilePath(opts.cwd);
73
+ if (!fs.existsSync(twinPath)) {
74
+ throw Object.assign(
75
+ new Error(
76
+ 'no twin memory (.sparda/twin.json) — evolution needs the ghost to practice on.',
77
+ ),
78
+ {
79
+ code: 'USER',
80
+ hint: 'run `npx sparda-mcp twin --learn` against the live app first',
81
+ },
82
+ );
83
+ }
84
+ let exemplars = {};
85
+ try {
86
+ exemplars = JSON.parse(fs.readFileSync(twinPath, 'utf8')).exemplars ?? {};
87
+ } catch {
88
+ throw Object.assign(new Error('.sparda/twin.json is not valid JSON.'), {
89
+ code: 'USER',
90
+ hint: 're-run `npx sparda-mcp twin --learn`',
91
+ });
92
+ }
93
+
94
+ const grammar = buildGrammar(manifest, exemplars);
95
+ const candidates = candidateChains(grammar, manifest.labs?.circuits ?? {});
96
+ if (!candidates.length) {
97
+ console.log(
98
+ '· Nothing to evolve — no untested hypothesis chains (grammar has no new guesses).',
99
+ );
100
+ return { survivors: [], failed: [] };
101
+ }
102
+
103
+ // the practice arena: an in-process twin on an ephemeral port
104
+ const server = createTwinServer(manifest, exemplars);
105
+ const port = await new Promise((resolve, reject) => {
106
+ server.once('error', reject);
107
+ server.listen(0, '127.0.0.1', () => resolve(server.address().port));
108
+ });
109
+
110
+ const survivors = [];
111
+ const failed = [];
112
+ try {
113
+ for (const c of candidates) {
114
+ const verdict = await trialCandidate(c, { port, localKey: manifest.localKey });
115
+ if (verdict.ok) survivors.push(c);
116
+ else failed.push({ key: c.key, why: verdict.why });
117
+ }
118
+ } finally {
119
+ server.close();
120
+ }
121
+
122
+ if (survivors.length) {
123
+ manifest.labs ??= {};
124
+ manifest.labs.circuits ??= {};
125
+ const now = new Date().toISOString();
126
+ for (const c of survivors) {
127
+ manifest.labs.circuits[c.key] = {
128
+ steps: c.steps,
129
+ links: c.links,
130
+ seen: 0, // heredity without confirmation: reality still has to speak
131
+ evolved: true,
132
+ firstSeen: now,
133
+ lastSeen: now,
134
+ };
135
+ }
136
+ mergeManifestKeySync(manifestPath, 'labs', manifest.labs);
137
+ }
138
+
139
+ console.log(
140
+ `✓ Evolution run: ${candidates.length} candidate(s) trialed against the twin — ${survivors.length} survivor(s), ${failed.length} culled`,
141
+ );
142
+ for (const c of survivors)
143
+ console.log(
144
+ ` + suggested circuit: ${c.steps.join(' → ')} (seen: 0 — crystallizes only after real observations)`,
145
+ );
146
+ for (const f of failed) console.log(` - culled ${f.key}: ${f.why}`);
147
+ console.log(' The host was never touched: every trial ran against the ghost.');
148
+ return { survivors, failed };
149
+ }
@@ -0,0 +1,138 @@
1
+ // commands/grammar.js — R3.3, the Rosetta stone: which call sequences MEAN
2
+ // something in this app. Derived, deterministic, never authoritative
3
+ // (ADR-021 §4): observed edges come from Labs circuits (real data flows seen
4
+ // N times); hypothesis edges come from twin exemplar response keys matching
5
+ // another tool's param names — always labelled, never acted on by themselves.
6
+ // The artifact lives in .sparda/grammar.json (regenerable, never committed).
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { walkPayload } from '../server/condenser.js';
10
+ import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
11
+ import { twinFilePath } from './twin.js';
12
+
13
+ const MAX_HYPOTHESES = 50;
14
+ const GRAMMAR_VERSION = 'sparda-grammar/v1';
15
+
16
+ // bounded key harvest — same walk the condenser trusts, so what the grammar
17
+ // hypothesizes is exactly what a composite could later re-feed
18
+ export function responseKeysOf(exemplarData) {
19
+ const keys = new Set();
20
+ walkPayload(exemplarData, (k) => {
21
+ keys.add(k);
22
+ if (keys.size >= 40) return false;
23
+ });
24
+ return keys;
25
+ }
26
+
27
+ // pure: (manifest, exemplars|null) → the grammar
28
+ export function buildGrammar(manifest, exemplars = null) {
29
+ const tools = manifest.tools ?? {};
30
+ const edges = [];
31
+ const seenEdge = new Set();
32
+ const edgeKey = (e) => `${e.from}>${e.to}:${e.arg}`;
33
+
34
+ // observed: the circuits ARE the sentences reality already spoke
35
+ const phrases = [];
36
+ for (const [key, cir] of Object.entries(manifest.labs?.circuits ?? {})) {
37
+ phrases.push({
38
+ key,
39
+ steps: cir.steps ?? [],
40
+ seen: cir.seen ?? 0,
41
+ crystallized: Boolean(cir.composite),
42
+ ...(cir.evolved ? { evolved: true } : {}),
43
+ });
44
+ for (const l of cir.links ?? []) {
45
+ const e = {
46
+ from: l.from,
47
+ to: l.to,
48
+ fromKey: l.fromKey,
49
+ arg: l.arg,
50
+ source: 'observed',
51
+ };
52
+ if (!seenEdge.has(edgeKey(e))) {
53
+ seenEdge.add(edgeKey(e));
54
+ edges.push(e);
55
+ }
56
+ }
57
+ }
58
+
59
+ // hypotheses: an exemplar of A answers with a key another tool B takes as a
60
+ // param → maybe A feeds B. A guess, said out loud as a guess.
61
+ if (exemplars) {
62
+ for (const [from, ex] of Object.entries(exemplars)) {
63
+ if (!tools[from] || ex?.data === undefined) continue;
64
+ const keys = responseKeysOf(ex.data);
65
+ for (const [to, t] of Object.entries(tools)) {
66
+ if (to === from || !t.enabled || t.method !== 'GET') continue;
67
+ for (const p of t.params ?? []) {
68
+ if (edges.length >= MAX_HYPOTHESES + seenEdge.size) break;
69
+ if (!keys.has(p.name)) continue;
70
+ const e = { from, to, fromKey: p.name, arg: p.name, source: 'hypothesis' };
71
+ if (seenEdge.has(edgeKey(e))) continue; // reality already confirmed it
72
+ seenEdge.add(edgeKey(e));
73
+ edges.push(e);
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ return {
80
+ version: GRAMMAR_VERSION,
81
+ builtAt: new Date().toISOString(),
82
+ nodes: Object.keys(tools),
83
+ edges,
84
+ phrases,
85
+ };
86
+ }
87
+
88
+ export async function runGrammar(opts) {
89
+ const manifestPath = path.join(opts.cwd, 'sparda.json');
90
+ if (!fs.existsSync(manifestPath)) {
91
+ throw Object.assign(new Error('sparda.json not found.'), {
92
+ code: 'USER',
93
+ hint: 'run `npx sparda-mcp init` first',
94
+ });
95
+ }
96
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
97
+
98
+ let exemplars = null;
99
+ const twinPath = twinFilePath(opts.cwd);
100
+ if (fs.existsSync(twinPath)) {
101
+ try {
102
+ exemplars = JSON.parse(fs.readFileSync(twinPath, 'utf8')).exemplars ?? null;
103
+ } catch {
104
+ exemplars = null;
105
+ }
106
+ }
107
+
108
+ const grammar = buildGrammar(manifest, exemplars);
109
+ const outPath = path.join(opts.cwd, '.sparda', 'grammar.json');
110
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
111
+ atomicWrite(outPath, JSON.stringify(grammar, null, 2) + '\n');
112
+
113
+ const observed = grammar.edges.filter((e) => e.source === 'observed');
114
+ const hypotheses = grammar.edges.filter((e) => e.source === 'hypothesis');
115
+ console.log(`✓ Grammar written: .sparda/grammar.json — ${grammar.nodes.length} tools`);
116
+ if (grammar.phrases.length) {
117
+ console.log(` Sentences reality has spoken (${grammar.phrases.length}):`);
118
+ for (const p of grammar.phrases.slice(0, 10))
119
+ console.log(
120
+ ` ${p.steps.join(' → ')} ×${p.seen}${p.crystallized ? ' (crystallized)' : ''}${p.evolved ? ' (evolved, unconfirmed)' : ''}`,
121
+ );
122
+ } else
123
+ console.log(
124
+ ' · no sentences observed yet — enable labs.recordSequences and use the app',
125
+ );
126
+ if (observed.length) console.log(` Observed data flows: ${observed.length}`);
127
+ if (hypotheses.length) {
128
+ console.log(
129
+ ` Hypotheses from twin exemplars (${hypotheses.length}) — guesses, said as guesses:`,
130
+ );
131
+ for (const e of hypotheses.slice(0, 10))
132
+ console.log(` ${e.from} —'${e.fromKey}'→ ${e.to}`);
133
+ } else if (!exemplars)
134
+ console.log(
135
+ ' · no hypotheses — run `sparda twin --learn` first to give the grammar eyes',
136
+ );
137
+ return { grammar };
138
+ }
@@ -272,6 +272,26 @@ export async function runSeed(opts, args) {
272
272
  console.log(
273
273
  ' Local knowledge always wins; keys, policies and enabled flags were never read.',
274
274
  );
275
+ // R4.5 full — germination: the derived organs regrow from the imported
276
+ // structure on THIS machine (values never travelled; they never do).
277
+ if (opts.germinate) {
278
+ const { buildGrammar } = await import('./grammar.js');
279
+ const { twinFilePath } = await import('./twin.js');
280
+ let exemplars = null;
281
+ try {
282
+ exemplars =
283
+ JSON.parse(fs.readFileSync(twinFilePath(opts.cwd), 'utf8')).exemplars ?? null;
284
+ } catch {
285
+ exemplars = null; // no local twin memory yet — grammar grows from structure alone
286
+ }
287
+ const grammar = buildGrammar(merged, exemplars);
288
+ const outPath = path.join(opts.cwd, '.sparda', 'grammar.json');
289
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
290
+ atomicWrite(outPath, JSON.stringify(grammar, null, 2) + '\n');
291
+ console.log(
292
+ `✓ Germinated: grammar regrown from the imported genome (${grammar.phrases.length} phrases, ${grammar.edges.length} edges) → .sparda/grammar.json`,
293
+ );
294
+ }
275
295
  return { report };
276
296
  }
277
297
 
@@ -0,0 +1,247 @@
1
+ // commands/twin.js — R3.2, the holographic principle: reconstruct a living
2
+ // mock of the app from its boundary (manifest structure + observed I/O).
3
+ //
4
+ // Two verbs, one value boundary (ADR-021):
5
+ // sparda twin --learn asks the LIVE router once per eligible GET tool and
6
+ // stores capped exemplars in .sparda/twin.json —
7
+ // machine-local, gitignored, never the manifest.
8
+ // sparda twin serves the clone: same routes, same /mcp surface,
9
+ // answered from exemplars. Writes return 202 echoes
10
+ // and touch nothing. Stop the app, run the twin, and
11
+ // every agent exercises a ghost instead of the truth.
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import http from 'node:http';
15
+ import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
16
+
17
+ const EXEMPLAR_CAP = 16384; // 16KB per exemplar — a shape, not an archive
18
+
19
+ export function twinFilePath(cwd) {
20
+ return path.join(cwd, '.sparda', 'twin.json');
21
+ }
22
+
23
+ // ── learn: one real call per eligible tool, through the live router ────────
24
+
25
+ export function eligibleForLearning(tool) {
26
+ return tool.enabled && tool.method === 'GET' && (tool.pathParams ?? []).length === 0;
27
+ }
28
+
29
+ export async function learnExemplars(manifest, fetchFn = fetch) {
30
+ const exemplars = {};
31
+ const skipped = [];
32
+ for (const [name, t] of Object.entries(manifest.tools ?? {})) {
33
+ if (!eligibleForLearning(t)) {
34
+ skipped.push({
35
+ tool: name,
36
+ reason: !t.enabled
37
+ ? 'disabled'
38
+ : t.method !== 'GET'
39
+ ? 'write tool — a twin never learns from side effects'
40
+ : 'required path params — no observed values to fill them (v0.1)',
41
+ });
42
+ continue;
43
+ }
44
+ try {
45
+ const res = await fetchFn(`http://127.0.0.1:${manifest.port}/mcp/invoke`, {
46
+ method: 'POST',
47
+ headers: {
48
+ 'content-type': 'application/json',
49
+ 'x-sparda-key': manifest.localKey,
50
+ },
51
+ body: JSON.stringify({ tool: name, args: {} }),
52
+ signal: AbortSignal.timeout(10_000),
53
+ });
54
+ const payload = await res.json();
55
+ if (payload?.upstreamStatus === 200 && payload.data !== undefined) {
56
+ const raw = JSON.stringify(payload.data);
57
+ if (raw.length <= EXEMPLAR_CAP) {
58
+ exemplars[name] = { data: payload.data, learnedAt: new Date().toISOString() };
59
+ } else {
60
+ skipped.push({ tool: name, reason: `response over ${EXEMPLAR_CAP}B cap` });
61
+ }
62
+ } else {
63
+ skipped.push({
64
+ tool: name,
65
+ reason: `live call answered ${payload?.upstreamStatus ?? res.status}`,
66
+ });
67
+ }
68
+ } catch (err) {
69
+ skipped.push({ tool: name, reason: `unreachable: ${err.message}` });
70
+ }
71
+ }
72
+ return { exemplars, skipped };
73
+ }
74
+
75
+ // ── serve: the ghost — same routes, same /mcp surface, zero side effects ───
76
+
77
+ function matchTool(manifest, method, pathname) {
78
+ for (const [name, t] of Object.entries(manifest.tools ?? {})) {
79
+ if (t.method !== method) continue;
80
+ const pattern = t.path.replace(/:(\w+)/g, '([^/]+)');
81
+ if (new RegExp(`^${pattern}$`).test(pathname)) return { name, tool: t };
82
+ }
83
+ return null;
84
+ }
85
+
86
+ export function createTwinServer(manifest, exemplars) {
87
+ const json = (res, status, obj) => {
88
+ res.writeHead(status, { 'content-type': 'application/json' });
89
+ res.end(JSON.stringify(obj));
90
+ };
91
+ const answerFor = (name) =>
92
+ exemplars[name]?.data !== undefined
93
+ ? exemplars[name].data
94
+ : {
95
+ __sparda_twin__: true,
96
+ tool: name,
97
+ note: 'no exemplar learned — run `sparda twin --learn` against the live app',
98
+ };
99
+
100
+ return http.createServer(async (req, res) => {
101
+ const url = new URL(req.url, 'http://127.0.0.1');
102
+ const pathname = url.pathname;
103
+
104
+ // the /mcp surface, so the unchanged bridge drives the ghost
105
+ if (pathname.startsWith('/mcp')) {
106
+ if (req.headers['x-sparda-key'] !== manifest.localKey)
107
+ return json(res, 401, { error: 'unauthorized' });
108
+ if (pathname === '/mcp/tools') return json(res, 200, manifest.tools ?? {});
109
+ if (pathname === '/mcp/stats')
110
+ return json(res, 200, {
111
+ twin: true,
112
+ uptimeSec: Math.round(process.uptime()),
113
+ stats: {},
114
+ quarantine: {},
115
+ recycle: { servedByCircle: 0, paidFull: 0, ratePct: 0 },
116
+ purity: {},
117
+ });
118
+ if (pathname === '/mcp/events')
119
+ return json(res, 200, { seq: 0, events: [], twin: true });
120
+ if (pathname === '/mcp/invoke' && req.method === 'POST') {
121
+ let body = '';
122
+ for await (const chunk of req) {
123
+ body += chunk;
124
+ if (body.length > 65536) return json(res, 413, { error: 'payload too large' });
125
+ }
126
+ let parsed;
127
+ try {
128
+ parsed = JSON.parse(body || '{}');
129
+ } catch {
130
+ return json(res, 400, { error: 'invalid JSON body' });
131
+ }
132
+ const spec = manifest.tools?.[parsed.tool];
133
+ if (!spec)
134
+ return json(res, 404, { error: `unknown tool: ${parsed.tool}`, twin: true });
135
+ if (spec.method !== 'GET')
136
+ return json(res, 200, {
137
+ upstreamStatus: 202,
138
+ data: {
139
+ __sparda_twin__: true,
140
+ echo: parsed.args ?? {},
141
+ note: 'twin: write acknowledged, nothing was touched',
142
+ },
143
+ twin: true,
144
+ });
145
+ return json(res, 200, {
146
+ upstreamStatus: 200,
147
+ data: answerFor(parsed.tool),
148
+ twin: true,
149
+ });
150
+ }
151
+ return json(res, 404, { error: 'not found', twin: true });
152
+ }
153
+
154
+ // the plain routes themselves, for anything that talks HTTP directly
155
+ const hit = matchTool(manifest, req.method, pathname);
156
+ if (!hit)
157
+ return json(res, 404, {
158
+ __sparda_twin__: true,
159
+ error: 'route not in the manifest',
160
+ });
161
+ if (hit.tool.method !== 'GET')
162
+ return json(res, 202, {
163
+ __sparda_twin__: true,
164
+ note: 'twin: write acknowledged, nothing was touched',
165
+ });
166
+ return json(res, 200, answerFor(hit.name));
167
+ });
168
+ }
169
+
170
+ // ── the command ─────────────────────────────────────────────────────────────
171
+
172
+ export async function runTwin(opts, args) {
173
+ const manifestPath = path.join(opts.cwd, 'sparda.json');
174
+ if (!fs.existsSync(manifestPath)) {
175
+ throw Object.assign(new Error('sparda.json not found.'), {
176
+ code: 'USER',
177
+ hint: 'run `npx sparda-mcp init` first',
178
+ });
179
+ }
180
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
181
+ const twinPath = twinFilePath(opts.cwd);
182
+
183
+ if (args.includes('--learn') || opts.learn) {
184
+ console.error(
185
+ '[sparda] twin: learning from the LIVE app (one call per eligible GET)...',
186
+ );
187
+ const { exemplars, skipped } = await learnExemplars(manifest);
188
+ const learned = Object.keys(exemplars).length;
189
+ if (!learned && skipped.every((s) => s.reason.startsWith('unreachable'))) {
190
+ throw Object.assign(
191
+ new Error(
192
+ `host unreachable on :${manifest.port} — the twin learns from the living.`,
193
+ ),
194
+ {
195
+ code: 'USER',
196
+ hint: 'start the app, then re-run `npx sparda-mcp twin --learn`',
197
+ },
198
+ );
199
+ }
200
+ fs.mkdirSync(path.dirname(twinPath), { recursive: true });
201
+ atomicWrite(
202
+ twinPath,
203
+ JSON.stringify(
204
+ { version: 'sparda-twin/v1', learnedAt: new Date().toISOString(), exemplars },
205
+ null,
206
+ 2,
207
+ ),
208
+ );
209
+ console.log(
210
+ `✓ Twin learned ${learned} exemplar(s) → .sparda/twin.json (local only, never committed, never seeded)`,
211
+ );
212
+ for (const s of skipped) console.log(` · skipped ${s.tool}: ${s.reason}`);
213
+ return { learned, skipped };
214
+ }
215
+
216
+ let exemplars = {};
217
+ if (fs.existsSync(twinPath)) {
218
+ try {
219
+ exemplars = JSON.parse(fs.readFileSync(twinPath, 'utf8')).exemplars ?? {};
220
+ } catch {
221
+ /* corrupt twin file → shape-only ghost, stated per answer */
222
+ }
223
+ }
224
+ const port = Number(opts.port) || manifest.port;
225
+ const server = createTwinServer(manifest, exemplars);
226
+ await new Promise((resolve, reject) => {
227
+ server.once('error', reject);
228
+ server.listen(port, '127.0.0.1', resolve);
229
+ }).catch((err) => {
230
+ throw Object.assign(
231
+ new Error(
232
+ `cannot listen on :${port} — ${err.code === 'EADDRINUSE' ? 'the real app is still running there' : err.message}`,
233
+ ),
234
+ {
235
+ code: 'USER',
236
+ hint: 'stop the app first (the twin replaces it), or pass --port <n>',
237
+ },
238
+ );
239
+ });
240
+ console.log(
241
+ `✓ Twin serving on :${port} — ${Object.keys(exemplars).length} exemplar(s), writes are 202 echoes, the real app is untouched`,
242
+ );
243
+ console.log(
244
+ ' Connect the bridge as usual (`npx sparda-mcp dev`) — it cannot tell the ghost from the flesh.',
245
+ );
246
+ return { server, port };
247
+ }
package/src/index.js CHANGED
@@ -21,6 +21,8 @@ const opts = {
21
21
  html: flags.has('--html'),
22
22
  json: flags.has('--json'),
23
23
  app: flags.has('--app'),
24
+ learn: flags.has('--learn'),
25
+ germinate: flags.has('--germinate'),
24
26
  port: getOpt('port', null),
25
27
  out: getOpt('out', null),
26
28
  cwd: process.cwd(),
@@ -77,6 +79,21 @@ try {
77
79
  );
78
80
  break;
79
81
  }
82
+ case 'twin': {
83
+ const { runTwin } = await import('./commands/twin.js');
84
+ await runTwin(opts, rest);
85
+ break;
86
+ }
87
+ case 'grammar': {
88
+ const { runGrammar } = await import('./commands/grammar.js');
89
+ await runGrammar(opts);
90
+ break;
91
+ }
92
+ case 'evolve': {
93
+ const { runEvolve } = await import('./commands/evolve.js');
94
+ await runEvolve(opts);
95
+ break;
96
+ }
80
97
  default:
81
98
  console.log(`SPARDA v${VERSION} — Turn any codebase into an MCP server.
82
99
 
@@ -89,7 +106,10 @@ Usage:
89
106
  npx sparda-mcp remove Remove SPARDA from this project (clean git diff)
90
107
  npx sparda-mcp doctor Diagnose your setup (--app: negentropy scan — drift, dead routes, rot)
91
108
  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>)
109
+ npx sparda-mcp seed Export/import the learned genome (export [--out f] | import <f> [--germinate])
110
+ npx sparda-mcp twin The living mock (--learn from the live app, then serve the ghost)
111
+ npx sparda-mcp grammar Which call sequences mean something (observed + hypotheses)
112
+ npx sparda-mcp evolve Trial hypothesis chains against the twin; survivors become suggestions
93
113
 
94
114
  Flags: --yes (skip prompts) --port <n> --quiet --verbose
95
115
  --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) => {