sparda-mcp 0.8.0 → 0.9.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
@@ -6,23 +6,27 @@
6
6
 
7
7
  <br/>
8
8
 
9
- **Your AI can write code. It still can't operate your app.**
9
+ **Compile your backend into a deterministic behavior runtime.**
10
10
 
11
- Claude, Cursor & friends read your *files* not your *running product*. They can
12
- refactor a controller, but they can't create an order, fetch a real user, or see why
13
- production is failing. And giving an AI real access to your API usually means: write
14
- an OpenAPI spec, build an MCP server, host it, secure it, keep it in sync with every
15
- route change — and pray it never `DELETE`s the wrong row. Days of glue code, per
16
- project, forever drifting.
11
+ For twenty years, software communicated via APIs. Then AI agents arrived. The industry's response was simply to expose more endpoints (MCP). But an agent doesn't understand your application; it only sees a list of disconnected tools. That is enough for a human, but not for an autonomous machine.
17
12
 
18
- SPARDA deletes that work:
13
+ SPARDA analyzes your Express, FastAPI, or Next.js application, extracts its behavior, and compiles it into a **SPARDA Behavior Graph (SBG)**.
14
+
15
+ This graph acts as a local, deterministic runtime that powers:
16
+ * **MCP Server Integration**: Exposing your endpoints to AI clients dynamically.
17
+ * **SPARDA Twin**: An executable, offline simulation clone of your backend for safe agent testing.
18
+ * **SPARDA Immune**: Real-time graph-based anomaly defense, latency alerts, and auto-quarantine.
19
+ * **SPARDA Evolution**: Statically inferred grammar mapping and in-memory workflow optimization.
20
+
21
+ All executed 100% locally, with zero runtime dependencies and zero cloud connection.
19
22
 
20
23
  ```bash
21
- npx sparda-mcp init # scan your Express/FastAPI app, inject the MCP router 3 minutes
22
- npx sparda-mcp dev # connect Claude Desktop / Claude Code. Done.
24
+ npx sparda-mcp init # Scan your app, compile the UBG, inject the SPARDA Runtime
25
+ npx sparda-mcp dev # Connect Claude Desktop / Cursor over stdio
23
26
  ```
24
27
 
25
- No OpenAPI spec. No account. No API key. No server to host.
28
+ No OpenAPI spec to write. No cloud account. No API key. No server to host.
29
+ Exposing raw APIs to AI is the old way. SPARDA compiles the whole system's behavior.
26
30
 
27
31
  ## Quickstart
28
32
 
@@ -107,6 +111,9 @@ What we *don't* promise: the honest limits in [docs/SECURITY.md](./docs/SECURITY
107
111
  4. Suspicious docstrings are sanitized before they ever reach the AI (prompt-injection defense).
108
112
  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
113
  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.
114
+ 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`.
115
+ 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.
116
+ 9. `npx sparda-mcp evolve` mutates candidate chains and tests them against the twin in-memory, promoting successful chains to evolved workflow suggestions.
110
117
 
111
118
  ## What SPARDA gives your AI
112
119
 
@@ -166,6 +173,7 @@ runtime, so the guidance never goes stale.
166
173
 
167
174
  ## Security posture (honest)
168
175
  - 4 runtime dependencies, exact-pinned.
176
+ - **Dynamic Local Key Resolution.** The generated router contains no baked secrets. It resolves authorization keys at runtime from the `SPARDA_LOCAL_KEY` environment variable or the local gitignored `.sparda/key` file, and fails closed (503) when neither is found. For custom production or staging setups, you can override this behavior by exposing `SPARDA_LOCAL_KEY` in your environment.
169
177
  - Local key on every router call; self-reference loop protection; 30s timeouts; 8 KB output truncation.
170
178
  - AST-positioned injection with backup and post-injection re-parse; `npx sparda-mcp remove` leaves a clean git diff.
171
179
  - Persistence is **value-free**: SPARDA records structure (tool names, field names, fingerprints), never your payloads.
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.8.0",
3
+ "version": "0.9.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",
@@ -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,7 +56,8 @@ 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,
@@ -10,6 +10,7 @@ import { buildGrammar } from './grammar.js';
10
10
  import { createTwinServer, twinFilePath } from './twin.js';
11
11
  import { findByKey } from '../server/crystallize.js';
12
12
  import { mergeManifestKeySync } from '../server/persistence.js';
13
+ import { resolveSpardaKey } from '../generator/manifest.js';
13
14
 
14
15
  const MAX_CANDIDATES_PER_RUN = 10;
15
16
 
@@ -101,7 +102,8 @@ export async function runEvolve(opts) {
101
102
  }
102
103
 
103
104
  // the practice arena: an in-process twin on an ephemeral port
104
- const server = createTwinServer(manifest, exemplars);
105
+ const localKey = resolveSpardaKey(opts.cwd, manifest);
106
+ const server = createTwinServer(manifest, localKey, exemplars);
105
107
  const port = await new Promise((resolve, reject) => {
106
108
  server.once('error', reject);
107
109
  server.listen(0, '127.0.0.1', () => resolve(server.address().port));
@@ -111,7 +113,7 @@ export async function runEvolve(opts) {
111
113
  const failed = [];
112
114
  try {
113
115
  for (const c of candidates) {
114
- const verdict = await trialCandidate(c, { port, localKey: manifest.localKey });
116
+ const verdict = await trialCandidate(c, { port, localKey });
115
117
  if (verdict.ok) survivors.push(c);
116
118
  else failed.push({ key: c.key, why: verdict.why });
117
119
  }
@@ -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))
@@ -13,6 +13,7 @@ import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import http from 'node:http';
15
15
  import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
16
+ import { resolveSpardaKey } from '../generator/manifest.js';
16
17
 
17
18
  const EXEMPLAR_CAP = 16384; // 16KB per exemplar — a shape, not an archive
18
19
 
@@ -26,7 +27,14 @@ export function eligibleForLearning(tool) {
26
27
  return tool.enabled && tool.method === 'GET' && (tool.pathParams ?? []).length === 0;
27
28
  }
28
29
 
29
- export async function learnExemplars(manifest, fetchFn = fetch) {
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
+
30
38
  const exemplars = {};
31
39
  const skipped = [];
32
40
  for (const [name, t] of Object.entries(manifest.tools ?? {})) {
@@ -46,7 +54,7 @@ export async function learnExemplars(manifest, fetchFn = fetch) {
46
54
  method: 'POST',
47
55
  headers: {
48
56
  'content-type': 'application/json',
49
- 'x-sparda-key': manifest.localKey,
57
+ 'x-sparda-key': key,
50
58
  },
51
59
  body: JSON.stringify({ tool: name, args: {} }),
52
60
  signal: AbortSignal.timeout(10_000),
@@ -83,13 +91,23 @@ function matchTool(manifest, method, pathname) {
83
91
  return null;
84
92
  }
85
93
 
86
- export function createTwinServer(manifest, exemplars) {
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
+ }
87
105
  const json = (res, status, obj) => {
88
106
  res.writeHead(status, { 'content-type': 'application/json' });
89
107
  res.end(JSON.stringify(obj));
90
108
  };
91
109
  const answerFor = (name) =>
92
- exemplars[name]?.data !== undefined
110
+ (exemplars ?? {})[name]?.data !== undefined
93
111
  ? exemplars[name].data
94
112
  : {
95
113
  __sparda_twin__: true,
@@ -103,7 +121,7 @@ export function createTwinServer(manifest, exemplars) {
103
121
 
104
122
  // the /mcp surface, so the unchanged bridge drives the ghost
105
123
  if (pathname.startsWith('/mcp')) {
106
- if (req.headers['x-sparda-key'] !== manifest.localKey)
124
+ if (req.headers['x-sparda-key'] !== localKey)
107
125
  return json(res, 401, { error: 'unauthorized' });
108
126
  if (pathname === '/mcp/tools') return json(res, 200, manifest.tools ?? {});
109
127
  if (pathname === '/mcp/stats')
@@ -178,13 +196,14 @@ export async function runTwin(opts, args) {
178
196
  });
179
197
  }
180
198
  const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
199
+ const localKey = resolveSpardaKey(opts.cwd, manifest);
181
200
  const twinPath = twinFilePath(opts.cwd);
182
201
 
183
202
  if (args.includes('--learn') || opts.learn) {
184
203
  console.error(
185
204
  '[sparda] twin: learning from the LIVE app (one call per eligible GET)...',
186
205
  );
187
- const { exemplars, skipped } = await learnExemplars(manifest);
206
+ const { exemplars, skipped } = await learnExemplars(manifest, localKey);
188
207
  const learned = Object.keys(exemplars).length;
189
208
  if (!learned && skipped.every((s) => s.reason.startsWith('unreachable'))) {
190
209
  throw Object.assign(
@@ -222,7 +241,7 @@ export async function runTwin(opts, args) {
222
241
  }
223
242
  }
224
243
  const port = Number(opts.port) || manifest.port;
225
- const server = createTwinServer(manifest, exemplars);
244
+ const server = createTwinServer(manifest, localKey, exemplars);
226
245
  await new Promise((resolve, reject) => {
227
246
  server.once('error', reject);
228
247
  server.listen(port, '127.0.0.1', resolve);
@@ -0,0 +1,71 @@
1
+ // commands/ubg.js — compile the codebase to its Unified Behavior Graph.
2
+ // The UBG is the IR every future SPARDA tool reads (time-travel debuggers,
3
+ // deploy provers, state-machine runtimes): compile once here, write passes
4
+ // there. Artifact: .sparda/ubg.json — regenerable, deterministic, never
5
+ // committed. `--json` prints the raw graph, `--out <file>` redirects it.
6
+ import { compileUBG } from '../ubg/compile.js';
7
+
8
+ export async function runUbg(opts) {
9
+ const { report, json, outPath } = compileUBG(opts.cwd, {
10
+ write: true,
11
+ out: opts.out,
12
+ });
13
+
14
+ if (opts.json) {
15
+ console.log(json);
16
+ return { report };
17
+ }
18
+
19
+ const { counts } = report;
20
+ console.log(
21
+ `✓ UBG compiled: ${rel(outPath, opts.cwd)} — ${counts.totalNodes} nodes, ${counts.totalEdges} edges`,
22
+ );
23
+ console.log(
24
+ ` ${report.framework} · ${report.routes} routes · ${report.tables} SQL tables`,
25
+ );
26
+ console.log(` Nodes: ${fmtCounts(counts.nodes)}`);
27
+ console.log(` Edges: ${fmtCounts(counts.edges)}`);
28
+
29
+ for (const p of report.passes) {
30
+ if (p.pass === 'DeadPathElimination' && p.removed)
31
+ console.log(` ${p.pass}: ${p.removed} dead node(s) removed`);
32
+ else if (p.pass === 'StateMinimization' && p.merged)
33
+ console.log(` ${p.pass}: ${p.merged} logic block(s) merged`);
34
+ else if (p.pass === 'TypePropagation' && (p.resolved || p.entrypointsTyped))
35
+ console.log(
36
+ ` ${p.pass}: ${p.entrypointsTyped} entrypoint return schema(s), ${p.resolved} type(s) resolved`,
37
+ );
38
+ else if (p.pass === 'EffectAlgebra' && p.classified)
39
+ console.log(
40
+ ` ${p.pass}: ${p.classified} effect(s) classified, ${p.observable} observable`,
41
+ );
42
+ else if (p.pass === 'ConsistencyDomains' && Object.keys(p.domains ?? {}).length) {
43
+ const names = Object.keys(p.domains).sort();
44
+ console.log(
45
+ ` ${p.pass}: ${names.length} domain(s) — ${names.join(', ')}${p.cycles.length ? ` (⚠ FK cycle: ${p.cycles.join(', ')})` : ''}`,
46
+ );
47
+ }
48
+ }
49
+
50
+ if (report.link.inferredTables.length)
51
+ console.log(
52
+ ` ⚠ tables referenced in code but absent from .sql schemas: ${report.link.inferredTables.join(', ')}`,
53
+ );
54
+ if (report.skipped.length) {
55
+ console.log(` · ${report.skipped.length} construct(s) out of static reach:`);
56
+ for (const s of report.skipped.slice(0, opts.verbose ? 100 : 5))
57
+ console.log(` - ${s.reason}${s.file ? ` (${s.file})` : ''}`);
58
+ if (!opts.verbose && report.skipped.length > 5)
59
+ console.log(` … ${report.skipped.length - 5} more (--verbose)`);
60
+ }
61
+ return { report };
62
+ }
63
+
64
+ const fmtCounts = (obj) =>
65
+ Object.entries(obj)
66
+ .sort(([a], [b]) => a.localeCompare(b))
67
+ .map(([k, v]) => `${k} ${v}`)
68
+ .join(' · ');
69
+
70
+ const rel = (abs, cwd) =>
71
+ abs.startsWith(cwd) ? abs.slice(cwd.length + 1).replaceAll('\\', '/') : abs;
@@ -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
@@ -94,6 +94,11 @@ try {
94
94
  await runEvolve(opts);
95
95
  break;
96
96
  }
97
+ case 'ubg': {
98
+ const { runUbg } = await import('./commands/ubg.js');
99
+ await runUbg(opts);
100
+ break;
101
+ }
97
102
  default:
98
103
  console.log(`SPARDA v${VERSION} — Turn any codebase into an MCP server.
99
104
 
@@ -110,6 +115,7 @@ Usage:
110
115
  npx sparda-mcp twin The living mock (--learn from the live app, then serve the ghost)
111
116
  npx sparda-mcp grammar Which call sequences mean something (observed + hypotheses)
112
117
  npx sparda-mcp evolve Trial hypothesis chains against the twin; survivors become suggestions
118
+ npx sparda-mcp ubg Compile the codebase to its Unified Behavior Graph (.sparda/ubg.json)
113
119
 
114
120
  Flags: --yes (skip prompts) --port <n> --quiet --verbose
115
121
  --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