sparda-mcp 0.5.2 → 0.5.4

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.
@@ -5,13 +5,21 @@ import path from 'node:path';
5
5
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
7
  import {
8
- CallToolRequestSchema, ListToolsRequestSchema,
9
- ListPromptsRequestSchema, GetPromptRequestSchema,
8
+ CallToolRequestSchema,
9
+ ListToolsRequestSchema,
10
+ ListPromptsRequestSchema,
11
+ GetPromptRequestSchema,
10
12
  } from '@modelcontextprotocol/sdk/types.js';
11
13
  import { sanitizeDescription } from '../security/sanitize.js';
12
14
  import { createIdleHarvester } from './idle.js';
13
15
  import { createSequenceRecorder, sequenceRecordingEnabled } from './condenser.js';
14
- import { eligibleForCrystallization, fallbackComposite, normalizeCompositeName, compositeSchema, runComposite } from './crystallize.js';
16
+ import {
17
+ eligibleForCrystallization,
18
+ fallbackComposite,
19
+ normalizeCompositeName,
20
+ compositeSchema,
21
+ runComposite,
22
+ } from './crystallize.js';
15
23
  import { writeManifestSync, mergeManifestKeySync } from './persistence.js';
16
24
  import { createSpardaEngine } from './engine.js';
17
25
  import { initiateWrite, preapproveWrite, confirmWrite } from './confirmation.js';
@@ -30,14 +38,34 @@ export async function startStdioBridge({ cwd, portOverride }) {
30
38
 
31
39
  const manifestPath = path.join(cwd, 'sparda.json');
32
40
  if (!fs.existsSync(manifestPath)) {
33
- throw Object.assign(new Error('sparda.json not found.'), { code: 'USER', hint: 'Run `npx sparda-mcp init` first.' });
41
+ throw Object.assign(new Error('sparda.json not found.'), {
42
+ code: 'USER',
43
+ hint: 'Run `npx sparda-mcp init` first.',
44
+ });
45
+ }
46
+ let manifest;
47
+ try {
48
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
49
+ } catch (e) {
50
+ // A truncated/garbled sparda.json (interrupted write, bad merge) must fail
51
+ // with the same code:'USER'+hint discipline used everywhere else — never a
52
+ // raw SyntaxError that crashes the bridge with an unreadable stack.
53
+ throw Object.assign(
54
+ new Error(`sparda.json is unreadable or corrupted: ${e.message}`),
55
+ {
56
+ code: 'USER',
57
+ hint: 'Restore it from git, or re-run `npx sparda-mcp init` to regenerate.',
58
+ },
59
+ );
34
60
  }
35
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
36
61
  const port = Number(portOverride ?? manifest.port);
37
62
  const framework = manifest.framework;
38
63
  const key = manifest.localKey;
39
64
  if (!key) {
40
- throw Object.assign(new Error('localKey missing from sparda.json.'), { code: 'USER', hint: 'Re-run `npx sparda-mcp init` to regenerate it.' });
65
+ throw Object.assign(new Error('localKey missing from sparda.json.'), {
66
+ code: 'USER',
67
+ hint: 'Re-run `npx sparda-mcp init` to regenerate it.',
68
+ });
41
69
  }
42
70
 
43
71
  // Brief #4 — tenant context carrier. Resolved ONCE, here, from operator-pinned
@@ -46,23 +74,37 @@ export async function startStdioBridge({ cwd, portOverride }) {
46
74
  // per call) is the only scope it cannot choose. Throws USER on CRLF/over-bound so
47
75
  // a malformed scope stops the bridge instead of degrading to "no scope". Absent
48
76
  // config → frozen empty map → injectContext is a no-op (byte-identical forwarding).
49
- const ctx = resolveContext({ argv: process.argv, env: process.env, config: manifest.contextPropagation ?? null });
77
+ const ctx = resolveContext({
78
+ argv: process.argv,
79
+ env: process.env,
80
+ config: manifest.contextPropagation ?? null,
81
+ });
50
82
  const ctxFp = fingerprintContext(ctx); // value-free: names + 8-hex SHA-256 only (R2/R7)
51
83
  if (Object.keys(ctxFp).length > 0) {
52
- console.error(`[sparda] context carrier active (forwarded verbatim on every host call): ${JSON.stringify(ctxFp)}`);
84
+ console.error(
85
+ `[sparda] context carrier active (forwarded verbatim on every host call): ${JSON.stringify(ctxFp)}`,
86
+ );
53
87
  }
54
88
 
55
89
  const base = await waitForHost(port, key, framework, ctx);
56
90
 
57
91
  // full tool specs live in the generated router; fetch them (single source of truth)
58
- const toolSpecs = await (await fetch(`${base}/mcp/tools`, { headers: hostHeaders(key, ctx) })).json();
92
+ const toolSpecs = await (
93
+ await fetch(`${base}/mcp/tools`, { headers: hostHeaders(key, ctx) })
94
+ ).json();
59
95
 
60
96
  const enabled = () => Object.entries(toolSpecs).filter(([, t]) => t.enabled);
61
97
  const disabled = () => Object.entries(toolSpecs).filter(([, t]) => !t.enabled);
62
98
 
63
99
  const server = new Server(
64
100
  { name: `sparda-${path.basename(cwd)}`, version: '0.5.2' },
65
- { capabilities: { tools: { listChanged: true }, prompts: { listChanged: true }, logging: {} } },
101
+ {
102
+ capabilities: {
103
+ tools: { listChanged: true },
104
+ prompts: { listChanged: true },
105
+ logging: {},
106
+ },
107
+ },
66
108
  );
67
109
 
68
110
  // R4.1, intelligence side of the gauge: sampling calls NOT spent because cached
@@ -87,12 +129,20 @@ export async function startStdioBridge({ cwd, portOverride }) {
87
129
  const composites = new Map(); // composite tool name -> { sig, circuit }
88
130
  const recorder = sequenceRecordingEnabled(manifest)
89
131
  ? createSequenceRecorder({
90
- manifest, manifestPath, harvester,
91
- onCircuit: (sig, c) => { crystallizeCircuit(sig, c).catch((e) => console.error(`[sparda] crystallization skipped: ${e.message}`)); },
132
+ manifest,
133
+ manifestPath,
134
+ harvester,
135
+ onCircuit: (sig, c) => {
136
+ crystallizeCircuit(sig, c).catch((e) =>
137
+ console.error(`[sparda] crystallization skipped: ${e.message}`),
138
+ );
139
+ },
92
140
  })
93
141
  : null;
94
142
 
95
- const compositeNameTaken = (n) => Boolean(toolSpecs[n]) || composites.has(n) ||
143
+ const compositeNameTaken = (n) =>
144
+ Boolean(toolSpecs[n]) ||
145
+ composites.has(n) ||
96
146
  ['sparda_info', 'sparda_list_disabled_tools', 'sparda_get_context'].includes(n);
97
147
  const uniqueCompositeName = (base) => {
98
148
  if (!compositeNameTaken(base)) return base;
@@ -102,33 +152,56 @@ export async function startStdioBridge({ cwd, portOverride }) {
102
152
  async function crystallizeCircuit(sig, circuit) {
103
153
  if (circuit.composite || !eligibleForCrystallization(circuit, toolSpecs)) {
104
154
  // observed but not crystallizable (write step, missing fromKey…): say so, once
105
- await server.sendLoggingMessage({
106
- level: 'info', logger: 'sparda',
107
- data: { source: 'condenser', circuit: sig, seen: circuit.seen, note: `circuit observed ${circuit.seen}× — not crystallized (composites are GET-only and need a traceable data flow)` },
108
- }).catch(() => {});
155
+ await server
156
+ .sendLoggingMessage({
157
+ level: 'info',
158
+ logger: 'sparda',
159
+ data: {
160
+ source: 'condenser',
161
+ circuit: sig,
162
+ seen: circuit.seen,
163
+ note: `circuit observed ${circuit.seen}× — not crystallized (composites are GET-only and need a traceable data flow)`,
164
+ },
165
+ })
166
+ .catch(() => {});
109
167
  return;
110
168
  }
111
169
  // the client's LLM names the newborn (one call, ever); no sampling → deterministic name
112
170
  let named = null;
113
171
  if (server.getClientCapabilities()?.sampling) {
114
172
  try {
115
- const flow = circuit.links.map((l) => `'${l.arg}' of ${l.to} comes from '${l.fromKey}' in the response of ${l.from}`).join('; ');
173
+ const flow = circuit.links
174
+ .map(
175
+ (l) =>
176
+ `'${l.arg}' of ${l.to} comes from '${l.fromKey}' in the response of ${l.from}`,
177
+ )
178
+ .join('; ');
116
179
  const res = await server.createMessage({
117
- messages: [{
118
- role: 'user',
119
- content: {
120
- type: 'text',
121
- text: `A repeated tool-call sequence was observed ${circuit.seen}× in a live ${manifest.framework} app:\nsteps: ${circuit.steps.join(' -> ')}\ndata flow: ${flow}\ntools:\n${circuit.steps.map((s) => `- ${s}: ${toolSpecs[s].method} ${toolSpecs[s].path}`).join('\n')}\n\nReply with ONLY a JSON object {"name": "<snake_case verb_noun, max 40 chars>", "description": "<one sentence: what this combined operation achieves in business terms>"}.`,
180
+ messages: [
181
+ {
182
+ role: 'user',
183
+ content: {
184
+ type: 'text',
185
+ text: `A repeated tool-call sequence was observed ${circuit.seen}× in a live ${manifest.framework} app:\nsteps: ${circuit.steps.join(' -> ')}\ndata flow: ${flow}\ntools:\n${circuit.steps.map((s) => `- ${s}: ${toolSpecs[s].method} ${toolSpecs[s].path}`).join('\n')}\n\nReply with ONLY a JSON object {"name": "<snake_case verb_noun, max 40 chars>", "description": "<one sentence: what this combined operation achieves in business terms>"}.`,
186
+ },
122
187
  },
123
- }],
188
+ ],
124
189
  maxTokens: CRYSTAL_TOKENS,
125
190
  });
126
191
  const raw = res?.content?.type === 'text' ? res.content.text : '';
127
- const parsed = JSON.parse(raw.replace(/^```(json)?\s*/i, '').replace(/```\s*$/, ''));
192
+ const parsed = JSON.parse(
193
+ raw.replace(/^```(json)?\s*/i, '').replace(/```\s*$/, ''),
194
+ );
128
195
  const name = normalizeCompositeName(parsed.name);
129
- const { text: desc, flagged } = sanitizeDescription(String(parsed.description ?? ''), '');
130
- if (name && desc && !flagged) named = { name, description: desc, source: 'mcp-sampling' };
131
- } catch { /* graceful degradation: deterministic naming below */ }
196
+ const { text: desc, flagged } = sanitizeDescription(
197
+ String(parsed.description ?? ''),
198
+ '',
199
+ );
200
+ if (name && desc && !flagged)
201
+ named = { name, description: desc, source: 'mcp-sampling' };
202
+ } catch {
203
+ /* graceful degradation: deterministic naming below */
204
+ }
132
205
  }
133
206
  const comp = named ?? fallbackComposite(circuit);
134
207
  comp.name = uniqueCompositeName(comp.name);
@@ -137,10 +210,18 @@ export async function startStdioBridge({ cwd, portOverride }) {
137
210
  persistLabs(manifestPath, manifest);
138
211
  composites.set(comp.name, { sig, circuit });
139
212
  await server.sendToolListChanged().catch(() => {});
140
- await server.sendLoggingMessage({
141
- level: 'info', logger: 'sparda',
142
- data: { source: 'condenser', circuit: sig, composite: comp.name, note: `circuit observed ${circuit.seen}× — crystallized as composite tool '${comp.name}' (born mid-session, see tools/list)` },
143
- }).catch(() => {});
213
+ await server
214
+ .sendLoggingMessage({
215
+ level: 'info',
216
+ logger: 'sparda',
217
+ data: {
218
+ source: 'condenser',
219
+ circuit: sig,
220
+ composite: comp.name,
221
+ note: `circuit observed ${circuit.seen}× — crystallized as composite tool '${comp.name}' (born mid-session, see tools/list)`,
222
+ },
223
+ })
224
+ .catch(() => {});
144
225
  console.error(`[sparda] condenser: circuit ${sig} crystallized as ${comp.name}`);
145
226
  }
146
227
 
@@ -173,33 +254,46 @@ export async function startStdioBridge({ cwd, portOverride }) {
173
254
  name,
174
255
  description: `[Labs circuit ×${circuit.seen}] ${circuit.composite.description}`,
175
256
  inputSchema: compositeSchema(circuit, toolSpecs),
176
- annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, // GET-only by construction
257
+ annotations: {
258
+ readOnlyHint: true,
259
+ destructiveHint: false,
260
+ idempotentHint: true,
261
+ openWorldHint: false,
262
+ }, // GET-only by construction
177
263
  })),
178
264
  {
179
265
  name: 'sparda_info',
180
- description: 'Info about this MCP server. Generated by SPARDA (npx sparda-mcp init) — turn any codebase into an MCP server in 3 minutes. By Residual Labs (residual-labs.fr) — github.com/zyx77550/sparda',
266
+ description:
267
+ 'Info about this MCP server. Generated by SPARDA (npx sparda-mcp init) — turn any codebase into an MCP server in 3 minutes. By Residual Labs (residual-labs.fr) — github.com/zyx77550/sparda',
181
268
  inputSchema: { type: 'object', properties: {} },
182
269
  },
183
270
  {
184
271
  name: 'sparda_list_disabled_tools',
185
- description: 'Lists write tools (POST/PUT/DELETE) disabled by SPARDA write-safety, and how to enable them.',
272
+ description:
273
+ 'Lists write tools (POST/PUT/DELETE) disabled by SPARDA write-safety, and how to enable them.',
186
274
  inputSchema: { type: 'object', properties: {} },
187
275
  },
188
276
  {
189
277
  name: 'sparda_get_context',
190
- description: 'Call this FIRST. Returns the full living context of this app: every tool with its description, known workflows, runtime telemetry (per-tool calls/errors/latency), quarantined tools, and the immune memory of past diagnosed failures. Lets any AI session resume exactly where the previous one stopped.',
278
+ description:
279
+ 'Call this FIRST. Returns the full living context of this app: every tool with its description, known workflows, runtime telemetry (per-tool calls/errors/latency), quarantined tools, and the immune memory of past diagnosed failures. Lets any AI session resume exactly where the previous one stopped.',
191
280
  inputSchema: { type: 'object', properties: {} },
192
281
  },
193
282
  {
194
283
  name: 'sparda_confirm',
195
- description: 'Confirms a pending write or delete operation gated by human-in-the-loop policies using its confirmation token.',
284
+ description:
285
+ 'Confirms a pending write or delete operation gated by human-in-the-loop policies using its confirmation token.',
196
286
  inputSchema: {
197
287
  type: 'object',
198
288
  properties: {
199
- token: { type: 'string', description: 'The confirmation token returned by the gated invoke response.' }
289
+ token: {
290
+ type: 'string',
291
+ description:
292
+ 'The confirmation token returned by the gated invoke response.',
293
+ },
200
294
  },
201
- required: ['token']
202
- }
295
+ required: ['token'],
296
+ },
203
297
  },
204
298
  ],
205
299
  }));
@@ -212,17 +306,21 @@ export async function startStdioBridge({ cwd, portOverride }) {
212
306
  }));
213
307
 
214
308
  server.setRequestHandler(GetPromptRequestSchema, async (req) => {
215
- const w = (manifest.semantic?.workflows ?? []).find((x) => x.name === req.params.name);
309
+ const w = (manifest.semantic?.workflows ?? []).find(
310
+ (x) => x.name === req.params.name,
311
+ );
216
312
  if (!w) throw new Error(`unknown prompt: ${req.params.name}`);
217
313
  return {
218
314
  description: w.description,
219
- messages: [{
220
- role: 'user',
221
- content: {
222
- type: 'text',
223
- text: `Goal: ${w.description}\n\nUse the available SPARDA tools in this order, adapting arguments from each result:\n${w.steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`,
315
+ messages: [
316
+ {
317
+ role: 'user',
318
+ content: {
319
+ type: 'text',
320
+ text: `Goal: ${w.description}\n\nUse the available SPARDA tools in this order, adapting arguments from each result:\n${w.steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`,
321
+ },
224
322
  },
225
- }],
323
+ ],
226
324
  };
227
325
  });
228
326
 
@@ -230,64 +328,121 @@ export async function startStdioBridge({ cwd, portOverride }) {
230
328
  const { name, arguments: args = {} } = req.params;
231
329
 
232
330
  if (name === 'sparda_info') {
233
- return text(JSON.stringify({
234
- project: path.basename(cwd), framework: manifest.framework,
235
- tools_enabled: enabled().length, tools_disabled_write_safety: disabled().length,
236
- workflows: (manifest.semantic?.workflows ?? []).length,
237
- immune_antibodies: Object.keys(manifest.immune?.antibodies ?? {}).length,
238
- labs_sequence_recording: recorder ? 'on' : 'off (opt-in: set labs.recordSequences=true in sparda.json)',
239
- circuits_observed: Object.keys(manifest.labs?.circuits ?? {}).length,
240
- composite_tools: composites.size,
241
- generated_by: 'SPARDA by Residual Labs (residual-labs.fr) npx sparda-mcp init — github.com/zyx77550/sparda',
242
- }, null, 2));
331
+ return text(
332
+ JSON.stringify(
333
+ {
334
+ project: path.basename(cwd),
335
+ framework: manifest.framework,
336
+ tools_enabled: enabled().length,
337
+ tools_disabled_write_safety: disabled().length,
338
+ workflows: (manifest.semantic?.workflows ?? []).length,
339
+ immune_antibodies: Object.keys(manifest.immune?.antibodies ?? {}).length,
340
+ labs_sequence_recording: recorder
341
+ ? 'on'
342
+ : 'off (opt-in: set labs.recordSequences=true in sparda.json)',
343
+ circuits_observed: Object.keys(manifest.labs?.circuits ?? {}).length,
344
+ composite_tools: composites.size,
345
+ generated_by:
346
+ 'SPARDA by Residual Labs (residual-labs.fr) — npx sparda-mcp init — github.com/zyx77550/sparda',
347
+ },
348
+ null,
349
+ 2,
350
+ ),
351
+ );
243
352
  }
244
353
  if (name === 'sparda_get_context') {
245
354
  const headers = hostHeaders(key, ctx);
246
355
  const [stats, events] = await Promise.all([
247
- fetch(`${base}/mcp/stats`, { headers, signal: AbortSignal.timeout(2000) }).then((r) => (r.ok ? r.json() : null)).catch(() => null),
248
- fetch(`${base}/mcp/events?since=0`, { headers, signal: AbortSignal.timeout(2000) }).then((r) => (r.ok ? r.json() : null)).catch(() => null),
356
+ fetch(`${base}/mcp/stats`, { headers, signal: AbortSignal.timeout(2000) })
357
+ .then((r) => (r.ok ? r.json() : null))
358
+ .catch(() => null),
359
+ fetch(`${base}/mcp/events?since=0`, {
360
+ headers,
361
+ signal: AbortSignal.timeout(2000),
362
+ })
363
+ .then((r) => (r.ok ? r.json() : null))
364
+ .catch(() => null),
249
365
  ]);
250
366
  // lifetime savings are derived from antibody hits — no extra state to maintain:
251
367
  // the first hit paid the diagnosis, every later one was served from memory
252
- const antibodyHits = Object.values(manifest.immune?.antibodies ?? {})
253
- .reduce((n, a) => n + Math.max(0, (a.hits ?? 1) - 1), 0);
368
+ const antibodyHits = Object.values(manifest.immune?.antibodies ?? {}).reduce(
369
+ (n, a) => n + Math.max(0, (a.hits ?? 1) - 1),
370
+ 0,
371
+ );
254
372
  const behavior = engine.snapshot();
255
373
  const fly = behavior.flywheel.stats;
256
- return text(JSON.stringify({
257
- project: path.basename(cwd),
258
- framework: manifest.framework,
259
- port,
260
- tools: Object.fromEntries(Object.entries(toolSpecs).map(([n, t]) => [n, { method: t.method, path: t.path, enabled: t.enabled, description: descFor(n, t) }])),
261
- workflows: manifest.semantic?.workflows ?? [],
262
- runtime: stats,
263
- recentEvents: (events?.events ?? []).slice(-20),
264
- immuneMemory: manifest.immune?.antibodies ?? {},
265
- recycling: {
266
- compute: stats?.recycle ?? null,
267
- // R4.3: host calls the flywheel answered from its own RAM — a category the
268
- // router cannot count, because the request never reached it. armed = reads
269
- // proven stable enough to serve right now.
270
- flywheel: { servedFromMemory: fly.served, armed: fly.ready },
271
- intelligence: {
272
- session: { samplingAvoided: intel.samplingAvoided, tokensAvoidedEst: intel.tokensAvoidedEst },
273
- lifetime: { antibodyHits, tokensAvoidedEst: antibodyHits * DIAGNOSIS_TOKENS },
374
+ return text(
375
+ JSON.stringify(
376
+ {
377
+ project: path.basename(cwd),
378
+ framework: manifest.framework,
379
+ port,
380
+ tools: Object.fromEntries(
381
+ Object.entries(toolSpecs).map(([n, t]) => [
382
+ n,
383
+ {
384
+ method: t.method,
385
+ path: t.path,
386
+ enabled: t.enabled,
387
+ description: descFor(n, t),
388
+ },
389
+ ]),
390
+ ),
391
+ workflows: manifest.semantic?.workflows ?? [],
392
+ runtime: stats,
393
+ recentEvents: (events?.events ?? []).slice(-20),
394
+ immuneMemory: manifest.immune?.antibodies ?? {},
395
+ recycling: {
396
+ compute: stats?.recycle ?? null,
397
+ // R4.3: host calls the flywheel answered from its own RAM — a category the
398
+ // router cannot count, because the request never reached it. armed = reads
399
+ // proven stable enough to serve right now.
400
+ flywheel: { servedFromMemory: fly.served, armed: fly.ready },
401
+ intelligence: {
402
+ session: {
403
+ samplingAvoided: intel.samplingAvoided,
404
+ tokensAvoidedEst: intel.tokensAvoidedEst,
405
+ },
406
+ lifetime: {
407
+ antibodyHits,
408
+ tokensAvoidedEst: antibodyHits * DIAGNOSIS_TOKENS,
409
+ },
410
+ },
411
+ },
412
+ sparding: manifest.sparding ?? {},
413
+ behavior,
414
+ labs: {
415
+ recordSequences: Boolean(recorder),
416
+ compositeTools: [...composites.keys()],
417
+ circuits: manifest.labs?.circuits ?? {},
418
+ },
419
+ hint: "runtime.quarantine lists tools temporarily blocked by the immune system (503 until cooldown). immuneMemory maps failure signatures (source|tool|status) to cached diagnoses — same failure later costs zero tokens. recycling measures how much compute/intelligence was served from SPARDA's own memory instead of being paid again. runtime.purity classifies each route by observation: pure = same args keep returning the same response (recyclable), volatile = it changed, erasing = writes. labs.circuits are observed call sequences where one tool's output fed the next one's input. behavior.stability is the engine's passive read of each tool across this session: stable lists response fields that never changed (predictable — future recycling candidates), volatile lists fields that moved; calls is how many responses were observed. behavior.rhythm flags tools called on a steady cadence (periodMs = the beat, confidence 0-1, nextEstimate = when the next call is expected) — a regular rhythm plus a stable result is the textbook pre-fetch candidate. behavior.myelin learns habitual tool succession: an edge \"A-->B\" means B tends to be called right after A; strength (0-10) grows with every traversal and myelinated edges (strength>=3) are entrenched habits — succession candidates the condenser misses because no data flows between the two tools. behavior.dependencies is the engine's map of what the other observers cannot see: invariants are response fields that stayed conserved (>=85% over >=5 reads) even while writes hit the app — true constants, the safest thing to cache hard; ghosts are hidden couplings where a write tool reliably MOVES some unrelated read (writeTool affects a different read tool, correlation 0-1), discovered purely by observation — no data flows between them and they need not be adjacent, yet the write keeps perturbing that read. behavior.flywheel is Bloc B acting on all of the above: once a read has returned the identical response >=3 times for the same arguments it is served straight from memory and the host call is skipped entirely (R4.3), with ready = how many reads are armed to serve right now; recycling.flywheel.servedFromMemory counts how many host calls were already answered from RAM this session — the one recycling category the router cannot see, because the request never reached it (SPARDA_FLYWHEEL=off stops serving while every organ keeps learning).",
274
420
  },
275
- },
276
- sparding: manifest.sparding ?? {},
277
- behavior,
278
- labs: { recordSequences: Boolean(recorder), compositeTools: [...composites.keys()], circuits: manifest.labs?.circuits ?? {} },
279
- hint: 'runtime.quarantine lists tools temporarily blocked by the immune system (503 until cooldown). immuneMemory maps failure signatures (source|tool|status) to cached diagnoses — same failure later costs zero tokens. recycling measures how much compute/intelligence was served from SPARDA\'s own memory instead of being paid again. runtime.purity classifies each route by observation: pure = same args keep returning the same response (recyclable), volatile = it changed, erasing = writes. labs.circuits are observed call sequences where one tool\'s output fed the next one\'s input. behavior.stability is the engine\'s passive read of each tool across this session: stable lists response fields that never changed (predictable — future recycling candidates), volatile lists fields that moved; calls is how many responses were observed. behavior.rhythm flags tools called on a steady cadence (periodMs = the beat, confidence 0-1, nextEstimate = when the next call is expected) — a regular rhythm plus a stable result is the textbook pre-fetch candidate. behavior.myelin learns habitual tool succession: an edge "A-->B" means B tends to be called right after A; strength (0-10) grows with every traversal and myelinated edges (strength>=3) are entrenched habits — succession candidates the condenser misses because no data flows between the two tools. behavior.dependencies is the engine\'s map of what the other observers cannot see: invariants are response fields that stayed conserved (>=85% over >=5 reads) even while writes hit the app — true constants, the safest thing to cache hard; ghosts are hidden couplings where a write tool reliably MOVES some unrelated read (writeTool affects a different read tool, correlation 0-1), discovered purely by observation — no data flows between them and they need not be adjacent, yet the write keeps perturbing that read. behavior.flywheel is Bloc B acting on all of the above: once a read has returned the identical response >=3 times for the same arguments it is served straight from memory and the host call is skipped entirely (R4.3), with ready = how many reads are armed to serve right now; recycling.flywheel.servedFromMemory counts how many host calls were already answered from RAM this session — the one recycling category the router cannot see, because the request never reached it (SPARDA_FLYWHEEL=off stops serving while every organ keeps learning).',
280
- }, null, 2));
421
+ null,
422
+ 2,
423
+ ),
424
+ );
281
425
  }
282
426
  if (name === 'sparda_list_disabled_tools') {
283
- return text(disabled().length
284
- ? `Disabled (write-safety):\n${disabled().map(([n, t]) => `- ${n} (${t.method} ${t.path})`).join('\n')}\n\nTo enable: set "enabled": true in sparda.json, then re-run \`npx sparda-mcp init\` and restart this bridge.`
285
- : 'No disabled tools.');
427
+ return text(
428
+ disabled().length
429
+ ? `Disabled (write-safety):\n${disabled()
430
+ .map(([n, t]) => `- ${n} (${t.method} ${t.path})`)
431
+ .join(
432
+ '\n',
433
+ )}\n\nTo enable: set "enabled": true in sparda.json, then re-run \`npx sparda-mcp init\` and restart this bridge.`
434
+ : 'No disabled tools.',
435
+ );
286
436
  }
287
437
  if (name === 'sparda_confirm') {
288
438
  const token = args.token;
289
439
  if (typeof token !== 'string' || !token) {
290
- return { content: [{ type: 'text', text: 'Error: missing or invalid confirmation token.' }], isError: true };
440
+ return {
441
+ content: [
442
+ { type: 'text', text: 'Error: missing or invalid confirmation token.' },
443
+ ],
444
+ isError: true,
445
+ };
291
446
  }
292
447
  // SIGNAL 2 (Brief #1): the AI holds the token (Signal 1, reachable over stdio) but the
293
448
  // write proceeds only if a human approved out-of-band — an OS-dialog click, or a prior
@@ -295,14 +450,28 @@ export async function startStdioBridge({ cwd, portOverride }) {
295
450
  // confirm can no longer pass. Necessary-but-not-sufficient by construction.
296
451
  const gate = confirmWrite(token);
297
452
  if (!gate.ok) {
298
- return { content: [{ type: 'text', text: signal2Denial(gate.reason) }], isError: true };
453
+ return {
454
+ content: [{ type: 'text', text: signal2Denial(gate.reason) }],
455
+ isError: true,
456
+ };
299
457
  }
300
458
  const payload = await confirmInvoke(base, key, token, ctx);
301
459
  if (payload === null) {
302
- return { content: [{ type: 'text', text: `Host app error. Check that your server is still running on :${port}.` }], isError: true };
460
+ return {
461
+ content: [
462
+ {
463
+ type: 'text',
464
+ text: `Host app error. Check that your server is still running on :${port}.`,
465
+ },
466
+ ],
467
+ isError: true,
468
+ };
303
469
  }
304
470
  const pretty = JSON.stringify(payload, null, 2);
305
- const isError = payload.upstreamStatus !== undefined ? payload.upstreamStatus >= 400 : Boolean(payload.error);
471
+ const isError =
472
+ payload.upstreamStatus !== undefined
473
+ ? payload.upstreamStatus >= 400
474
+ : Boolean(payload.error);
306
475
  return { content: [{ type: 'text', text: pretty }], isError };
307
476
  }
308
477
 
@@ -310,10 +479,23 @@ export async function startStdioBridge({ cwd, portOverride }) {
310
479
  // write confirmation to bypass), auto-feeding linked args between steps
311
480
  const comp = composites.get(name);
312
481
  if (comp) {
313
- const result = await runComposite({ circuit: comp.circuit, args, toolSpecs, invokeFn: (tool, a) => invoke(base, key, tool, a, ctx) });
482
+ const result = await runComposite({
483
+ circuit: comp.circuit,
484
+ args,
485
+ toolSpecs,
486
+ invokeFn: (tool, a) => invoke(base, key, tool, a, ctx),
487
+ });
314
488
  const pretty = JSON.stringify(result, null, 2);
315
489
  return {
316
- content: [{ type: 'text', text: pretty.length > 8000 ? `${pretty.slice(0, 8000)}\n[truncated — ${pretty.length} chars total]` : pretty }],
490
+ content: [
491
+ {
492
+ type: 'text',
493
+ text:
494
+ pretty.length > 8000
495
+ ? `${pretty.slice(0, 8000)}\n[truncated — ${pretty.length} chars total]`
496
+ : pretty,
497
+ },
498
+ ],
317
499
  isError: !result.ok,
318
500
  };
319
501
  }
@@ -326,14 +508,18 @@ export async function startStdioBridge({ cwd, portOverride }) {
326
508
  // host's confirm round-trip never prompts the operator a second time.
327
509
  let elicitationApproved = false;
328
510
  if (isWrite && server.getClientCapabilities()?.elicitation) {
329
- const answer = await server.elicitInput({
330
- message: `SPARDA: allow ${spec.method} ${spec.path}? This is a write operation on your live app.`,
331
- requestedSchema: {
332
- type: 'object',
333
- properties: { confirm: { type: 'boolean', title: `Allow ${spec.method} ${spec.path}` } },
334
- required: ['confirm'],
335
- },
336
- }).catch(() => null);
511
+ const answer = await server
512
+ .elicitInput({
513
+ message: `SPARDA: allow ${spec.method} ${spec.path}? This is a write operation on your live app.`,
514
+ requestedSchema: {
515
+ type: 'object',
516
+ properties: {
517
+ confirm: { type: 'boolean', title: `Allow ${spec.method} ${spec.path}` },
518
+ },
519
+ required: ['confirm'],
520
+ },
521
+ })
522
+ .catch(() => null);
337
523
  if (!answer || answer.action !== 'accept' || answer.content?.confirm !== true) {
338
524
  const mockProof = {
339
525
  version: 'sparding-proof/v0.1',
@@ -342,7 +528,15 @@ export async function startStdioBridge({ cwd, portOverride }) {
342
528
  reasons: ['Write declined by user'],
343
529
  };
344
530
  recordSparding(manifestPath, manifest, name, mockProof);
345
- return { content: [{ type: 'text', text: `Write declined by user: ${spec.method} ${spec.path} was NOT executed.` }], isError: true };
531
+ return {
532
+ content: [
533
+ {
534
+ type: 'text',
535
+ text: `Write declined by user: ${spec.method} ${spec.path} was NOT executed.`,
536
+ },
537
+ ],
538
+ isError: true,
539
+ };
346
540
  }
347
541
  elicitationApproved = true;
348
542
  }
@@ -358,7 +552,10 @@ export async function startStdioBridge({ cwd, portOverride }) {
358
552
  if (cached.hit) {
359
553
  const body = { data: cached.value, upstreamStatus: 200, servedByFlywheel: true };
360
554
  const pretty = JSON.stringify(body, null, 2);
361
- const truncated = pretty.length > 8000 ? `${pretty.slice(0, 8000)}\n[truncated — ${pretty.length} chars total]` : pretty;
555
+ const truncated =
556
+ pretty.length > 8000
557
+ ? `${pretty.slice(0, 8000)}\n[truncated — ${pretty.length} chars total]`
558
+ : pretty;
362
559
  return { content: [{ type: 'text', text: truncated }], isError: false };
363
560
  }
364
561
  }
@@ -372,7 +569,15 @@ export async function startStdioBridge({ cwd, portOverride }) {
372
569
  reasons: ['Host app error or unreachable'],
373
570
  };
374
571
  recordSparding(manifestPath, manifest, name, mockProof);
375
- return { content: [{ type: 'text', text: `Host app error. Check that your server is still running on :${port}.` }], isError: true };
572
+ return {
573
+ content: [
574
+ {
575
+ type: 'text',
576
+ text: `Host app error. Check that your server is still running on :${port}.`,
577
+ },
578
+ ],
579
+ isError: true,
580
+ };
376
581
  }
377
582
 
378
583
  // human-in-the-loop, channel 2 (Brief #1): the host gated this write and minted a single-use
@@ -380,18 +585,31 @@ export async function startStdioBridge({ cwd, portOverride }) {
380
585
  // human yes → pre-approve (no second prompt). Everyone else gets an OS dialog (fires async,
381
586
  // R1) that `sparda_confirm` will require before the write can run. This is what closes the
382
587
  // forgeable-confirmation hole on clients without elicitation.
383
- if (isWrite && payload.status === 'awaiting_confirmation' && typeof payload.confirm === 'string') {
588
+ if (
589
+ isWrite &&
590
+ payload.status === 'awaiting_confirmation' &&
591
+ typeof payload.confirm === 'string'
592
+ ) {
384
593
  if (elicitationApproved) preapproveWrite(payload.confirm);
385
- else initiateWrite({ token: payload.confirm, label: `${spec.method} ${spec.path}` });
594
+ else
595
+ initiateWrite({ token: payload.confirm, label: `${spec.method} ${spec.path}` });
386
596
  }
387
597
 
388
- const proofForRecord = payload.spardingProof ? { ...payload.spardingProof } : { version: 'sparding-proof/v0.1', decision: 'allow', risk: 'low', reasons: [] };
598
+ const proofForRecord = payload.spardingProof
599
+ ? { ...payload.spardingProof }
600
+ : { version: 'sparding-proof/v0.1', decision: 'allow', risk: 'low', reasons: [] };
389
601
  if (payload.upstreamStatus !== undefined && payload.upstreamStatus >= 400) {
390
602
  proofForRecord.isExecutionError = true;
391
- proofForRecord.reasons = [...(proofForRecord.reasons || []), `upstream error status ${payload.upstreamStatus}`];
603
+ proofForRecord.reasons = [
604
+ ...(proofForRecord.reasons || []),
605
+ `upstream error status ${payload.upstreamStatus}`,
606
+ ];
392
607
  } else if (payload.error) {
393
608
  proofForRecord.isExecutionError = true;
394
- proofForRecord.reasons = [...(proofForRecord.reasons || []), `invocation error: ${payload.error}`];
609
+ proofForRecord.reasons = [
610
+ ...(proofForRecord.reasons || []),
611
+ `invocation error: ${payload.error}`,
612
+ ];
395
613
  }
396
614
  recordSparding(manifestPath, manifest, name, proofForRecord);
397
615
 
@@ -404,7 +622,9 @@ export async function startStdioBridge({ cwd, portOverride }) {
404
622
  // here on the hot path; classify in idle. (The proof-after-write read-back below
405
623
  // is internal, not an AI workflow step, so it is deliberately not observed.)
406
624
  const observedAt = Date.now();
407
- harvester.enqueue(() => engine.observe(name, payload.data, observedAt, isWrite, args));
625
+ harvester.enqueue(() =>
626
+ engine.observe(name, payload.data, observedAt, isWrite, args),
627
+ );
408
628
  // condenser tap (Labs, opt-in): detect circuits where one output feeds the next
409
629
  if (recorder) recorder.record(name, args, payload.data);
410
630
  }
@@ -419,27 +639,41 @@ export async function startStdioBridge({ cwd, portOverride }) {
419
639
  for (const [n, t] of Object.entries(toolSpecs)) {
420
640
  if (t.method === 'GET' && t.path === spec.path) engine.invalidateCache(n);
421
641
  }
422
- const getter = Object.entries(toolSpecs).find(([, t]) => t.enabled && t.method === 'GET' && t.path === spec.path);
642
+ const getter = Object.entries(toolSpecs).find(
643
+ ([, t]) => t.enabled && t.method === 'GET' && t.path === spec.path,
644
+ );
423
645
  if (getter) {
424
646
  const after = await invoke(base, key, getter[0], pickPathArgs(spec, args), ctx);
425
- if (after && after.upstreamStatus < 400) proof = { readBack: getter[0], state: after.data };
647
+ if (after && after.upstreamStatus < 400)
648
+ proof = { readBack: getter[0], state: after.data };
426
649
  }
427
650
  }
428
651
 
429
652
  const body = proof ? { ...payload, proof } : payload;
430
653
  const pretty = JSON.stringify(body, null, 2);
431
- const truncated = pretty.length > 8000 ? `${pretty.slice(0, 8000)}\n[truncated — ${pretty.length} chars total]` : pretty;
654
+ const truncated =
655
+ pretty.length > 8000
656
+ ? `${pretty.slice(0, 8000)}\n[truncated — ${pretty.length} chars total]`
657
+ : pretty;
432
658
  // router-level rejections (quarantine, disabled tool, bad params) carry `error` and no upstreamStatus
433
- const isError = payload.upstreamStatus !== undefined ? payload.upstreamStatus >= 400 : Boolean(payload.error);
659
+ const isError =
660
+ payload.upstreamStatus !== undefined
661
+ ? payload.upstreamStatus >= 400
662
+ : Boolean(payload.error);
434
663
  return { content: [{ type: 'text', text: truncated }], isError };
435
664
  });
436
665
 
437
666
  let pollTimer = null;
438
667
  server.oninitialized = () => {
439
668
  // a session resumed on a cached semantic pass skipped the enrichment sampling call
440
- if (manifest.semantic) { intel.samplingAvoided += 1; intel.tokensAvoidedEst += SEMANTIC_TOKENS; }
669
+ if (manifest.semantic) {
670
+ intel.samplingAvoided += 1;
671
+ intel.tokensAvoidedEst += SEMANTIC_TOKENS;
672
+ }
441
673
  pollTimer = startEventPolling(server, base, key, manifest, manifestPath, intel, ctx);
442
- runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs }).catch((e) => console.error(`[sparda] semantic pass skipped: ${e.message}`));
674
+ runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs }).catch((e) =>
675
+ console.error(`[sparda] semantic pass skipped: ${e.message}`),
676
+ );
443
677
  };
444
678
  server.onclose = () => {
445
679
  if (pollTimer) clearInterval(pollTimer);
@@ -449,7 +683,9 @@ export async function startStdioBridge({ cwd, portOverride }) {
449
683
 
450
684
  const transport = new StdioServerTransport();
451
685
  await server.connect(transport);
452
- console.error(`[sparda] MCP bridge running. ${enabled().length} tools enabled, ${disabled().length} disabled (write-safety).${recorder ? ' Labs: sequence recording ON.' : ''} Host: ${base}`);
686
+ console.error(
687
+ `[sparda] MCP bridge running. ${enabled().length} tools enabled, ${disabled().length} disabled (write-safety).${recorder ? ' Labs: sequence recording ON.' : ''} Host: ${base}`,
688
+ );
453
689
  }
454
690
 
455
691
  // One place to build the outbound header set for any bridge→host call. The
@@ -471,7 +707,10 @@ async function invoke(base, key, tool, args, ctx) {
471
707
  body: JSON.stringify({ tool, args }),
472
708
  signal: AbortSignal.timeout(30_000),
473
709
  });
474
- return await res.json().catch(() => ({ upstreamStatus: res.status, error: 'non-JSON response from host' }));
710
+ return await res.json().catch(() => ({
711
+ upstreamStatus: res.status,
712
+ error: 'non-JSON response from host',
713
+ }));
475
714
  } catch {
476
715
  return null;
477
716
  }
@@ -485,7 +724,10 @@ async function confirmInvoke(base, key, token, ctx) {
485
724
  body: JSON.stringify({ confirm: token }),
486
725
  signal: AbortSignal.timeout(30_000),
487
726
  });
488
- return await res.json().catch(() => ({ upstreamStatus: res.status, error: 'non-JSON response from host' }));
727
+ return await res.json().catch(() => ({
728
+ upstreamStatus: res.status,
729
+ error: 'non-JSON response from host',
730
+ }));
489
731
  } catch {
490
732
  return null;
491
733
  }
@@ -505,10 +747,16 @@ function startEventPolling(server, base, key, manifest, manifestPath, intel, ctx
505
747
  let lastSeq = null; // first poll sets the baseline; only NEW errors are reported
506
748
  const timer = setInterval(async () => {
507
749
  try {
508
- const r = await fetch(`${base}/mcp/events?since=${lastSeq ?? 0}`, { headers: hostHeaders(key, ctx), signal: AbortSignal.timeout(2000) });
750
+ const r = await fetch(`${base}/mcp/events?since=${lastSeq ?? 0}`, {
751
+ headers: hostHeaders(key, ctx),
752
+ signal: AbortSignal.timeout(2000),
753
+ });
509
754
  if (!r.ok) return;
510
755
  const { seq, events } = await r.json();
511
- if (lastSeq === null) { lastSeq = seq; return; }
756
+ if (lastSeq === null) {
757
+ lastSeq = seq;
758
+ return;
759
+ }
512
760
  lastSeq = Math.max(lastSeq, seq);
513
761
  for (const ev of events) {
514
762
  const sig = `${ev.source}|${ev.tool ?? ''}|${ev.status ?? ''}`;
@@ -516,16 +764,28 @@ function startEventPolling(server, base, key, manifest, manifestPath, intel, ctx
516
764
  if (antibody) {
517
765
  antibody.hits = (antibody.hits ?? 0) + 1;
518
766
  antibody.lastSeen = ev.ts;
519
- intel.samplingAvoided += 1; // this diagnosis would have been a sampling call
767
+ intel.samplingAvoided += 1; // this diagnosis would have been a sampling call
520
768
  intel.tokensAvoidedEst += DIAGNOSIS_TOKENS;
521
769
  persistImmune(manifestPath, manifest);
522
- await server.sendLoggingMessage({ level: 'error', logger: 'sparda', data: { ...ev, diagnosis: antibody.diagnosis } }).catch(() => {});
770
+ await server
771
+ .sendLoggingMessage({
772
+ level: 'error',
773
+ logger: 'sparda',
774
+ data: { ...ev, diagnosis: antibody.diagnosis },
775
+ })
776
+ .catch(() => {});
523
777
  } else {
524
- await server.sendLoggingMessage({ level: 'error', logger: 'sparda', data: ev }).catch(() => {});
525
- diagnoseAndRemember({ server, manifest, manifestPath, ev, sig }).catch(() => {});
778
+ await server
779
+ .sendLoggingMessage({ level: 'error', logger: 'sparda', data: ev })
780
+ .catch(() => {});
781
+ diagnoseAndRemember({ server, manifest, manifestPath, ev, sig }).catch(
782
+ () => {},
783
+ );
526
784
  }
527
785
  }
528
- } catch { /* host briefly unreachable — next tick */ }
786
+ } catch {
787
+ /* host briefly unreachable — next tick */
788
+ }
529
789
  }, EVENT_POLL_MS);
530
790
  timer.unref?.();
531
791
  return timer;
@@ -544,13 +804,15 @@ async function diagnoseAndRemember({ server, manifest, manifestPath, ev, sig })
544
804
  diagnosing.add(sig);
545
805
  try {
546
806
  const res = await server.createMessage({
547
- messages: [{
548
- role: 'user',
549
- content: {
550
- type: 'text',
551
- text: `A tool of a live ${manifest.framework} app just failed.\nTool: ${ev.tool ?? 'n/a'}\nSource: ${ev.source}\nStatus: ${ev.status ?? 'n/a'}\nError message: ${ev.message}\n\nReply with ONE short sentence (max 140 chars): most likely root cause and fix direction. No preamble.`,
807
+ messages: [
808
+ {
809
+ role: 'user',
810
+ content: {
811
+ type: 'text',
812
+ text: `A tool of a live ${manifest.framework} app just failed.\nTool: ${ev.tool ?? 'n/a'}\nSource: ${ev.source}\nStatus: ${ev.status ?? 'n/a'}\nError message: ${ev.message}\n\nReply with ONE short sentence (max 140 chars): most likely root cause and fix direction. No preamble.`,
813
+ },
552
814
  },
553
- }],
815
+ ],
554
816
  maxTokens: DIAGNOSIS_TOKENS,
555
817
  });
556
818
  const raw = res?.content?.type === 'text' ? res.content.text.trim() : '';
@@ -558,10 +820,18 @@ async function diagnoseAndRemember({ server, manifest, manifestPath, ev, sig })
558
820
  if (!clean || flagged) return;
559
821
  antibodies[sig] = { diagnosis: clean, firstSeen: ev.ts, lastSeen: ev.ts, hits: 1 };
560
822
  persistImmune(manifestPath, manifest);
561
- await server.sendLoggingMessage({
562
- level: 'info', logger: 'sparda',
563
- data: { source: 'immune', signature: sig, diagnosis: clean, note: 'antibody stored in sparda.json — the same failure will be diagnosed instantly, zero tokens' },
564
- }).catch(() => {});
823
+ await server
824
+ .sendLoggingMessage({
825
+ level: 'info',
826
+ logger: 'sparda',
827
+ data: {
828
+ source: 'immune',
829
+ signature: sig,
830
+ diagnosis: clean,
831
+ note: 'antibody stored in sparda.json — the same failure will be diagnosed instantly, zero tokens',
832
+ },
833
+ })
834
+ .catch(() => {});
565
835
  console.error(`[sparda] immune: antibody stored for ${sig}`);
566
836
  } finally {
567
837
  diagnosing.delete(sig);
@@ -577,7 +847,13 @@ function persistImmune(manifestPath, manifest) {
577
847
 
578
848
  function recordSparding(manifestPath, manifest, tool, proof) {
579
849
  try {
580
- manifest.sparding ??= { version: 1, policies: {}, events: [], failures: {}, toolFingerprints: {} };
850
+ manifest.sparding ??= {
851
+ version: 1,
852
+ policies: {},
853
+ events: [],
854
+ failures: {},
855
+ toolFingerprints: {},
856
+ };
581
857
  manifest.sparding.events ??= [];
582
858
  manifest.sparding.failures ??= {};
583
859
 
@@ -596,16 +872,22 @@ function recordSparding(manifestPath, manifest, tool, proof) {
596
872
  const isBlock = proof.decision === 'block';
597
873
  const isError = proof.isExecutionError;
598
874
  if (isBlock || isError) {
599
- const reasonCode = proof.reasons && proof.reasons[0] ? proof.reasons[0].replace(/\s+/g, '_').toLowerCase() : 'unknown_error';
875
+ const reasonCode =
876
+ proof.reasons && proof.reasons[0]
877
+ ? proof.reasons[0].replace(/\s+/g, '_').toLowerCase()
878
+ : 'unknown_error';
600
879
  const sig = `${tool}|${reasonCode}`;
601
880
  const prevFail = manifest.sparding.failures[sig] || { count: 0, lesson: '' };
602
-
881
+
603
882
  let lesson = `Execution failed for tool: ${tool}.`;
604
883
  if (reasonCode.includes('quarantined')) {
605
884
  lesson = `Tool ${tool} was quarantined due to repeated failures.`;
606
885
  } else if (reasonCode.includes('disabled')) {
607
886
  lesson = `Tool ${tool} is disabled by write-safety policies.`;
608
- } else if (reasonCode.includes('missing_path_param') || reasonCode.includes('missing_path')) {
887
+ } else if (
888
+ reasonCode.includes('missing_path_param') ||
889
+ reasonCode.includes('missing_path')
890
+ ) {
609
891
  lesson = `Client omitted a required path parameter on route ${tool}.`;
610
892
  } else if (reasonCode.includes('declined')) {
611
893
  lesson = `Human user declined confirmation for write tool ${tool}.`;
@@ -638,7 +920,9 @@ function persistLabs(manifestPath, manifest) {
638
920
  const onDisk = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
639
921
  onDisk.labs = { ...onDisk.labs, circuits: manifest.labs.circuits };
640
922
  writeManifestSync(manifestPath, onDisk);
641
- } catch { /* disk briefly unavailable — the circuit stays in memory */ }
923
+ } catch {
924
+ /* disk briefly unavailable — the circuit stays in memory */
925
+ }
642
926
  }
643
927
 
644
928
  // semantic pass: uses the CLIENT's own model via MCP sampling — zero key, zero cost.
@@ -646,16 +930,22 @@ function persistLabs(manifestPath, manifest) {
646
930
  async function runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs }) {
647
931
  if (manifest.semantic || !server.getClientCapabilities()?.sampling) return;
648
932
 
649
- const inventory = Object.entries(toolSpecs).map(([n, t]) =>
650
- `- ${n}: ${t.method} ${t.path}${t.description ? ` — ${t.description}` : ''}`).join('\n');
933
+ const inventory = Object.entries(toolSpecs)
934
+ .map(
935
+ ([n, t]) =>
936
+ `- ${n}: ${t.method} ${t.path}${t.description ? ` — ${t.description}` : ''}`,
937
+ )
938
+ .join('\n');
651
939
  const res = await server.createMessage({
652
- messages: [{
653
- role: 'user',
654
- content: {
655
- type: 'text',
656
- text: `These are API tools extracted from a ${manifest.framework} codebase:\n${inventory}\n\nReply with ONLY a JSON object, no prose, shaped as {"descriptions": {"<tool>": "<one clear sentence of what it does in business terms>"}, "workflows": [{"name": "<snake_case>", "description": "<goal>", "steps": ["<tool>", ...]}]}. Include 1-3 workflows that chain tools toward a realistic business goal. Use only the tool names listed above.`,
940
+ messages: [
941
+ {
942
+ role: 'user',
943
+ content: {
944
+ type: 'text',
945
+ text: `These are API tools extracted from a ${manifest.framework} codebase:\n${inventory}\n\nReply with ONLY a JSON object, no prose, shaped as {"descriptions": {"<tool>": "<one clear sentence of what it does in business terms>"}, "workflows": [{"name": "<snake_case>", "description": "<goal>", "steps": ["<tool>", ...]}]}. Include 1-3 workflows that chain tools toward a realistic business goal. Use only the tool names listed above.`,
946
+ },
657
947
  },
658
- }],
948
+ ],
659
949
  maxTokens: SEMANTIC_TOKENS,
660
950
  });
661
951
 
@@ -669,21 +959,37 @@ async function runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs
669
959
  if (clean && !flagged) descriptions[n] = clean;
670
960
  }
671
961
  const workflows = (Array.isArray(parsed.workflows) ? parsed.workflows : [])
672
- .filter((w) => w && typeof w.name === 'string' && typeof w.description === 'string' && Array.isArray(w.steps))
962
+ .filter(
963
+ (w) =>
964
+ w &&
965
+ typeof w.name === 'string' &&
966
+ typeof w.description === 'string' &&
967
+ Array.isArray(w.steps),
968
+ )
673
969
  .slice(0, 5)
674
970
  .map((w) => ({
675
- name: w.name.toLowerCase().replace(/[^a-z0-9_]/g, '_').slice(0, 60),
971
+ name: w.name
972
+ .toLowerCase()
973
+ .replace(/[^a-z0-9_]/g, '_')
974
+ .slice(0, 60),
676
975
  description: sanitizeDescription(w.description, 'workflow').text,
677
976
  steps: w.steps.filter((s) => typeof s === 'string' && toolSpecs[s]),
678
977
  }))
679
978
  .filter((w) => w.steps.length > 0);
680
979
 
681
- manifest.semantic = { enrichedAt: new Date().toISOString(), source: 'mcp-sampling', descriptions, workflows };
980
+ manifest.semantic = {
981
+ enrichedAt: new Date().toISOString(),
982
+ source: 'mcp-sampling',
983
+ descriptions,
984
+ workflows,
985
+ };
682
986
  mergeManifestKeySync(manifestPath, 'semantic', manifest.semantic);
683
987
 
684
988
  await server.sendToolListChanged().catch(() => {});
685
989
  await server.sendPromptListChanged().catch(() => {});
686
- console.error(`[sparda] semantic pass done: ${Object.keys(descriptions).length} descriptions, ${workflows.length} workflows (cached in sparda.json)`);
990
+ console.error(
991
+ `[sparda] semantic pass done: ${Object.keys(descriptions).length} descriptions, ${workflows.length} workflows (cached in sparda.json)`,
992
+ );
687
993
  }
688
994
 
689
995
  // MCP annotations: without them clients assume the scariest defaults and show
@@ -698,15 +1004,21 @@ function annotationsFor(method) {
698
1004
  }
699
1005
 
700
1006
  function schemaFor(t) {
701
- const properties = {}; const required = [];
1007
+ const properties = {};
1008
+ const required = [];
702
1009
  for (const p of t.params ?? []) {
703
- properties[p.name] = { type: p.type === 'unknown' ? 'string' : p.type, description: p.description ?? p.in };
1010
+ properties[p.name] = {
1011
+ type: p.type === 'unknown' ? 'string' : p.type,
1012
+ description: p.description ?? p.in,
1013
+ };
704
1014
  if (p.required) required.push(p.name);
705
1015
  }
706
1016
  return { type: 'object', properties, ...(required.length ? { required } : {}) };
707
1017
  }
708
1018
 
709
- function text(t) { return { content: [{ type: 'text', text: t }] }; }
1019
+ function text(t) {
1020
+ return { content: [{ type: 'text', text: t }] };
1021
+ }
710
1022
 
711
1023
  // AI-facing message when the Signal-2 gate (Brief #1) refuses a `sparda_confirm`. Each reason
712
1024
  // tells the model what to do next without implying it can approve the write itself.
@@ -730,16 +1042,32 @@ async function waitForHost(port, key, framework, ctx) {
730
1042
  for (let attempt = 0; attempt < 40; attempt++) {
731
1043
  for (const h of hosts) {
732
1044
  try {
733
- const r = await fetch(`http://${h}:${port}/mcp/tools`, { headers: hostHeaders(key, ctx), signal: AbortSignal.timeout(1500) });
1045
+ const r = await fetch(`http://${h}:${port}/mcp/tools`, {
1046
+ headers: hostHeaders(key, ctx),
1047
+ signal: AbortSignal.timeout(1500),
1048
+ });
734
1049
  if (r.ok) return `http://${h}:${port}`;
735
- if (r.status === 401) throw Object.assign(new Error('Host app reachable but key mismatch — re-run `npx sparda-mcp init`.'), { code: 'USER' });
736
- } catch (e) { if (e.code === 'USER') throw e; }
1050
+ if (r.status === 401)
1051
+ throw Object.assign(
1052
+ new Error(
1053
+ 'Host app reachable but key mismatch — re-run `npx sparda-mcp init`.',
1054
+ ),
1055
+ { code: 'USER' },
1056
+ );
1057
+ } catch (e) {
1058
+ if (e.code === 'USER') throw e;
1059
+ }
737
1060
  }
738
1061
  if (attempt === 0) {
739
1062
  const startCmd = framework === 'express' ? 'npm run dev' : 'fastapi dev';
740
- console.error(`[sparda] Waiting for host app on :${port} ... (start it with ${startCmd} — Ctrl+C to abort)`);
1063
+ console.error(
1064
+ `[sparda] Waiting for host app on :${port} ... (start it with ${startCmd} — Ctrl+C to abort)`,
1065
+ );
741
1066
  }
742
1067
  await new Promise((r) => setTimeout(r, 3000));
743
1068
  }
744
- throw Object.assign(new Error(`Host app unreachable on :${port} after 2 minutes.`), { code: 'USER', hint: 'Start your server first, then run `npx sparda-mcp dev`.' });
1069
+ throw Object.assign(new Error(`Host app unreachable on :${port} after 2 minutes.`), {
1070
+ code: 'USER',
1071
+ hint: 'Start your server first, then run `npx sparda-mcp dev`.',
1072
+ });
745
1073
  }