prism-mcp-server 20.5.3 → 20.7.0

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
@@ -56,11 +56,120 @@ Prism works locally without an account, API key, or cloud subscription. Add a
56
56
  Synalux subscription when you want cloud memory, paid-tier skills, or team
57
57
  features.
58
58
 
59
+ ### What `prism connect` changes about host subagents
60
+
61
+ `connect` steers bounded work to `prism_infer` on your machine rather than to
62
+ host-spawned agents. What it writes differs per host, and **it does not disable
63
+ subagents everywhere** — Claude Code keeps them and is pointed at an economy
64
+ model instead. Prism's local workers stay available over MCP in every case.
65
+
66
+ | Host | Setting written | Effect |
67
+ |---|---|---|
68
+ | Claude Code | `env.CLAUDE_CODE_SUBAGENT_MODEL = "sonnet"` in `~/.claude/settings.json` | Subagents stay **enabled**, pinned to an economy model. Fan-out is discouraged by policy text, not by config |
69
+ | Gemini CLI | `experimental.enableAgents = false` in `~/.gemini/settings.json` | Subagents **off**. Gemini exposes one boolean, so that is all there is to set |
70
+ | Codex | `features.multi_agent = false` in `$CODEX_HOME/config.toml` (default `~/.codex`), plus a bounded fallback: 2 threads, depth 1, cheap subagent model, 900s cap | Subagents **off**, with a bounded profile underneath so a deliberate re-enable lands somewhere sane |
71
+
72
+ Two things worth knowing:
73
+
74
+ - **`experimental` is Gemini's namespace, not ours.** Prism is not enabling
75
+ anything experimental — it writes `false` to a flag Gemini already defines at
76
+ that path. Writing anywhere else would have no effect.
77
+ - **That namespace is by definition temporary.** If Gemini promotes
78
+ `enableAgents` out of `experimental`, Prism keeps writing the old path, Gemini
79
+ reads the new one, and host subagents quietly turn back on. Nothing errors and
80
+ the settings file still looks correct. If you see host subagents running while
81
+ `enableAgents` reads `false`, check whether the key has moved before assuming
82
+ `connect` failed to write it.
83
+
84
+ Both writes are idempotent in the sense that a host already configured this way
85
+ is left untouched — but they are **re-applied on every `prism connect` run**,
86
+ not only on `--refresh`. If you deliberately re-enable host subagents, the next
87
+ `connect` will turn them off again. Keep them on by not re-running `connect`,
88
+ or by re-enabling after each run.
89
+
59
90
  ---
60
91
 
61
92
  <details>
62
93
  <summary>Release history (optional)</summary>
63
94
 
95
+ ## What's New in v20.6.0
96
+
97
+ ### Delivery Is Not a Suggestion
98
+
99
+ An audit of a real incident (an agent wiped demo data after *announcing* the
100
+ wipe — with the ask-first rule committed, bundled, and absent from what any
101
+ agent actually received) found the protected floor had outgrown every delivery
102
+ budget: "unprotected" had quietly come to mean "never delivered".
103
+
104
+ - **`ask-first` and `feature-preservation` join the protected floor** (14 → 16).
105
+ Protected skills are always inlined; these two now reach every session.
106
+ - **Sync conflicts are loud and named.** Startup used to say "· 2 local
107
+ conflicts preserved" while safety skills sat months stale; it now names each
108
+ frozen skill and states how to resume updates.
109
+ - **`--storage` accepts `auto` and `synalux`** — the CLI rejected its own
110
+ documented default and the production backend.
111
+ - **Disclosure:** skill delivery informs; it does not gate. A live probe showed
112
+ a host agent still edit unverified source with the rule loaded. If your
113
+ threat model includes an agent acting against a loaded rule under task
114
+ pressure, pair this package with mechanical gates (hooks, permissions,
115
+ least-privilege roles). True of every prior release; stated from this one.
116
+
117
+ ## What's New in v20.5.3
118
+
119
+ ### Grounding Evidence Carries Its Age
120
+
121
+ Memory-grounded answers labelled their sources but never dated them, so a
122
+ two-year-old note and yesterday's reached the model identically. Nothing in the
123
+ evidence let it discount the stale one. Prompted by an external review naming
124
+ the right risk for local-first memory: *the data stays local, but bad grounding
125
+ becomes permanent* — storing everything on your machine removes the outside
126
+ pressure that would otherwise surface a stale note.
127
+
128
+ Evidence now reads:
129
+
130
+ ```
131
+ [SOURCE 1: ledger:8286581d (recorded 2025-05-29, 431 days ago)]
132
+ ```
133
+
134
+ The date already existed in storage and was being dropped at the snippet layer,
135
+ so this is plumbing rather than new data collection. Zone-less SQLite
136
+ timestamps are normalised to UTC — read as local, a ten-minute-old record
137
+ parsed hours into the future and its age was suppressed entirely, meaning the
138
+ feature silently did nothing on the freshest memories. An absent or unparseable
139
+ date renders as nothing rather than defaulting to now; defaulting would make
140
+ the oldest memories, the ones most likely to be stale, appear freshest.
141
+
142
+ `tests/integration/grounding-staleness.test.ts` runs the reviewer's own probe —
143
+ seed a deliberately outdated note beside a contradicting fresh one and assert
144
+ the model receives both, visibly dated. Anyone can run it.
145
+
146
+ **Not solved, and not claimed:** retrieval does not weight recency. A stale note
147
+ shown *beside* a fresh one is the easy case — the model sees both dates and can
148
+ weigh them. The hard case is a stale note retrieved *alone*, because ranking is
149
+ by keyword match and an old store returns old results; then the age label is the
150
+ only defence and there is no fresher record to compare against. Tracked as
151
+ `TECH_DEBT.md` #4.
152
+
153
+ ## What's New in v20.5.0 – v20.5.2
154
+
155
+ ### The First Message Never Leaves Your Machine
156
+
157
+ Symptom-triggered skills — the rules that fire on "can't see X", "no rows",
158
+ "the list is empty" — are meant to load on the turn an incident report arrives.
159
+ They never did: every host template called `session_bootstrap` with `{}`, so
160
+ there was no prompt to match against.
161
+
162
+ Fixing that raised the question of where matching happens. It now happens
163
+ locally. The 28 keyword rules are already public, so there was nothing a local
164
+ match could not compute, and `callPortal()` has no `prompt` parameter at all —
165
+ the guarantee is structural, not a promise. The portal request carries the
166
+ project and role only.
167
+
168
+ A matched rule now arrives as **content**, not as a name. Native hosts outside
169
+ the skill-file mirror had no way to read a rule they were only told about, so
170
+ the rule body is inlined into the startup display, bounded and sized against
171
+ the real per-project budget.
172
+
64
173
  ## What's New in v20.4.0
65
174
 
66
175
  ### An Explicitly Named Cloud Backend Fails Loud
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Agent Definition Sync — materializes portal-served agent definitions
3
+ * (subagent routing policy: model tier, effort cap, tool surface) into
4
+ * Claude Code's agent root (~/.claude/agents/<name>.md).
5
+ *
6
+ * Deliberately separate from skillManifestSync's directory-package engine:
7
+ * agent definitions are SINGLE FILES, so this module implements file-level
8
+ * semantics with the same two protective properties the skill engine pins:
9
+ *
10
+ * 1. A hand-edited or foreign file is NEVER overwritten or deleted — it is
11
+ * reported as a conflict and left byte-identical on disk.
12
+ * 2. Ownership is provable, not assumed: a file is "ours" only when its
13
+ * current content digest matches the digest recorded in the sidecar
14
+ * index at the time we last wrote it.
15
+ *
16
+ * The server contract (portal /api/v1/prism/skill-manifest) ships agents
17
+ * under separate `agents` + `agents_generation` fields precisely so that
18
+ * clients which predate this module ignore them; `generation` remains a
19
+ * skills-only hash. Do not fold agents into the skills generation on either
20
+ * side.
21
+ */
22
+ import { createHash, randomUUID } from "node:crypto";
23
+ import { link, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
24
+ import { homedir } from "node:os";
25
+ import { join } from "node:path";
26
+ const OWNER = "prism-mcp";
27
+ const INDEX = ".prism-agents.json";
28
+ const SAFE_NAME = /^[A-Za-z0-9_-]+$/;
29
+ const SHA256 = /^[a-f0-9]{64}$/i;
30
+ const MAX_AGENTS = 64;
31
+ const MAX_AGENT_BYTES = 256 * 1024;
32
+ export function parseAgentDefinition(content) {
33
+ const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
34
+ if (!match)
35
+ return { fields: {}, body: content };
36
+ const fields = {};
37
+ for (const line of match[1].split("\n")) {
38
+ const entry = line.match(/^([A-Za-z_-]+):\s*(.*)$/);
39
+ if (entry)
40
+ fields[entry[1]] = entry[2].trim();
41
+ }
42
+ return { fields, body: content.slice(match[0].length) };
43
+ }
44
+ // ─── Host renderers ──────────────────────────────────────────
45
+ //
46
+ // The canonical format is Claude Code's agent markdown; other hosts receive a
47
+ // translation of the universally-expressible subset. Model tier is
48
+ // DELIBERATELY not translated: model namespaces are host-specific and rotate
49
+ // (gpt-5.6-* today), so foreign hosts keep their own configured defaults
50
+ // (codex: default_subagent_model) and only the proven effort/tool-surface
51
+ // levers travel.
52
+ /** Claude Code: the canonical content verbatim. */
53
+ export const renderClaudeAgent = (agent) => ({
54
+ file: `${agent.name}.md`,
55
+ content: agent.content,
56
+ });
57
+ const CODEX_EFFORT = {
58
+ low: "low", medium: "medium", high: "high", xhigh: "high", max: "high",
59
+ };
60
+ function tomlString(value) {
61
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
62
+ }
63
+ function tomlMultiline(value) {
64
+ // Escaping every backslash and quote keeps any body (including """ runs)
65
+ // valid inside a multiline basic string while preserving literal newlines.
66
+ return `"""\n${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"""`;
67
+ }
68
+ /**
69
+ * Codex: ~/.codex/agents/<name>.toml. Keys verified against codex 0.146.0:
70
+ * name, description, developer_instructions, model_reasoning_effort,
71
+ * sandbox_mode. A Bash-bearing tool surface implies the agent must execute
72
+ * reproductions, so it gets workspace-write; otherwise read-only.
73
+ */
74
+ export const renderCodexAgent = (agent) => {
75
+ const { fields, body } = parseAgentDefinition(agent.content);
76
+ const effort = CODEX_EFFORT[fields.effort ?? ""];
77
+ const tools = (fields.tools ?? "").split(",").map((tool) => tool.trim());
78
+ const sandbox = tools.includes("Bash") ? "workspace-write" : "read-only";
79
+ const lines = [
80
+ `name = ${tomlString(agent.name)}`,
81
+ `description = ${tomlString(fields.description ?? agent.name)}`,
82
+ ...(effort ? [`model_reasoning_effort = ${tomlString(effort)}`] : []),
83
+ `sandbox_mode = ${tomlString(sandbox)}`,
84
+ `developer_instructions = ${tomlMultiline(body)}`,
85
+ ];
86
+ return { file: `${agent.name}.toml`, content: `${lines.join("\n")}\n` };
87
+ };
88
+ /**
89
+ * Gemini CLI: ~/.gemini/agents/<name>.md (verified loadAgentsFromDirectory:
90
+ * .md files, "_"-prefixed skipped). Frontmatter is reduced to the fields
91
+ * gemini understands; Claude-specific keys (tools/model/effort) are dropped
92
+ * rather than risked against a stricter parser. Coexists with the
93
+ * prism-connect agent policy that owns ~/.gemini/settings.json overrides.
94
+ */
95
+ export const renderGeminiAgent = (agent) => {
96
+ const { fields, body } = parseAgentDefinition(agent.content);
97
+ const frontmatter = [
98
+ "---",
99
+ `name: ${agent.name}`,
100
+ `description: ${fields.description ?? agent.name}`,
101
+ "---",
102
+ ].join("\n");
103
+ return { file: `${agent.name}.md`, content: `${frontmatter}\n\n${body}` };
104
+ };
105
+ function sha256(value) {
106
+ return createHash("sha256").update(value).digest("hex");
107
+ }
108
+ /**
109
+ * Parse the optional agents section of a skill-manifest payload.
110
+ * Returns null when the server predates agents (fields absent).
111
+ * Throws on a malformed section — the caller decides whether that is fatal;
112
+ * it must never be silently treated as "no agents", because that would turn
113
+ * contract drift into an invisible prune signal.
114
+ */
115
+ export function validateAgentSection(payload) {
116
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
117
+ return null;
118
+ const value = payload;
119
+ if (value.agents === undefined && value.agents_generation === undefined)
120
+ return null;
121
+ if (!Array.isArray(value.agents) || typeof value.agents_generation !== "string" ||
122
+ !SHA256.test(value.agents_generation)) {
123
+ throw new Error("invalid agent section");
124
+ }
125
+ if (value.agents.length > MAX_AGENTS)
126
+ throw new Error("agent section exceeds bounds");
127
+ const names = new Set();
128
+ const agents = value.agents.map((raw, index) => {
129
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
130
+ throw new Error(`invalid agent at index ${index}`);
131
+ const agent = raw;
132
+ if (typeof agent.name !== "string" || !SAFE_NAME.test(agent.name))
133
+ throw new Error(`invalid agent name at index ${index}`);
134
+ const folded = agent.name.toLocaleLowerCase("en-US");
135
+ if (names.has(folded))
136
+ throw new Error(`duplicate agent name: ${agent.name}`);
137
+ names.add(folded);
138
+ if (typeof agent.content !== "string" || !agent.content.trim())
139
+ throw new Error(`empty agent content: ${agent.name}`);
140
+ if (Buffer.byteLength(agent.content, "utf8") > MAX_AGENT_BYTES)
141
+ throw new Error(`agent definition too large: ${agent.name}`);
142
+ if (typeof agent.digest !== "string" || !SHA256.test(agent.digest) ||
143
+ sha256(Buffer.from(agent.content, "utf8")) !== agent.digest.toLowerCase()) {
144
+ throw new Error(`agent digest mismatch: ${agent.name}`);
145
+ }
146
+ return { name: agent.name, content: agent.content, digest: agent.digest.toLowerCase() };
147
+ });
148
+ return { agents, generation: value.agents_generation.toLowerCase() };
149
+ }
150
+ /**
151
+ * Claude Code's agent-definition root, mirroring the skill sync's detection
152
+ * guard: auto-detect only in the production default configuration so tests
153
+ * and custom-root callers stay isolated.
154
+ */
155
+ export async function resolveClaudeAgentsDir(options = {}) {
156
+ if (options.claudeCodeAgentsDir === false)
157
+ return null;
158
+ if (typeof options.claudeCodeAgentsDir === "string")
159
+ return options.claudeCodeAgentsDir;
160
+ if (options.agentsSkillsDir !== undefined)
161
+ return null;
162
+ const claudeHome = join(options.homeDir ?? homedir(), ".claude");
163
+ try {
164
+ const stat = await lstat(claudeHome);
165
+ if (!stat.isDirectory() || stat.isSymbolicLink())
166
+ return null;
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ return join(claudeHome, "agents");
172
+ }
173
+ async function detectHostAgentsDir(hostHome) {
174
+ try {
175
+ const stat = await lstat(hostHome);
176
+ if (!stat.isDirectory() || stat.isSymbolicLink())
177
+ return null;
178
+ }
179
+ catch {
180
+ return null;
181
+ }
182
+ return join(hostHome, "agents");
183
+ }
184
+ /** Codex agent root (~/.codex/agents or $CODEX_HOME/agents), same guards. */
185
+ export async function resolveCodexAgentsDir(options = {}) {
186
+ if (options.codexAgentsDir === false)
187
+ return null;
188
+ if (typeof options.codexAgentsDir === "string")
189
+ return options.codexAgentsDir;
190
+ if (options.agentsSkillsDir !== undefined)
191
+ return null;
192
+ const configured = (options.env ?? process.env).CODEX_HOME?.trim();
193
+ const codexHome = configured || join(options.homeDir ?? homedir(), ".codex");
194
+ return detectHostAgentsDir(codexHome);
195
+ }
196
+ /** Gemini CLI agent root (~/.gemini/agents), same guards. */
197
+ export async function resolveGeminiAgentsDir(options = {}) {
198
+ if (options.geminiAgentsDir === false)
199
+ return null;
200
+ if (typeof options.geminiAgentsDir === "string")
201
+ return options.geminiAgentsDir;
202
+ if (options.agentsSkillsDir !== undefined)
203
+ return null;
204
+ return detectHostAgentsDir(join(options.homeDir ?? homedir(), ".gemini"));
205
+ }
206
+ const SAFE_FILE = /^[A-Za-z0-9_-]+\.(md|toml)$/;
207
+ async function readIndex(path) {
208
+ try {
209
+ const parsed = JSON.parse(await readFile(path, "utf8"));
210
+ if (parsed?.owner !== OWNER || typeof parsed.files !== "object" || !parsed.files)
211
+ return null;
212
+ const files = {};
213
+ for (const [name, entry] of Object.entries(parsed.files)) {
214
+ if (!SAFE_NAME.test(name) || !entry || typeof entry !== "object")
215
+ continue;
216
+ const { digest, file } = entry;
217
+ if (typeof digest === "string" && SHA256.test(digest) && typeof file === "string" && SAFE_FILE.test(file)) {
218
+ files[name] = { digest: digest.toLowerCase(), file };
219
+ }
220
+ }
221
+ return { owner: OWNER, generation: typeof parsed.generation === "string" ? parsed.generation : "", files };
222
+ }
223
+ catch {
224
+ return null;
225
+ }
226
+ }
227
+ async function currentDigest(path) {
228
+ try {
229
+ const stat = await lstat(path);
230
+ if (!stat.isFile() || stat.isSymbolicLink())
231
+ return null;
232
+ return sha256(await readFile(path));
233
+ }
234
+ catch {
235
+ return null;
236
+ }
237
+ }
238
+ /**
239
+ * Exclusive install: hard-link the temp file into place. Unlike rename(2),
240
+ * link(2) fails with EEXIST instead of overwriting, so a file that appears
241
+ * between our existence check and the install cannot be clobbered (TOCTOU).
242
+ * Returns false when the target already exists.
243
+ */
244
+ async function installExclusive(dir, target, content) {
245
+ const temp = join(dir, `.prism-agent-${randomUUID()}.tmp`);
246
+ await writeFile(temp, content, { mode: 0o600 });
247
+ try {
248
+ await link(temp, target);
249
+ return true;
250
+ }
251
+ catch (error) {
252
+ if (isErrno(error, "EEXIST"))
253
+ return false;
254
+ // Filesystems without hard-link support (exFAT/FAT32, several SMB and
255
+ // container-volume mounts) reject link(2) outright. Without this fallback
256
+ // those users receive ZERO agent definitions and only a stderr line —
257
+ // the exclusivity hardening would have silently disabled the feature.
258
+ // rename(2) is marginally weaker against a concurrent writer inside the
259
+ // check→act window; delivering nothing is strictly worse.
260
+ if (isErrno(error, "EPERM") || isErrno(error, "ENOTSUP") ||
261
+ isErrno(error, "EOPNOTSUPP") || isErrno(error, "EXDEV")) {
262
+ if (await currentDigest(target) !== null)
263
+ return false;
264
+ await rename(temp, target);
265
+ return true;
266
+ }
267
+ throw error;
268
+ }
269
+ finally {
270
+ await rm(temp, { force: true });
271
+ }
272
+ }
273
+ /**
274
+ * Claim-then-verify mutation: atomically rename the target out of the root,
275
+ * re-hash the CLAIMED bytes, and only act when they still match the recorded
276
+ * digest. A mismatch (someone edited the file inside the check→act window)
277
+ * restores the file byte-identical and reports a conflict. This closes the
278
+ * check-then-act race CodeQL flags (js/file-system-race): after the rename we
279
+ * operate on a path no other writer targets, and the decision digest is
280
+ * computed from those exact bytes.
281
+ */
282
+ async function claimVerified(dir, target, expectedDigest) {
283
+ const claimPath = join(dir, `.prism-agent-claim-${randomUUID()}.tmp`);
284
+ try {
285
+ await rename(target, claimPath);
286
+ }
287
+ catch (error) {
288
+ if (isErrno(error, "ENOENT"))
289
+ return "gone";
290
+ throw error;
291
+ }
292
+ const bytes = await readFile(claimPath);
293
+ if (sha256(bytes) !== expectedDigest) {
294
+ await rename(claimPath, target); // restore byte-identical
295
+ return "mismatch";
296
+ }
297
+ return { claimed: claimPath };
298
+ }
299
+ function isErrno(error, code) {
300
+ return typeof error === "object" && error !== null && "code" in error
301
+ && error.code === code;
302
+ }
303
+ /**
304
+ * Apply the agent section to a host agent root. Every write is an atomic
305
+ * tmp+rename of a single small file; the index commits last, so a crash
306
+ * mid-run leaves files that the next run re-proves ownership of by digest.
307
+ */
308
+ export async function materializeAgentDefinitions(section, targetDir, render = renderClaudeAgent) {
309
+ await mkdir(targetDir, { recursive: true, mode: 0o700 });
310
+ const rootStat = await lstat(targetDir);
311
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
312
+ throw new Error("agent root must be a real directory");
313
+ }
314
+ const indexPath = join(targetDir, INDEX);
315
+ const index = await readIndex(indexPath);
316
+ const owned = index?.files ?? {};
317
+ const incoming = new Map();
318
+ for (const agent of section.agents) {
319
+ const rendered = render(agent);
320
+ if (rendered === null)
321
+ continue;
322
+ if (!SAFE_FILE.test(rendered.file))
323
+ throw new Error(`unsafe rendered agent file: ${rendered.file}`);
324
+ incoming.set(agent.name, rendered);
325
+ }
326
+ const installed = [];
327
+ const updated = [];
328
+ const pruned = [];
329
+ const conflicts = [];
330
+ const finalFiles = {};
331
+ // Downgrades first, mirroring the skill engine's ordering: entitlement
332
+ // removal must not depend on the success of later installs. Deletion is
333
+ // claim-then-verify: the digest that authorizes the rm is computed from the
334
+ // bytes AFTER they leave the discovery root, so an edit racing the check
335
+ // can never be destroyed.
336
+ for (const [name, entry] of Object.entries(owned)) {
337
+ if (incoming.has(name))
338
+ continue;
339
+ const target = join(targetDir, entry.file);
340
+ const claim = await claimVerified(targetDir, target, entry.digest);
341
+ if (claim === "gone")
342
+ continue; // already gone
343
+ if (claim === "mismatch") {
344
+ // Hand-edited content survives; we merely stop claiming it.
345
+ conflicts.push(name);
346
+ continue;
347
+ }
348
+ await rm(claim.claimed, { force: true });
349
+ pruned.push(name);
350
+ }
351
+ for (const [name, rendered] of incoming) {
352
+ const target = join(targetDir, rendered.file);
353
+ const renderedDigest = sha256(Buffer.from(rendered.content, "utf8"));
354
+ const digestOnDisk = await currentDigest(target);
355
+ if (digestOnDisk === null) {
356
+ // Exclusive install: a file appearing inside the window makes link()
357
+ // fail instead of being overwritten; re-judge it as foreign content.
358
+ if (await installExclusive(targetDir, target, rendered.content)) {
359
+ installed.push(name);
360
+ finalFiles[name] = { digest: renderedDigest, file: rendered.file };
361
+ }
362
+ else if (await currentDigest(target) === renderedDigest) {
363
+ finalFiles[name] = { digest: renderedDigest, file: rendered.file };
364
+ }
365
+ else {
366
+ conflicts.push(name);
367
+ }
368
+ continue;
369
+ }
370
+ const record = owned[name];
371
+ const pristine = record !== undefined && record.file === rendered.file && digestOnDisk === record.digest;
372
+ if (!pristine) {
373
+ if (digestOnDisk === renderedDigest) {
374
+ // Byte-identical foreign file: adopt without writing. Content equality
375
+ // is the strongest ownership evidence available for a single file.
376
+ finalFiles[name] = { digest: renderedDigest, file: rendered.file };
377
+ }
378
+ else {
379
+ conflicts.push(name);
380
+ }
381
+ continue;
382
+ }
383
+ if (digestOnDisk === renderedDigest) {
384
+ finalFiles[name] = { digest: renderedDigest, file: rendered.file };
385
+ continue;
386
+ }
387
+ // Update = claim the old version out (verifying the recorded digest on
388
+ // the claimed bytes), then exclusively install the new render. A racer
389
+ // in either window wins the file and we report a conflict instead of
390
+ // clobbering; the claimed old version is ours and safe to discard.
391
+ const claim = await claimVerified(targetDir, target, record.digest);
392
+ if (claim === "mismatch") {
393
+ conflicts.push(name);
394
+ continue;
395
+ }
396
+ const installedNow = await installExclusive(targetDir, target, rendered.content);
397
+ if (claim !== "gone")
398
+ await rm(claim.claimed, { force: true });
399
+ if (installedNow) {
400
+ updated.push(name);
401
+ finalFiles[name] = { digest: renderedDigest, file: rendered.file };
402
+ }
403
+ else {
404
+ conflicts.push(name);
405
+ }
406
+ }
407
+ const nextIndex = { owner: OWNER, generation: section.generation, files: finalFiles };
408
+ const tempIndex = join(targetDir, `${INDEX}.${randomUUID()}.tmp`);
409
+ await writeFile(tempIndex, `${JSON.stringify(nextIndex, null, 2)}\n`, { mode: 0o600 });
410
+ await rename(tempIndex, indexPath);
411
+ return {
412
+ installed: installed.sort(),
413
+ updated: updated.sort(),
414
+ pruned: pruned.sort(),
415
+ conflicts: [...new Set(conflicts)].sort(),
416
+ };
417
+ }
package/dist/cli.js CHANGED
@@ -393,7 +393,11 @@ program
393
393
  // v9.2.2: --storage flag overrides PRISM_STORAGE env var to prevent
394
394
  // split-brain when CLI environment differs from MCP server config.
395
395
  if (storage) {
396
- const validStorages = ['local', 'supabase'];
396
+ // Must mirror the PRISM_STORAGE union in config.ts. This list shipped
397
+ // as ['local','supabase'] — rejecting 'auto' (the documented default in
398
+ // this very command's --storage help text) and 'synalux' (the actual
399
+ // production backend). Found 2026-08-03; the config type is the truth.
400
+ const validStorages = ['auto', 'local', 'supabase', 'synalux'];
397
401
  if (!validStorages.includes(storage)) {
398
402
  console.error(`Error: Invalid storage "${storage}". Must be one of: ${validStorages.join(', ')}`);
399
403
  process.exit(1);
@@ -512,7 +516,11 @@ saveCmd
512
516
  try {
513
517
  // Storage override
514
518
  if (options.storage) {
515
- const validStorages = ['local', 'supabase'];
519
+ // Must mirror the PRISM_STORAGE union in config.ts. This list shipped
520
+ // as ['local','supabase'] — rejecting 'auto' (the documented default in
521
+ // this very command's --storage help text) and 'synalux' (the actual
522
+ // production backend). Found 2026-08-03; the config type is the truth.
523
+ const validStorages = ['auto', 'local', 'supabase', 'synalux'];
516
524
  if (!validStorages.includes(options.storage)) {
517
525
  console.error(`Error: Invalid storage "${options.storage}". Must be one of: ${validStorages.join(', ')}`);
518
526
  process.exit(1);
@@ -565,7 +573,11 @@ saveCmd
565
573
  try {
566
574
  // Storage override
567
575
  if (options.storage) {
568
- const validStorages = ['local', 'supabase'];
576
+ // Must mirror the PRISM_STORAGE union in config.ts. This list shipped
577
+ // as ['local','supabase'] — rejecting 'auto' (the documented default in
578
+ // this very command's --storage help text) and 'synalux' (the actual
579
+ // production backend). Found 2026-08-03; the config type is the truth.
580
+ const validStorages = ['auto', 'local', 'supabase', 'synalux'];
569
581
  if (!validStorages.includes(options.storage)) {
570
582
  console.error(`Error: Invalid storage "${options.storage}". Must be one of: ${validStorages.join(', ')}`);
571
583
  process.exit(1);
package/dist/connect.js CHANGED
@@ -60,15 +60,48 @@ const LEGACY_CLAUDE_PROJECT_PRISM_ENTRY = {
60
60
  };
61
61
  const INSTALLATION_RECEIPT_SCHEMA_VERSION = 1;
62
62
  const INSTALLATION_RECEIPT_OWNER = "prism-connect";
63
+ /**
64
+ * Economy subagent model per host.
65
+ *
66
+ * The local-first policy already permits exactly this — "a host-native subagent
67
+ * is a last resort: at most one, no nesting, using the configured economy
68
+ * model" — but only Claude was configured that way. Gemini and Codex banned
69
+ * subagents outright, which is stricter than the policy Prism publishes.
70
+ *
71
+ * The stated reason for Gemini was "native/remote subagents". That does not
72
+ * survive checking: Gemini CLI subagents execute locally, in-process, with
73
+ * optional local container sandboxes. The model is remote, which is equally
74
+ * true of Claude and Codex. There was no egress difference to justify the
75
+ * asymmetry.
76
+ *
77
+ * Cursor is deliberately absent. Its Background Agents really do run in remote
78
+ * cloud sandboxes with repository access — a different risk class — and no
79
+ * subagent-model setting was found for it.
80
+ */
63
81
  const CLAUDE_FALLBACK_SUBAGENT_MODEL = "sonnet";
82
+ /** Fastest tier of the GPT-5.6 line (Sol > Terra > Luna) for simple work. */
83
+ const CODEX_ECONOMY_SUBAGENT_MODEL = "gpt-5.6-luna";
84
+ /**
85
+ * Gemini's speed tier for agentic work.
86
+ *
87
+ * Confirmed working in practice. An earlier revision changed this to
88
+ * `gemini-3-flash-preview` because `gemini-3.6-flash` appears in none of the
89
+ * 116 files of gemini-cli 0.49.0 — but the CLI does not validate Gemini model
90
+ * names at all. Its only ALLOWED_MODELS list gates Whisper speech binaries.
91
+ * Model names are passed through to the API, so absence from the bundle says
92
+ * nothing about resolvability. That reversion was a regression to an older ID.
93
+ */
94
+ const GEMINI_ECONOMY_SUBAGENT_MODEL = "gemini-3.6-flash";
64
95
  const CODEX_LOCAL_FIRST_POLICY = {
65
96
  features: {
66
- multi_agent: false,
97
+ // Subagents permitted, but bounded: the ceiling below is what keeps this
98
+ // from becoming fan-out. Previously false, which contradicted the policy.
99
+ multi_agent: true,
67
100
  },
68
101
  agents: {
69
- max_threads: 2,
70
- max_depth: 1,
71
- default_subagent_model: "gpt-5.6-terra",
102
+ max_threads: 1, // "at most one" — was 2
103
+ max_depth: 1, // "no nesting"
104
+ default_subagent_model: CODEX_ECONOMY_SUBAGENT_MODEL,
72
105
  default_subagent_reasoning_effort: "low",
73
106
  job_max_runtime_seconds: 900,
74
107
  },
@@ -827,7 +860,21 @@ export function configureClaudeAgentPolicy(homeDir = homedir(), dryRun = false,
827
860
  return true;
828
861
  }, dryRun, beforeCommit);
829
862
  }
830
- /** Disable Gemini's native/remote subagents; Prism local workers remain available over MCP. */
863
+ /**
864
+ * Pin Gemini's subagents to the economy model rather than banning them.
865
+ *
866
+ * Previously this wrote `enableAgents = false`, which is stricter than the
867
+ * local-first policy Prism publishes — that policy allows "at most one, no
868
+ * nesting, using the configured economy model", and Claude was configured
869
+ * exactly that way. The stated reason was "native/remote subagents", which does
870
+ * not hold: Gemini CLI subagents execute locally, in-process, with optional
871
+ * local container sandboxes. Only the model is remote, as it is for every host.
872
+ *
873
+ * `agents.overrides.<name>.modelConfig.model` is the documented shape. Only
874
+ * `codebase_investigator` is pinned: it is the built-in whose name is published,
875
+ * and inventing names for the others would write dead config. Unpinned
876
+ * subagents inherit the session model, which is the pre-existing behaviour.
877
+ */
831
878
  export function configureGeminiAgentPolicy(homeDir = homedir(), dryRun = false, beforeCommit) {
832
879
  const configPath = join(homeDir, ".gemini", "settings.json");
833
880
  return configureJsonAgentPolicy(configPath, "Gemini agent settings", (config) => {
@@ -835,11 +882,42 @@ export function configureGeminiAgentPolicy(homeDir = homedir(), dryRun = false,
835
882
  if (currentExperimental !== undefined && !isJsonObject(currentExperimental)) {
836
883
  throw new Error('"experimental" must be a JSON object');
837
884
  }
885
+ const currentAgents = config.agents;
886
+ if (currentAgents !== undefined && !isJsonObject(currentAgents)) {
887
+ throw new Error('"agents" must be a JSON object');
888
+ }
838
889
  const experimental = (currentExperimental ?? {});
839
- if (experimental.enableAgents === false)
890
+ const agents = (currentAgents ?? {});
891
+ const currentOverrides = agents.overrides;
892
+ if (currentOverrides !== undefined && !isJsonObject(currentOverrides)) {
893
+ throw new Error('"agents.overrides" must be a JSON object');
894
+ }
895
+ const overrides = (currentOverrides ?? {});
896
+ const investigator = (isJsonObject(overrides.codebase_investigator)
897
+ ? overrides.codebase_investigator
898
+ : {});
899
+ const modelConfig = (isJsonObject(investigator.modelConfig)
900
+ ? investigator.modelConfig
901
+ : {});
902
+ // Respect a deliberate opt-out. Prism previously forced enableAgents=false
903
+ // on every run; flipping that to an unconditional `true` would repeat the
904
+ // same mistake pointing the other way, and switching something ON that a
905
+ // user turned OFF is the more intrusive direction. Only enable when the
906
+ // key is absent — pin the model either way, so a user who keeps subagents
907
+ // off still gets the economy default if they later turn them on.
908
+ const userDisabledDeliberately = experimental.enableAgents === false;
909
+ const alreadyApplied = (userDisabledDeliberately || experimental.enableAgents === true)
910
+ && modelConfig.model === GEMINI_ECONOMY_SUBAGENT_MODEL;
911
+ if (alreadyApplied)
840
912
  return false;
841
- experimental.enableAgents = false;
913
+ if (!userDisabledDeliberately)
914
+ experimental.enableAgents = true;
915
+ modelConfig.model = GEMINI_ECONOMY_SUBAGENT_MODEL;
916
+ investigator.modelConfig = modelConfig;
917
+ overrides.codebase_investigator = investigator;
918
+ agents.overrides = overrides;
842
919
  config.experimental = experimental;
920
+ config.agents = agents;
843
921
  return true;
844
922
  }, dryRun, beforeCommit);
845
923
  }
@@ -933,7 +1011,12 @@ export function configureCodexAgentPolicy(homeDir, dryRun = false, beforeCommit,
933
1011
  : undefined;
934
1012
  if (!parsedFeatures
935
1013
  || !parsedAgents
936
- || !isDeepStrictEqual(parsedFeatures.multi_agent, false)
1014
+ // Validate against the declared policy, not a literal. Hardcoding `false`
1015
+ // here meant the guard rejected any change to CODEX_LOCAL_FIRST_POLICY
1016
+ // rather than verifying the write matched it — a guard pinned to a value
1017
+ // instead of to the invariant it exists to protect.
1018
+ || !Object.entries(CODEX_LOCAL_FIRST_POLICY.features)
1019
+ .every(([key, value]) => isDeepStrictEqual(parsedFeatures[key], value))
937
1020
  || !Object.entries(CODEX_LOCAL_FIRST_POLICY.agents)
938
1021
  .every(([key, value]) => isDeepStrictEqual(parsedAgents[key], value))) {
939
1022
  throw new Error("Generated Codex local-first policy failed validation");
@@ -4,6 +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 { materializeAgentDefinitions, renderClaudeAgent, renderCodexAgent, renderGeminiAgent, resolveClaudeAgentsDir, resolveCodexAgentsDir, resolveGeminiAgentsDir, validateAgentSection, } from "./agentManifestSync.js";
7
8
  import { FREE_NATIVE_SKILL_NAMES, REQUIRED_NATIVE_SKILL_NAMES } from "./tools/skillRouting.js";
8
9
  import { getSynaluxJwt, invalidateSynaluxJwt } from "./utils/synaluxJwt.js";
9
10
  const OWNER = "prism-skill-sync-v1";
@@ -763,6 +764,10 @@ async function fetchManifest(options) {
763
764
  if (!headers.Authorization && manifest.tier !== "free") {
764
765
  throw new Error("unauthenticated skill manifest must be free tier");
765
766
  }
767
+ // Agents ride the same response under separate fields. A malformed section
768
+ // is contract drift and must fail the fetch loudly — treating it as "no
769
+ // agents" would convert server corruption into a silent prune signal.
770
+ manifest.agentSection = validateAgentSection(payload);
766
771
  return manifest;
767
772
  }
768
773
  async function acquireSyncLock(agentsSkillsDir, waitMs = LOCK_WAIT_MS) {
@@ -851,6 +856,35 @@ async function acquireSyncLock(agentsSkillsDir, waitMs = LOCK_WAIT_MS) {
851
856
  }
852
857
  };
853
858
  }
859
+ /**
860
+ * Converge every detected host's agent root onto the portal-validated agent
861
+ * section. Prefixed outcomes per host; a failure on one host degrades to a
862
+ * conflict rather than aborting the others or the skill result.
863
+ */
864
+ async function materializeAgentsAcrossHosts(section, options) {
865
+ const result = { installed: [], updated: [], pruned: [], conflicts: [] };
866
+ const hostTargets = [
867
+ { prefix: "agent", dir: await resolveClaudeAgentsDir(options), render: renderClaudeAgent },
868
+ { prefix: "agent-codex", dir: await resolveCodexAgentsDir(options), render: renderCodexAgent },
869
+ { prefix: "agent-gemini", dir: await resolveGeminiAgentsDir(options), render: renderGeminiAgent },
870
+ ];
871
+ for (const host of hostTargets) {
872
+ if (!host.dir)
873
+ continue;
874
+ try {
875
+ const outcome = await materializeAgentDefinitions(section, host.dir, host.render);
876
+ result.installed.push(...outcome.installed.map((name) => `${host.prefix}:${name}`));
877
+ result.updated.push(...outcome.updated.map((name) => `${host.prefix}:${name}`));
878
+ result.pruned.push(...outcome.pruned.map((name) => `${host.prefix}:${name}`));
879
+ result.conflicts.push(...outcome.conflicts.map((name) => `${host.prefix}:${name}`));
880
+ }
881
+ catch (error) {
882
+ console.error(`[Prism Skill Sync] ${host.prefix} materialization failed: ${error instanceof Error ? error.message : String(error)}`);
883
+ result.conflicts.push(`${host.prefix}:sync-failed`);
884
+ }
885
+ }
886
+ return result;
887
+ }
854
888
  export async function synchronizeSkillManifest(options = {}) {
855
889
  const empty = { installed: [], updated: [], pruned: [], conflicts: [] };
856
890
  let nativeSkillsDirs = [];
@@ -890,6 +924,17 @@ export async function synchronizeSkillManifest(options = {}) {
890
924
  nativeResults.push(await materializeNative(manifest, nativeSkillsDir, options));
891
925
  }
892
926
  const native = mergeNativeResults(nativeResults);
927
+ // Agent definitions are additive: they piggyback on the manifest with
928
+ // their own generation, and each host's outcome reports under its own
929
+ // prefix. A failure on one host never rolls back skill state or the
930
+ // other hosts — it surfaces as a conflict instead.
931
+ if (manifest.agentSection) {
932
+ const outcome = await materializeAgentsAcrossHosts(manifest.agentSection, options);
933
+ native.installed.push(...outcome.installed);
934
+ native.updated.push(...outcome.updated);
935
+ native.pruned.push(...outcome.pruned);
936
+ native.conflicts.push(...outcome.conflicts);
937
+ }
893
938
  const status = native.installed.length || native.updated.length || native.pruned.length ? "applied" : "unchanged";
894
939
  return {
895
940
  status,
@@ -915,6 +960,19 @@ export async function synchronizeSkillManifest(options = {}) {
915
960
  catch (enforcement) {
916
961
  enforcementErrors.push(`${nativeSkillsDir}: ${enforcement instanceof Error ? enforcement.message : String(enforcement)}`);
917
962
  }
963
+ // Agent definitions need the SAME downgrade guarantee as skills. Without
964
+ // this, a tier downgrade whose DB apply throws prunes paid skills but
965
+ // leaves paid agent definitions in every host root until some later
966
+ // successful sync — exactly the local-fault-becomes-entitlement-bypass
967
+ // the skill path above exists to prevent.
968
+ if (manifest.agentSection) {
969
+ try {
970
+ await materializeAgentsAcrossHosts(manifest.agentSection, options);
971
+ }
972
+ catch (enforcement) {
973
+ enforcementErrors.push(`agents: ${enforcement instanceof Error ? enforcement.message : String(enforcement)}`);
974
+ }
975
+ }
918
976
  enforcementError = enforcementErrors.length > 0
919
977
  ? `; entitlement cleanup failed: ${enforcementErrors.join(", ")}`
920
978
  : "";
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as nodePath from "node:path";
3
3
  import * as os from "node:os";
4
+ import * as http from "node:http";
4
5
  import { randomUUID } from "node:crypto";
5
6
  import { redactSettings, toMarkdown } from "./commonHelpers.js";
6
7
  import { scanAndRedactPHI } from "../utils/phiGuard.js";
@@ -24,11 +25,12 @@ import { buildVaultDirectory } from "../utils/vaultExporter.js";
24
25
  * ═══════════════════════════════════════════════════════════════════
25
26
  */
26
27
  import { debugLog } from "../utils/logger.js";
28
+ import { FREE_ENTITLEMENTS } from "../utils/entitlements.js";
27
29
  import { getStorage, activeStorageBackend } from "../storage/index.js";
28
30
  import { toKeywordArray } from "../utils/keywordExtractor.js";
29
31
  import { getLLMProvider } from "../utils/llm/factory.js";
30
32
  import { getCurrentGitState, getGitDrift } from "../utils/git.js";
31
- import { getSetting, getAllSettings, refreshConfigStorageCache } from "../storage/configStorage.js";
33
+ import { getSetting, setSetting, getAllSettings, refreshConfigStorageCache } from "../storage/configStorage.js";
32
34
  import { mergeHandoff, dbToHandoffSchema, sanitizeForMerge } from "../utils/crdtMerge.js";
33
35
  import { resolveProject } from "../utils/projectResolver.js";
34
36
  import { isRecoverableStartupStorageError, LOCAL_STARTUP_FALLBACK_NOTICE, } from "../utils/startupRecovery.js";
@@ -276,8 +278,20 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
276
278
  const superSkills = snapshot.names.filter((name) => name.endsWith("-super-skill"));
277
279
  const superSkillAliases = superSkills.map((name) => `${name.slice(0, -"-super-skill".length)} (${name})`);
278
280
  const otherTierSkills = snapshot.names.filter((name) => !coreSkillSet.has(name) && !name.endsWith("-super-skill"));
281
+ // Conflicts must be LOUD, named, and actionable. This used to render as
282
+ // "· 2 local conflicts preserved" — a count with benign phrasing, so two
283
+ // safety skills (ask-first among them) sat 4 months stale on disk while
284
+ // every sync silently skipped them and nobody was ever told which ones.
279
285
  const conflictSuffix = snapshot.conflicts.length > 0
280
- ? ` · ${snapshot.conflicts.length} local conflict${snapshot.conflicts.length === 1 ? "" : "s"} preserved`
286
+ ? ` · ${snapshot.conflicts.length} conflict${snapshot.conflicts.length === 1 ? "" : "s"} — see warning`
287
+ : "";
288
+ const conflictWarning = snapshot.conflicts.length > 0
289
+ ? `\n> - ⚠️ **SKILLS NOT UPDATING (local copy has no Prism ownership marker):** ` +
290
+ `${formatBoundedSkillNames([...snapshot.conflicts].sort(), "blocked")}. ` +
291
+ `Each named skill is frozen at whatever version is on disk — updates are ` +
292
+ `withheld to protect local edits. To resume updates: move the skill's ` +
293
+ `directory out of the native skills folder and rerun \`prism connect\` ` +
294
+ `(or a session bootstrap) to reinstall the managed copy.`
281
295
  : "";
282
296
  if (snapshot.source === "validated-partial") {
283
297
  return `> **Prism System Ready**\n>\n` +
@@ -287,14 +301,18 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
287
301
  `> - 🧩 **Super-skill entitlements:** ${formatBoundedSkillNames(superSkillAliases, "entitled")}\n` +
288
302
  `> - 🛠️ **Other tier entitlements:** ${formatBoundedSkillNames(otherTierSkills, "entitled")}\n` +
289
303
  `> - 🧠 **Context depth:** ${depth}\n` +
290
- `> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · native materialization incomplete${conflictSuffix}`;
304
+ `> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · native materialization incomplete${conflictSuffix}` +
305
+ conflictWarning +
306
+ freeTierUpgradeLine(snapshot.tier);
291
307
  }
292
308
  if (snapshot.source === "tier-fallback") {
293
309
  return `> **Prism System Ready**\n>\n` +
294
310
  `> - 🪪 **Subscription tier:** ${snapshot.tier}\n` +
295
311
  `> - 🛡️ **Fallback skill names:** ${formatBoundedSkillNames(snapshot.names, "fallback")}\n` +
296
312
  `> - 🧠 **Context depth:** ${depth}\n` +
297
- `> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · no committed manifest${conflictSuffix}`;
313
+ `> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · no committed manifest${conflictSuffix}` +
314
+ conflictWarning +
315
+ freeTierUpgradeLine(snapshot.tier);
298
316
  }
299
317
  return `> **Prism System Ready**\n>\n` +
300
318
  `> - 🪪 **Subscription tier:** ${snapshot.tier}\n` +
@@ -303,7 +321,63 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
303
321
  `> - 🧩 **Super-skills provisioned:** ${formatBoundedSkillNames(superSkillAliases, "provisioned")}\n` +
304
322
  `> - 🛠️ **Other tier skills provisioned:** ${formatBoundedSkillNames(otherTierSkills, "provisioned")}\n` +
305
323
  `> - 🧠 **Context depth:** ${depth}\n` +
306
- `> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · committed manifest${conflictSuffix}`;
324
+ `> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · committed manifest${conflictSuffix}` +
325
+ conflictWarning +
326
+ freeTierUpgradeLine(snapshot.tier);
327
+ }
328
+ /**
329
+ * The paid funnel's one startup line. Before 2026-08-05 the upgrade_url was
330
+ * surfaced only AFTER a user hit an entitlement gate (sessionDriftHandler,
331
+ * queryMemoryNaturalHandler); the startup path — the only guaranteed
332
+ * impression — referenced it zero times.
333
+ */
334
+ function freeTierUpgradeLine(tier) {
335
+ if (tier !== "free")
336
+ return "";
337
+ return `\n> - 💎 **Free tier:** paid plans unlock the full skill library, ` +
338
+ `super-skills, and agent routing → ${FREE_ENTITLEMENTS.upgrade_url}`;
339
+ }
340
+ /**
341
+ * Dashboard URL for startup output. The bound port is announced on stderr
342
+ * only (MCP stdio owns stdout), so users never saw it; the dashboard also
343
+ * writes the port to ~/.prism-mcp/dashboard.port — read that, then the env
344
+ * override, then the default.
345
+ */
346
+ async function readDashboardUrl() {
347
+ // Precedence: explicit env override > recorded port file > default. The
348
+ // file is written by whatever dashboard ran last and persists across boots,
349
+ // so it must never outrank configuration the operator set for THIS process.
350
+ let port = (process.env.PRISM_DASHBOARD_PORT || "").trim();
351
+ if (!port) {
352
+ try {
353
+ const recorded = fs.readFileSync(nodePath.join(os.homedir(), ".prism-mcp", "dashboard.port"), "utf8").trim();
354
+ if (/^\d{2,5}$/.test(recorded))
355
+ port = recorded;
356
+ }
357
+ catch {
358
+ // port file absent — dashboard not started yet this boot; default holds
359
+ }
360
+ }
361
+ if (!port)
362
+ port = "3000";
363
+ // The port file persists across boots and is never cleaned up, so it is
364
+ // evidence of a PREVIOUS dashboard, not a running one. Advertising a dead
365
+ // URL as the first-run headline action is worse than omitting it.
366
+ //
367
+ // A TCP connect is NOT sufficient: it proves something is listening, not
368
+ // that it is Prism. The default is 3000 — the single most commonly occupied
369
+ // port on a developer machine — so a bare liveness check would confidently
370
+ // point a first-run user at their own dev server. Hit the dashboard's
371
+ // /api/health instead, so identity is verified rather than assumed.
372
+ const healthy = await new Promise((resolveProbe) => {
373
+ const request = http.get({ host: "127.0.0.1", port: Number(port), path: "/api/health", timeout: 300 }, (response) => {
374
+ response.resume(); // drain so the socket can close
375
+ resolveProbe(response.statusCode === 200);
376
+ });
377
+ request.once("timeout", () => { request.destroy(); resolveProbe(false); });
378
+ request.once("error", () => resolveProbe(false));
379
+ });
380
+ return healthy ? `http://localhost:${port}` : null;
307
381
  }
308
382
  function capNativeStartupText(text, level, requestedMaxChars, suffix = "") {
309
383
  const maxChars = effectiveNativeBudget(level, requestedMaxChars);
@@ -1653,15 +1727,57 @@ export async function sessionBootstrapHandler(args = {}, options = {}) {
1653
1727
  const role = sanitizeNativeIdentity(defaultRole) || "global";
1654
1728
  const manifestSnapshot = await resolveNativeSkillManifestSnapshot(skillSyncResult);
1655
1729
  const systemReadyBlock = await buildNativeSystemReadyBlock(manifestSnapshot, depth);
1656
- const greeting = `👋 Welcome back, ${greetingName}. Prism is loading ${depth} context.`;
1730
+ // First run = the dashboard has never been touched: no agent identity AND no
1731
+ // projects. Measured 2026-08-05: a brand-new free-tier install was greeted
1732
+ // with "Welcome back", three "Not loaded" rows, a warning, and three
1733
+ // statements of what it doesn't have — an all-absence payload with no next
1734
+ // step, no dashboard URL (stderr-only), and no path to the paid tier.
1735
+ // First run must mean NEW, not merely unconfigured: a user with saved
1736
+ // sessions who never set a name or projects would otherwise be told "first
1737
+ // run detected" every single session. A durable marker is written after the
1738
+ // first bootstrap, so this is decisive rather than heuristic.
1739
+ const bootstrapSeen = (await getSetting("first_bootstrap_at", "")).trim();
1740
+ const isFirstRun = projects.length === 0 && !configuredGreetingName && !bootstrapSeen;
1741
+ if (!bootstrapSeen) {
1742
+ try {
1743
+ await setSetting("first_bootstrap_at", new Date().toISOString());
1744
+ }
1745
+ catch {
1746
+ // Marker is an optimization; a write failure must never block startup.
1747
+ }
1748
+ }
1749
+ const greeting = isFirstRun
1750
+ ? `👋 Welcome to Prism — first run detected. Let's get you productive in a few minutes.`
1751
+ : `👋 Welcome back, ${greetingName}. Prism is loading ${depth} context.`;
1657
1752
  const identityBlock = `- 🤖 **Agent Identity:** ${escapeNativeMarkdown(compactWithOmissionCount(role, 80))} — ${greetingName}`;
1658
- const startupHeader = `${greeting}\n\n${identityBlock}`;
1753
+ const startupHeader = isFirstRun ? greeting : `${greeting}\n\n${identityBlock}`;
1659
1754
  if (projects.length === 0) {
1755
+ const dashboardUrl = await readDashboardUrl();
1756
+ const dashboardLine = dashboardUrl
1757
+ ? `- 🎛️ **Dashboard:** ${dashboardUrl} — configure projects, identity, and context depth`
1758
+ : `- 🎛️ **Dashboard:** not running — start Prism's dashboard to configure projects, identity, and context depth`;
1759
+ if (isFirstRun) {
1760
+ // Action-first instead of absence-first: every line is a capability or
1761
+ // a next step. The wizard exists and is well-built; route to it.
1762
+ const firstRunText = `${greeting}\n\n` +
1763
+ `- 🚀 **Get started:** run the \`onboarding_wizard\` tool (guided setup, ~3 minutes)\n` +
1764
+ `${dashboardLine}\n` +
1765
+ `- 💾 **Already working?** \`session_save_ledger\` records this session; the next one resumes with full context\n\n` +
1766
+ `${systemReadyBlock}`;
1767
+ return {
1768
+ content: [{
1769
+ type: "text",
1770
+ text: capNativeStartupText(firstRunText, depth),
1771
+ }],
1772
+ isError: false,
1773
+ structuredContent: { conversation_id: conversationId, projects: [], depth, first_run: true },
1774
+ };
1775
+ }
1660
1776
  const unconfiguredState = (depth === "quick" ? "" : `\n- 📝 **Last Session Summary:** Not loaded`) +
1661
1777
  `\n- ✅ **Open TODOs:** Not loaded` +
1662
1778
  `\n- 🔄 **Session Version:** Not loaded`;
1663
1779
  const noProjectsText = `${startupHeader}${unconfiguredState}\n\n` +
1664
- `⚠️ No Auto-Load Projects are configured in the Prism dashboard.\n\n${systemReadyBlock}`;
1780
+ `⚠️ No Auto-Load Projects are configured in the Prism dashboard.\n${dashboardLine}\n\n${systemReadyBlock}`;
1665
1781
  return {
1666
1782
  content: [{
1667
1783
  type: "text",
@@ -1462,8 +1462,8 @@ export const ONBOARDING_WIZARD_TOOL = {
1462
1462
  description: "Interactive setup wizard for new Prism users. Provides a step-by-step " +
1463
1463
  "guided experience to get productive in under 3 minutes.\n\n" +
1464
1464
  "**Actions:**\n" +
1465
- "- `start` — Begin the wizard from step 1\n" +
1466
- "- `next` — Advance to the next step\n" +
1465
+ "- `start` (default when omitted) — Begin the wizard from step 1\n" +
1466
+ "- `next` — Advance past `step` (pass the step number you are on)\n" +
1467
1467
  "- `status` — Check current wizard progress\n" +
1468
1468
  "- `skip` — Skip to completion\n\n" +
1469
1469
  "Each step returns instructions, code snippets, and progress percentage.",
@@ -1473,7 +1473,12 @@ export const ONBOARDING_WIZARD_TOOL = {
1473
1473
  action: {
1474
1474
  type: "string",
1475
1475
  enum: ["start", "next", "status", "skip"],
1476
- description: "Wizard action to perform.",
1476
+ description: "Wizard action to perform. Omitted = start.",
1477
+ },
1478
+ step: {
1479
+ type: "integer",
1480
+ minimum: 0,
1481
+ description: "The step_index from the previous response; used by `next` and `status`.",
1477
1482
  },
1478
1483
  project_name: {
1479
1484
  type: "string",
@@ -1485,16 +1490,21 @@ export const ONBOARDING_WIZARD_TOOL = {
1485
1490
  description: "IDE client for config generation.",
1486
1491
  },
1487
1492
  },
1488
- required: ["action"],
1489
1493
  },
1490
1494
  };
1491
1495
  export function isOnboardingWizardArgs(args) {
1492
1496
  if (typeof args !== "object" || args === null)
1493
1497
  return false;
1494
1498
  const a = args;
1495
- if (typeof a.action !== "string")
1496
- return false;
1497
- if (!["start", "next", "status", "skip"].includes(a.action))
1499
+ // A bare call is the front door for brand-new users (the startup greeting
1500
+ // routes here) — it must work, defaulting to `start`. Measured 2026-08-05:
1501
+ // requiring `action` made the tool the first thing a new user touches AND
1502
+ // the first error they see.
1503
+ if (a.action !== undefined &&
1504
+ (typeof a.action !== "string" || !["start", "next", "status", "skip"].includes(a.action))) {
1505
+ return false;
1506
+ }
1507
+ if (a.step !== undefined && (!Number.isInteger(a.step) || a.step < 0))
1498
1508
  return false;
1499
1509
  if (a.project_name !== undefined && typeof a.project_name !== "string")
1500
1510
  return false;
@@ -46,6 +46,13 @@ export const REQUIRED_PROTECTED_SKILL_NAMES = [
46
46
  // so the two sources disagreed. A portal test now asserts they match.
47
47
  'data-before-code',
48
48
  'critical-thinking-debug',
49
+ // Added 2026-08-03. The floor had grown past every budget tranche, so an
50
+ // unprotected universal skill could NEVER inline — including the destruction
51
+ // gate, during the very audit of a demo-venue wipe it should have prevented.
52
+ // If these two are absent, nothing tells the agent that announcing a wipe is
53
+ // not the same as being allowed to run it.
54
+ 'ask-first',
55
+ 'feature-preservation',
49
56
  ];
50
57
  /**
51
58
  * Native skills that paid subscription tiers receive through `prism connect`.
@@ -11,7 +11,6 @@
11
11
  */
12
12
  import { debugLog } from "../utils/logger.js";
13
13
  import { isOnboardingWizardArgs, isExtractEntitiesArgs, isBackupDatabaseArgs, isConfigureNotificationsArgs, } from "./sessionMemoryDefinitions.js";
14
- // ─── Onboarding Wizard Handler ───────────────────────────────
15
14
  export async function onboardingWizardHandler(args) {
16
15
  if (!isOnboardingWizardArgs(args)) {
17
16
  return {
@@ -19,7 +18,18 @@ export async function onboardingWizardHandler(args) {
19
18
  isError: true,
20
19
  };
21
20
  }
22
- const { step, responses } = args;
21
+ // The schema advertises action-based navigation while this handler
22
+ // historically read `{step, responses}` — fields the validator never
23
+ // passed, so `next`/`status`/`skip` all silently rendered step 1 and a
24
+ // bare call was rejected outright (measured 2026-08-05). Map the
25
+ // advertised contract onto the stateless wizard: the client carries the
26
+ // step number between calls.
27
+ const action = args.action ?? "start";
28
+ const currentStep = typeof args.step === "number" ? args.step : 0;
29
+ const step = action === "start" ? undefined
30
+ : action === "next" ? currentStep + 1
31
+ : action === "status" ? currentStep
32
+ : Number.MAX_SAFE_INTEGER; // skip → past the final step = completion
23
33
  try {
24
34
  const wizard = await import("../onboarding/wizard.js");
25
35
  if (step === undefined || step === null) {
@@ -32,6 +42,7 @@ export async function onboardingWizardHandler(args) {
32
42
  text: JSON.stringify({
33
43
  status: "in_progress",
34
44
  current_step: state.currentStep,
45
+ step_index: 0,
35
46
  total_steps: 8,
36
47
  step: content,
37
48
  summary: wizard.getWizardSummary(state),
@@ -39,12 +50,15 @@ export async function onboardingWizardHandler(args) {
39
50
  }],
40
51
  };
41
52
  }
42
- // Advance to next step
53
+ // Advance to the requested step position. Wizard steps are NAMED
54
+ // ("welcome", "storage", …); the numeric step_index in every response
55
+ // is what clients echo back for `next`/`status`.
43
56
  const state = wizard.createWizardState();
44
- // Advance to the requested step position
45
57
  let currentState = state;
46
- for (let i = 0; i < step; i++) {
58
+ let stepIndex = 0;
59
+ for (let i = 0; i < step && !wizard.isWizardComplete(currentState); i++) {
47
60
  currentState = wizard.advanceWizard(currentState);
61
+ stepIndex += 1;
48
62
  }
49
63
  if (wizard.isWizardComplete(currentState)) {
50
64
  return {
@@ -66,6 +80,7 @@ export async function onboardingWizardHandler(args) {
66
80
  text: JSON.stringify({
67
81
  status: "in_progress",
68
82
  current_step: currentState.currentStep,
83
+ step_index: stepIndex,
69
84
  step: content,
70
85
  summary: wizard.getWizardSummary(currentState),
71
86
  }, null, 2),
@@ -50,6 +50,16 @@ async function ensureTable() {
50
50
  }
51
51
  /** Reset DB connection and in-memory buffer (for tests). */
52
52
  export function _resetDb() {
53
+ // Close before dropping the reference. Nulling alone leaks the underlying
54
+ // sqlite handle: harmless on POSIX (unlink works on open files), but on
55
+ // Windows the file stays locked, so a suite that resets between cases
56
+ // accumulates locks on the analytics DB and cannot clean up its temp dir.
57
+ try {
58
+ _db?.close();
59
+ }
60
+ catch {
61
+ // A close failure must never break a test reset.
62
+ }
53
63
  _db = null;
54
64
  _tableReady = false;
55
65
  BUFFER.length = 0;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.5.3",
3
+ "version": "20.7.0",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
- "description": "Prism Coder Cognitive memory + tool-calling intelligence for AI agents. Mind Palace persistent memory (BFCL Gold Certified, 100% Tool-Call Accuracy, 114 Agent Skills, PHI Guard, Tier Enforcement, Prompt-Based Skill Routing, Zero-Search HDC/HRR retrieval, HRR Semantic Drift Detection across BCBA/Coding/AAC domains, HIPAA-hardened local or subscription-gated Synalux storage, SLERP-optimized GRPO alignment) plus the prism-coder 1.7B–32B open-weights LLM fleet.",
5
+ "description": "Prism Coder \u2014 Cognitive memory + tool-calling intelligence for AI agents. Mind Palace persistent memory (BFCL Gold Certified, 100% Tool-Call Accuracy, 114 Agent Skills, PHI Guard, Tier Enforcement, Prompt-Based Skill Routing, Zero-Search HDC/HRR retrieval, HRR Semantic Drift Detection across BCBA/Coding/AAC domains, HIPAA-hardened local or subscription-gated Synalux storage, SLERP-optimized GRPO alignment) plus the prism-coder 1.7B\u201332B open-weights LLM fleet.",
6
6
  "module": "index.ts",
7
7
  "type": "module",
8
8
  "main": "dist/server.js",