prism-mcp-server 20.2.7 → 20.2.9

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
@@ -25,6 +25,10 @@ A paid subscription adds cloud sync, higher model tiers, and team features throu
25
25
  - **Local-first inference** — bounded work is routed through local Ollama models
26
26
  first, with automatic 2B/4B/9B/27B selection based on installed models,
27
27
  available RAM, context fit, and subscription entitlements.
28
+ - **Route-output enforcement** — route mode returns only well-formed calls to
29
+ tools the host actually advertised. Standard and higher plans can add
30
+ authenticated deterministic correction; `route_guard: "local"` keeps the
31
+ prompt and draft entirely on-device.
28
32
  - **One setup for every agent** — `prism connect` configures Claude Code,
29
33
  Claude Desktop, Cursor, Gemini CLI, and Codex while preserving unrelated
30
34
  settings.
@@ -144,10 +148,16 @@ that would require a host lifecycle hook, launcher, extension, or Prism-owned
144
148
  panel. Context loading itself remains complete even when a host shortens the
145
149
  visible reply.
146
150
 
147
- Free accounts receive the protected 12-skill foundation. Paid accounts receive
148
- the current subscribed routing set. Upgrades install newly entitled packages;
149
- downgrades remove only Prism-owned packages while preserving local skills and
150
- locally modified conflicts.
151
+ Free accounts receive only the public hook-free `prism-startup` package; the MCP
152
+ server still supplies a compact, non-proprietary safety and evidence contract.
153
+ Authenticated paid accounts receive the protected behavioral and engineering
154
+ packages plus the current subscribed routing set. The paid
155
+ `evidence-first-protocol` keeps ordinary coding lightweight: one correlated
156
+ reproduction is enough to begin an edit, while strict acceptance starts only
157
+ before a completion claim, push, or release and inspects only the exact artifacts
158
+ used as proof. Upgrades install newly entitled packages; verified downgrades
159
+ remove only Prism-owned packages while preserving local skills and locally
160
+ modified conflicts.
151
161
 
152
162
  When upgrading an older Claude Code installation, `prism connect` removes only
153
163
  the exact Prism-owned startup, skill-sync, handoff, and drift hook actions from
@@ -448,10 +458,15 @@ The free tier runs entirely on your machine. Paid tiers add cloud sync through t
448
458
  | Inference | Local Ollama models | Local models + Gemini 3.6 Flash fallback |
449
459
  | API keys required | None | Synalux subscription key |
450
460
  | Web search / scrape | Not included | Via Synalux portal (provider keys server-side) |
451
- | What leaves your machine | Nothing | Memory text + file paths + search queries, sent to the portal over TLS (PHI-redacted before transit) |
461
+ | What leaves your machine | Nothing | Memory text, file paths, search queries, and inference prompts/drafts when their cloud feature is used, sent to the portal over TLS. Cloud memory writes are PHI-redacted; inference and route requests are transient. |
452
462
  | Works offline | ✅ | Local features yes; sync/cloud no |
453
463
 
454
- **Handling sensitive data.** All cloud writes pass through automatic redaction (SSNs, dates of birth, medical record numbers, phone numbers, emails, and clinical identifiers are stripped before transit). For regulated workloads, run the **local tier** for full air-gap, or use **Enterprise** which includes a HIPAA Business Associate Agreement.
464
+ **Handling sensitive data.** Cloud memory writes pass through automatic
465
+ redaction (SSNs, dates of birth, medical record numbers, phone numbers, emails,
466
+ and clinical identifiers are stripped before storage). Cloud inference and
467
+ route correction send the request over TLS for processing and do not store it
468
+ as Prism memory; use `route_guard: "local"` or the **local tier** for a full
469
+ air-gap. **Enterprise** includes a HIPAA Business Associate Agreement.
455
470
 
456
471
  ---
457
472
 
@@ -461,6 +476,14 @@ The `prism-coder` fleet uses Qwen3.5 for MCP tool-routing AND general inference.
461
476
 
462
477
  `prism_infer` supports three modes: `route` (tool routing, fast, nothink), `chat` (conversation with thinking), and `code` (code generation with thinking). In chat/code modes, the model uses `<think>` blocks for chain-of-thought reasoning, which are stripped before the response is served. If the local model fails a quality gate (empty, think-only, or truncated), paid tiers automatically escalate to Gemini 3.6 Flash via the Synalux portal.
463
478
 
479
+ Every route-mode result is parsed locally and checked against `allowed_tools`
480
+ before it reaches the host. Malformed or unadvertised calls become `NO_TOOL`.
481
+ With `route_guard: "auto"` (the default), Standard and higher plans also send
482
+ a well-formed draft for one of Prism's seven trained tools—or an unadvertised
483
+ draft that may need correction—to Synalux for authenticated deterministic
484
+ correction. Advertised custom host tools remain local. Set
485
+ `route_guard: "local"` for a fully on-device route path.
486
+
464
487
  | Model | Ollama tag | Size | [BFCL](https://gorilla.cs.berkeley.edu/blogs/12_bfcl_v3_multi_turn.html) Accuracy | Role | Automatic routing tier |
465
488
  |---|---|---|---|---|---|
466
489
  | Qwen3.5-4B Q3_K_M | `prism-coder:2b` | 2.3 GB | 99.1% × 3 seeds | iPhone / mobile first gate | Free |
@@ -486,17 +509,25 @@ query → prism-coder:9b (local router, default)
486
509
 
487
510
  ### Multi-Layer Verification
488
511
 
489
- Every tool-grounded answer on paid tiers passes through deterministic L3 routing rules and an NLI grounding verifier before reaching the user. Free-tier users get the deterministic gates (L1, L3-Tool, L3-Tier0) without the model-based NLI check.
512
+ Route output and evidence-grounded answers use separate gates. Every tier gets
513
+ the local route parser and advertised-tool registry; Standard and higher plans
514
+ can add the private deterministic route correction. Evidence verification is
515
+ opt-in (or automatic when evidence is supplied) and remains separate from route
516
+ selection.
490
517
 
491
518
  | Layer | What | Model | Cost |
492
519
  |---|---|---|---|
493
520
  | **L1** | Crisis/medical safety gate | None (regex) | 0 ms |
494
- | **L3-Tool** | Tool name remap + false-positive rejection | None (deterministic) | 0 ms |
521
+ | **L3-Registry** | Envelope validation + advertised-tool enforcement (all tiers) | None | 0 ms |
522
+ | **L3-Route** | Authenticated deterministic route correction (Standard+) | None | Network latency |
495
523
  | **L3-Tier0** | Integer grounding (set membership) | None (deterministic) | 0 ms |
496
524
  | **L3-Tier2** | NLI verifier (claim → ENTAILED/NEUTRAL/CONTRADICTED) | prism-coder:2b | ~200 ms |
497
525
  | **L4** | Hallucination judge (opt-out for clinical) | prism-coder:4b | ~500 ms |
498
526
 
499
- Fail-closed on the verified path: when the grounding verifier runs (Standard tier and up), timeout, ambiguity, or missing evidence yields a refusal, not pass-through. Free-tier users get the deterministic L1/L3-Tool gates but not the NLI verifier.
527
+ Fail-closed on the verified path: when the grounding verifier runs, timeout,
528
+ ambiguity, or missing evidence yields a refusal, not pass-through. If the paid
529
+ route correction is unavailable, the local registry still blocks malformed
530
+ and unadvertised calls and reports an allowed preserved route as degraded.
500
531
 
501
532
  ---
502
533
 
package/dist/cli.js CHANGED
@@ -12,6 +12,7 @@ import { configureClaudeAgentPolicy, configureClaudeNativeStartup, configureCode
12
12
  import { runBrowserCli } from './browserCli.js';
13
13
  import { filterPrismMemoryContext } from './utils/memoryQuality.js';
14
14
  import { isRecoverableStartupStorageError } from './utils/startupRecovery.js';
15
+ import { verifyBehaviorHandler } from './tools/behavioralVerifierHandler.js';
15
16
  const program = new Command();
16
17
  /** Build the stable `prism load --json` envelope from depth-specific context. */
17
18
  export function buildLoadJsonOutput(project, data, level, metadata) {
@@ -111,6 +112,50 @@ program
111
112
  .command('bootstrap')
112
113
  .description('Print the canonical dashboard-configured first-turn Prism greeting')
113
114
  .action(runBootstrapCommand);
115
+ /**
116
+ * One-shot fallback for hosts whose long-lived MCP transport has closed.
117
+ * This calls the canonical handler, including its authenticated portal path
118
+ * and fail-closed offline scenario; it never asks the host to invent one.
119
+ */
120
+ export async function runVerifyBehaviorCommand(options) {
121
+ try {
122
+ const baseUrl = process.env.PRISM_SYNALUX_BASE_URL?.trim() ||
123
+ process.env.SYNALUX_BASE_URL?.trim() ||
124
+ (await getSetting('PRISM_SYNALUX_BASE_URL', '')).trim() ||
125
+ (await getSetting('SYNALUX_BASE_URL', '')).trim() ||
126
+ 'https://synalux.ai';
127
+ const apiKey = process.env.PRISM_SYNALUX_API_KEY?.trim() ||
128
+ (await getSetting('PRISM_SYNALUX_API_KEY', '')).trim();
129
+ process.env.PRISM_SYNALUX_BASE_URL = baseUrl.replace(/\/+$/, '');
130
+ if (apiKey)
131
+ process.env.PRISM_SYNALUX_API_KEY = apiKey;
132
+ const result = await verifyBehaviorHandler({
133
+ file_path: options.file,
134
+ change_summary: options.summary,
135
+ ...(options.project ? { project: options.project } : {}),
136
+ ...(options.workspaceId ? { workspace_id: options.workspaceId } : {}),
137
+ });
138
+ const output = result.content
139
+ ?.map((part) => part?.text)
140
+ .filter(Boolean)
141
+ .join('\n') || '';
142
+ if (!output)
143
+ throw new Error('verify_behavior returned no scenario');
144
+ console.log(output);
145
+ }
146
+ catch (err) {
147
+ console.error(`Behavioral verification failed: ${err instanceof Error ? err.message : String(err)}`);
148
+ process.exitCode = 1;
149
+ }
150
+ }
151
+ program
152
+ .command('verify-behavior')
153
+ .description('Get the behavioral edit scenario when an MCP transport is unavailable')
154
+ .requiredOption('--file <path>', 'Path of the file about to be edited')
155
+ .requiredOption('--summary <text>', 'Brief description of the intended change')
156
+ .option('--project <project>', 'Project identifier for workspace-scoped scenarios')
157
+ .option('--workspace-id <id>', 'Workspace ID for custom scenarios')
158
+ .action(runVerifyBehaviorCommand);
114
159
  // Parsed by the direct dispatch at the bottom so all Python CLI flags pass
115
160
  // through unchanged. Registering it here keeps the command visible in help.
116
161
  program
package/dist/connect.js CHANGED
@@ -4,6 +4,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32 as w
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { isDeepStrictEqual } from "node:util";
6
6
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
7
+ import { EVIDENCE_WORKFLOW_POLICY_LINES } from "./evidenceWorkflowPolicy.js";
7
8
  import { LOCAL_FIRST_POLICY_ID, LOCAL_FIRST_POLICY_LINES } from "./localFirstPolicy.js";
8
9
  export const CONNECT_HOSTS = [
9
10
  "claude-code",
@@ -46,6 +47,8 @@ const CODEX_STARTUP_BODY = [
46
47
  "block is managed by `prism connect`; do not edit it manually.",
47
48
  "",
48
49
  ...LOCAL_FIRST_POLICY_LINES,
50
+ "",
51
+ ...EVIDENCE_WORKFLOW_POLICY_LINES,
49
52
  ];
50
53
  const CONNECT_STORAGE_BACKENDS = ["auto", "local", "synalux", "supabase"];
51
54
  const LEGACY_CLAUDE_PROJECT_PRISM_ENTRY = {
@@ -542,6 +545,8 @@ function serializeClaudeStartupBlock(newline) {
542
545
  "managed by `prism connect`; do not edit it manually.",
543
546
  "",
544
547
  ...LOCAL_FIRST_POLICY_LINES,
548
+ "",
549
+ ...EVIDENCE_WORKFLOW_POLICY_LINES,
545
550
  CLAUDE_STARTUP_MANAGED_END,
546
551
  "",
547
552
  ].join(newline);
@@ -617,6 +622,8 @@ function serializeGeminiStartupBlock(newline) {
617
622
  "session_detect_drift calls. This block is managed by `prism connect`; do not edit it manually.",
618
623
  "",
619
624
  ...LOCAL_FIRST_POLICY_LINES,
625
+ "",
626
+ ...EVIDENCE_WORKFLOW_POLICY_LINES,
620
627
  GEMINI_STARTUP_MANAGED_END,
621
628
  "",
622
629
  ].join(newline);
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Minimum evidence contract shared by every MCP host.
3
+ *
4
+ * Native-skill hosts also receive the full evidence-first protocol. Keep this
5
+ * compact copy in MCP initialize instructions so hosts without a filesystem
6
+ * skill surface, including Claude Desktop, still follow the same workflow.
7
+ */
8
+ export const EVIDENCE_WORKFLOW_POLICY_LINES = [
9
+ "## Prism evidence workflow",
10
+ "During diagnosis and editing, one trustworthy correlated reproduction is enough; do not block coding on",
11
+ "inspecting unrelated diagnostic screenshots, trace frames, or abandoned attempts.",
12
+ "Before a completion claim, push, or release, exercise the corrected path with fresh evidence from the current",
13
+ "build and bind stateful proof to the exact run and entity. Inspect every artifact used to support the claim.",
14
+ "When a screenshot is requested or used as proof, the active agent must open it and compare its visible state",
15
+ "with the issue's expected and forbidden states. Complete that review yourself; do not ask the user to verify it.",
16
+ "A screenshot is an observation, not absolute truth. Reject stale, wrong-run, wrong-entity, or visibly failing",
17
+ "evidence even when its metadata says passed.",
18
+ ];
19
+ export const EVIDENCE_WORKFLOW_POLICY_TEXT = EVIDENCE_WORKFLOW_POLICY_LINES.join(" ");
package/dist/lifecycle.js CHANGED
@@ -262,4 +262,7 @@ export function registerShutdownHandlers() {
262
262
  process.stdin.on("close", () => {
263
263
  shutdown("CLIENT_DISCONNECTED_STDIN_CLOSED");
264
264
  });
265
+ return (reason) => {
266
+ void shutdown(reason);
267
+ };
265
268
  }
@@ -0,0 +1,70 @@
1
+ const DEFAULT_KEEPALIVE_INTERVAL_MS = 60_000;
2
+ const DEFAULT_PING_TIMEOUT_MS = 15_000;
3
+ /**
4
+ * Keeps long-idle stdio MCP sessions active and converts a silently dead
5
+ * client transport into a clean server shutdown. Some hosts retain the child
6
+ * pipes after their protocol worker closes, so stdin "close" alone cannot
7
+ * detect the failure.
8
+ */
9
+ export function monitorMcpTransport(server, options) {
10
+ const intervalMs = options.intervalMs ?? DEFAULT_KEEPALIVE_INTERVAL_MS;
11
+ const timeoutMs = options.timeoutMs ?? DEFAULT_PING_TIMEOUT_MS;
12
+ const previousOnClose = server.onclose;
13
+ let stopped = false;
14
+ let pingInFlight = false;
15
+ const stop = () => {
16
+ if (stopped)
17
+ return;
18
+ stopped = true;
19
+ clearInterval(timer);
20
+ if (server.onclose === handleClose) {
21
+ server.onclose = previousOnClose;
22
+ }
23
+ };
24
+ const fail = (reason, error) => {
25
+ if (stopped)
26
+ return;
27
+ stop();
28
+ options.onFailure(reason, error);
29
+ };
30
+ const handleClose = () => {
31
+ let closeError;
32
+ try {
33
+ previousOnClose?.();
34
+ }
35
+ catch (error) {
36
+ closeError = error instanceof Error ? error : new Error(String(error));
37
+ }
38
+ finally {
39
+ fail("MCP_TRANSPORT_CLOSED", closeError);
40
+ }
41
+ };
42
+ server.onclose = handleClose;
43
+ const check = async () => {
44
+ if (stopped || pingInFlight)
45
+ return;
46
+ pingInFlight = true;
47
+ let timeout;
48
+ try {
49
+ await Promise.race([
50
+ server.ping(),
51
+ new Promise((_, reject) => {
52
+ timeout = setTimeout(() => reject(new Error(`MCP keepalive timed out after ${timeoutMs}ms`)), timeoutMs);
53
+ }),
54
+ ]);
55
+ }
56
+ catch (error) {
57
+ fail("MCP_KEEPALIVE_FAILED", error instanceof Error ? error : new Error(String(error)));
58
+ }
59
+ finally {
60
+ if (timeout)
61
+ clearTimeout(timeout);
62
+ pingInFlight = false;
63
+ }
64
+ };
65
+ const timer = setInterval(() => {
66
+ void check();
67
+ }, intervalMs);
68
+ timer.unref?.();
69
+ return stop;
70
+ }
package/dist/server.js CHANGED
@@ -39,6 +39,7 @@
39
39
  */
40
40
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
41
41
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
42
+ import { monitorMcpTransport } from "./mcpTransportHealth.js";
42
43
  import { buildVSCodePrompt } from "./aba-protocol.js";
43
44
  import { CallToolRequestSchema, ListToolsRequestSchema,
44
45
  // ─── v0.4.0: MCP Prompts support (Enhancement #1) ───
@@ -84,6 +85,7 @@ import { inferenceMetricsHandler } from "./utils/inferenceMetrics.js";
84
85
  import { recordInvocation } from "./utils/analytics.js";
85
86
  import { BOUNDARIES_TEXT } from "./boundaries/boundaries.js";
86
87
  import { triggerSkillManifestSync } from "./skillManifestSync.js";
88
+ import { EVIDENCE_WORKFLOW_POLICY_TEXT } from "./evidenceWorkflowPolicy.js";
87
89
  import { LOCAL_FIRST_POLICY_TEXT } from "./localFirstPolicy.js";
88
90
  // ─── Import Tool Definitions (schemas) and Handlers (implementations) ─────
89
91
  import { WEB_SEARCH_TOOL, BRAVE_WEB_SEARCH_CODE_MODE_TOOL, LOCAL_SEARCH_TOOL, BRAVE_LOCAL_SEARCH_CODE_MODE_TOOL, CODE_MODE_TRANSFORM_TOOL, BRAVE_ANSWERS_TOOL, RESEARCH_PAPER_ANALYSIS_TOOL, webSearchHandler, braveWebSearchCodeModeHandler, localSearchHandler, braveLocalSearchCodeModeHandler, codeModeTransformHandler, braveAnswersHandler, researchPaperAnalysisHandler, } from "./tools/index.js";
@@ -327,6 +329,7 @@ export const PRISM_SERVER_INSTRUCTIONS = `Prism MCP — The Mind Palace for AI A
327
329
  `Reuse the conversation_id returned by session_bootstrap in structuredContent for those saves and for ` +
328
330
  `session_detect_drift, the 60-minute goal-alignment drift check. Do not add the id to the visible greeting.\n\n` +
329
331
  `${LOCAL_FIRST_POLICY_TEXT}\n\n` +
332
+ `${EVIDENCE_WORKFLOW_POLICY_TEXT}\n\n` +
330
333
  `Architecture: session_save_ledger and session_save_handoff require context loaded by session_bootstrap ` +
331
334
  `or session_load_context when a conversation_id is supplied. ${BOUNDARIES_TEXT} ` +
332
335
  `All cloud inference routes through the Synalux portal for billing, tier-gating, and audit.`;
@@ -1252,7 +1255,15 @@ export async function startServer() {
1252
1255
  // Register graceful shutdown handlers (SIGTERM, SIGINT, SIGHUP, stdin close).
1253
1256
  // The stdin close handler is critical — when MCP clients disconnect, they
1254
1257
  // often just close the pipe without sending a signal, leaving zombie processes.
1255
- registerShutdownHandlers();
1258
+ const requestShutdown = registerShutdownHandlers();
1259
+ monitorMcpTransport(server, {
1260
+ onFailure: (reason, error) => {
1261
+ if (error) {
1262
+ console.error(`[Prism] MCP transport health check failed: ${error.message}`);
1263
+ }
1264
+ requestShutdown(reason);
1265
+ },
1266
+ });
1256
1267
  // Pre-warm storage AFTER connecting — fired async so we never block the
1257
1268
  // stdio handshake. Supabase REST initialization can take 500ms–5s; blocking
1258
1269
  // on it before server.connect() was the root cause of the 1m 56s CLI delay.
@@ -4,7 +4,7 @@ import { access, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realp
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  import { applyManagedSkillManifest, getSetting, refreshConfigStorageCache, } from "./storage/configStorage.js";
7
- import { REQUIRED_NATIVE_SKILL_NAMES } from "./tools/skillRouting.js";
7
+ import { FREE_NATIVE_SKILL_NAMES, REQUIRED_NATIVE_SKILL_NAMES } from "./tools/skillRouting.js";
8
8
  import { getSynaluxJwt, invalidateSynaluxJwt } from "./utils/synaluxJwt.js";
9
9
  const OWNER = "prism-skill-sync-v1";
10
10
  const MARKER = ".prism-managed.json";
@@ -181,17 +181,20 @@ export function validateSkillManifest(payload) {
181
181
  }
182
182
  }
183
183
  }
184
- const requiredNames = new Set(REQUIRED_NATIVE_SKILL_NAMES);
185
- for (const required of REQUIRED_NATIVE_SKILL_NAMES) {
184
+ const requiredForTier = value.tier === "free"
185
+ ? FREE_NATIVE_SKILL_NAMES
186
+ : REQUIRED_NATIVE_SKILL_NAMES;
187
+ const requiredNames = new Set(requiredForTier);
188
+ for (const required of requiredForTier) {
186
189
  const requiredSkill = skills.find((skill) => skill.name === required);
187
190
  if (!requiredSkill)
188
- throw new Error(`manifest is missing required protected skill: ${required}`);
191
+ throw new Error(`manifest is missing required native skill: ${required}`);
189
192
  if (!requiredSkill.metadata.protected || !requiredSkill.metadata.categories.includes("universal")) {
190
- throw new Error(`required skill is not protected universal: ${required}`);
193
+ throw new Error(`required native skill is not protected universal: ${required}`);
191
194
  }
192
195
  }
193
196
  if (value.tier === "free" && (skills.length !== requiredNames.size || skills.some((skill) => !requiredNames.has(skill.name)))) {
194
- throw new Error("free manifest must contain exactly the protected skill floor");
197
+ throw new Error("free manifest must contain exactly the public startup package");
195
198
  }
196
199
  const normalized = {
197
200
  schema_version: 1,
@@ -20,6 +20,7 @@
20
20
  * Methods migrated to portal:
21
21
  * - saveLedger → POST /api/v1/prism/memory action=save_ledger
22
22
  * - saveHandoff → POST /api/v1/prism/memory action=save_handoff
23
+ * - saveHistorySnapshot → POST /api/v1/prism/memory action=save_history_snapshot
23
24
  * - loadContext → POST /api/v1/prism/memory action=load_context
24
25
  * - searchKnowledge → POST /api/v1/prism/memory action=search
25
26
  * - softDeleteLedger → POST /api/v1/prism/memory action=forget_memory (Phase 3 Tier A)
@@ -203,7 +204,46 @@ export class SynaluxStorage extends SupabaseStorage {
203
204
  role: handoff.role,
204
205
  expected_version: expectedVersion ?? undefined,
205
206
  });
206
- return (result.handoff ?? result);
207
+ const candidate = Object.prototype.hasOwnProperty.call(result, "result")
208
+ ? result.result
209
+ : Object.prototype.hasOwnProperty.call(result, "handoff")
210
+ ? result.handoff
211
+ : result;
212
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
213
+ throw new Error("[SynaluxStorage] Invalid save_handoff response: missing result");
214
+ }
215
+ const value = candidate;
216
+ if (value.status === "conflict" && Number.isSafeInteger(value.current_version)) {
217
+ return {
218
+ status: "conflict",
219
+ current_version: value.current_version,
220
+ };
221
+ }
222
+ if ((value.status === "created" || value.status === "updated")
223
+ && Number.isSafeInteger(value.version)) {
224
+ return {
225
+ status: value.status,
226
+ version: value.version,
227
+ };
228
+ }
229
+ // Older portal RPC wrappers returned only the new version. Preserve that
230
+ // rolling-upgrade contract without accepting an unversioned success.
231
+ if (value.status === undefined && Number.isSafeInteger(value.version)) {
232
+ return {
233
+ status: "updated",
234
+ version: value.version,
235
+ };
236
+ }
237
+ throw new Error("[SynaluxStorage] Invalid save_handoff response: malformed OCC result");
238
+ }
239
+ async saveHistorySnapshot(handoff, branch = "main") {
240
+ await this.portalPost("/api/v1/prism/memory", {
241
+ action: "save_history_snapshot",
242
+ project: handoff.project,
243
+ version: handoff.version,
244
+ snapshot: handoff,
245
+ branch,
246
+ });
207
247
  }
208
248
  // ─── Context ─────────────────────────────────────────────────
209
249
  async loadContext(project, level, userId, role) {
@@ -8,7 +8,7 @@
8
8
  * FAIL-CLOSED: if the portal is unreachable, returns a generic
9
9
  * verification challenge rather than skipping verification.
10
10
  */
11
- import { PRISM_SYNALUX_BASE_URL, SYNALUX_CONFIGURED } from "../config.js";
11
+ import { PRISM_SYNALUX_BASE_URL } from "../config.js";
12
12
  import { getSynaluxJwt } from "../utils/synaluxJwt.js";
13
13
  const FALLBACK_SCENARIO = [
14
14
  "⚠️ BEHAVIORAL VERIFICATION (OFFLINE MODE)",
@@ -33,7 +33,13 @@ export async function verifyBehaviorHandler(args) {
33
33
  return { content: [{ type: "text", text: await buildScenarioText(args) }] };
34
34
  }
35
35
  async function buildScenarioText(args) {
36
- if (!SYNALUX_CONFIGURED || !PRISM_SYNALUX_BASE_URL) {
36
+ const baseUrl = process.env.PRISM_SYNALUX_BASE_URL?.trim() ||
37
+ process.env.SYNALUX_BASE_URL?.trim() ||
38
+ PRISM_SYNALUX_BASE_URL;
39
+ // OAuth/JWT-backed installs intentionally do not copy a long-lived API key
40
+ // into host configuration. A valid portal URL is enough to attempt the
41
+ // short-lived JWT flow; getSynaluxJwt() remains the fail-closed auth gate.
42
+ if (!baseUrl) {
37
43
  return FALLBACK_SCENARIO;
38
44
  }
39
45
  const jwt = await getSynaluxJwt();
@@ -42,7 +48,7 @@ async function buildScenarioText(args) {
42
48
  return FALLBACK_SCENARIO;
43
49
  }
44
50
  try {
45
- const url = `${PRISM_SYNALUX_BASE_URL}/api/v1/prism/verify-behavior`;
51
+ const url = `${baseUrl.replace(/\/+$/, "")}/api/v1/prism/verify-behavior`;
46
52
  const res = await fetch(url, {
47
53
  method: "POST",
48
54
  headers: {
@@ -60,7 +66,11 @@ async function buildScenarioText(args) {
60
66
  console.error(`[verify-behavior] ⚠️ portal returned ${res.status} — fail-closed. URL: ${url}`);
61
67
  return FALLBACK_SCENARIO;
62
68
  }
63
- const data = (await res.json());
69
+ const data = await res.json();
70
+ if (!isVerifyBehaviorResult(data)) {
71
+ console.error("[verify-behavior] ⚠️ portal returned a malformed response — fail-closed");
72
+ return FALLBACK_SCENARIO;
73
+ }
64
74
  return formatResult(data);
65
75
  }
66
76
  catch (err) {
@@ -68,6 +78,12 @@ async function buildScenarioText(args) {
68
78
  return FALLBACK_SCENARIO;
69
79
  }
70
80
  }
81
+ function isVerifyBehaviorResult(value) {
82
+ return typeof value === "object" &&
83
+ value !== null &&
84
+ !Array.isArray(value) &&
85
+ typeof value.requires_verification === "boolean";
86
+ }
71
87
  function formatResult(data) {
72
88
  if (!data.requires_verification) {
73
89
  return JSON.stringify({ requires_verification: false, reason: data.reason || "non-behavioral file" });