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