sparda-mcp 0.8.1 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
 
@@ -80,6 +84,30 @@ To output raw JSON for integration:
80
84
  npx sparda-mcp report --json
81
85
  ```
82
86
 
87
+ ## Deployment Proof: Apocalypse
88
+
89
+ SPARDA's Behavior Graph is a formal model of your system. Instead of waiting for runtime failures or relying on static analysis vibes, you can statically prove the safety of your backend before any deployment:
90
+ ```bash
91
+ npx sparda-mcp apocalypse
92
+ ```
93
+ This command reads the compiled `.sparda/ubg.json` (with zero source code parsing at runtime) and discharges five static correctness obligations:
94
+ * **Unguarded Mutation (Critical)**: Flags any mutation path that does not cross a security `guard`.
95
+ * **Non-Atomic Aggregate Write (High)**: Flags when an API writes to multiple tables of the same Consistency Domain (Aggregate) outside a single transaction scope.
96
+ * **Unvalidated Constrained Write (Medium)**: Flags writes into columns with SQL invariants (CHECK, NOT NULL, UNIQUE) without prior validation (Zod/Pydantic).
97
+ * **Irreversible Observable Effect (High)**: Flags out-of-process actions (like Stripe charges) that happen alongside state writes without a structural compensation path (like a catch-refund).
98
+ * **Aggregate Member Bypass (Info)**: Flags mutating a member table directly without routing through the aggregate root.
99
+
100
+ To save your current graph as a safe baseline:
101
+ ```bash
102
+ npx sparda-mcp apocalypse --save-baseline
103
+ ```
104
+ Subsequent runs will diff the candidate graph against this baseline to detect regression vectors:
105
+ * Deletion of any security `guard` (Critical).
106
+ * Deletion of a database SQL invariant (High).
107
+ * API blast radius expansion (Medium).
108
+
109
+ If any Critical or High finding is found, `apocalypse` exits with a non-zero code to block your CI pipeline.
110
+
83
111
  To undo everything: **`npx sparda-mcp remove`** restores your code byte-for-byte.
84
112
 
85
113
  ## The promise — every word is backed by a test in CI
@@ -169,6 +197,7 @@ runtime, so the guidance never goes stale.
169
197
 
170
198
  ## Security posture (honest)
171
199
  - 4 runtime dependencies, exact-pinned.
200
+ - **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.
172
201
  - Local key on every router call; self-reference loop protection; 30s timeouts; 8 KB output truncation.
173
202
  - AST-positioned injection with backup and post-injection re-parse; `npx sparda-mcp remove` leaves a clean git diff.
174
203
  - Persistence is **value-free**: SPARDA records structure (tool names, field names, fingerprints), never your payloads.
package/SKILL.md CHANGED
@@ -1,40 +1,30 @@
1
1
  ---
2
2
  name: sparda-mcp
3
3
  description: >-
4
- Drive a SPARDA-generated MCP server to its full potential. Use this whenever
5
- you are connected to an MCP server built by SPARDA (sparda-mcp) recognizable
6
- by the tools `sparda_get_context`, `sparda_info`, `sparda_confirm`, or composite
7
- tools labelled "[Labs circuit]". It teaches the call-context-first workflow, how
8
- to exploit the response-recycling flywheel, the circuit-breaker, crystallized
9
- circuits and adaptive immunity, and the mandatory two-phase confirm protocol for
10
- write tools. Reach for it before calling raw endpoints, before any write, or when
11
- a tool returns 202 (awaiting confirmation) or 503 (quarantined).
4
+ Drive a SPARDA compiled Behavior Runtime to its full potential. Use this whenever
5
+ you are connected to a backend running the SPARDA Runtime (compiled into a SPARDA
6
+ Behavior Graph - SBG) — recognizable by the tools `sparda_get_context`, `sparda_info`,
7
+ `sparda_confirm`, or composite tools. It teaches the graph-context-first workflow,
8
+ how to exploit the response-recycling flywheel, the circuit-breaker, crystallized
9
+ circuits, offline Twin simulations, and the two-phase confirm protocol for writes.
12
10
  ---
13
11
 
14
- # Driving a SPARDA MCP server
12
+ # Driving a SPARDA Behavior Runtime
15
13
 
16
- A SPARDA server is **not** a flat list of HTTP endpoints. SPARDA injected a live
17
- `/mcp` router *inside a real running app*, so its tools are that app's actual
18
- routes — wrapped in an intelligence layer that makes repeated use **cheaper** (it
19
- recycles stable answers from memory) and **safer** (it quarantines failing tools
20
- and gates writes behind human confirmation). Used naively it's just an API. Used
21
- well, it gets faster and safer the more you call it. This skill is how to use it
22
- well.
14
+ A SPARDA server is driven by a compiled **SPARDA Behavior Graph (SBG)**. Instead of exposing raw, disconnected endpoints, SPARDA has compiled the host application's states, transitions, permissions, and side-effects into a deterministic behavioral model. The local **SPARDA Runtime** dynamically executes this graph inside the live host process, powering the MCP interface, the Twin simulation clone, and the Immune system offline.
23
15
 
24
16
  ## Rule 0 — call `sparda_get_context` first, every session
25
17
 
26
- Before anything else, call **`sparda_get_context`** (no params). It returns the
27
- *live* picture, so you never guess the surface:
18
+ Before anything else, call **`sparda_get_context`** (no params). It returns the *live* state of the SPARDA Behavior Graph:
28
19
 
29
- - the enabled tools + their descriptions, and SPARDA's suggested **workflows**;
30
- - `runtime` — current `/mcp/stats` (per-tool call/error counts, tool "purity",
31
- quarantine state);
32
- - the last ~20 **events** (errors, latency anomalies, immune diagnoses);
33
- - **immune memory** (known failure signatures + cached fixes);
34
- - **recycling** stats (how many calls were served from memory);
35
- - a `behavior` snapshot (which response fields are stable).
20
+ - the active routes/tools, workflows, and type-propagated schemas;
21
+ - `runtime` — current stats (calls, errors, quarantine states, Twin mode active);
22
+ - the last ~20 **events** (errors, latency anomalies, immune alerts);
23
+ - **immune memory** (cached antibodies and error diagnoses);
24
+ - **recycling** metrics (flywheel memory hit rates);
25
+ - a `behavior` snapshot of stable variables.
36
26
 
37
- Read it before acting. `sparda_info` gives a lighter summary if you only need counts.
27
+ Read it to orient yourself inside the graph. `sparda_info` gives a lighter summary of counts.
38
28
 
39
29
  ## The tools you'll see
40
30
 
@@ -146,6 +136,7 @@ Writes are **disabled by default**. The protocol is not optional:
146
136
  - **General health** → `sparda doctor` checks Node version, framework detection,
147
137
  manifest validity, the semantic/immune cache, host reachability, and quarantine;
148
138
  it exits non-zero so it can gate CI.
139
+ - **Formal Deployment Proof** → `sparda apocalypse` reads the compiled graph (`ubg.json`) and proves five correctness obligations: catches unguarded mutations, non-atomic aggregate writes, unvalidated writes to constrained tables, uncompensated observable effects, and aggregate root bypasses. Run `sparda apocalypse --save-baseline` to store the reference graph; subsequent runs diff against the baseline to catch dropped guards, dropped SQL invariants, or grown blast radiuses.
149
140
  - **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
141
  - **Learn exemplars** → Start your live app and run `sparda twin --learn` to fetch actual response data and construct `.sparda/twin.json` locally.
151
142
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.8.1",
3
+ "version": "0.10.1",
4
4
  "mcpName": "io.github.zyx77550/sparda-mcp",
5
5
  "description": "Turn any codebase into an MCP server with one command. Express & FastAPI → Claude-ready in 3 minutes.",
6
6
  "type": "module",
@@ -0,0 +1,187 @@
1
+ // commands/timeless.js — production bugs become unit tests.
2
+ // sparda timeless list recorded flights
3
+ // sparda timeless replay <id> re-fly the request against current code
4
+ // sparda timeless export <id> generate a vitest test that replays it
5
+ // Recording side: mount the recorder in the app (2 lines, documented in the
6
+ // README) — `const flight = createFlightBox(); app.use(flight.middleware())`.
7
+ // Replay imports the real entry file with `listen()` suppressed, feeds every
8
+ // recorded tap back in strict order, and compares the response byte-for-byte.
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import http from 'node:http';
12
+ import { pathToFileURL } from 'node:url';
13
+ import { detectStack } from '../detect.js';
14
+ import { getFlightBox } from '../flight/box.js';
15
+ import { replayFlight } from '../flight/replayer.js';
16
+ import { listFlights, loadFlight } from '../flight/format.js';
17
+ import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
18
+
19
+ const err = (message, hint) => Object.assign(new Error(message), { code: 'USER', hint });
20
+
21
+ export async function runTimeless(opts, args) {
22
+ const [sub, id] = args.filter((a) => !a.startsWith('--'));
23
+
24
+ if (!sub || sub === 'list') return list(opts);
25
+ if (sub === 'replay') return replay(opts, requireId(id));
26
+ if (sub === 'export') return exportTest(opts, requireId(id));
27
+ throw err(
28
+ `unknown timeless subcommand "${sub}"`,
29
+ 'use: list | replay <id> | export <id>',
30
+ );
31
+ }
32
+
33
+ const requireId = (id) => {
34
+ if (!id) throw err('flight id required', 'run `sparda timeless` to list flights');
35
+ return id;
36
+ };
37
+
38
+ function list(opts) {
39
+ const flights = listFlights(opts.cwd);
40
+ if (!flights.length) {
41
+ console.log(
42
+ 'No flights recorded. Mount the recorder in your app (ESM):\n' +
43
+ " import { getFlightBox } from 'sparda-mcp/src/flight/box.js';\n" +
44
+ ' const box = getFlightBox(); box.arm();\n' +
45
+ ' app.use(box.middleware());\n' +
46
+ ' const db = box.wrapClient(rawDb); // your query client\n' +
47
+ " (CJS: const { getFlightBox } = await import('sparda-mcp/src/flight/box.js'))",
48
+ );
49
+ return { flights };
50
+ }
51
+ console.log(`${flights.length} flight(s) in .sparda/flight/`);
52
+ for (const f of flights) {
53
+ const { request, response } = loadFlight(opts.cwd, f);
54
+ console.log(
55
+ ` ${f} ${request.method} ${request.url} → ${response.status} (${response.body.length}b, ${loadFlight(opts.cwd, f).taps.length} taps)`,
56
+ );
57
+ }
58
+ return { flights };
59
+ }
60
+
61
+ async function replay(opts, id) {
62
+ const flight = loadFlight(opts.cwd, id);
63
+ // the SHARED box: if the app integrates the recorder, its wrapClient rides
64
+ // the same AsyncLocalStorage this replay store lands on — load it BEFORE
65
+ // the app so both resolve the singleton
66
+ const box = getFlightBox();
67
+ const app = await loadApp(opts.cwd);
68
+ box.arm();
69
+ let result;
70
+ try {
71
+ result = await replayFlight(app, flight, box);
72
+ } finally {
73
+ box.disarm();
74
+ }
75
+
76
+ console.log(
77
+ `TIMELESS — replaying ${flight.request.method} ${flight.request.url} (${flight.taps.length} taps virtualized)`,
78
+ );
79
+ if (result.match) {
80
+ console.log(
81
+ `✓ byte-identical replay — status ${result.actual.status}, every tap consumed, zero divergence`,
82
+ );
83
+ } else {
84
+ if (!result.statusMatch)
85
+ console.log(
86
+ ` ✗ status: expected ${result.expected.status}, got ${result.actual.status}`,
87
+ );
88
+ if (!result.bodyMatch)
89
+ console.log(
90
+ ` ✗ body differs\n expected: ${result.expected.body.slice(0, 200)}\n actual: ${result.actual.body.slice(0, 200)}`,
91
+ );
92
+ for (const d of result.divergences)
93
+ console.log(
94
+ ` ✗ divergence: expected ${d.kind} "${d.expected}", code asked "${d.got}"`,
95
+ );
96
+ for (const l of result.leftover)
97
+ console.log(
98
+ ` ✗ ${l.unconsumed} unconsumed ${l.kind} tap(s) — the code skipped step(s)`,
99
+ );
100
+ console.log(
101
+ '✗ NOT identical — the code no longer does what it did during the flight',
102
+ );
103
+ process.exitCode = 1;
104
+ }
105
+ return { result };
106
+ }
107
+
108
+ async function exportTest(opts, id) {
109
+ const flight = loadFlight(opts.cwd, id);
110
+ const stack = detectStack(opts.cwd);
111
+ if (stack.framework !== 'express')
112
+ throw err(
113
+ 'timeless export supports Express apps in v1',
114
+ 'FastAPI replay is a later round',
115
+ );
116
+
117
+ const testDir = path.join(opts.cwd, 'tests');
118
+ fs.mkdirSync(testDir, { recursive: true });
119
+ const testFile = path.join(testDir, `flight-${id}.test.js`);
120
+ const relEntry = path
121
+ .relative(testDir, path.resolve(opts.cwd, stack.entryFile))
122
+ .split(path.sep)
123
+ .join('/');
124
+
125
+ atomicWrite(
126
+ testFile,
127
+ `// flight-${id}.test.js — generated by \`sparda timeless export ${id}\`
128
+ // Replays a recorded production request against the current code. A failure
129
+ // means the behavior changed: if the change is the intended fix, re-record
130
+ // the flight and re-export — this file asserts the PAST, on purpose.
131
+ import { describe, it, expect } from 'vitest';
132
+ import { getFlightBox } from 'sparda-mcp/src/flight/box.js';
133
+ import { replayFlight } from 'sparda-mcp/src/flight/replayer.js';
134
+ import { loadFlight } from 'sparda-mcp/src/flight/format.js';
135
+ import appModule from '${relEntry.startsWith('.') ? relEntry : './' + relEntry}';
136
+
137
+ describe('flight ${id} — ${flight.request.method} ${flight.request.url}', () => {
138
+ it('replays byte-identically', async () => {
139
+ const app = appModule.default ?? appModule;
140
+ const flight = loadFlight(process.cwd(), '${id}');
141
+ // the SHARED box — the app's own wrapClient must see the replay store
142
+ const box = getFlightBox();
143
+ box.arm();
144
+ try {
145
+ const result = await replayFlight(app, flight, box);
146
+ expect(result.divergences).toEqual([]);
147
+ expect(result.actual.status).toBe(result.expected.status);
148
+ expect(result.bodyMatch).toBe(true);
149
+ expect(result.leftover).toEqual([]);
150
+ } finally {
151
+ box.disarm();
152
+ }
153
+ });
154
+ });
155
+ `,
156
+ );
157
+ console.log(`✓ Test written: tests/flight-${id}.test.js — the bug is now a unit test`);
158
+ return { testFile };
159
+ }
160
+
161
+ // import the real entry with listen() suppressed — replay never binds ports
162
+ async function loadApp(cwd) {
163
+ const stack = detectStack(cwd);
164
+ if (stack.framework !== 'express')
165
+ throw err(
166
+ 'timeless replay supports Express apps in v1',
167
+ 'FastAPI replay is a later round',
168
+ );
169
+ const origListen = http.Server.prototype.listen;
170
+ http.Server.prototype.listen = function suppressedListen(...args) {
171
+ const cb = args.find((a) => typeof a === 'function');
172
+ if (cb) setImmediate(cb);
173
+ return this;
174
+ };
175
+ try {
176
+ const mod = await import(pathToFileURL(path.resolve(cwd, stack.entryFile)).href);
177
+ const app = mod.default ?? mod;
178
+ if (typeof app !== 'function')
179
+ throw err(
180
+ 'the entry file does not export the Express app',
181
+ 'add `module.exports = app` (CJS) or `export default app` (ESM) to your entry',
182
+ );
183
+ return app;
184
+ } finally {
185
+ http.Server.prototype.listen = origListen;
186
+ }
187
+ }
@@ -0,0 +1,80 @@
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
+ } else if (p.pass === 'CapabilityExtraction' && p.capabilities)
48
+ console.log(
49
+ ` ${p.pass}: ${p.capabilities} capability(ies), ${p.guardsAnnotated} guard(s) annotated`,
50
+ );
51
+ else if (p.pass === 'ResourceLifetimes' && p.annotated)
52
+ console.log(
53
+ ` ${p.pass}: ${p.annotated} state(s)${p.immortal.length ? ` — immortal: ${p.immortal.join(', ')}` : ''}`,
54
+ );
55
+ else if (p.pass === 'StateMachineInference' && p.machines)
56
+ console.log(` ${p.pass}: ${p.machines} machine(s) inferred`);
57
+ }
58
+
59
+ if (report.link.inferredTables.length)
60
+ console.log(
61
+ ` ⚠ tables referenced in code but absent from .sql schemas: ${report.link.inferredTables.join(', ')}`,
62
+ );
63
+ if (report.skipped.length) {
64
+ console.log(` · ${report.skipped.length} construct(s) out of static reach:`);
65
+ for (const s of report.skipped.slice(0, opts.verbose ? 100 : 5))
66
+ console.log(` - ${s.reason}${s.file ? ` (${s.file})` : ''}`);
67
+ if (!opts.verbose && report.skipped.length > 5)
68
+ console.log(` … ${report.skipped.length - 5} more (--verbose)`);
69
+ }
70
+ return { report };
71
+ }
72
+
73
+ const fmtCounts = (obj) =>
74
+ Object.entries(obj)
75
+ .sort(([a], [b]) => a.localeCompare(b))
76
+ .map(([k, v]) => `${k} ${v}`)
77
+ .join(' · ');
78
+
79
+ const rel = (abs, cwd) =>
80
+ abs.startsWith(cwd) ? abs.slice(cwd.length + 1).replaceAll('\\', '/') : abs;
@@ -0,0 +1,245 @@
1
+ // flight/box.js — the FlightBox: one set of patches, two modes.
2
+ // In RECORD mode every nondeterminism point (fetch, Date.now, Math.random,
3
+ // crypto.randomUUID, wrapped db clients) passes through to reality and leaves
4
+ // a tap behind. In REPLAY mode the same points are fed the recorded values in
5
+ // strict FIFO order per kind — label-checked, fail-loud: any mismatch is a
6
+ // FlightDivergence, never a silent fuzzy match. Code OUTSIDE a request
7
+ // context is untouched (AsyncLocalStorage decides), so arming the box in
8
+ // production changes nothing for non-instrumented paths.
9
+ import { AsyncLocalStorage } from 'node:async_hooks';
10
+ import { saveFlight } from './format.js';
11
+
12
+ const MAX_BODY_BYTES = 262144; // 256 KB per recorded http body — bounded flights
13
+
14
+ export class FlightDivergence extends Error {
15
+ constructor(message, detail = {}) {
16
+ super(message);
17
+ this.name = 'FlightDivergence';
18
+ this.detail = detail;
19
+ }
20
+ }
21
+
22
+ export function createFlightBox() {
23
+ const als = new AsyncLocalStorage();
24
+ const originals = {};
25
+ let armed = false;
26
+
27
+ function arm() {
28
+ if (armed) return;
29
+ armed = true;
30
+ originals.fetch = globalThis.fetch;
31
+ originals.dateNow = Date.now;
32
+ originals.random = Math.random;
33
+ originals.randomUUID = globalThis.crypto?.randomUUID?.bind(globalThis.crypto);
34
+
35
+ Date.now = function spardaDateNow() {
36
+ const store = als.getStore();
37
+ if (!store) return originals.dateNow();
38
+ if (store.mode === 'record') {
39
+ const v = originals.dateNow();
40
+ tapOut(store, 'time', 'Date.now', v);
41
+ return v;
42
+ }
43
+ return takeTap(store, 'time', 'Date.now');
44
+ };
45
+
46
+ Math.random = function spardaRandom() {
47
+ const store = als.getStore();
48
+ if (!store) return originals.random();
49
+ if (store.mode === 'record') {
50
+ const v = originals.random();
51
+ tapOut(store, 'random', 'Math.random', v);
52
+ return v;
53
+ }
54
+ return takeTap(store, 'random', 'Math.random');
55
+ };
56
+
57
+ if (originals.randomUUID) {
58
+ Object.defineProperty(globalThis.crypto, 'randomUUID', {
59
+ configurable: true,
60
+ value: function spardaRandomUUID() {
61
+ const store = als.getStore();
62
+ if (!store) return originals.randomUUID();
63
+ if (store.mode === 'record') {
64
+ const v = originals.randomUUID();
65
+ tapOut(store, 'uuid', 'crypto.randomUUID', v);
66
+ return v;
67
+ }
68
+ return takeTap(store, 'uuid', 'crypto.randomUUID');
69
+ },
70
+ });
71
+ }
72
+
73
+ globalThis.fetch = async function spardaFetch(input, init) {
74
+ const store = als.getStore();
75
+ if (!store) return originals.fetch(input, init);
76
+ const url = typeof input === 'string' ? input : (input?.url ?? String(input));
77
+ const method = (init?.method ?? 'GET').toUpperCase();
78
+ const label = `${method} ${url}`;
79
+ if (store.mode === 'record') {
80
+ const res = await originals.fetch(input, init);
81
+ const text = (await res.text()).slice(0, MAX_BODY_BYTES);
82
+ const headers = { 'content-type': res.headers.get('content-type') ?? '' };
83
+ tapOut(store, 'http', label, { status: res.status, headers, body: text });
84
+ return new Response(text, { status: res.status, headers });
85
+ }
86
+ const rec = takeTap(store, 'http', label);
87
+ return new Response(rec.body, { status: rec.status, headers: rec.headers });
88
+ };
89
+ }
90
+
91
+ function disarm() {
92
+ if (!armed) return;
93
+ armed = false;
94
+ globalThis.fetch = originals.fetch;
95
+ Date.now = originals.dateNow;
96
+ Math.random = originals.random;
97
+ if (originals.randomUUID)
98
+ Object.defineProperty(globalThis.crypto, 'randomUUID', {
99
+ configurable: true,
100
+ value: originals.randomUUID,
101
+ });
102
+ }
103
+
104
+ // any client exposing .query()/.execute() — pg, mysql2, sqlite wrappers …
105
+ function wrapClient(client, name = 'db') {
106
+ const wrapMethod = (method) =>
107
+ async function spardaQuery(...args) {
108
+ const store = als.getStore();
109
+ if (!store) return client[method](...args);
110
+ const label = `${name}.${method}:${String(args[0]).slice(0, 60)}`;
111
+ if (store.mode === 'record') {
112
+ const result = await client[method](...args);
113
+ tapOut(store, 'db', label, jsonClone(result, label));
114
+ return result;
115
+ }
116
+ return takeTap(store, 'db', label);
117
+ };
118
+ return new Proxy(client, {
119
+ get(target, prop, receiver) {
120
+ if (
121
+ (prop === 'query' || prop === 'execute') &&
122
+ typeof target[prop] === 'function'
123
+ )
124
+ return wrapMethod(prop);
125
+ return Reflect.get(target, prop, receiver);
126
+ },
127
+ });
128
+ }
129
+
130
+ // Express middleware — mount early; the request's whole async tree records.
131
+ // The flight is finalized and written on response finish.
132
+ function middleware({ cwd = process.cwd(), onFlight = null } = {}) {
133
+ return function spardaFlightRecorder(req, res, next) {
134
+ // replay wins over record: when the timeless CLI replays through an app
135
+ // that mounts this recorder, the outer replay store must reach the taps
136
+ // untouched — re-recording a replay would shadow it with live values
137
+ const outer = als.getStore();
138
+ if (outer?.mode === 'replay') return next();
139
+ if (process.env.SPARDA_FLIGHT === 'off') return next();
140
+ const store = { mode: 'record', taps: [] };
141
+ const chunks = [];
142
+ const origWrite = res.write.bind(res);
143
+ const origEnd = res.end.bind(res);
144
+ res.write = (chunk, ...rest) => {
145
+ if (chunk) chunks.push(Buffer.from(chunk));
146
+ return origWrite(chunk, ...rest);
147
+ };
148
+ res.end = (chunk, ...rest) => {
149
+ if (chunk && typeof chunk !== 'function') chunks.push(Buffer.from(chunk));
150
+ return origEnd(chunk, ...rest);
151
+ };
152
+ res.on('finish', () => {
153
+ const flight = {
154
+ request: {
155
+ method: req.method,
156
+ url: req.originalUrl ?? req.url,
157
+ headers: { 'content-type': req.headers['content-type'] ?? '' },
158
+ body: req.body ?? null,
159
+ },
160
+ response: {
161
+ status: res.statusCode,
162
+ headers: { 'content-type': res.getHeader('content-type') ?? '' },
163
+ body: Buffer.concat(chunks).toString('utf8').slice(0, MAX_BODY_BYTES),
164
+ },
165
+ taps: store.taps,
166
+ };
167
+ const saved = saveFlight(cwd, flight);
168
+ if (onFlight) onFlight(saved, flight);
169
+ });
170
+ als.run(store, next);
171
+ };
172
+ }
173
+
174
+ // replay plumbing — one store per replayed request
175
+ function makeReplayStore(flight) {
176
+ const queues = new Map();
177
+ for (const tap of flight.taps) {
178
+ if (!queues.has(tap.kind)) queues.set(tap.kind, []);
179
+ queues.get(tap.kind).push(tap);
180
+ }
181
+ return { mode: 'replay', queues, divergences: [] };
182
+ }
183
+
184
+ const runWith = (store, fn) => als.run(store, fn);
185
+
186
+ return { arm, disarm, wrapClient, middleware, makeReplayStore, runWith };
187
+ }
188
+
189
+ function tapOut(store, kind, label, result) {
190
+ store.taps.push({ seq: store.taps.length, kind, label, result });
191
+ }
192
+
193
+ // strict FIFO per kind, label-checked — the "quasi infallible" contract:
194
+ // the replayed code must ask reality the SAME questions in the SAME order
195
+ function takeTap(store, kind, label) {
196
+ const queue = store.queues.get(kind);
197
+ const tap = queue?.shift();
198
+ if (!tap) {
199
+ const d = { kind, expected: null, got: label };
200
+ store.divergences.push(d);
201
+ throw new FlightDivergence(
202
+ `replay diverged: code asked for ${kind} "${label}" but the flight has no more ${kind} taps`,
203
+ d,
204
+ );
205
+ }
206
+ if (tap.label !== label) {
207
+ const d = { kind, expected: tap.label, got: label };
208
+ store.divergences.push(d);
209
+ throw new FlightDivergence(
210
+ `replay diverged: flight expected ${kind} "${tap.label}", code asked for "${label}"`,
211
+ d,
212
+ );
213
+ }
214
+ return tap.result;
215
+ }
216
+
217
+ function jsonClone(value, label) {
218
+ try {
219
+ const s = JSON.stringify(value);
220
+ if (s === undefined) throw new Error('undefined');
221
+ return JSON.parse(s);
222
+ } catch {
223
+ // honesty over convenience: an unserializable tap cannot replay — record
224
+ // the fact so replay fails loudly instead of returning garbage
225
+ throw new FlightDivergence(`tap "${label}" returned an unserializable value`, {
226
+ label,
227
+ });
228
+ }
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Process singleton — THE box for app integration and the timeless CLI.
233
+ // The app's wrapClient and the replayer must share ONE AsyncLocalStorage or
234
+ // replay stores are invisible to the app's taps (silent passthrough to the
235
+ // real db — the exact failure mode this singleton exists to kill). Module
236
+ // caching guarantees identity as long as both sides resolve the same
237
+ // sparda-mcp install, which `npx sparda` inside the app's project does.
238
+ // createFlightBox() stays exported for isolated tests.
239
+ // ---------------------------------------------------------------------------
240
+ let singleton = null;
241
+
242
+ export function getFlightBox() {
243
+ if (!singleton) singleton = createFlightBox();
244
+ return singleton;
245
+ }