sparda-mcp 0.7.1 → 0.8.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
@@ -107,6 +107,9 @@ What we *don't* promise: the honest limits in [docs/SECURITY.md](./docs/SECURITY
107
107
  4. Suspicious docstrings are sanitized before they ever reach the AI (prompt-injection defense).
108
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
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.
110
+ 7. `npx sparda-mcp twin` starts a safe, simulated mock server of your backend on the original port. It serves GET calls from learned exemplars (observed response shapes & mock data) and returns simulated 202 writes without ever touching your real database or production APIs. Learn exemplars by running `npx sparda-mcp twin --learn`.
111
+ 8. `npx sparda-mcp grammar` maps the graph of valid sequences of tool calls (observed circuits and candidate hypotheses) to prevent LLM hallucination of routes.
112
+ 9. `npx sparda-mcp evolve` mutates candidate chains and tests them against the twin in-memory, promoting successful chains to evolved workflow suggestions.
110
113
 
111
114
  ## What SPARDA gives your AI
112
115
 
package/SKILL.md CHANGED
@@ -84,6 +84,16 @@ adapt — don't blindly retry the same call.
84
84
  **5. Latency anomalies.** A call far slower than a tool's own baseline surfaces as
85
85
  an `immune` event in `/mcp/events`. Treat it as a hint to back off or warn the user.
86
86
 
87
+ **6. Twin Simulation Mode — practice safely on a clone.**
88
+ When `/mcp/stats` or `sparda_get_context.runtime` contains `"twin": true`, you are connected to a safe, in-memory mock clone of the application.
89
+ - All GET reads return learned exemplars (observed response shapes and mock values).
90
+ - All write tools return simulated `202` echoes but do not write to database or external APIs.
91
+ - Use this twin mode to practice multi-step workflows, debug tool sequences, and test your plans without touching the live production backend.
92
+
93
+ **7. Grammar & Evolution — discover optimal workflows.**
94
+ - You can query or contribute to the app's grammar (`.sparda/grammar.json`). The grammar maps valid sequences of tool calls (edges).
95
+ - Running `sparda evolve` mutates and runs candidate chains against the twin. The successful evolved sequences are suggested as mid-session workflows.
96
+
87
97
  ## Writing safely — the mandatory two-phase protocol
88
98
 
89
99
  Writes are **disabled by default**. The protocol is not optional:
@@ -136,6 +146,8 @@ Writes are **disabled by default**. The protocol is not optional:
136
146
  - **General health** → `sparda doctor` checks Node version, framework detection,
137
147
  manifest validity, the semantic/immune cache, host reachability, and quarantine;
138
148
  it exits non-zero so it can gate CI.
149
+ - **Clone learning / Transfer sémantique** → Use `sparda seed export` to package your app's descriptions, workflows, and antibodies. Then `sparda seed import --germinate` in another clone to import the structure and germinate simulated twin instances.
150
+ - **Learn exemplars** → Start your live app and run `sparda twin --learn` to fetch actual response data and construct `.sparda/twin.json` locally.
139
151
 
140
152
  ---
141
153
  *This skill ships with `sparda-mcp` and is regenerated from SPARDA's capability
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.7.1",
3
+ "version": "0.8.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",
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { detectStack } from '../detect.js';
5
5
  import { buildNegentropy, renderNegentropy } from './negentropy.js';
6
+ import { resolveSpardaKey } from '../generator/manifest.js';
6
7
 
7
8
  // Returns { healthy } so the CLI can exit non-zero on any ✗ — scripts/CI can
8
9
  // gate on `sparda doctor` (E-012). Informational '·' lines never fail it.
@@ -55,18 +56,19 @@ export async function runDoctor(opts) {
55
56
  );
56
57
 
57
58
  if (stack) {
58
- const headers = { 'x-sparda-key': m.localKey };
59
+ const key = resolveSpardaKey(opts.cwd, m);
60
+ const headers = { 'x-sparda-key': key };
59
61
  try {
60
62
  const r = await fetch(`http://127.0.0.1:${m.port}/mcp/tools`, {
61
63
  headers,
62
- signal: AbortSignal.timeout(1500),
64
+ signal: AbortSignal.timeout(5000),
63
65
  });
64
66
  if (!r.ok) {
65
67
  fail(` ✗ Host app on :${m.port} answered ${r.status}`);
66
68
  } else {
67
69
  const s = await fetch(`http://127.0.0.1:${m.port}/mcp/stats`, {
68
70
  headers,
69
- signal: AbortSignal.timeout(1500),
71
+ signal: AbortSignal.timeout(5000),
70
72
  })
71
73
  .then((x) => (x.ok ? x.json() : null))
72
74
  .catch(() => null);
@@ -0,0 +1,151 @@
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
+ import { resolveSpardaKey } from '../generator/manifest.js';
14
+
15
+ const MAX_CANDIDATES_PER_RUN = 10;
16
+
17
+ // pure: hypothesis edges that no observed circuit already covers → candidates
18
+ export function candidateChains(grammar, circuits = {}) {
19
+ const out = [];
20
+ for (const e of grammar.edges ?? []) {
21
+ if (e.source !== 'hypothesis') continue;
22
+ const key = `${e.from}>${e.to}`;
23
+ if (circuits[key]) continue; // reality (or a past run) got there first
24
+ if (out.some((c) => c.key === key)) continue;
25
+ out.push({
26
+ key,
27
+ steps: [e.from, e.to],
28
+ links: [{ from: e.from, to: e.to, arg: e.arg, fromKey: e.fromKey }],
29
+ });
30
+ if (out.length >= MAX_CANDIDATES_PER_RUN) break;
31
+ }
32
+ return out;
33
+ }
34
+
35
+ // trial one candidate against a running twin: A answers, the linked key is
36
+ // really there, B accepts the fed value. All against the ghost, never the host.
37
+ export async function trialCandidate(candidate, { port, localKey }) {
38
+ const invoke = async (tool, args) => {
39
+ const res = await fetch(`http://127.0.0.1:${port}/mcp/invoke`, {
40
+ method: 'POST',
41
+ headers: { 'content-type': 'application/json', 'x-sparda-key': localKey },
42
+ body: JSON.stringify({ tool, args }),
43
+ signal: AbortSignal.timeout(5000),
44
+ });
45
+ return res.json();
46
+ };
47
+ const [a, b] = candidate.steps;
48
+ const link = candidate.links[0];
49
+ const first = await invoke(a, {});
50
+ if (first?.upstreamStatus !== 200)
51
+ return { ok: false, why: `${a} answered ${first?.upstreamStatus}` };
52
+ const fed = findByKey(first.data, link.fromKey);
53
+ if (fed === undefined)
54
+ return { ok: false, why: `'${link.fromKey}' not found in ${a}'s exemplar` };
55
+ if (typeof fed === 'object')
56
+ return { ok: false, why: `'${link.fromKey}' is not a scalar` };
57
+ const second = await invoke(b, { [link.arg]: fed });
58
+ if (second?.upstreamStatus !== 200)
59
+ return { ok: false, why: `${b} answered ${second?.upstreamStatus}` };
60
+ return { ok: true, fedValueType: typeof fed };
61
+ }
62
+
63
+ export async function runEvolve(opts) {
64
+ const manifestPath = path.join(opts.cwd, 'sparda.json');
65
+ if (!fs.existsSync(manifestPath)) {
66
+ throw Object.assign(new Error('sparda.json not found.'), {
67
+ code: 'USER',
68
+ hint: 'run `npx sparda-mcp init` first',
69
+ });
70
+ }
71
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
72
+
73
+ const twinPath = twinFilePath(opts.cwd);
74
+ if (!fs.existsSync(twinPath)) {
75
+ throw Object.assign(
76
+ new Error(
77
+ 'no twin memory (.sparda/twin.json) — evolution needs the ghost to practice on.',
78
+ ),
79
+ {
80
+ code: 'USER',
81
+ hint: 'run `npx sparda-mcp twin --learn` against the live app first',
82
+ },
83
+ );
84
+ }
85
+ let exemplars = {};
86
+ try {
87
+ exemplars = JSON.parse(fs.readFileSync(twinPath, 'utf8')).exemplars ?? {};
88
+ } catch {
89
+ throw Object.assign(new Error('.sparda/twin.json is not valid JSON.'), {
90
+ code: 'USER',
91
+ hint: 're-run `npx sparda-mcp twin --learn`',
92
+ });
93
+ }
94
+
95
+ const grammar = buildGrammar(manifest, exemplars);
96
+ const candidates = candidateChains(grammar, manifest.labs?.circuits ?? {});
97
+ if (!candidates.length) {
98
+ console.log(
99
+ '· Nothing to evolve — no untested hypothesis chains (grammar has no new guesses).',
100
+ );
101
+ return { survivors: [], failed: [] };
102
+ }
103
+
104
+ // the practice arena: an in-process twin on an ephemeral port
105
+ const localKey = resolveSpardaKey(opts.cwd, manifest);
106
+ const server = createTwinServer(manifest, localKey, exemplars);
107
+ const port = await new Promise((resolve, reject) => {
108
+ server.once('error', reject);
109
+ server.listen(0, '127.0.0.1', () => resolve(server.address().port));
110
+ });
111
+
112
+ const survivors = [];
113
+ const failed = [];
114
+ try {
115
+ for (const c of candidates) {
116
+ const verdict = await trialCandidate(c, { port, localKey });
117
+ if (verdict.ok) survivors.push(c);
118
+ else failed.push({ key: c.key, why: verdict.why });
119
+ }
120
+ } finally {
121
+ server.close();
122
+ }
123
+
124
+ if (survivors.length) {
125
+ manifest.labs ??= {};
126
+ manifest.labs.circuits ??= {};
127
+ const now = new Date().toISOString();
128
+ for (const c of survivors) {
129
+ manifest.labs.circuits[c.key] = {
130
+ steps: c.steps,
131
+ links: c.links,
132
+ seen: 0, // heredity without confirmation: reality still has to speak
133
+ evolved: true,
134
+ firstSeen: now,
135
+ lastSeen: now,
136
+ };
137
+ }
138
+ mergeManifestKeySync(manifestPath, 'labs', manifest.labs);
139
+ }
140
+
141
+ console.log(
142
+ `✓ Evolution run: ${candidates.length} candidate(s) trialed against the twin — ${survivors.length} survivor(s), ${failed.length} culled`,
143
+ );
144
+ for (const c of survivors)
145
+ console.log(
146
+ ` + suggested circuit: ${c.steps.join(' → ')} (seen: 0 — crystallizes only after real observations)`,
147
+ );
148
+ for (const f of failed) console.log(` - culled ${f.key}: ${f.why}`);
149
+ console.log(' The host was never touched: every trial ran against the ghost.');
150
+ return { survivors, failed };
151
+ }
@@ -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
+ }
@@ -7,6 +7,7 @@
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import crypto from 'node:crypto';
10
+ import { resolveSpardaKey } from '../generator/manifest.js';
10
11
 
11
12
  // same fingerprint the generators stamp into sparding.toolFingerprints —
12
13
  // method|path|params, sha256/8. Divergence = the route's shape changed.
@@ -191,17 +192,17 @@ export function buildNegentropy({ manifest, currentRoutes, live, detectedPort, c
191
192
  `${f} is recorded in sparda.json but absent on disk — the organism has a memory of a body it no longer has`,
192
193
  'npx sparda-mcp init --yes (regenerates; carry-over keeps your state)',
193
194
  );
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
- );
195
+ } else {
196
+ const key = resolveSpardaKey(cwd, manifest);
197
+ if (key && !fs.readFileSync(abs, 'utf8').includes(key)) {
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
+ }
205
206
  }
206
207
  }
207
208
 
@@ -6,6 +6,7 @@
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { c, gradient } from '../ui/style.js';
9
+ import { resolveSpardaKey } from '../generator/manifest.js';
9
10
 
10
11
  // ── pure: manifest (+ optional live stats) → report data ──────────────────
11
12
 
@@ -342,9 +343,10 @@ export async function runReport(opts) {
342
343
  }
343
344
 
344
345
  let live = null;
345
- if (manifest.port && manifest.localKey) {
346
+ const key = resolveSpardaKey(opts.cwd, manifest);
347
+ if (manifest.port && key) {
346
348
  live = await fetch(`http://127.0.0.1:${manifest.port}/mcp/stats`, {
347
- headers: { 'x-sparda-key': manifest.localKey },
349
+ headers: { 'x-sparda-key': key },
348
350
  signal: AbortSignal.timeout(1500),
349
351
  })
350
352
  .then((x) => (x.ok ? x.json() : null))
@@ -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,266 @@
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
+ import { resolveSpardaKey } from '../generator/manifest.js';
17
+
18
+ const EXEMPLAR_CAP = 16384; // 16KB per exemplar — a shape, not an archive
19
+
20
+ export function twinFilePath(cwd) {
21
+ return path.join(cwd, '.sparda', 'twin.json');
22
+ }
23
+
24
+ // ── learn: one real call per eligible tool, through the live router ────────
25
+
26
+ export function eligibleForLearning(tool) {
27
+ return tool.enabled && tool.method === 'GET' && (tool.pathParams ?? []).length === 0;
28
+ }
29
+
30
+ export async function learnExemplars(manifest, localKey, fetchFn = fetch) {
31
+ let key = localKey;
32
+ if (typeof localKey === 'function') {
33
+ fetchFn = localKey;
34
+ key = undefined;
35
+ }
36
+ key = key ?? manifest.localKey ?? resolveSpardaKey(process.cwd(), manifest);
37
+
38
+ const exemplars = {};
39
+ const skipped = [];
40
+ for (const [name, t] of Object.entries(manifest.tools ?? {})) {
41
+ if (!eligibleForLearning(t)) {
42
+ skipped.push({
43
+ tool: name,
44
+ reason: !t.enabled
45
+ ? 'disabled'
46
+ : t.method !== 'GET'
47
+ ? 'write tool — a twin never learns from side effects'
48
+ : 'required path params — no observed values to fill them (v0.1)',
49
+ });
50
+ continue;
51
+ }
52
+ try {
53
+ const res = await fetchFn(`http://127.0.0.1:${manifest.port}/mcp/invoke`, {
54
+ method: 'POST',
55
+ headers: {
56
+ 'content-type': 'application/json',
57
+ 'x-sparda-key': key,
58
+ },
59
+ body: JSON.stringify({ tool: name, args: {} }),
60
+ signal: AbortSignal.timeout(10_000),
61
+ });
62
+ const payload = await res.json();
63
+ if (payload?.upstreamStatus === 200 && payload.data !== undefined) {
64
+ const raw = JSON.stringify(payload.data);
65
+ if (raw.length <= EXEMPLAR_CAP) {
66
+ exemplars[name] = { data: payload.data, learnedAt: new Date().toISOString() };
67
+ } else {
68
+ skipped.push({ tool: name, reason: `response over ${EXEMPLAR_CAP}B cap` });
69
+ }
70
+ } else {
71
+ skipped.push({
72
+ tool: name,
73
+ reason: `live call answered ${payload?.upstreamStatus ?? res.status}`,
74
+ });
75
+ }
76
+ } catch (err) {
77
+ skipped.push({ tool: name, reason: `unreachable: ${err.message}` });
78
+ }
79
+ }
80
+ return { exemplars, skipped };
81
+ }
82
+
83
+ // ── serve: the ghost — same routes, same /mcp surface, zero side effects ───
84
+
85
+ function matchTool(manifest, method, pathname) {
86
+ for (const [name, t] of Object.entries(manifest.tools ?? {})) {
87
+ if (t.method !== method) continue;
88
+ const pattern = t.path.replace(/:(\w+)/g, '([^/]+)');
89
+ if (new RegExp(`^${pattern}$`).test(pathname)) return { name, tool: t };
90
+ }
91
+ return null;
92
+ }
93
+
94
+ export function createTwinServer(manifest, localKeyOrExemplars, maybeExemplars) {
95
+ let localKey = localKeyOrExemplars;
96
+ let exemplars = maybeExemplars;
97
+ if (
98
+ maybeExemplars === undefined &&
99
+ localKeyOrExemplars &&
100
+ typeof localKeyOrExemplars === 'object'
101
+ ) {
102
+ exemplars = localKeyOrExemplars;
103
+ localKey = resolveSpardaKey(process.cwd(), manifest);
104
+ }
105
+ const json = (res, status, obj) => {
106
+ res.writeHead(status, { 'content-type': 'application/json' });
107
+ res.end(JSON.stringify(obj));
108
+ };
109
+ const answerFor = (name) =>
110
+ (exemplars ?? {})[name]?.data !== undefined
111
+ ? exemplars[name].data
112
+ : {
113
+ __sparda_twin__: true,
114
+ tool: name,
115
+ note: 'no exemplar learned — run `sparda twin --learn` against the live app',
116
+ };
117
+
118
+ return http.createServer(async (req, res) => {
119
+ const url = new URL(req.url, 'http://127.0.0.1');
120
+ const pathname = url.pathname;
121
+
122
+ // the /mcp surface, so the unchanged bridge drives the ghost
123
+ if (pathname.startsWith('/mcp')) {
124
+ if (req.headers['x-sparda-key'] !== localKey)
125
+ return json(res, 401, { error: 'unauthorized' });
126
+ if (pathname === '/mcp/tools') return json(res, 200, manifest.tools ?? {});
127
+ if (pathname === '/mcp/stats')
128
+ return json(res, 200, {
129
+ twin: true,
130
+ uptimeSec: Math.round(process.uptime()),
131
+ stats: {},
132
+ quarantine: {},
133
+ recycle: { servedByCircle: 0, paidFull: 0, ratePct: 0 },
134
+ purity: {},
135
+ });
136
+ if (pathname === '/mcp/events')
137
+ return json(res, 200, { seq: 0, events: [], twin: true });
138
+ if (pathname === '/mcp/invoke' && req.method === 'POST') {
139
+ let body = '';
140
+ for await (const chunk of req) {
141
+ body += chunk;
142
+ if (body.length > 65536) return json(res, 413, { error: 'payload too large' });
143
+ }
144
+ let parsed;
145
+ try {
146
+ parsed = JSON.parse(body || '{}');
147
+ } catch {
148
+ return json(res, 400, { error: 'invalid JSON body' });
149
+ }
150
+ const spec = manifest.tools?.[parsed.tool];
151
+ if (!spec)
152
+ return json(res, 404, { error: `unknown tool: ${parsed.tool}`, twin: true });
153
+ if (spec.method !== 'GET')
154
+ return json(res, 200, {
155
+ upstreamStatus: 202,
156
+ data: {
157
+ __sparda_twin__: true,
158
+ echo: parsed.args ?? {},
159
+ note: 'twin: write acknowledged, nothing was touched',
160
+ },
161
+ twin: true,
162
+ });
163
+ return json(res, 200, {
164
+ upstreamStatus: 200,
165
+ data: answerFor(parsed.tool),
166
+ twin: true,
167
+ });
168
+ }
169
+ return json(res, 404, { error: 'not found', twin: true });
170
+ }
171
+
172
+ // the plain routes themselves, for anything that talks HTTP directly
173
+ const hit = matchTool(manifest, req.method, pathname);
174
+ if (!hit)
175
+ return json(res, 404, {
176
+ __sparda_twin__: true,
177
+ error: 'route not in the manifest',
178
+ });
179
+ if (hit.tool.method !== 'GET')
180
+ return json(res, 202, {
181
+ __sparda_twin__: true,
182
+ note: 'twin: write acknowledged, nothing was touched',
183
+ });
184
+ return json(res, 200, answerFor(hit.name));
185
+ });
186
+ }
187
+
188
+ // ── the command ─────────────────────────────────────────────────────────────
189
+
190
+ export async function runTwin(opts, args) {
191
+ const manifestPath = path.join(opts.cwd, 'sparda.json');
192
+ if (!fs.existsSync(manifestPath)) {
193
+ throw Object.assign(new Error('sparda.json not found.'), {
194
+ code: 'USER',
195
+ hint: 'run `npx sparda-mcp init` first',
196
+ });
197
+ }
198
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
199
+ const localKey = resolveSpardaKey(opts.cwd, manifest);
200
+ const twinPath = twinFilePath(opts.cwd);
201
+
202
+ if (args.includes('--learn') || opts.learn) {
203
+ console.error(
204
+ '[sparda] twin: learning from the LIVE app (one call per eligible GET)...',
205
+ );
206
+ const { exemplars, skipped } = await learnExemplars(manifest, localKey);
207
+ const learned = Object.keys(exemplars).length;
208
+ if (!learned && skipped.every((s) => s.reason.startsWith('unreachable'))) {
209
+ throw Object.assign(
210
+ new Error(
211
+ `host unreachable on :${manifest.port} — the twin learns from the living.`,
212
+ ),
213
+ {
214
+ code: 'USER',
215
+ hint: 'start the app, then re-run `npx sparda-mcp twin --learn`',
216
+ },
217
+ );
218
+ }
219
+ fs.mkdirSync(path.dirname(twinPath), { recursive: true });
220
+ atomicWrite(
221
+ twinPath,
222
+ JSON.stringify(
223
+ { version: 'sparda-twin/v1', learnedAt: new Date().toISOString(), exemplars },
224
+ null,
225
+ 2,
226
+ ),
227
+ );
228
+ console.log(
229
+ `✓ Twin learned ${learned} exemplar(s) → .sparda/twin.json (local only, never committed, never seeded)`,
230
+ );
231
+ for (const s of skipped) console.log(` · skipped ${s.tool}: ${s.reason}`);
232
+ return { learned, skipped };
233
+ }
234
+
235
+ let exemplars = {};
236
+ if (fs.existsSync(twinPath)) {
237
+ try {
238
+ exemplars = JSON.parse(fs.readFileSync(twinPath, 'utf8')).exemplars ?? {};
239
+ } catch {
240
+ /* corrupt twin file → shape-only ghost, stated per answer */
241
+ }
242
+ }
243
+ const port = Number(opts.port) || manifest.port;
244
+ const server = createTwinServer(manifest, localKey, exemplars);
245
+ await new Promise((resolve, reject) => {
246
+ server.once('error', reject);
247
+ server.listen(port, '127.0.0.1', resolve);
248
+ }).catch((err) => {
249
+ throw Object.assign(
250
+ new Error(
251
+ `cannot listen on :${port} — ${err.code === 'EADDRINUSE' ? 'the real app is still running there' : err.message}`,
252
+ ),
253
+ {
254
+ code: 'USER',
255
+ hint: 'stop the app first (the twin replaces it), or pass --port <n>',
256
+ },
257
+ );
258
+ });
259
+ console.log(
260
+ `✓ Twin serving on :${port} — ${Object.keys(exemplars).length} exemplar(s), writes are 202 echoes, the real app is untouched`,
261
+ );
262
+ console.log(
263
+ ' Connect the bridge as usual (`npx sparda-mcp dev`) — it cannot tell the ghost from the flesh.',
264
+ );
265
+ return { server, port };
266
+ }
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { parse } from '@babel/parser';
7
7
  import { toolNameFor } from '../parser/express.js';
8
- import { carryOverManifest, defaultSpardingMemory } from './manifest.js';
8
+ import { carryOverManifest, defaultSpardingMemory, ensureSpardaKey } from './manifest.js';
9
9
  import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
10
10
 
11
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -58,7 +58,7 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
58
58
  sparding.toolFingerprints = newFingerprints;
59
59
 
60
60
  // stable across re-runs so a running bridge/host pair never desyncs
61
- const localKey = prev?.localKey ?? crypto.randomUUID();
61
+ const localKey = ensureSpardaKey(cwd, prev);
62
62
  const entryAbs = path.resolve(cwd, entryFile);
63
63
  const entryDir = path.dirname(entryAbs);
64
64
  const ext = entryFile.endsWith('.ts')
@@ -111,7 +111,10 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
111
111
  )
112
112
  .replace('__JSON_MW__', "express.json({ limit: '64kb' })")
113
113
  .replace('__TOOLS_JSON__', JSON.stringify(tools, null, 2))
114
- .replace('__LOCAL_KEY__', localKey)
114
+ .replace(
115
+ '__FS_IMPORT__',
116
+ isESM ? "import spardaFs from 'node:fs';" : "const spardaFs = require('node:fs');",
117
+ )
115
118
  .replace('__PORT__', String(port))
116
119
  .replace(/__REQ_TYPE__/g, reqType)
117
120
  .replace(/__RES_TYPE__/g, resType)
@@ -150,7 +153,15 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
150
153
  ...(prev?.labs ? { labs: prev.labs } : {}),
151
154
  sparding,
152
155
  };
153
- atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
156
+ // ADR-022: the disk copy NEVER carries the key — it lives in .sparda/key
157
+ // (written by ensureSpardaKey above). The in-memory return keeps it for
158
+ // this process only.
159
+ const manifestOnDisk = { ...manifest };
160
+ delete manifestOnDisk.localKey;
161
+ atomicWrite(
162
+ path.join(cwd, 'sparda.json'),
163
+ JSON.stringify(manifestOnDisk, null, 2) + '\n',
164
+ );
154
165
 
155
166
  return {
156
167
  tools,
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { toolNameFor } from '../parser/express.js';
8
- import { carryOverManifest, defaultSpardingMemory } from './manifest.js';
8
+ import { carryOverManifest, defaultSpardingMemory, ensureSpardaKey } from './manifest.js';
9
9
  import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
10
10
 
11
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -65,7 +65,7 @@ export function generateFastAPI({
65
65
  sparding.toolFingerprints = newFingerprints;
66
66
 
67
67
  // stable across re-runs so a running bridge/host pair never desyncs
68
- const localKey = prev?.localKey ?? crypto.randomUUID();
68
+ const localKey = ensureSpardaKey(cwd, prev);
69
69
  const entryAbs = path.resolve(cwd, entryFile);
70
70
  const entryDir = path.dirname(entryAbs);
71
71
  const routerFileName = 'sparda_router.py';
@@ -84,7 +84,6 @@ export function generateFastAPI({
84
84
  '__SPARDING_POLICIES__',
85
85
  JSON.stringify(JSON.stringify(sparding.policies ?? {})),
86
86
  )
87
- .replace('__LOCAL_KEY__', localKey)
88
87
  .replace('__PORT__', String(port));
89
88
 
90
89
  atomicWrite(routerAbs, tpl);
@@ -124,7 +123,15 @@ export function generateFastAPI({
124
123
  sparding,
125
124
  };
126
125
 
127
- atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
126
+ // ADR-022: the disk copy NEVER carries the key — it lives in .sparda/key
127
+ // (written by ensureSpardaKey above). The in-memory return keeps it for
128
+ // this process only.
129
+ const manifestOnDisk = { ...manifest };
130
+ delete manifestOnDisk.localKey;
131
+ atomicWrite(
132
+ path.join(cwd, 'sparda.json'),
133
+ JSON.stringify(manifestOnDisk, null, 2) + '\n',
134
+ );
128
135
 
129
136
  return {
130
137
  tools,
@@ -1,6 +1,40 @@
1
1
  // generator/manifest.js — carry user state across re-runs of `sparda init`
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+
6
+ export function resolveSpardaKey(cwd, prevManifest = null) {
7
+ if (process.env.SPARDA_LOCAL_KEY) {
8
+ return process.env.SPARDA_LOCAL_KEY;
9
+ }
10
+ const spardaDir = path.join(cwd, '.sparda');
11
+ const keyFile = path.join(spardaDir, 'key');
12
+ if (fs.existsSync(keyFile)) {
13
+ try {
14
+ const key = fs.readFileSync(keyFile, 'utf8').trim();
15
+ if (key) return key;
16
+ } catch {}
17
+ }
18
+ if (prevManifest && prevManifest.localKey) {
19
+ try {
20
+ fs.mkdirSync(spardaDir, { recursive: true });
21
+ fs.writeFileSync(keyFile, prevManifest.localKey, 'utf8');
22
+ } catch {}
23
+ return prevManifest.localKey;
24
+ }
25
+ return null;
26
+ }
27
+
28
+ export function ensureSpardaKey(cwd, prevManifest = null) {
29
+ let key = resolveSpardaKey(cwd, prevManifest);
30
+ if (!key) {
31
+ key = crypto.randomUUID();
32
+ const spardaDir = path.join(cwd, '.sparda');
33
+ fs.mkdirSync(spardaDir, { recursive: true });
34
+ fs.writeFileSync(path.join(spardaDir, 'key'), key, 'utf8');
35
+ }
36
+ return key;
37
+ }
4
38
 
5
39
  // Re-running init must not wipe what the user (or the semantic/immune/Labs
6
40
  // passes) wrote into sparda.json: per-tool `enabled` overrides, the cached
@@ -9,7 +9,7 @@ import crypto from 'node:crypto';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { toolNameFor } from '../parser/express.js';
11
11
  import { ensureGitignore } from './express.js';
12
- import { carryOverManifest, defaultSpardingMemory } from './manifest.js';
12
+ import { carryOverManifest, defaultSpardingMemory, ensureSpardaKey } from './manifest.js';
13
13
  import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
14
14
 
15
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -60,7 +60,7 @@ export function generateNext({ cwd, appDir, port, routes }) {
60
60
  sparding.toolFingerprints = newFingerprints;
61
61
 
62
62
  // stable across re-runs so a running bridge/host pair never desyncs
63
- const localKey = prev?.localKey ?? crypto.randomUUID();
63
+ const localKey = ensureSpardaKey(cwd, prev);
64
64
 
65
65
  // .js on purpose: Next enables allowJs in the tsconfig it manages, so the
66
66
  // generated handler compiles untouched inside TS projects too.
@@ -75,7 +75,6 @@ export function generateNext({ cwd, appDir, port, routes }) {
75
75
  tpl = tpl
76
76
  .replace('__TOOLS_JSON__', JSON.stringify(tools, null, 2))
77
77
  .replace('__SPARDING_POLICIES__', JSON.stringify(sparding.policies ?? {}))
78
- .replace('__LOCAL_KEY__', localKey)
79
78
  .replace('__PORT__', String(port));
80
79
  atomicWrite(routerAbs, tpl);
81
80
 
@@ -102,7 +101,15 @@ export function generateNext({ cwd, appDir, port, routes }) {
102
101
  ...(prev?.labs ? { labs: prev.labs } : {}),
103
102
  sparding,
104
103
  };
105
- atomicWrite(path.join(cwd, 'sparda.json'), JSON.stringify(manifest, null, 2) + '\n');
104
+ // ADR-022: the disk copy NEVER carries the key — it lives in .sparda/key
105
+ // (written by ensureSpardaKey above). The in-memory return keeps it for
106
+ // this process only.
107
+ const manifestOnDisk = { ...manifest };
108
+ delete manifestOnDisk.localKey;
109
+ atomicWrite(
110
+ path.join(cwd, 'sparda.json'),
111
+ JSON.stringify(manifestOnDisk, null, 2) + '\n',
112
+ );
106
113
 
107
114
  return {
108
115
  tools,
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)
@@ -25,6 +25,7 @@ import { writeManifestSync, mergeManifestKeySync } from './persistence.js';
25
25
  import { createSpardaEngine } from './engine.js';
26
26
  import { initiateWrite, preapproveWrite, confirmWrite } from './confirmation.js';
27
27
  import { resolveContext, injectContext, fingerprintContext } from './context-carrier.js';
28
+ import { resolveSpardaKey } from '../generator/manifest.js';
28
29
 
29
30
  const EVENT_POLL_MS = Number(process.env.SPARDA_EVENT_POLL_MS ?? 5000);
30
31
  // the sampling budgets below are also what the recycling gauge counts as "avoided"
@@ -61,11 +62,11 @@ export async function startStdioBridge({ cwd, portOverride }) {
61
62
  }
62
63
  const port = Number(portOverride ?? manifest.port);
63
64
  const framework = manifest.framework;
64
- const key = manifest.localKey;
65
+ const key = resolveSpardaKey(cwd, manifest);
65
66
  if (!key) {
66
- throw Object.assign(new Error('localKey missing from sparda.json.'), {
67
+ throw Object.assign(new Error('localKey missing or not configured.'), {
67
68
  code: 'USER',
68
- hint: 'Re-run `npx sparda-mcp init` to regenerate it.',
69
+ hint: 'Re-run `npx sparda-mcp init` to generate it, or set SPARDA_LOCAL_KEY.',
69
70
  });
70
71
  }
71
72
 
@@ -7,7 +7,21 @@ __IMPORT_LINE__
7
7
  const SPARDA_TOOLS = __TOOLS_JSON__;
8
8
  const SPARDA_POLICIES = __SPARDING_POLICIES__;
9
9
 
10
- const SPARDA_LOCAL_KEY = "__LOCAL_KEY__";
10
+ // ADR-022: the key is NEVER baked into this committed file. It resolves at
11
+ // runtime — env first, then the gitignored .sparda/key — and fails CLOSED:
12
+ // no key, no /mcp surface. A deploy that ships without .sparda/ is inert.
13
+ __FS_IMPORT__
14
+ let SPARDA_LOCAL_KEY = process.env.SPARDA_LOCAL_KEY || null;
15
+ if (!SPARDA_LOCAL_KEY) {
16
+ try {
17
+ for (const p of ['.sparda/key', '../.sparda/key', '../../.sparda/key']) {
18
+ if (spardaFs.existsSync(p)) {
19
+ SPARDA_LOCAL_KEY = spardaFs.readFileSync(p, 'utf8').trim() || null;
20
+ if (SPARDA_LOCAL_KEY) break;
21
+ }
22
+ }
23
+ } catch { /* stays null -> fail closed */ }
24
+ }
11
25
  const SPARDA_PORT = __PORT__;
12
26
 
13
27
  const SPARDA_STATS__STATS_TYPE__ = {};
@@ -299,6 +313,9 @@ async function spardaExecute(tool__ANY_TYPE__, spec__ANY_TYPE__, args__ANY_TYPE_
299
313
  __ROUTER_DECL__
300
314
 
301
315
  spardaRouter.use((req__REQ_TYPE__, res__RES_TYPE__, next__NEXT_TYPE__) => {
316
+ if (!SPARDA_LOCAL_KEY) {
317
+ return res.status(503).json({ error: 'key not configured' });
318
+ }
302
319
  if (req.headers['x-sparda-key'] !== SPARDA_LOCAL_KEY) {
303
320
  return res.status(401).json({ error: 'unauthorized' });
304
321
  }
@@ -19,7 +19,20 @@ import threading
19
19
  # json.loads, not a Python literal: JSON true/false/null are not valid Python (E-009)
20
20
  SPARDA_TOOLS = json.loads(__TOOLS_JSON__)
21
21
  SPARDA_POLICIES = json.loads(__SPARDING_POLICIES__)
22
- SPARDA_LOCAL_KEY = "__LOCAL_KEY__"
22
+ # ADR-022: the key is NEVER baked into this committed file. It resolves at
23
+ # runtime — env first, then the gitignored .sparda/key — and fails CLOSED:
24
+ # no key, no /mcp surface. A deploy that ships without .sparda/ is inert.
25
+ SPARDA_LOCAL_KEY = os.environ.get("SPARDA_LOCAL_KEY") or None
26
+ if not SPARDA_LOCAL_KEY:
27
+ try:
28
+ for p in [".sparda/key", "../.sparda/key", "../../.sparda/key"]:
29
+ if os.path.exists(p):
30
+ with open(p, "r", encoding="utf-8") as f:
31
+ SPARDA_LOCAL_KEY = f.read().strip() or None
32
+ if SPARDA_LOCAL_KEY:
33
+ break
34
+ except Exception:
35
+ pass # stays None -> fail closed
23
36
  SPARDA_PORT = __PORT__
24
37
 
25
38
  SPARDA_STATS = {}
@@ -363,18 +376,24 @@ async def sparda_read_json(request):
363
376
 
364
377
  @sparda_router.get("/tools")
365
378
  async def get_tools(request: Request):
379
+ if not SPARDA_LOCAL_KEY:
380
+ return JSONResponse(status_code=503, content={"error": "key not configured"})
366
381
  if request.headers.get("x-sparda-key") != SPARDA_LOCAL_KEY:
367
382
  return JSONResponse(status_code=401, content={"error": "unauthorized"})
368
383
  return SPARDA_TOOLS
369
384
 
370
385
  @sparda_router.get("/stats")
371
386
  async def get_stats(request: Request):
387
+ if not SPARDA_LOCAL_KEY:
388
+ return JSONResponse(status_code=503, content={"error": "key not configured"})
372
389
  if request.headers.get("x-sparda-key") != SPARDA_LOCAL_KEY:
373
390
  return JSONResponse(status_code=401, content={"error": "unauthorized"})
374
391
  return {"uptimeSec": round(time.time() - SPARDA_START), "stats": SPARDA_STATS, "quarantine": SPARDA_QUARANTINE, "recycle": {**SPARDA_RECYCLE, "ratePct": sparda_recycle_rate()}, "purity": sparda_purity_snapshot()}
375
392
 
376
393
  @sparda_router.get("/events")
377
394
  async def get_events(request: Request):
395
+ if not SPARDA_LOCAL_KEY:
396
+ return JSONResponse(status_code=503, content={"error": "key not configured"})
378
397
  if request.headers.get("x-sparda-key") != SPARDA_LOCAL_KEY:
379
398
  return JSONResponse(status_code=401, content={"error": "unauthorized"})
380
399
  try:
@@ -388,6 +407,8 @@ async def gossip(request: Request):
388
407
  # peer-to-peer convergence (Brief #2), behind the same x-sparda-key as every route.
389
408
  # Absorbs a peer's grow-only counts via CRDT max-merge; 204 on success. An unknown tool
390
409
  # or malformed count is silently dropped (bounded, no injection).
410
+ if not SPARDA_LOCAL_KEY:
411
+ return JSONResponse(status_code=503, content={"error": "key not configured"})
391
412
  if request.headers.get("x-sparda-key") != SPARDA_LOCAL_KEY:
392
413
  return JSONResponse(status_code=401, content={"error": "unauthorized"})
393
414
  body, err = await sparda_read_json(request)
@@ -398,6 +419,8 @@ async def gossip(request: Request):
398
419
 
399
420
  @sparda_router.post("/invoke")
400
421
  async def invoke_tool(request: Request):
422
+ if not SPARDA_LOCAL_KEY:
423
+ return JSONResponse(status_code=503, content={"error": "key not configured"})
401
424
  if request.headers.get("x-sparda-key") != SPARDA_LOCAL_KEY:
402
425
  return JSONResponse(status_code=401, content={"error": "unauthorized"})
403
426
 
@@ -543,6 +566,8 @@ async def invoke_tool(request: Request):
543
566
 
544
567
  @sparda_router.post("/invoke/confirm")
545
568
  async def confirm_tool_invocation(request: Request):
569
+ if not SPARDA_LOCAL_KEY:
570
+ return JSONResponse(status_code=503, content={"error": "key not configured"})
546
571
  if request.headers.get("x-sparda-key") != SPARDA_LOCAL_KEY:
547
572
  return JSONResponse(status_code=401, content={"error": "unauthorized"})
548
573
 
@@ -8,7 +8,21 @@
8
8
  const SPARDA_TOOLS = __TOOLS_JSON__;
9
9
  const SPARDA_POLICIES = __SPARDING_POLICIES__;
10
10
 
11
- const SPARDA_LOCAL_KEY = "__LOCAL_KEY__";
11
+ // ADR-022: the key is NEVER baked into this committed file. It resolves at
12
+ // runtime — env first, then the gitignored .sparda/key — and fails CLOSED:
13
+ // no key, no /mcp surface. A Vercel deploy that ships without .sparda/ is inert.
14
+ import spardaFs from 'node:fs';
15
+ let SPARDA_LOCAL_KEY = process.env.SPARDA_LOCAL_KEY || null;
16
+ if (!SPARDA_LOCAL_KEY) {
17
+ try {
18
+ for (const p of ['.sparda/key', '../.sparda/key', '../../.sparda/key']) {
19
+ if (spardaFs.existsSync(p)) {
20
+ SPARDA_LOCAL_KEY = spardaFs.readFileSync(p, 'utf8').trim() || null;
21
+ if (SPARDA_LOCAL_KEY) break;
22
+ }
23
+ }
24
+ } catch { /* stays null -> fail closed */ }
25
+ }
12
26
  const SPARDA_PORT = __PORT__;
13
27
 
14
28
  // never statically optimized, always on the Node.js runtime (process, timers)
@@ -336,6 +350,9 @@ async function spardaConfirm(request) {
336
350
 
337
351
  async function spardaHandle(request, ctx) {
338
352
  try {
353
+ if (!SPARDA_LOCAL_KEY) {
354
+ return spardaJson(503, { error: 'key not configured' });
355
+ }
339
356
  if (request.headers.get('x-sparda-key') !== SPARDA_LOCAL_KEY) {
340
357
  return spardaJson(401, { error: 'unauthorized' });
341
358
  }