sparda-mcp 0.5.0 → 0.5.2

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.
@@ -0,0 +1,269 @@
1
+ // server/persistence.js — durable, pluggable state persistence (Chantier 1).
2
+ //
3
+ // Two clearly-separated concerns live here:
4
+ //
5
+ // 1. THE MANIFEST (`sparda.json`) is a LOCAL git artifact: `remove`, `sync`,
6
+ // `doctor` and carry-over all read it from disk, and it is committed. It
7
+ // must never move to a remote store. What it needs is durability — a crash
8
+ // mid-write must never leave it truncated. `atomicWriteFileSync` writes to
9
+ // a temp file, fsyncs it, then renames (rename is atomic on POSIX/NTFS), so
10
+ // a reader sees either the old bytes or the new bytes, never a half-file.
11
+ //
12
+ // 2. PLUGGABLE STATE DRIVERS (Memory / LocalFile / Redis) persist arbitrary,
13
+ // engine-agnostic state by instanceId. This is the seam for the future
14
+ // living-engine state (the bounded brain snapshot) and for multi-node
15
+ // deployments — not for `sparda.json`. Redis is a LAZY import: it is never
16
+ // a hard dependency (hard rule #8 — the 4 pinned deps stay 4), it only
17
+ // loads if a user explicitly selects SPARDA_DRIVER=redis.
18
+ //
19
+ // Host never pays (hard rule #1): nothing here sits on the request path.
20
+
21
+ import fs from 'node:fs';
22
+ import fsp from 'node:fs/promises';
23
+ import path from 'node:path';
24
+ import os from 'node:os';
25
+ import crypto from 'node:crypto';
26
+
27
+ // ───────────────────────────────────────────────────────────────────────────
28
+ // ATOMIC FILE WRITE (the single source of truth — generators + bridge use it)
29
+ // temp file → fsync → rename. fsync is the part the old `atomicWrite` lacked:
30
+ // without it, rename can land before the data is flushed and a power loss
31
+ // leaves a zero-length file. The temp sits next to the target (same fs) so the
32
+ // rename is a real atomic move, not a cross-device copy.
33
+ // ───────────────────────────────────────────────────────────────────────────
34
+
35
+ // Windows reality: Defender or the search indexer can hold a sub-second lock on the
36
+ // target mid-rename, surfacing as a transient EPERM/EACCES/EBUSY (seen as a ~1-in-N
37
+ // flake in the byte-for-byte fixture tests). A bounded retry with a short, non-
38
+ // spinning backoff clears it. A genuine failure (ENOENT, ENOSPC, EROFS) is NOT a
39
+ // lock — it is surfaced immediately, never retried. Off the hot path (rule #1), so
40
+ // the few ms of blocking on the rare retry never touch a request.
41
+ export const RENAME_MAX_ATTEMPTS = 5;
42
+ const RENAME_BACKOFF_MS = 10;
43
+ const TRANSIENT_LOCK_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
44
+
45
+ // synchronous, non-spinning wait — blocks this thread for ms without burning CPU.
46
+ function sleepSync(ms) {
47
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
48
+ }
49
+
50
+ export function atomicWriteFileSync(file, content) {
51
+ const tmp = `${file}.sparda-tmp`;
52
+ let fd;
53
+ try {
54
+ fd = fs.openSync(tmp, 'w');
55
+ fs.writeSync(fd, content);
56
+ fs.fsyncSync(fd);
57
+ } finally {
58
+ if (fd !== undefined) fs.closeSync(fd);
59
+ }
60
+ for (let attempt = 1; ; attempt++) {
61
+ try {
62
+ fs.renameSync(tmp, file);
63
+ return;
64
+ } catch (err) {
65
+ if (!TRANSIENT_LOCK_CODES.has(err.code) || attempt >= RENAME_MAX_ATTEMPTS) {
66
+ try { fs.unlinkSync(tmp); } catch { /* best effort */ }
67
+ throw err;
68
+ }
69
+ sleepSync(RENAME_BACKOFF_MS * attempt); // 10, 20, 30, 40 ms — clears a brief AV/indexer lock
70
+ }
71
+ }
72
+ }
73
+
74
+ // Convenience for the manifest: pretty JSON + trailing newline, written atomically.
75
+ // Mirrors the exact byte shape the generators produce so diffs stay clean.
76
+ export function writeManifestSync(manifestPath, manifest) {
77
+ atomicWriteFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
78
+ }
79
+
80
+ // Merge-write a single top-level key into the on-disk manifest without
81
+ // clobbering keys another writer (immune, sparding, labs, semantic) may have
82
+ // touched since. Re-reads, sets one field, writes atomically. Silent on a brief
83
+ // disk hiccup — the in-memory copy is the source of truth and will retry.
84
+ export function mergeManifestKeySync(manifestPath, key, value) {
85
+ try {
86
+ const onDisk = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
87
+ onDisk[key] = value;
88
+ writeManifestSync(manifestPath, onDisk);
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ // ───────────────────────────────────────────────────────────────────────────
96
+ // PLUGGABLE STATE DRIVERS (engine-agnostic key/value by instanceId)
97
+ // Interface: save(id, data) load(id) delete(id) listInstances() → all async.
98
+ // ───────────────────────────────────────────────────────────────────────────
99
+
100
+ // Driver 1 — in-memory. Zero setup, ephemeral. Default for tests and for runs
101
+ // that don't want any state on disk.
102
+ export class MemoryDriver {
103
+ #store = new Map();
104
+ async save(instanceId, data) {
105
+ this.#store.set(instanceId, JSON.parse(JSON.stringify(data)));
106
+ return true;
107
+ }
108
+ async load(instanceId) {
109
+ return this.#store.get(instanceId) ?? null;
110
+ }
111
+ async delete(instanceId) {
112
+ return this.#store.delete(instanceId);
113
+ }
114
+ async listInstances() {
115
+ return [...this.#store.keys()];
116
+ }
117
+ }
118
+
119
+ // Driver 2 — local file, one JSON per instance under baseDir, written with the
120
+ // same atomic+fsync guarantee as the manifest. Filenames are hashed so an
121
+ // instanceId can be any string without path-injection risk.
122
+ export class LocalFileDriver {
123
+ #baseDir;
124
+ #ttlMs;
125
+ constructor(baseDir = './sparda_state/', ttlDays = 0) {
126
+ this.#baseDir = path.resolve(baseDir);
127
+ this.#ttlMs = ttlDays > 0 ? ttlDays * 86_400_000 : 0;
128
+ fs.mkdirSync(this.#baseDir, { recursive: true });
129
+ }
130
+ #filePath(instanceId) {
131
+ const hash = crypto.createHash('sha256').update(instanceId).digest('hex').slice(0, 16);
132
+ return path.join(this.#baseDir, `sparda_${hash}.json`);
133
+ }
134
+ async save(instanceId, data) {
135
+ try {
136
+ atomicWriteFileSync(this.#filePath(instanceId), JSON.stringify(data, null, 2));
137
+ return true;
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+ async load(instanceId) {
143
+ const target = this.#filePath(instanceId);
144
+ try {
145
+ if (this.#ttlMs > 0) {
146
+ const stat = await fsp.stat(target);
147
+ if (Date.now() - stat.mtimeMs > this.#ttlMs) {
148
+ await fsp.unlink(target).catch(() => {});
149
+ return null;
150
+ }
151
+ }
152
+ return JSON.parse(await fsp.readFile(target, 'utf8'));
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+ async delete(instanceId) {
158
+ try {
159
+ await fsp.unlink(this.#filePath(instanceId));
160
+ return true;
161
+ } catch {
162
+ return false;
163
+ }
164
+ }
165
+ async listInstances() {
166
+ try {
167
+ const files = await fsp.readdir(this.#baseDir);
168
+ return files
169
+ .filter((f) => f.startsWith('sparda_') && f.endsWith('.json'))
170
+ .map((f) => f.slice('sparda_'.length, -'.json'.length));
171
+ } catch {
172
+ return [];
173
+ }
174
+ }
175
+ }
176
+
177
+ // Driver 3 — Redis, for distributed/multi-pod nodes. ioredis is imported lazily
178
+ // and is NOT a package dependency: selecting this driver without ioredis
179
+ // installed throws a clear, actionable error rather than failing at import.
180
+ export class RedisDriver {
181
+ #prefix;
182
+ #ttl;
183
+ #client = null;
184
+ #ready;
185
+ constructor(options = {}) {
186
+ this.#prefix = options.keyPrefix ?? 'sparda:';
187
+ this.#ttl = options.ttlSeconds ?? 0;
188
+ this.#ready = this.#init(options);
189
+ }
190
+ async #init(options) {
191
+ let Redis;
192
+ try {
193
+ ({ default: Redis } = await import('ioredis'));
194
+ } catch {
195
+ throw Object.assign(
196
+ new Error('RedisDriver requires the optional "ioredis" package.'),
197
+ { code: 'USER', hint: 'Run `npm install ioredis`, or use the default file driver.' },
198
+ );
199
+ }
200
+ this.#client = new Redis({
201
+ host: options.host ?? 'localhost',
202
+ port: options.port ?? 6379,
203
+ password: options.password,
204
+ db: options.db ?? 0,
205
+ tls: options.tls ? {} : undefined,
206
+ lazyConnect: true,
207
+ });
208
+ }
209
+ #key(instanceId) {
210
+ return `${this.#prefix}${instanceId}`;
211
+ }
212
+ async save(instanceId, data) {
213
+ try {
214
+ await this.#ready;
215
+ const payload = JSON.stringify(data);
216
+ if (this.#ttl > 0) await this.#client.setex(this.#key(instanceId), this.#ttl, payload);
217
+ else await this.#client.set(this.#key(instanceId), payload);
218
+ return true;
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+ async load(instanceId) {
224
+ try {
225
+ await this.#ready;
226
+ const raw = await this.#client.get(this.#key(instanceId));
227
+ return raw ? JSON.parse(raw) : null;
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+ async delete(instanceId) {
233
+ try {
234
+ await this.#ready;
235
+ return (await this.#client.del(this.#key(instanceId))) > 0;
236
+ } catch {
237
+ return false;
238
+ }
239
+ }
240
+ async listInstances() {
241
+ try {
242
+ await this.#ready;
243
+ const keys = await this.#client.keys(`${this.#prefix}*`);
244
+ return keys.map((k) => k.slice(this.#prefix.length));
245
+ } catch {
246
+ return [];
247
+ }
248
+ }
249
+ }
250
+
251
+ // Factory from env. Default is the local file driver — survives restarts with
252
+ // zero setup, no new dependency. `memory` for tests, `redis` opts into the lazy
253
+ // dependency. Unknown values fall back to file (fail safe, not silent crash).
254
+ export function createStateDriverFromEnv(env = process.env) {
255
+ switch ((env.SPARDA_DRIVER ?? 'file').toLowerCase()) {
256
+ case 'memory':
257
+ return new MemoryDriver();
258
+ case 'redis':
259
+ return new RedisDriver({
260
+ host: env.SPARDA_REDIS_HOST ?? 'localhost',
261
+ port: env.SPARDA_REDIS_PORT ? Number(env.SPARDA_REDIS_PORT) : 6379,
262
+ password: env.SPARDA_REDIS_PASSWORD,
263
+ keyPrefix: env.SPARDA_REDIS_PREFIX ?? 'sparda:',
264
+ });
265
+ case 'file':
266
+ default:
267
+ return new LocalFileDriver(env.SPARDA_STATE_DIR ?? './sparda_state/');
268
+ }
269
+ }
@@ -12,6 +12,10 @@ import { sanitizeDescription } from '../security/sanitize.js';
12
12
  import { createIdleHarvester } from './idle.js';
13
13
  import { createSequenceRecorder, sequenceRecordingEnabled } from './condenser.js';
14
14
  import { eligibleForCrystallization, fallbackComposite, normalizeCompositeName, compositeSchema, runComposite } from './crystallize.js';
15
+ import { writeManifestSync, mergeManifestKeySync } from './persistence.js';
16
+ import { createSpardaEngine } from './engine.js';
17
+ import { initiateWrite, preapproveWrite, confirmWrite } from './confirmation.js';
18
+ import { resolveContext, injectContext, fingerprintContext } from './context-carrier.js';
15
19
 
16
20
  const EVENT_POLL_MS = Number(process.env.SPARDA_EVENT_POLL_MS ?? 5000);
17
21
  // the sampling budgets below are also what the recycling gauge counts as "avoided"
@@ -35,16 +39,29 @@ export async function startStdioBridge({ cwd, portOverride }) {
35
39
  if (!key) {
36
40
  throw Object.assign(new Error('localKey missing from sparda.json.'), { code: 'USER', hint: 'Re-run `npx sparda-mcp init` to regenerate it.' });
37
41
  }
38
- const base = await waitForHost(port, key, framework);
42
+
43
+ // Brief #4 — tenant context carrier. Resolved ONCE, here, from operator-pinned
44
+ // sources (CLI --context > env SPARDA_CONTEXT_* > sparda.json names the headers).
45
+ // The AI is the adversary and can forge anything it sends; pinning at launch (not
46
+ // per call) is the only scope it cannot choose. Throws USER on CRLF/over-bound so
47
+ // a malformed scope stops the bridge instead of degrading to "no scope". Absent
48
+ // 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 });
50
+ const ctxFp = fingerprintContext(ctx); // value-free: names + 8-hex SHA-256 only (R2/R7)
51
+ if (Object.keys(ctxFp).length > 0) {
52
+ console.error(`[sparda] context carrier active (forwarded verbatim on every host call): ${JSON.stringify(ctxFp)}`);
53
+ }
54
+
55
+ const base = await waitForHost(port, key, framework, ctx);
39
56
 
40
57
  // full tool specs live in the generated router; fetch them (single source of truth)
41
- const toolSpecs = await (await fetch(`${base}/mcp/tools`, { headers: { 'x-sparda-key': key } })).json();
58
+ const toolSpecs = await (await fetch(`${base}/mcp/tools`, { headers: hostHeaders(key, ctx) })).json();
42
59
 
43
60
  const enabled = () => Object.entries(toolSpecs).filter(([, t]) => t.enabled);
44
61
  const disabled = () => Object.entries(toolSpecs).filter(([, t]) => !t.enabled);
45
62
 
46
63
  const server = new Server(
47
- { name: `sparda-${path.basename(cwd)}`, version: '0.5.0' },
64
+ { name: `sparda-${path.basename(cwd)}`, version: '0.5.2' },
48
65
  { capabilities: { tools: { listChanged: true }, prompts: { listChanged: true }, logging: {} } },
49
66
  );
50
67
 
@@ -55,6 +72,16 @@ export async function startStdioBridge({ cwd, portOverride }) {
55
72
 
56
73
  // R4.4: every internal organ works only when the event loop is quiet
57
74
  const harvester = createIdleHarvester();
75
+ // engine (Bloc B, slice 1 — "cerveau de stabilité"): a passive per-tool read of
76
+ // which response fields stay put vs move. Fed off the hot path via the harvester,
77
+ // holds field fingerprints only (never values, ADR-014), runtime-only — no
78
+ // carry-over, same posture as the router's purity detector.
79
+ const engine = createSpardaEngine();
80
+ // R4.3 kill-switch (decision A, ADR-020): the flywheel SERVES proven-stable reads
81
+ // from memory by default. SPARDA_FLYWHEEL=off keeps every organ learning but stops
82
+ // serving, so a suspect cache can be cut without losing the brain. Gated here at the
83
+ // bridge; the engine itself stays env-free.
84
+ const flywheelServes = process.env.SPARDA_FLYWHEEL !== 'off';
58
85
  // R2.1 (Labs, default OFF): record the current of calls, detect circuits;
59
86
  // R2.2: at the observation threshold a circuit crystallizes into a composite tool
60
87
  const composites = new Map(); // composite tool name -> { sig, circuit }
@@ -163,6 +190,17 @@ export async function startStdioBridge({ cwd, portOverride }) {
163
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.',
164
191
  inputSchema: { type: 'object', properties: {} },
165
192
  },
193
+ {
194
+ name: 'sparda_confirm',
195
+ description: 'Confirms a pending write or delete operation gated by human-in-the-loop policies using its confirmation token.',
196
+ inputSchema: {
197
+ type: 'object',
198
+ properties: {
199
+ token: { type: 'string', description: 'The confirmation token returned by the gated invoke response.' }
200
+ },
201
+ required: ['token']
202
+ }
203
+ },
166
204
  ],
167
205
  }));
168
206
 
@@ -204,7 +242,7 @@ export async function startStdioBridge({ cwd, portOverride }) {
204
242
  }, null, 2));
205
243
  }
206
244
  if (name === 'sparda_get_context') {
207
- const headers = { 'x-sparda-key': key };
245
+ const headers = hostHeaders(key, ctx);
208
246
  const [stats, events] = await Promise.all([
209
247
  fetch(`${base}/mcp/stats`, { headers, signal: AbortSignal.timeout(2000) }).then((r) => (r.ok ? r.json() : null)).catch(() => null),
210
248
  fetch(`${base}/mcp/events?since=0`, { headers, signal: AbortSignal.timeout(2000) }).then((r) => (r.ok ? r.json() : null)).catch(() => null),
@@ -213,6 +251,8 @@ export async function startStdioBridge({ cwd, portOverride }) {
213
251
  // the first hit paid the diagnosis, every later one was served from memory
214
252
  const antibodyHits = Object.values(manifest.immune?.antibodies ?? {})
215
253
  .reduce((n, a) => n + Math.max(0, (a.hits ?? 1) - 1), 0);
254
+ const behavior = engine.snapshot();
255
+ const fly = behavior.flywheel.stats;
216
256
  return text(JSON.stringify({
217
257
  project: path.basename(cwd),
218
258
  framework: manifest.framework,
@@ -224,14 +264,19 @@ export async function startStdioBridge({ cwd, portOverride }) {
224
264
  immuneMemory: manifest.immune?.antibodies ?? {},
225
265
  recycling: {
226
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 },
227
271
  intelligence: {
228
272
  session: { samplingAvoided: intel.samplingAvoided, tokensAvoidedEst: intel.tokensAvoidedEst },
229
273
  lifetime: { antibodyHits, tokensAvoidedEst: antibodyHits * DIAGNOSIS_TOKENS },
230
274
  },
231
275
  },
232
276
  sparding: manifest.sparding ?? {},
277
+ behavior,
233
278
  labs: { recordSequences: Boolean(recorder), compositeTools: [...composites.keys()], circuits: manifest.labs?.circuits ?? {} },
234
- 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.',
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).',
235
280
  }, null, 2));
236
281
  }
237
282
  if (name === 'sparda_list_disabled_tools') {
@@ -239,12 +284,33 @@ export async function startStdioBridge({ cwd, portOverride }) {
239
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.`
240
285
  : 'No disabled tools.');
241
286
  }
287
+ if (name === 'sparda_confirm') {
288
+ const token = args.token;
289
+ if (typeof token !== 'string' || !token) {
290
+ return { content: [{ type: 'text', text: 'Error: missing or invalid confirmation token.' }], isError: true };
291
+ }
292
+ // SIGNAL 2 (Brief #1): the AI holds the token (Signal 1, reachable over stdio) but the
293
+ // write proceeds only if a human approved out-of-band — an OS-dialog click, or a prior
294
+ // native-elicitation accept. Neither channel is reachable by the AI, so a self-issued
295
+ // confirm can no longer pass. Necessary-but-not-sufficient by construction.
296
+ const gate = confirmWrite(token);
297
+ if (!gate.ok) {
298
+ return { content: [{ type: 'text', text: signal2Denial(gate.reason) }], isError: true };
299
+ }
300
+ const payload = await confirmInvoke(base, key, token, ctx);
301
+ if (payload === null) {
302
+ return { content: [{ type: 'text', text: `Host app error. Check that your server is still running on :${port}.` }], isError: true };
303
+ }
304
+ const pretty = JSON.stringify(payload, null, 2);
305
+ const isError = payload.upstreamStatus !== undefined ? payload.upstreamStatus >= 400 : Boolean(payload.error);
306
+ return { content: [{ type: 'text', text: pretty }], isError };
307
+ }
242
308
 
243
309
  // composite tools run their whole chain (GET-only by construction — no
244
310
  // write confirmation to bypass), auto-feeding linked args between steps
245
311
  const comp = composites.get(name);
246
312
  if (comp) {
247
- const result = await runComposite({ circuit: comp.circuit, args, toolSpecs, invokeFn: (tool, a) => invoke(base, key, tool, a) });
313
+ const result = await runComposite({ circuit: comp.circuit, args, toolSpecs, invokeFn: (tool, a) => invoke(base, key, tool, a, ctx) });
248
314
  const pretty = JSON.stringify(result, null, 2);
249
315
  return {
250
316
  content: [{ type: 'text', text: pretty.length > 8000 ? `${pretty.slice(0, 8000)}\n[truncated — ${pretty.length} chars total]` : pretty }],
@@ -255,7 +321,10 @@ export async function startStdioBridge({ cwd, portOverride }) {
255
321
  const spec = toolSpecs[name];
256
322
  const isWrite = spec && spec.method !== 'GET';
257
323
 
258
- // human-in-the-loop: confirm writes natively in the client UI when supported
324
+ // human-in-the-loop, channel 1: confirm writes natively in the client UI when supported.
325
+ // A native accept is a real out-of-band human yes — it pre-approves Signal 2 below, so the
326
+ // host's confirm round-trip never prompts the operator a second time.
327
+ let elicitationApproved = false;
259
328
  if (isWrite && server.getClientCapabilities()?.elicitation) {
260
329
  const answer = await server.elicitInput({
261
330
  message: `SPARDA: allow ${spec.method} ${spec.path}? This is a write operation on your live app.`,
@@ -275,9 +344,26 @@ export async function startStdioBridge({ cwd, portOverride }) {
275
344
  recordSparding(manifestPath, manifest, name, mockProof);
276
345
  return { content: [{ type: 'text', text: `Write declined by user: ${spec.method} ${spec.path} was NOT executed.` }], isError: true };
277
346
  }
347
+ elicitationApproved = true;
348
+ }
349
+
350
+ // Bloc B flywheel (R4.3): the first organ that ACTS. For a read proven stable —
351
+ // the same response >=3x for these exact args, within TTL — serve straight from the
352
+ // engine's RAM and never make the host call. Writes always fall through to the host
353
+ // (and still pass write-confirmation above), so hard rule #3 is untouched. The served
354
+ // `data` is byte-identical to what the host returned; servedByFlywheel marks the
355
+ // envelope so a hit is observable. Not re-observed (that would feed the cache itself).
356
+ if (spec && !isWrite && flywheelServes) {
357
+ const cached = engine.preCall(name, args);
358
+ if (cached.hit) {
359
+ const body = { data: cached.value, upstreamStatus: 200, servedByFlywheel: true };
360
+ const pretty = JSON.stringify(body, null, 2);
361
+ const truncated = pretty.length > 8000 ? `${pretty.slice(0, 8000)}\n[truncated — ${pretty.length} chars total]` : pretty;
362
+ return { content: [{ type: 'text', text: truncated }], isError: false };
363
+ }
278
364
  }
279
365
 
280
- const payload = await invoke(base, key, name, args);
366
+ const payload = await invoke(base, key, name, args, ctx);
281
367
  if (payload === null) {
282
368
  const mockProof = {
283
369
  version: 'sparding-proof/v0.1',
@@ -289,6 +375,16 @@ export async function startStdioBridge({ cwd, portOverride }) {
289
375
  return { content: [{ type: 'text', text: `Host app error. Check that your server is still running on :${port}.` }], isError: true };
290
376
  }
291
377
 
378
+ // human-in-the-loop, channel 2 (Brief #1): the host gated this write and minted a single-use
379
+ // confirm token. Arm Signal 2 NOW, keyed by that token. Elicitation clients already have a
380
+ // human yes → pre-approve (no second prompt). Everyone else gets an OS dialog (fires async,
381
+ // R1) that `sparda_confirm` will require before the write can run. This is what closes the
382
+ // forgeable-confirmation hole on clients without elicitation.
383
+ if (isWrite && payload.status === 'awaiting_confirmation' && typeof payload.confirm === 'string') {
384
+ if (elicitationApproved) preapproveWrite(payload.confirm);
385
+ else initiateWrite({ token: payload.confirm, label: `${spec.method} ${spec.path}` });
386
+ }
387
+
292
388
  const proofForRecord = payload.spardingProof ? { ...payload.spardingProof } : { version: 'sparding-proof/v0.1', decision: 'allow', risk: 'low', reasons: [] };
293
389
  if (payload.upstreamStatus !== undefined && payload.upstreamStatus >= 400) {
294
390
  proofForRecord.isExecutionError = true;
@@ -299,18 +395,33 @@ export async function startStdioBridge({ cwd, portOverride }) {
299
395
  }
300
396
  recordSparding(manifestPath, manifest, name, proofForRecord);
301
397
 
302
- // condenser tap (Labs): only AI-driven calls that succeeded feed the current —
303
- // internal read-backs and failures are not workflow steps
304
- if (recorder && payload.upstreamStatus !== undefined && payload.upstreamStatus < 400) {
305
- recorder.record(name, args, payload.data);
398
+ // successful AI-driven reads feed SPARDA's passive observers, both off the hot
399
+ // path (idle harvester). Internal read-backs and failures are not workflow steps.
400
+ if (payload.upstreamStatus !== undefined && payload.upstreamStatus < 400) {
401
+ // engine: stability + rhythm + myelin (Blocs A/B) and the dependency map
402
+ // (Bloc D — invariants conserved across writes, ghost write->read couplings).
403
+ // isWrite lets Bloc D tell a state read from a mutation. Capture the call time
404
+ // here on the hot path; classify in idle. (The proof-after-write read-back below
405
+ // is internal, not an AI workflow step, so it is deliberately not observed.)
406
+ const observedAt = Date.now();
407
+ harvester.enqueue(() => engine.observe(name, payload.data, observedAt, isWrite, args));
408
+ // condenser tap (Labs, opt-in): detect circuits where one output feeds the next
409
+ if (recorder) recorder.record(name, args, payload.data);
306
410
  }
307
411
 
308
412
  // proof-after-write: re-read the same path so the AI sees the effect of its write
309
413
  let proof = null;
310
414
  if (isWrite && payload.upstreamStatus < 400) {
415
+ // flywheel coherence (5b): this write just moved whatever a GET on the same path
416
+ // returns, so drop that GET's cached answer NOW — synchronous, before the next read
417
+ // can be served stale. The engine purges by learned ghost couplings in idle; this
418
+ // covers the structural same-path case it can't see (no path info in the engine).
419
+ for (const [n, t] of Object.entries(toolSpecs)) {
420
+ if (t.method === 'GET' && t.path === spec.path) engine.invalidateCache(n);
421
+ }
311
422
  const getter = Object.entries(toolSpecs).find(([, t]) => t.enabled && t.method === 'GET' && t.path === spec.path);
312
423
  if (getter) {
313
- const after = await invoke(base, key, getter[0], pickPathArgs(spec, args));
424
+ const after = await invoke(base, key, getter[0], pickPathArgs(spec, args), ctx);
314
425
  if (after && after.upstreamStatus < 400) proof = { readBack: getter[0], state: after.data };
315
426
  }
316
427
  }
@@ -327,7 +438,7 @@ export async function startStdioBridge({ cwd, portOverride }) {
327
438
  server.oninitialized = () => {
328
439
  // a session resumed on a cached semantic pass skipped the enrichment sampling call
329
440
  if (manifest.semantic) { intel.samplingAvoided += 1; intel.tokensAvoidedEst += SEMANTIC_TOKENS; }
330
- pollTimer = startEventPolling(server, base, key, manifest, manifestPath, intel);
441
+ pollTimer = startEventPolling(server, base, key, manifest, manifestPath, intel, ctx);
331
442
  runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs }).catch((e) => console.error(`[sparda] semantic pass skipped: ${e.message}`));
332
443
  };
333
444
  server.onclose = () => {
@@ -341,11 +452,22 @@ export async function startStdioBridge({ cwd, portOverride }) {
341
452
  console.error(`[sparda] MCP bridge running. ${enabled().length} tools enabled, ${disabled().length} disabled (write-safety).${recorder ? ' Labs: sequence recording ON.' : ''} Host: ${base}`);
342
453
  }
343
454
 
344
- async function invoke(base, key, tool, args) {
455
+ // One place to build the outbound header set for any bridge→host call. The
456
+ // operator-pinned tenant context (Brief #4) is copied on LAST so it is always
457
+ // present and can never be shadowed by a per-call header — the AI cannot forge
458
+ // the scope. When context is off, injectContext is a no-op and the headers are
459
+ // byte-identical to before.
460
+ function hostHeaders(key, ctx, extra) {
461
+ const h = { 'x-sparda-key': key, ...(extra ?? {}) };
462
+ injectContext(h, ctx);
463
+ return h;
464
+ }
465
+
466
+ async function invoke(base, key, tool, args, ctx) {
345
467
  try {
346
468
  const res = await fetch(`${base}/mcp/invoke`, {
347
469
  method: 'POST',
348
- headers: { 'content-type': 'application/json', 'x-sparda-key': key },
470
+ headers: hostHeaders(key, ctx, { 'content-type': 'application/json' }),
349
471
  body: JSON.stringify({ tool, args }),
350
472
  signal: AbortSignal.timeout(30_000),
351
473
  });
@@ -355,6 +477,20 @@ async function invoke(base, key, tool, args) {
355
477
  }
356
478
  }
357
479
 
480
+ async function confirmInvoke(base, key, token, ctx) {
481
+ try {
482
+ const res = await fetch(`${base}/mcp/invoke/confirm`, {
483
+ method: 'POST',
484
+ headers: hostHeaders(key, ctx, { 'content-type': 'application/json' }),
485
+ body: JSON.stringify({ confirm: token }),
486
+ signal: AbortSignal.timeout(30_000),
487
+ });
488
+ return await res.json().catch(() => ({ upstreamStatus: res.status, error: 'non-JSON response from host' }));
489
+ } catch {
490
+ return null;
491
+ }
492
+ }
493
+
358
494
  // keep only the path params of the original call so the read-back targets the same resource
359
495
  function pickPathArgs(spec, args) {
360
496
  const out = {};
@@ -365,11 +501,11 @@ function pickPathArgs(spec, args) {
365
501
  // live error feed: host app errors reach the AI as MCP logging notifications.
366
502
  // immune memory: known failure signatures carry their cached diagnosis (zero tokens);
367
503
  // new signatures wake the client's LLM once, and the antibody is stored in sparda.json.
368
- function startEventPolling(server, base, key, manifest, manifestPath, intel) {
504
+ function startEventPolling(server, base, key, manifest, manifestPath, intel, ctx) {
369
505
  let lastSeq = null; // first poll sets the baseline; only NEW errors are reported
370
506
  const timer = setInterval(async () => {
371
507
  try {
372
- const r = await fetch(`${base}/mcp/events?since=${lastSeq ?? 0}`, { headers: { 'x-sparda-key': key }, signal: AbortSignal.timeout(2000) });
508
+ const r = await fetch(`${base}/mcp/events?since=${lastSeq ?? 0}`, { headers: hostHeaders(key, ctx), signal: AbortSignal.timeout(2000) });
373
509
  if (!r.ok) return;
374
510
  const { seq, events } = await r.json();
375
511
  if (lastSeq === null) { lastSeq = seq; return; }
@@ -433,11 +569,10 @@ async function diagnoseAndRemember({ server, manifest, manifestPath, ev, sig })
433
569
  }
434
570
 
435
571
  function persistImmune(manifestPath, manifest) {
436
- try {
437
- const onDisk = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
438
- onDisk.immune = manifest.immune;
439
- fs.writeFileSync(manifestPath, JSON.stringify(onDisk, null, 2) + '\n');
440
- } catch { /* disk briefly unavailable — the antibody stays in memory */ }
572
+ // merge-write (atomic + fsync): re-read so we never clobber sparding/labs/
573
+ // semantic written by another path; on a brief disk hiccup the antibody
574
+ // stays in memory and the next write retries.
575
+ mergeManifestKeySync(manifestPath, 'immune', manifest.immune);
441
576
  }
442
577
 
443
578
  function recordSparding(manifestPath, manifest, tool, proof) {
@@ -493,18 +628,16 @@ function recordSparding(manifestPath, manifest, tool, proof) {
493
628
  }
494
629
 
495
630
  function persistSparding(manifestPath, manifest) {
496
- try {
497
- const onDisk = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
498
- onDisk.sparding = manifest.sparding;
499
- fs.writeFileSync(manifestPath, JSON.stringify(onDisk, null, 2) + '\n');
500
- } catch { /* disk briefly unavailable */ }
631
+ mergeManifestKeySync(manifestPath, 'sparding', manifest.sparding);
501
632
  }
502
633
 
503
634
  function persistLabs(manifestPath, manifest) {
635
+ // value depends on the re-read (merge into labs, keep other labs fields), so
636
+ // this can't use mergeManifestKeySync — but the write is still atomic+fsync.
504
637
  try {
505
638
  const onDisk = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
506
639
  onDisk.labs = { ...onDisk.labs, circuits: manifest.labs.circuits };
507
- fs.writeFileSync(manifestPath, JSON.stringify(onDisk, null, 2) + '\n');
640
+ writeManifestSync(manifestPath, onDisk);
508
641
  } catch { /* disk briefly unavailable — the circuit stays in memory */ }
509
642
  }
510
643
 
@@ -546,9 +679,7 @@ async function runSemanticEnrichment({ server, manifest, manifestPath, toolSpecs
546
679
  .filter((w) => w.steps.length > 0);
547
680
 
548
681
  manifest.semantic = { enrichedAt: new Date().toISOString(), source: 'mcp-sampling', descriptions, workflows };
549
- const onDisk = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
550
- onDisk.semantic = manifest.semantic;
551
- fs.writeFileSync(manifestPath, JSON.stringify(onDisk, null, 2) + '\n');
682
+ mergeManifestKeySync(manifestPath, 'semantic', manifest.semantic);
552
683
 
553
684
  await server.sendToolListChanged().catch(() => {});
554
685
  await server.sendPromptListChanged().catch(() => {});
@@ -577,12 +708,29 @@ function schemaFor(t) {
577
708
 
578
709
  function text(t) { return { content: [{ type: 'text', text: t }] }; }
579
710
 
580
- async function waitForHost(port, key, framework) {
711
+ // AI-facing message when the Signal-2 gate (Brief #1) refuses a `sparda_confirm`. Each reason
712
+ // tells the model what to do next without implying it can approve the write itself.
713
+ function signal2Denial(reason) {
714
+ switch (reason) {
715
+ case 'awaiting_human':
716
+ return 'Awaiting human approval. A confirmation dialog is open on the host machine — the operator must click Allow. You cannot approve this yourself. Call sparda_confirm again with the same token once the operator has approved.';
717
+ case 'human_denied':
718
+ return 'The operator DENIED this write at the host confirmation dialog. It was NOT executed. Do not retry this token.';
719
+ case 'unknown_token':
720
+ return 'Unknown or already-used confirmation token. Re-issue the write to mint a fresh token (which opens a new human approval dialog).';
721
+ case 'expired':
722
+ return 'The confirmation expired before a human approved it. Re-issue the write to try again.';
723
+ default:
724
+ return 'Confirmation could not be validated. Re-issue the write to mint a fresh token.';
725
+ }
726
+ }
727
+
728
+ async function waitForHost(port, key, framework, ctx) {
581
729
  const hosts = ['127.0.0.1', 'localhost'];
582
730
  for (let attempt = 0; attempt < 40; attempt++) {
583
731
  for (const h of hosts) {
584
732
  try {
585
- const r = await fetch(`http://${h}:${port}/mcp/tools`, { headers: { 'x-sparda-key': key }, signal: AbortSignal.timeout(1500) });
733
+ const r = await fetch(`http://${h}:${port}/mcp/tools`, { headers: hostHeaders(key, ctx), signal: AbortSignal.timeout(1500) });
586
734
  if (r.ok) return `http://${h}:${port}`;
587
735
  if (r.status === 401) throw Object.assign(new Error('Host app reachable but key mismatch — re-run `npx sparda-mcp init`.'), { code: 'USER' });
588
736
  } catch (e) { if (e.code === 'USER') throw e; }