phaedo-mcp 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,14 +1,98 @@
1
1
  # Phaedo MCP server (`phaedo-mcp`)
2
2
 
3
- A local [MCP](https://modelcontextprotocol.io) server that brings your Phaedo
4
- cognitive fingerprint to any MCP client (Claude Desktop, Claude Code, Cursor,
5
- agent frameworks) — no Chrome extension needed. It injects your fingerprint
6
- **projection** so the assistant responds in your style, and adds **consultation**
7
- (decision/risk signals), **real-time escalation** to your paired phone for a live
8
- approve / deny / modify on a blocking decision, and **act-as-me** learning from
9
- your outcome marks. One integration, every MCP client.
3
+ Phaedo learns how you write, work and decide, and keeps that profile on your phone.
4
+ This server lets your AI apps use it: Claude Desktop, Claude Code, Cursor, and any
5
+ other app that supports [MCP](https://modelcontextprotocol.io). With it, they can:
10
6
 
11
- Implements `docs/protocol/mcp-binding.md` §3.1:
7
+ - **Answer the way you like** — your preferred length, tone and format, without
8
+ you repeating it in every chat.
9
+ - **Check with you before acting** — an AI agent asks Phaedo whether you'd want it
10
+ to go ahead. When it's unsure, your phone gets an approve / deny card.
11
+ - **Learn from your answers** — each approve or deny teaches it how you want work
12
+ handled when you're not watching.
13
+ - **Get your signature for purchases** — you sign a spending permission on your
14
+ phone with Face ID; the agent never holds the key.
15
+
16
+ Your profile comes from your phone encrypted. The copy kept on this computer, for
17
+ when your phone is offline, is encrypted too.
18
+
19
+ ## What you need
20
+
21
+ 1. **The Phaedo phone app.** Your profile lives there. The app isn't open to the
22
+ public yet — [phaedo.so](https://phaedo.so) has a link to hear when it is.
23
+ 2. **Node.js**, free from [nodejs.org](https://nodejs.org) (the LTS button).
24
+
25
+ Without the phone app there is no profile, and the server says so plainly rather
26
+ than guessing. You can still try Phaedo with a made-up example profile.
27
+
28
+ ## Try it with the example profile (no phone needed)
29
+
30
+ This loads a made-up person's profile, so you can see what Phaedo adds before you
31
+ have your own.
32
+
33
+ **Claude Code** — run this in Terminal (it applies to all your projects):
34
+
35
+ ```bash
36
+ claude mcp add -s user phaedo -e PHAEDO_FINGERPRINT=sample -- npx -y phaedo-mcp
37
+ ```
38
+
39
+ **Claude Desktop** — open Settings → Developer → Edit Config, add the `phaedo`
40
+ entry below inside `mcpServers`, save, then quit and reopen Claude Desktop:
41
+
42
+ ```json
43
+ {
44
+ "mcpServers": {
45
+ "phaedo": {
46
+ "command": "npx",
47
+ "args": ["-y", "phaedo-mcp"],
48
+ "env": { "PHAEDO_FINGERPRINT": "sample" }
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ If Claude Desktop reports that the server failed to start, it couldn't find
55
+ `npx`. Replace `"npx"` with the full path that this prints:
56
+
57
+ ```bash
58
+ which npx
59
+ ```
60
+
61
+ Then start a new chat and ask: *"Load my Phaedo profile."* To stop using the
62
+ example, delete the `env` line in Claude Desktop, or in Claude Code run
63
+ `claude mcp remove -s user phaedo` and add it again without `-e …`. The server
64
+ will then tell you there's no profile until you pair your phone.
65
+
66
+ ## Use your own profile (with the phone app)
67
+
68
+ 1. **Get this folder.** On [the GitHub page](https://github.com/phaedo-labs/phaedo-mcp),
69
+ click **Code → Download ZIP**, unzip it, and open the `mcp` folder inside.
70
+ 2. **Run the installer.**
71
+ - macOS: double-click **`installers/macos/Install Phaedo MCP.command`**. If macOS
72
+ says it can't be opened, right-click it and choose **Open**.
73
+ - Windows: double-click **`installers/windows/Install Phaedo MCP.bat`**.
74
+ - Linux: run `bash installers/linux/install-phaedo-mcp.sh`.
75
+
76
+ It adds Phaedo to Claude Desktop, Cursor and Claude Code — keeping your other
77
+ settings, and backing each file up first — then starts pairing.
78
+ 3. **Pair with your phone.** Open Phaedo on your phone. On the same Wi-Fi, approve
79
+ **"Phaedo MCP Server"** and check that the code matches the one in Terminal.
80
+ On a different network, tap **Pair with code** and type the code Terminal shows.
81
+ 4. **Quit and reopen your AI apps**, start a new chat, and ask: *"Load my Phaedo
82
+ profile."*
83
+
84
+ You can disconnect this computer at any time from the **Agents** screen on your
85
+ phone.
86
+
87
+ ---
88
+
89
+ ## Reference
90
+
91
+ Everything below is for developers: the full tool list, how the profile is
92
+ loaded, and the test suite.
93
+
94
+ The server implements the MCP binding of the Anamnesis protocol
95
+ (`docs/protocol/mcp-binding.md` §3.1):
12
96
 
13
97
  | Surface | Name | Returns |
14
98
  |---|---|---|
@@ -129,9 +213,13 @@ foregrounded/reachable at the configured `endpoint`. *(You can still hand-copy
129
213
  `pairing.sample.json` → `pairing.json` from the extension's `phaedo_local_pairing`
130
214
  if you prefer.)*
131
215
 
132
- **Phase 1a — local JSON (fallback / demo).** With no pairing record, reads a
133
- **vault payload** from `PHAEDO_FINGERPRINT`, then `mcp/fingerprint.json`, then
134
- the shipped `mcp/sample-fingerprint.json` (so it runs out of the box):
216
+ **Phase 1a — local JSON.** With no pairing record, reads a **vault payload**
217
+ from `PHAEDO_FINGERPRINT` (a path, or the word `sample` for the shipped
218
+ `mcp/sample-fingerprint.json`), then `mcp/fingerprint.json`. With neither, there
219
+ is **no profile**: every tool returns a plain-language `decryption_failed` error
220
+ saying so. The sample is never served unless asked for by name — until 0.3.1 it
221
+ was the silent fallback, so a fresh `npx phaedo-mcp` injected a made-up person's
222
+ style without saying so. The file looks like this:
135
223
  ```json
136
224
  {
137
225
  "phaedo_fingerprint": { "layers": { ... }, "fingerprint_id": "...", "persona_strength": 0.6 },
@@ -223,6 +311,35 @@ detectors, and a model call can never break or stall a session (30s budget, hook
223
311
  exits 0). Prefer the `setup:capture-model` config file over an env var — the file reaches
224
312
  Dock-launched Claude Desktop, a shell `export` does not.
225
313
 
314
+ ### Consult before shipping, from Claude Code (`hooks/pre-ship.mjs`)
315
+
316
+ `npm run setup:code-hook` also installs a **ship hook** on Bash (PreToolUse,
317
+ PermissionRequest, PostToolUse, PostToolUseFailure). Before an agent merges a PR
318
+ (`gh pr merge`), deploys (`fly deploy`), builds or submits a phone app (`eas build` /
319
+ `eas submit`) or pushes straight to `main`, the hook runs a full `phaedo_consult` on it
320
+ (`ship-actions.js` names these; every other command is ignored in ~50 ms) and writes a
321
+ §10.6 receipt, `agent_id: claude-code/ship-hook`.
322
+
323
+ - **It only ever adds friction.** It never returns `allow`. A permissive answer leaves
324
+ Claude Code's own permission flow untouched; `escalate` / `clarify` / `decline` returns
325
+ `ask`, so the action comes to you with one plain line saying why.
326
+ - **Outcomes are yours, never inferred.** The hook records `approved` only when it has
327
+ evidence you were asked: Claude Code fired PermissionRequest for that command, and the
328
+ command then ran. That holds in every mode except auto / dontAsk, where no person
329
+ answers. Decline and the agent records `rejected` (Claude Code fires no hook for a
330
+ decline). No prompt shown, nothing recorded; the agent is told so, and records your
331
+ reaction only if you give one.
332
+ - **What a merge changes.** Before `gh pr merge` the hook reads the PR from GitHub (read-only):
333
+ failing or unfinished checks, a large diff (> 1,000 code lines or > 20 files), or a sensitive
334
+ path (CI/deploy config, auth/crypto/secrets by name, or your repo's `.phaedo-sensitive` list,
335
+ read from the base branch) make it high-stakes. It can raise the stakes, never lower them.
336
+ - **Stale profile.** It reads the at-rest profile cache (so it never pings your phone);
337
+ when that copy is over 14 days old the agent tells you once a day, with the refresh:
338
+ `npm run pull` and approve on the phone.
339
+ - **See what it has done:** `npm run ship-readout` (`-- --days 90` to widen).
340
+ - **Fail-safe:** any error, timeout (12 s) or unusable profile → no output, the command
341
+ runs as if the hook weren't there.
342
+
226
343
  ## Consult at decision points (`phaedo_consult`, §10)
227
344
 
228
345
  Beyond injecting style, an agent can **ask the fingerprint how the user's pattern
@@ -296,17 +413,21 @@ returns a §10.3 signal. Any failure/timeout/parse error **falls back to the rul
296
413
  engine**, so the model can only improve on, never break, the floor (and a missing
297
414
  model never breaks consultation). The §10.4 boundary is enforced on parse.
298
415
 
299
- The **installer turns this on automatically**: it probes local Ollama and writes
300
- `PHAEDO_CONSULT_MODEL` into each client's server entry (picking an installed general
301
- model, else defaulting to `llama3.1:8b`). Toggle it at install time:
416
+ The judge is **off by default** — the rule engine answers. Measured 2026-10-18 against
417
+ one cached profile, `llama3.1:8b` answered a $12k irreversible payment `clarify 0.9` on
418
+ one run and `proceed_with_note 0.9` on the next, where the rules say `clarify` every
419
+ time. Opt in at install time once a model has earned it:
302
420
 
303
421
  ```bash
304
- npm run setup # model judge ON (auto-detect Ollama)
305
- node install.mjs --consult-model qwen2.5:7b # pin a specific model
422
+ npm run setup # model judge OFF (rule engine) — the default
423
+ node install.mjs --model-judge # ON, auto-detect a local Ollama chat model
424
+ node install.mjs --consult-model qwen2.5:7b # ON, pinned
306
425
  node install.mjs --consult-url http://localhost:11434 # custom endpoint
307
- node install.mjs --no-model-judge # rule engine only
308
426
  ```
309
427
 
428
+ Auto-detect prefers `llama3.1:8b` when installed and never picks an extraction
429
+ fine-tune or an embedding model (`nomic-embed-text` cannot answer a chat prompt).
430
+
310
431
  Use a **general** chat model (NOT the extraction fine-tune). To run it: `ollama
311
432
  pull llama3.1:8b`. Raw env (if configuring a client by hand):
312
433
  `PHAEDO_CONSULT_MODEL`, `PHAEDO_CONSULT_MODEL_URL`, `PHAEDO_CONSULT_API_KEY`
package/agreement.js CHANGED
@@ -77,6 +77,38 @@ export function computeAgreement(receipts, { windowDays = DEFAULT_WINDOW_DAYS, n
77
77
  };
78
78
  }
79
79
 
80
+ // ── Split by resolver revision (precedent track Phase 1b) ─────────────────────
81
+ // Which engine answered a receipt: the `resolverRevision` driver the consult stamped (the
82
+ // rules' RESOLVER_REVISION, or `judge:<model>`). A matched pre-flight is a standing rule
83
+ // answering, not the inferred path. Anything else predates the stamp (2026-10-20) or carried
84
+ // no inferred prediction (an abstain) — "not recorded", never guessed.
85
+ export const NOT_RECORDED = 'not recorded';
86
+ export function revisionOf(r) {
87
+ const d = Array.isArray(r?.drivers) ? r.drivers.find((x) => x?.cue === 'resolverRevision') : null;
88
+ if (d && typeof d.value === 'string' && d.value) return d.value;
89
+ if (r?.via === 'phaedo_check_authorization') return 'standing rule (pre-flight)';
90
+ return NOT_RECORDED;
91
+ }
92
+
93
+ /**
94
+ * (receipts, { windowDays, now }) -> { [revision]: overall-bucket }, so a behaviour change
95
+ * shows up as two rates instead of one blurred one. Same scoring as computeAgreement.
96
+ */
97
+ export function computeAgreementByRevision(receipts, { windowDays = DEFAULT_WINDOW_DAYS, now = Date.now() } = {}) {
98
+ const groups = new Map();
99
+ for (const r of Array.isArray(receipts) ? receipts : []) {
100
+ const k = revisionOf(r);
101
+ if (!groups.has(k)) groups.set(k, []);
102
+ groups.get(k).push(r);
103
+ }
104
+ const out = {};
105
+ for (const [k, rs] of groups) {
106
+ const rep = computeAgreement(rs, { windowDays, now });
107
+ if (rep.window.in_window > 0) out[k] = rep.overall;
108
+ }
109
+ return out;
110
+ }
111
+
80
112
  // ── The transportable summary (receipts digest → phone) ───────────────────────
81
113
  // `computeAgreement` is the full local report; this is the bounded slice that rides
82
114
  // the `agent_to_phone` receipts digest so the subject can see how well their oracle
@@ -119,9 +151,23 @@ export function agreementDigest(receipts, { windowDays = DEFAULT_WINDOW_DAYS, no
119
151
  // Decrypts the local receipt store with the local key; nothing leaves the device.
120
152
  // NOT product UX and NOT fed back into deference (see header).
121
153
  if (process.argv[1] && process.argv[1].endsWith('agreement.js')) {
122
- const days = Number(process.argv[2]) || DEFAULT_WINDOW_DAYS;
154
+ const args = process.argv.slice(2);
155
+ const byRevision = args.includes('--by-revision');
156
+ const days = Number(args.find((a) => /^\d+$/.test(a))) || DEFAULT_WINDOW_DAYS;
123
157
  Promise.all([import('./receipts.js'), import('./cache.js')]).then(async ([{ readReceipts }, { defaultStateDir }]) => {
124
- const report = computeAgreement(await readReceipts(defaultStateDir()), { windowDays: days });
158
+ const receipts = await readReceipts(defaultStateDir());
159
+ if (byRevision) {
160
+ const split = computeAgreementByRevision(receipts, { windowDays: days });
161
+ console.log(`Agreement by rule-engine version — last ${days}d\n`);
162
+ const names = Object.keys(split).sort((a, b) => (a === NOT_RECORDED) - (b === NOT_RECORDED) || a.localeCompare(b));
163
+ if (!names.length) console.log('No receipts in the window.');
164
+ for (const k of names) {
165
+ const b = split[k];
166
+ console.log(`${k.padEnd(34)} rate ${b.rate === null ? ' — ' : (b.rate * 100).toFixed(1) + '%'} (${b.agreements}✓ ${b.disagreements}✗ of ${b.scored} scored · ${b.non_directional} non-directional · ${b.awaiting_user_action} awaiting)`);
167
+ }
168
+ return;
169
+ }
170
+ const report = computeAgreement(receipts, { windowDays: days });
125
171
  const { overall: o, window: w } = report;
126
172
  console.log(`Agreement over the last ${w.days}d — ${w.in_window}/${w.total_receipts} receipts in window\n`);
127
173
  const line = (label, b) =>
package/consult-core.js CHANGED
@@ -356,6 +356,68 @@ function calibrate(vd, coverage, signalConfidence = 0) {
356
356
 
357
357
  const magnitudeRank = (m) => ({ low: 0, medium: 1, high: 2 }[m] ?? 1);
358
358
 
359
+ // ── Amount bands (precedent track G8 B1) ──────────────────────────────────────
360
+ // Until this, the inferred path never read `amount`: $12 and $12,000 answered alike
361
+ // unless the agent ALSO labelled the magnitude, and only authored §10.5 rules saw money.
362
+ // Now an amount sets a FLOOR under the magnitude the inferred path reasons about.
363
+ //
364
+ // Band edges are the subject's own numbers wherever they exist: every amount bound on an
365
+ // authored rule (amount_gt / amount_lt / amount_lte — a grant's ceiling says what is small
366
+ // to them as surely as a prohibition's floor says what is large). Rules scoped to the
367
+ // action's domain (or unscoped) win; failing those, any authored amount anywhere; failing
368
+ // that, DEFAULT_AMOUNT_EDGES — a stated default, recorded as such on the receipt, replaced
369
+ // by the subject's first money rule.
370
+ //
371
+ // CAUTION-ONLY, by construction: the floor is max()'d with the declared magnitude, so it
372
+ // can raise the stakes and never lower them. And it feeds the INFERRED path only — §10.5
373
+ // matching keeps reading the declared magnitude, so an authored grant's `magnitude_max`
374
+ // means exactly what the subject wrote.
375
+ export const DEFAULT_AMOUNT_EDGES = [100, 1000];
376
+
377
+ // ── Resolver revision (precedent track Phase 1b) ──────────────────────────────
378
+ // Which version of the inferred path answered. Stamped on every inferred receipt (as a
379
+ // `resolverRevision` driver — local provenance, never in the response) so agreement can be
380
+ // split before/after a behaviour change instead of blurring into one number.
381
+ //
382
+ // BUMP THIS WHENEVER THE INFERRED PATH'S ANSWERS CHANGE. You will not be able to forget:
383
+ // test-resolver-revision.mjs fingerprints the answers over a fixed grid and fails, naming
384
+ // this constant, when they move under an unchanged revision. Format: <date>.<what changed>.
385
+ // (receipts before 2026-10-20 carry none — the pre-B1 rules and the first days of B1)
386
+ export const RESOLVER_REVISION = '2026-10-19.amount-bands';
387
+
388
+ function ruleAmounts(r) {
389
+ return [r?.amountGt, r?.amountLt, r?.amountLte].filter((x) => typeof x === 'number' && Number.isFinite(x) && x > 0);
390
+ }
391
+ function ruleCoversDomain(r, domain) {
392
+ const ds = Array.isArray(r?.domains) ? r.domains : [];
393
+ if (!ds.length) return true; // unscoped → speaks for every domain
394
+ if (!domain) return false;
395
+ const canon = canonicalDomain(domain);
396
+ return ds.some((d) => domain.includes(d) || (canon && canonicalDomain(d) === canon));
397
+ }
398
+
399
+ /** The band edges for this action: { edges: [low, high] (ascending, low may equal high), basis }. */
400
+ export function amountEdges(policies, domain) {
401
+ const list = Array.isArray(policies) ? policies : [];
402
+ const scoped = list.filter((r) => ruleCoversDomain(r, domain)).flatMap(ruleAmounts);
403
+ const any = scoped.length ? scoped : list.flatMap(ruleAmounts);
404
+ if (!any.length) return { edges: DEFAULT_AMOUNT_EDGES.slice(), basis: null };
405
+ return { edges: [Math.min(...any), Math.max(...any)], basis: 'self_reported' };
406
+ }
407
+
408
+ /**
409
+ * The magnitude floor an amount implies, or null when it implies none.
410
+ * Above the high edge → `high`; above the low edge → `medium`. With a single authored
411
+ * number ("escalate above $1,000") both edges are that number: above it is `high`.
412
+ */
413
+ export function amountFloor(n, policies) {
414
+ const a = n?.amount;
415
+ if (typeof a !== 'number' || !Number.isFinite(a) || a <= 0) return null;
416
+ const { edges: [lo, hi], basis } = amountEdges(policies, n.domain);
417
+ const band = a > hi ? 'high' : a > lo ? 'medium' : null;
418
+ return band ? { band, rank: magnitudeRank(band), basis } : null;
419
+ }
420
+
359
421
  // ── Request validation/normalization (§10.2) ──────────────────────────────────
360
422
  export function normalizeRequest(request) {
361
423
  const type = request.consultation_type;
@@ -411,7 +473,8 @@ const USES_AMBIGUITY = new Set(['action_approval', 'escalation_default']);
411
473
  function resolveActionApproval(vd, n, suppressed) {
412
474
  const p = profile(vd, RELEVANT_CUES.action_approval, true, n.domain, suppressed);
413
475
  const irreversible = n.reversible === false;
414
- const mag = magnitudeRank(n.magnitude);
476
+ // Declared stakes, raised (never lowered) by what the amount means to this subject.
477
+ const mag = Math.max(magnitudeRank(n.magnitude), n.amountBand ? n.amountBand.rank : 0);
415
478
  const thinEvidence = n.evidenceProvided.length === 0;
416
479
 
417
480
  let signal;
@@ -481,7 +544,7 @@ function resolveVoiceDraft() {
481
544
  // sees (§10.4 holds: provenance is for the subject's audit/calibration, not the agent).
482
545
  // Pure + defensive: bad/redirect/abstain inputs → []. Takes the raw request; normalizes
483
546
  // internally so callers pass exactly what they passed to the consult.
484
- export function consultDrivers(vd, request) {
547
+ export function consultDrivers(vd, request, opts = {}) {
485
548
  let n;
486
549
  try { n = normalizeRequest(request || {}); } catch { return []; }
487
550
  const keys = RELEVANT_CUES[n.type];
@@ -499,6 +562,13 @@ export function consultDrivers(vd, request) {
499
562
  const a = readAmbiguity(vd, n.domain, suppressed);
500
563
  if (a.found) drivers.push(withOrigin({ cue: 'ambiguityPosture', value: a.value, confidence: a.confidence, basis: a.basis }, a.origin));
501
564
  }
565
+ // The amount band, when it raised the stakes. basis `self_reported` = the subject's own
566
+ // thresholds; null = the stated default (no subject evidence behind it).
567
+ if (n.type === 'action_approval') {
568
+ let band = null;
569
+ try { band = amountFloor(n, loadPolicies(vd, opts)); } catch { /* provenance is non-critical */ }
570
+ if (band) drivers.push({ cue: 'amountBand', value: band.band, confidence: 1, basis: band.basis });
571
+ }
502
572
  return drivers;
503
573
  }
504
574
 
@@ -796,7 +866,11 @@ const policyCtx = (opts = {}) => ({ now: opts.now, clientId: opts.clientId ?? nu
796
866
  export function resolveConsultationCore(vd, request = {}, opts = {}) {
797
867
  assertVaultUsable(vd);
798
868
  const n = normalizeRequest(request);
799
- return applyPolicies(ruleResponse(vd, n), n, loadPolicies(vd, opts), policyCtx(opts));
869
+ const policies = loadPolicies(vd, opts);
870
+ // G8 B1: the amount's band, read against the subject's own thresholds. On `n` so the
871
+ // resolver sees it; applyPolicies does not read it (§10.5 matches the declared magnitude).
872
+ n.amountBand = amountFloor(n, policies);
873
+ return applyPolicies(ruleResponse(vd, n), n, policies, policyCtx(opts));
800
874
  }
801
875
 
802
876
  // Back-compat alias: the prior public name of the sync rule entry. Kept so existing
@@ -0,0 +1,60 @@
1
+ // Consult runtime — the I/O half of a consultation, shared by every caller that resolves
2
+ // one: the MCP server (server.js) and the Claude Code ship hook (hooks/pre-ship.mjs).
3
+ //
4
+ // One copy, on purpose. This repo has paid twice for two consult paths that drifted — the
5
+ // stale-authority guard once lived on phaedo_check_authorization alone, and the tools an
6
+ // agent actually used answered from a revoked cache with no downgrade. A hook that loaded
7
+ // policies or delegation signals its own way would be the third time.
8
+
9
+ import { join } from 'path';
10
+ import { readFileSync, existsSync } from 'fs';
11
+ import { loadFingerprint, loadFingerprintPreferCache } from './fingerprint-source.js';
12
+ import { readServingClientId } from './phone-source.js';
13
+ import { consultOptsFromEnv, deriveDelegationSignals, withDelegationSignals, authoredDelegationSignals } from './consult.js';
14
+ import { defaultStateDir } from './cache.js';
15
+ import { readReceipts } from './receipts.js';
16
+
17
+ /**
18
+ * The consult options for a process rooted at `baseDir` (the mcp/ directory): env config,
19
+ * the subject's deference policies, and this install's own paired-client id.
20
+ */
21
+ export function buildConsultOpts(baseDir, env = process.env, { warn = (m) => process.stderr.write(m) } = {}) {
22
+ const opts = consultOptsFromEnv(env);
23
+ // Deference policies: PHAEDO_CONSULT_POLICIES if set, else the default file beside
24
+ // the server (mcp/consult-policies.json — what policy-editor.html produces). Zero
25
+ // config: drop the file in and restart the client. (Portable vault policies in
26
+ // phaedo_consult_policies always apply too, regardless of this file.)
27
+ const policiesPath = env.PHAEDO_CONSULT_POLICIES || join(baseDir, 'consult-policies.json');
28
+ if (existsSync(policiesPath)) {
29
+ try { opts.policies = JSON.parse(readFileSync(policiesPath, 'utf8')); }
30
+ catch (e) { warn(`[phaedo-mcp] ignoring malformed consult policies (${policiesPath}): ${e.message}\n`); }
31
+ }
32
+ // §10.5 `agents`: this process's OWN paired-client identity, resolved once at startup.
33
+ // One server serves one paired client, so it never asks a caller who is calling — it
34
+ // reads who it IS. Null (unpaired / unreadable) makes `agents`-scoped RELAXING rules
35
+ // inert, which is the fail-safe direction; the server still answers everything else.
36
+ opts.clientId = readServingClientId(baseDir);
37
+ return opts;
38
+ }
39
+
40
+ // Load the fingerprint for a consult and fold in the act-as-me channel: delegation
41
+ // signals derived from this subject's own override history (§10.6 receipts). The
42
+ // origin-aware resolver then lets a `delegation` correction outrank a `self` signal on
43
+ // the same dimension. Best-effort — if receipts are unreadable, the consult still
44
+ // answers from the plain fingerprint (delegation learning never breaks a consultation).
45
+ export async function loadConsultVd(baseDir, { preferCache = false, warn = (m) => process.stderr.write(m) } = {}) {
46
+ // preferCache (escalation, ship hook): read the at-rest cache instead of a live phone
47
+ // pull, so the caller doesn't add a fingerprint-read AUTHORIZE prompt on the phone.
48
+ const vd = preferCache ? await loadFingerprintPreferCache(baseDir) : await loadFingerprint(baseDir);
49
+ try {
50
+ // Two delegation sources, both origin-aware: live override history (every override)
51
+ // + the subject's ENDORSED, portable delegation rules (authored_delegation). The
52
+ // authored rule thus shapes consult too, not only the §9 injection it already renders in.
53
+ const signals = deriveDelegationSignals(await readReceipts(defaultStateDir()))
54
+ .concat(authoredDelegationSignals(vd && vd.phaedo_fingerprint));
55
+ return withDelegationSignals(vd, signals);
56
+ } catch (e) {
57
+ warn(`[phaedo-mcp] delegation derive skipped (consult unaffected): ${e.message}\n`);
58
+ return vd;
59
+ }
60
+ }
package/consult.js CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  shape, SIGNALS, CONSULTATION_TYPES, validateVaultShape, consultDrivers,
18
18
  deriveDelegationSignals, withDelegationSignals, buildDelegationPromotions, DELEGATION_PROMOTION_FLOOR,
19
19
  authoredDelegationSignals,
20
+ RESOLVER_REVISION,
20
21
  } from './consult-core.js';
21
22
 
22
23
  // Re-export the core surface so existing importers (server.js, tests,
@@ -110,6 +111,24 @@ export function consultOptsFromEnv(env = process.env) {
110
111
  };
111
112
  }
112
113
 
114
+ // Which engine answered a response this module returned: the opt-in model judge, or the
115
+ // deterministic rules. A WeakMap rather than a field because the §10.3 response shape is
116
+ // closed (additionalProperties:false) and this is local provenance, never for the agent.
117
+ const JUDGED_BY = new WeakMap();
118
+
119
+ /**
120
+ * Append the `resolverRevision` provenance driver to a consult's drivers: the rules'
121
+ * RESOLVER_REVISION, or `judge:<model>` when the model judge answered. Only when there are
122
+ * drivers already — the receipt schema documents drivers as absent for deterministic
123
+ * pre-flights and abstains, which carry no inferred prediction to attribute.
124
+ * Pass the response EXACTLY as resolveConsultation returned it (before any copy).
125
+ */
126
+ export function withRevision(drivers, response) {
127
+ if (!Array.isArray(drivers) || !drivers.length) return Array.isArray(drivers) ? drivers : [];
128
+ const judge = response && typeof response === 'object' ? JUDGED_BY.get(response) : undefined;
129
+ return [...drivers, { cue: 'resolverRevision', value: judge ? `judge:${judge}` : RESOLVER_REVISION, confidence: null, basis: null }];
130
+ }
131
+
113
132
  // Public async entry: model judge when configured, else the deterministic core.
114
133
  // voice_draft always takes the (redirect) core path. Authored policies (§10.5)
115
134
  // override either result, so a standing guardrail always wins. Never throws except
@@ -132,7 +151,9 @@ export async function resolveConsultation(vd, request = {}, opts = {}) {
132
151
  // Authored guardrails override the model judge too (applied last so they win).
133
152
  // Same §10.5 context as the deterministic path — including the serving
134
153
  // `client_id`, so `agents` scoping is not bypassed by configuring a model.
135
- return applyPolicies(judged, n, loadPolicies(vd, opts), { now: opts.now, clientId: opts.clientId ?? null });
154
+ const out = applyPolicies(judged, n, loadPolicies(vd, opts), { now: opts.now, clientId: opts.clientId ?? null });
155
+ if (out && typeof out === 'object') JUDGED_BY.set(out, String(opts.model));
156
+ return out;
136
157
  } catch (err) {
137
158
  process.stderr.write?.(`[phaedo-consult] model judge fell back to rules: ${err.message}\n`);
138
159
  }
@@ -15,6 +15,12 @@
15
15
  // principal for §10.5 `agents` scoping, so these two properties are in tension until
16
16
  // the extension's drain gains the phone's try-each-verified-client loop.
17
17
  //
18
+ // RESOLVED BY THE COURIER (verified 2026-09-24): the extension still cannot open an
19
+ // independently-paired MCP's deposit, but it no longer has to. The PHONE drains this lane
20
+ // with every verified client's key (mobile/src/lib/fpSync.ts drainAgentSuggestions) and
21
+ // forwards the suggestions inside its to_ext deposit, which since spec v0.1.70 reaches EVERY
22
+ // paired browser under that browser's own key. Independent pairing is the supported setup.
23
+ //
18
24
  // The extension union-merges
19
25
  // the suggestions (sync-merge.js mergeAgentSuggestions) into suggested_rules, where the
20
26
  // existing popup review card surfaces them for endorsement.
@@ -5,15 +5,17 @@
5
5
  // Phase 1b (phone): if a pairing record is configured (PHAEDO_PAIRING or
6
6
  // mcp/pairing.json), authorize → pull /v1/profile → unwrap the §7.2 envelope
7
7
  // from the paired phone (phone-source.js). Spec-faithful; no plaintext at rest.
8
- // Phase 1a (local): otherwise read a local vault-payload JSON (or the shipped
9
- // sample) — the decoupled stand-in that runs out of the box.
8
+ // Phase 1a (local): otherwise read a local vault-payload JSON. With neither, there is
9
+ // no profile — tools say so (NO_PROFILE_MESSAGE); the shipped sample is opt-in only.
10
10
 
11
11
  import { readFile } from 'fs/promises';
12
- import { resolve } from 'path';
12
+ import { resolve, dirname } from 'path';
13
+ import { fileURLToPath } from 'url';
13
14
  import { hasPairingRecord, loadPairingRecord, loadFingerprintFromPhone, PhoneError } from './phone-source.js';
14
15
  import { defaultStateDir, writeCache, readCache, clearCache } from './cache.js';
15
16
  import { foldLocalCapture, isLocalCaptureOwner } from './capture-local.js';
16
17
  import { Capabilities, AuthoredSource } from './lib/phaedo-crypto.js';
18
+ import { PhaedoError } from './errors.js';
17
19
 
18
20
  // §4.1 protocol-identity normalization (consumer defense-in-depth, 2026-06-16).
19
21
  // A stored fingerprint from a producer that predates identity stamping (or whose
@@ -74,14 +76,33 @@ export function foldConfigHome(vd) {
74
76
  }
75
77
 
76
78
  // Resolution order for the local fixture (1a):
77
- // 1. PHAEDO_FINGERPRINT env var (absolute or cwd-relative path)
79
+ // 1. PHAEDO_FINGERPRINT env var (absolute or cwd-relative path, or `sample`)
78
80
  // 2. ./fingerprint.json next to the server
79
- // 3. ./sample-fingerprint.json (shipped demo fixture)
81
+ // — nothing else. There is deliberately NO silent fallback to the shipped sample.
82
+ //
83
+ // NO PROFILE IS AN ANSWER, NOT A DEMO. Until 2026-10-20 an unpaired install with no file fell
84
+ // back to sample-fingerprint.json — so a stranger who ran `npx phaedo-mcp` had a made-up
85
+ // person's style injected into their assistant, with nothing anywhere saying so (the response
86
+ // carried `fingerprint_id: sample-0000…`, which no human reads). The sample is still one word
87
+ // away for anyone who wants to try it — PHAEDO_FINGERPRINT=sample — but it has to be asked for.
88
+ const SAMPLE_KEYWORD = 'sample';
89
+ // The sample ships beside this module, whatever base dir the caller serves from.
90
+ const SAMPLE_PATH = resolve(dirname(fileURLToPath(import.meta.url)), 'sample-fingerprint.json');
80
91
  function resolveSourcePath(baseDir) {
81
- if (process.env.PHAEDO_FINGERPRINT) return resolve(process.cwd(), process.env.PHAEDO_FINGERPRINT);
92
+ const env = process.env.PHAEDO_FINGERPRINT;
93
+ if (env === SAMPLE_KEYWORD) return SAMPLE_PATH;
94
+ if (env) return resolve(process.cwd(), env);
82
95
  return resolve(baseDir, 'fingerprint.json');
83
96
  }
84
97
 
98
+ // Read by the assistant (a tool error) and, through it, by the person — so plain words, and
99
+ // it says what did NOT happen as well as what to do.
100
+ export const NO_PROFILE_MESSAGE =
101
+ 'No Phaedo profile is set up on this computer, so there is nothing to load and Phaedo has ' +
102
+ 'not changed how the assistant responds. A profile comes from the Phaedo phone app, which is ' +
103
+ 'not open to the public yet (https://phaedo.so to hear when it is). To try Phaedo with a made-up example profile ' +
104
+ 'instead, set PHAEDO_FINGERPRINT=sample in this server\'s settings.';
105
+
85
106
  // Returns the vault payload:
86
107
  // { phaedo_fingerprint, phaedo_behavioral_signals, phaedo_linguistic_profile,
87
108
  // phaedo_session_metrics }
@@ -154,9 +175,13 @@ export async function loadFingerprint(baseDir) {
154
175
  let raw;
155
176
  try {
156
177
  raw = await readFile(path, 'utf8');
157
- } catch {
158
- // Fall back to the shipped sample so a fresh checkout runs out of the box.
159
- raw = await readFile(resolve(baseDir, 'sample-fingerprint.json'), 'utf8');
178
+ } catch (err) {
179
+ // Only the default location may be absent quietly. A path the user NAMED that doesn't
180
+ // read is their mistake to see, not "no profile".
181
+ if (!process.env.PHAEDO_FINGERPRINT && err?.code === 'ENOENT') {
182
+ throw new PhaedoError('decryption_failed', NO_PROFILE_MESSAGE);
183
+ }
184
+ throw new PhaedoError('decryption_failed', `Could not read the Phaedo profile at ${path} (${err?.code || err?.message || err}).`);
160
185
  }
161
186
  const vd = JSON.parse(raw);
162
187
  if (!vd || typeof vd !== 'object' || !vd.phaedo_fingerprint) {