phaedo-mcp 0.3.0 → 0.3.2
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 +146 -21
- package/agreement.js +48 -2
- package/consult-core.js +77 -3
- package/consult-runtime.js +60 -0
- package/consult.js +22 -1
- package/delegation-sync.js +6 -0
- package/fingerprint-source.js +34 -9
- package/hooks/pre-ship.mjs +409 -0
- package/hooks/session-end.mjs +6 -2
- package/hooks/stop-capture.mjs +140 -0
- package/install.mjs +71 -24
- package/package.json +19 -6
- package/phone-source.js +6 -1
- package/receipts.js +1 -1
- package/server.js +52 -62
- package/server.json +3 -3
- package/ship-actions.js +164 -0
- package/ship-readout.js +106 -0
- package/vendor/capture/detectors.js +4 -4
- package/vendor/capture/transcript.js +34 -3
- package/vendor/extraction/persist.js +7 -2
- package/vendor/extraction/reconcile.js +46 -5
- package/vendor/extraction/rule-promotion.js +6 -40
- package/vendor/signal-ledger.js +402 -0
- package/vendor/trait-phrases.js +656 -0
- package/vendored-files.mjs +4 -0
package/install.mjs
CHANGED
|
@@ -35,6 +35,18 @@ const CAPTURE_HOOK_PATH = join(BASE_DIR, 'hooks', 'session-end.mjs');
|
|
|
35
35
|
const ENTRY_KEY = 'phaedo';
|
|
36
36
|
const HOOK_MARKER = 'hooks/session-start.mjs'; // identifies our SessionStart entry
|
|
37
37
|
const CAPTURE_HOOK_MARKER = 'hooks/session-end.mjs'; // identifies our SessionEnd capture entry
|
|
38
|
+
// The same capture, run on Stop (end of each turn) for sessions that stay open for days
|
|
39
|
+
// (the desktop app's) — throttled per session inside the hook, so most Stops cost one read.
|
|
40
|
+
const STOP_CAPTURE_HOOK_PATH = join(BASE_DIR, 'hooks', 'stop-capture.mjs');
|
|
41
|
+
const STOP_CAPTURE_HOOK_MARKER = 'hooks/stop-capture.mjs';
|
|
42
|
+
const SHIP_HOOK_PATH = join(BASE_DIR, 'hooks', 'pre-ship.mjs');
|
|
43
|
+
const SHIP_HOOK_MARKER = 'hooks/pre-ship.mjs'; // identifies our ship-consult entries
|
|
44
|
+
// The ship hook (consult before a merge / deploy / phone build) runs on four Bash events:
|
|
45
|
+
// before the command, when a prompt is shown (the evidence a person was asked), and after
|
|
46
|
+
// it ran or failed (to record that person's yes). Its own budget is 12s; the timeout is
|
|
47
|
+
// the backstop.
|
|
48
|
+
export const SHIP_HOOK_EVENTS = ['PreToolUse', 'PermissionRequest', 'PostToolUse', 'PostToolUseFailure'];
|
|
49
|
+
const SHIP_HOOK_ENTRY = { matcher: 'Bash', timeout: 15 };
|
|
38
50
|
|
|
39
51
|
// ── Client registry ──────────────────────────────────────────────────────────
|
|
40
52
|
// Every supported client stores MCP servers as a top-level `mcpServers` object,
|
|
@@ -74,7 +86,13 @@ export function serverEntry({ nodePath = process.execPath, serverPath = SERVER_P
|
|
|
74
86
|
return entry;
|
|
75
87
|
}
|
|
76
88
|
|
|
77
|
-
// ── Consult model judge config (
|
|
89
|
+
// ── Consult model judge config (OFF by default — opt in) ──────────────────────
|
|
90
|
+
// Was on by default. Measured 2026-10-18 on the dev Mac against the same cached profile:
|
|
91
|
+
// llama3.1:8b answered a $12k IRREVERSIBLE payment `clarify 0.9` on one run and
|
|
92
|
+
// `proceed_with_note 0.9` on the next — flipping between runs, toward proceeding, with
|
|
93
|
+
// high confidence both times — where the rule engine says `clarify` every time. A judge
|
|
94
|
+
// that can talk itself into a payment does not get to be the default. Opt in with
|
|
95
|
+
// --model-judge (auto-detect) or --consult-model <id> (pin).
|
|
78
96
|
// The MCP server runs the consult model judge when PHAEDO_CONSULT_MODEL is set;
|
|
79
97
|
// it falls back to the local rule engine if the model/Ollama isn't there, so
|
|
80
98
|
// defaulting it ON is safe. We write a LOCAL endpoint only (privacy-clean); a
|
|
@@ -88,15 +106,21 @@ export function buildConsultEnv({ model = DEFAULT_CONSULT_MODEL, url = DEFAULT_C
|
|
|
88
106
|
}
|
|
89
107
|
|
|
90
108
|
// Probe local Ollama so "on by default" works with whatever model the user has.
|
|
91
|
-
//
|
|
92
|
-
//
|
|
109
|
+
// Prefers the default when it is installed, else the first installed CHAT model —
|
|
110
|
+
// skipping the phaedo extraction fine-tunes and embedding models. An embedding model
|
|
111
|
+
// cannot answer a chat prompt: `nomic-embed-text` was picked on the dev Mac (it sorts
|
|
112
|
+
// before llama3.1:8b), every judge call failed in ~15 ms and fell back to the rules,
|
|
113
|
+
// and the installer kept printing "model judge ON" for a judge that had never run.
|
|
114
|
+
// Falls back to the default string + reachable:false on any failure.
|
|
115
|
+
const NOT_A_JUDGE = /phaedo|extract|embed|nomic|bge|minilm|mxbai|arctic|e5-|gte-|rerank/i;
|
|
93
116
|
export async function detectConsultModel({ url = DEFAULT_CONSULT_URL, fallback = DEFAULT_CONSULT_MODEL, fetchImpl = globalThis.fetch } = {}) {
|
|
94
117
|
try {
|
|
95
118
|
const res = await fetchImpl(`${url.replace(/\/$/, '')}/api/tags`, { signal: AbortSignal.timeout(1500) });
|
|
96
119
|
if (!res.ok) return { model: fallback, reachable: false, hadModels: false };
|
|
97
120
|
const data = await res.json();
|
|
98
121
|
const names = (data.models || []).map((m) => m.name || m.model).filter(Boolean);
|
|
99
|
-
const
|
|
122
|
+
const bare = (n) => String(n).replace(/:latest$/, '');
|
|
123
|
+
const general = names.find((n) => bare(n) === bare(fallback)) || names.find((n) => !NOT_A_JUDGE.test(n));
|
|
100
124
|
return { model: general || fallback, reachable: true, hadModels: names.length > 0, picked: !!general };
|
|
101
125
|
} catch {
|
|
102
126
|
return { model: fallback, reachable: false, hadModels: false };
|
|
@@ -161,14 +185,18 @@ const isPhaedoHookEntryFor = (marker) => (e) =>
|
|
|
161
185
|
|
|
162
186
|
// Generic per-event merge/remove — the SessionStart (injection) and SessionEnd
|
|
163
187
|
// (capture) entries use the same shape, distinguished by their script-path marker.
|
|
164
|
-
|
|
188
|
+
// `matcher` / `timeout` (optional) are for tool events: the ship hook filters to Bash.
|
|
189
|
+
export function mergeHookEntry(settings, event, command, marker, { matcher, timeout } = {}) {
|
|
165
190
|
const isOurs = isPhaedoHookEntryFor(marker);
|
|
166
191
|
const next = { ...settings, hooks: { ...(settings.hooks || {}) } };
|
|
167
192
|
const prior = Array.isArray(next.hooks[event]) ? next.hooks[event] : [];
|
|
168
193
|
const others = prior.filter((e) => !isOurs(e)); // drop a stale phaedo entry (path refresh)
|
|
169
194
|
const existing = prior.find(isOurs);
|
|
170
|
-
const
|
|
171
|
-
|
|
195
|
+
const handler = { type: 'command', command, ...(timeout ? { timeout } : {}) };
|
|
196
|
+
const entry = { ...(matcher ? { matcher } : {}), hooks: [handler] };
|
|
197
|
+
const already = !!existing && existing.matcher === entry.matcher &&
|
|
198
|
+
existing.hooks.some((h) => h.command === command && h.timeout === handler.timeout);
|
|
199
|
+
next.hooks[event] = [...others, entry];
|
|
172
200
|
return { settings: next, changed: !already };
|
|
173
201
|
}
|
|
174
202
|
|
|
@@ -208,11 +236,18 @@ export function applyCodeHook({ action, dryRun = false, home = homedir() } = {})
|
|
|
208
236
|
if (!read.existed) return { name, status: 'absent', detail: 'no settings.json' };
|
|
209
237
|
const start = removeSessionStartHook(read.config);
|
|
210
238
|
const both = removeHookEntry(start.settings, 'SessionEnd', CAPTURE_HOOK_MARKER);
|
|
211
|
-
|
|
212
|
-
|
|
239
|
+
const stop = removeHookEntry(both.settings, 'Stop', STOP_CAPTURE_HOOK_MARKER);
|
|
240
|
+
let s = stop.settings;
|
|
241
|
+
let shipChanged = false;
|
|
242
|
+
for (const ev of SHIP_HOOK_EVENTS) {
|
|
243
|
+
const r = removeHookEntry(s, ev, SHIP_HOOK_MARKER);
|
|
244
|
+
s = r.settings; shipChanged = shipChanged || r.changed;
|
|
245
|
+
}
|
|
246
|
+
if (!start.changed && !both.changed && !stop.changed && !shipChanged) return { name, status: 'absent', detail: 'no phaedo hook to remove' };
|
|
247
|
+
if (!dryRun) { backup(path); writeConfig(path, s); }
|
|
213
248
|
return { name, status: dryRun ? 'would-remove' : 'removed', detail: path };
|
|
214
249
|
}
|
|
215
|
-
// Always install SessionStart injection. Only install the SessionEnd CAPTURE
|
|
250
|
+
// Always install SessionStart injection. Only install the SessionEnd + Stop CAPTURE hooks
|
|
216
251
|
// when its dependencies actually resolve from THIS install location: the hook loads
|
|
217
252
|
// capture/* and extraction/delta-lift.js via lib/capture-home.js — repo root in a
|
|
218
253
|
// clone, mcp/vendor/ in the published tarball (build-package.mjs vendors the
|
|
@@ -225,17 +260,27 @@ export function applyCodeHook({ action, dryRun = false, home = homedir() } = {})
|
|
|
225
260
|
let captureNote = '';
|
|
226
261
|
if (captureHookAvailable()) {
|
|
227
262
|
const both = mergeHookEntry(settings, 'SessionEnd', hookCommand({ hookPath: CAPTURE_HOOK_PATH }), CAPTURE_HOOK_MARKER);
|
|
228
|
-
|
|
263
|
+
const stop = mergeHookEntry(both.settings, 'Stop', hookCommand({ hookPath: STOP_CAPTURE_HOOK_PATH }), STOP_CAPTURE_HOOK_MARKER);
|
|
264
|
+
settings = stop.settings; captureChanged = both.changed || stop.changed;
|
|
229
265
|
} else {
|
|
230
266
|
// Never leave a broken capture hook wired if we can't run it here.
|
|
231
267
|
const rm = removeHookEntry(settings, 'SessionEnd', CAPTURE_HOOK_MARKER);
|
|
232
|
-
|
|
268
|
+
const rmStop = removeHookEntry(rm.settings, 'Stop', STOP_CAPTURE_HOOK_MARKER);
|
|
269
|
+
settings = rmStop.settings; captureChanged = rm.changed || rmStop.changed;
|
|
233
270
|
captureNote = ' (capture hook skipped — not available in this install; injection only)';
|
|
234
271
|
}
|
|
235
|
-
|
|
272
|
+
// Ship hook: consult before a merge / deploy / phone build. Its imports are all inside
|
|
273
|
+
// mcp/, so it runs wherever the server does — no availability gate needed.
|
|
274
|
+
let shipChanged = false;
|
|
275
|
+
for (const ev of SHIP_HOOK_EVENTS) {
|
|
276
|
+
const r = mergeHookEntry(settings, ev, hookCommand({ hookPath: SHIP_HOOK_PATH }), SHIP_HOOK_MARKER, SHIP_HOOK_ENTRY);
|
|
277
|
+
settings = r.settings; shipChanged = shipChanged || r.changed;
|
|
278
|
+
}
|
|
279
|
+
const changed = start.changed || captureChanged || shipChanged;
|
|
236
280
|
if (!changed) return { name, status: 'unchanged', detail: `already configured${captureNote}` };
|
|
237
281
|
if (!dryRun) { if (read.existed) backup(path); writeConfig(path, settings); }
|
|
238
|
-
return { name, status: dryRun ? 'would-write' : 'configured', detail: path + captureNote
|
|
282
|
+
return { name, status: dryRun ? 'would-write' : 'configured', detail: path + captureNote,
|
|
283
|
+
restartHint: 'Start a new Claude Code session — hooks load when a session starts.' };
|
|
239
284
|
}
|
|
240
285
|
|
|
241
286
|
// The SessionEnd capture hook can only run where its imported modules resolve —
|
|
@@ -370,6 +415,7 @@ function parseArgs(argv) {
|
|
|
370
415
|
else if (a === '--consult-model') opts.consultModel = argv[++i];
|
|
371
416
|
else if (a === '--consult-url') opts.consultUrl = argv[++i];
|
|
372
417
|
else if (a === '--no-model-judge') opts.noModelJudge = true;
|
|
418
|
+
else if (a === '--model-judge') opts.modelJudge = true;
|
|
373
419
|
else if (a === '--capture-model') opts.captureModel = argv[++i];
|
|
374
420
|
else if (a === '--capture-url') opts.captureUrl = argv[++i];
|
|
375
421
|
else if (a === '--help' || a === '-h') opts.help = true;
|
|
@@ -396,10 +442,11 @@ const HELP = `Phaedo MCP installer
|
|
|
396
442
|
node install.mjs --dry-run preview changes, write nothing
|
|
397
443
|
node install.mjs --only <id> one client only: claude-desktop | cursor | claude-code
|
|
398
444
|
|
|
399
|
-
Consult model judge (
|
|
400
|
-
node install.mjs --
|
|
445
|
+
Consult model judge (OFF by default — the rule engine answers; opt in below):
|
|
446
|
+
node install.mjs --model-judge turn it on, auto-detecting a local Ollama chat model
|
|
447
|
+
node install.mjs --consult-model <id> turn it on with a pinned model (e.g. llama3.1:8b)
|
|
401
448
|
node install.mjs --consult-url <url> endpoint (default http://localhost:11434)
|
|
402
|
-
node install.mjs --no-model-judge rule engine only (
|
|
449
|
+
node install.mjs --no-model-judge rule engine only (the default; kept for scripts)
|
|
403
450
|
`;
|
|
404
451
|
|
|
405
452
|
function runPairing() {
|
|
@@ -427,12 +474,12 @@ async function main(argv) {
|
|
|
427
474
|
|
|
428
475
|
const action = opts.uninstall ? 'uninstall' : 'install';
|
|
429
476
|
|
|
430
|
-
// Consult model judge:
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
477
|
+
// Consult model judge: OFF unless asked for (see the measurement above buildConsultEnv).
|
|
478
|
+
// --consult-model pins one; --model-judge probes local Ollama for an installed chat model.
|
|
479
|
+
// Always falls back to the rule engine at runtime, so a missing model never breaks
|
|
480
|
+
// consultation. A plain install REMOVES a judge env an earlier default-on install wrote.
|
|
434
481
|
let env = {}, consultNote = '';
|
|
435
|
-
if (action === 'install' && !opts.noModelJudge) {
|
|
482
|
+
if (action === 'install' && !opts.noModelJudge && (opts.consultModel || opts.modelJudge)) {
|
|
436
483
|
const url = opts.consultUrl || DEFAULT_CONSULT_URL;
|
|
437
484
|
if (opts.consultModel) {
|
|
438
485
|
env = buildConsultEnv({ model: opts.consultModel, url });
|
|
@@ -445,7 +492,7 @@ async function main(argv) {
|
|
|
445
492
|
: `model judge ON → ${d.model} @ ${url} (Ollama not detected — falls back to the rule engine until it's running)`;
|
|
446
493
|
}
|
|
447
494
|
} else if (action === 'install') {
|
|
448
|
-
consultNote = 'model judge OFF
|
|
495
|
+
consultNote = 'model judge OFF — the rule engine answers (opt in: --model-judge)';
|
|
449
496
|
}
|
|
450
497
|
const entry = serverEntry({ env });
|
|
451
498
|
console.log(`Phaedo MCP — ${action}${opts.dryRun ? ' (dry run)' : ''}`);
|
|
@@ -490,7 +537,7 @@ async function main(argv) {
|
|
|
490
537
|
console.log('\nNext:');
|
|
491
538
|
console.log(' 1. Pair with your phone: npm run pair (or re-run with --pair)');
|
|
492
539
|
console.log(' 2. Restart any client that was already open:');
|
|
493
|
-
for (const r of touched) console.log(` • ${r.name}: ${r.restartHint}`);
|
|
540
|
+
for (const r of touched) if (r.restartHint) console.log(` • ${r.name}: ${r.restartHint}`);
|
|
494
541
|
console.log(' 3. Inject your fingerprint at session start:');
|
|
495
542
|
console.log(' • any client: run the `/phaedo` prompt (one click), or call');
|
|
496
543
|
console.log(' the `phaedo_request_injection` tool.');
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "phaedo-mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"description": "
|
|
7
|
+
"description": "Lets Claude, Cursor and other MCP apps answer in your style, check with you before acting, send decisions to your phone to approve, and ask you to sign purchase approvals with Face ID. Needs the Phaedo phone app; set PHAEDO_FINGERPRINT=sample to try it.",
|
|
8
8
|
"mcpName": "io.github.phaedo-labs/phaedo-mcp",
|
|
9
9
|
"homepage": "https://phaedo.so",
|
|
10
10
|
"repository": {
|
|
@@ -14,11 +14,20 @@
|
|
|
14
14
|
},
|
|
15
15
|
"keywords": [
|
|
16
16
|
"mcp",
|
|
17
|
+
"mcp-server",
|
|
17
18
|
"model-context-protocol",
|
|
19
|
+
"claude",
|
|
20
|
+
"claude-code",
|
|
21
|
+
"cursor",
|
|
18
22
|
"personalization",
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
23
|
+
"human-in-the-loop",
|
|
24
|
+
"approval",
|
|
25
|
+
"guardrails",
|
|
26
|
+
"agent",
|
|
27
|
+
"ai-agents",
|
|
28
|
+
"ap2",
|
|
29
|
+
"agent-payments",
|
|
30
|
+
"anamnesis"
|
|
22
31
|
],
|
|
23
32
|
"bin": {
|
|
24
33
|
"phaedo-mcp": "server.js"
|
|
@@ -32,6 +41,9 @@
|
|
|
32
41
|
"consult.js",
|
|
33
42
|
"consult-core.js",
|
|
34
43
|
"consult-policy.js",
|
|
44
|
+
"consult-runtime.js",
|
|
45
|
+
"ship-actions.js",
|
|
46
|
+
"ship-readout.js",
|
|
35
47
|
"action-binding.js",
|
|
36
48
|
"autonomy-promotion.js",
|
|
37
49
|
"ap2.js",
|
|
@@ -80,9 +92,10 @@
|
|
|
80
92
|
"unsetup": "node install.mjs --uninstall",
|
|
81
93
|
"pair": "node pair.js",
|
|
82
94
|
"pull": "node pull.js",
|
|
83
|
-
"test": "node test-smoke.mjs && node test-phone-boundary.mjs && node test-cache.mjs && node test-pairing.mjs && node test-revoke.mjs && node test-install.mjs && node test-consult.mjs && node test-consult-policy.mjs && node test-action-binding.mjs && node test-autonomy-promotion.mjs && node test-ap2.mjs && node test-delegation-sync.mjs && node test-delegation-relay.mjs && node test-capture-sync.mjs && node test-capture-hook.mjs && node test-capture-local.mjs && node test-capture-a11y.mjs && node test-vendored-capture.mjs && node test-escalation.mjs && node test-domain.mjs && node test-receipts.mjs && node test-receipts-sync.mjs && node test-agreement.mjs && node test-calibration.mjs && node test-identity.mjs && node test-signing.mjs && node test-sign-request.mjs && node test-signing-audit.mjs && node test-delegation-domain-fragmentation.mjs && node test-stale-authority.mjs && node test-config-home-fold.mjs && node test-precedence-and-ceiling.mjs && node test-package-closure.mjs",
|
|
95
|
+
"test": "node test-smoke.mjs && node test-no-profile.mjs && node test-phone-boundary.mjs && node test-cache.mjs && node test-pairing.mjs && node test-revoke.mjs && node test-install.mjs && node test-consult.mjs && node test-consult-policy.mjs && node test-amount-bands.mjs && node test-resolver-revision.mjs && node test-ship-actions.mjs && node test-ship-hook.mjs && node test-ship-readout.mjs && node test-action-binding.mjs && node test-autonomy-promotion.mjs && node test-ap2.mjs && node test-delegation-sync.mjs && node test-delegation-relay.mjs && node test-capture-sync.mjs && node test-capture-hook.mjs && node test-stop-capture.mjs && node test-capture-local.mjs && node test-capture-a11y.mjs && node test-vendored-capture.mjs && node test-escalation.mjs && node test-domain.mjs && node test-receipts.mjs && node test-receipts-sync.mjs && node test-agreement.mjs && node test-calibration.mjs && node test-identity.mjs && node test-signing.mjs && node test-sign-request.mjs && node test-signing-audit.mjs && node test-delegation-domain-fragmentation.mjs && node test-stale-authority.mjs && node test-config-home-fold.mjs && node test-precedence-and-ceiling.mjs && node test-package-closure.mjs",
|
|
84
96
|
"receipts": "node receipts.js list",
|
|
85
97
|
"agreement": "node agreement.js",
|
|
98
|
+
"ship-readout": "node ship-readout.js",
|
|
86
99
|
"calibration": "node calibration.js",
|
|
87
100
|
"health": "node health.js",
|
|
88
101
|
"validate": "node ../spec/validate-fingerprint.mjs sample-fingerprint.json",
|
package/phone-source.js
CHANGED
|
@@ -163,7 +163,12 @@ async function getSession(rec, deps) {
|
|
|
163
163
|
// (consult-policy loadPolicies / context-block renderContextBlock); absent —
|
|
164
164
|
// pre-part-3 phone — consumers fall back to the fingerprint A-home unchanged.
|
|
165
165
|
export function toVaultPayload(inner) {
|
|
166
|
-
|
|
166
|
+
// Prefer every paired browser's counters combined (`metrics_combined`, 2026-10-01): the subject
|
|
167
|
+
// is one person across browsers, and `metrics` is only the browser that posted last. A phone
|
|
168
|
+
// that predates the sibling sends only `metrics`, which still serves.
|
|
169
|
+
const m = (inner.metrics_combined && typeof inner.metrics_combined === 'object' && !Array.isArray(inner.metrics_combined))
|
|
170
|
+
? inner.metrics_combined
|
|
171
|
+
: (inner.metrics || {});
|
|
167
172
|
return {
|
|
168
173
|
phaedo_fingerprint: inner.data,
|
|
169
174
|
...(inner.user_configuration && typeof inner.user_configuration === 'object'
|
package/receipts.js
CHANGED
|
@@ -261,7 +261,7 @@ if (process.argv[1] && process.argv[1].endsWith('receipts.js')) {
|
|
|
261
261
|
for (const r of rs.slice(-50)) {
|
|
262
262
|
const ad = r.request.action_descriptor;
|
|
263
263
|
console.log(
|
|
264
|
-
`${r.receipt_id.slice(0, 8)} ${r.created_at} ${r.via === 'phaedo_check_authorization' ? '[pre-flight]' : '[consult] '}` +
|
|
264
|
+
`${r.receipt_id.slice(0, 8)} ${r.created_at} ${r.via === 'phaedo_check_authorization' ? '[pre-flight]' : r.agent_id === 'claude-code/ship-hook' ? '[ship-hook] ' : '[consult] '}` +
|
|
265
265
|
` ${r.request.consultation_type} ${ad.domain || '-'}` +
|
|
266
266
|
` → ${r.response.signal} (conf ${r.response.confidence}, def ${r.response.deference_level})` +
|
|
267
267
|
` user_action: ${r.user_action ?? '—'}`
|
package/server.js
CHANGED
|
@@ -16,8 +16,8 @@ import { z } from 'zod';
|
|
|
16
16
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
17
17
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
18
18
|
|
|
19
|
-
import { loadFingerprint,
|
|
20
|
-
import { hasPairingRecord, loadPairingRecord
|
|
19
|
+
import { loadFingerprint, sourceOf } from './fingerprint-source.js';
|
|
20
|
+
import { hasPairingRecord, loadPairingRecord } from './phone-source.js';
|
|
21
21
|
import { depositDelegationSuggestions } from './delegation-sync.js';
|
|
22
22
|
import { depositReceiptsDigest, drainAndApplyMarks } from './receipts-sync.js';
|
|
23
23
|
import { buildDelegationPromotions } from './consult-core.js';
|
|
@@ -27,8 +27,9 @@ import { buildIntentMandatePayload } from './ap2.js';
|
|
|
27
27
|
import { depositSignRequest, pollSignOnce } from './signing-request.js';
|
|
28
28
|
import { buildSigningAuditRecord, appendSigningAudit } from './signing-audit.js';
|
|
29
29
|
import { buildInjectionResponse, errorResponse, PhaedoError } from './projection.js';
|
|
30
|
-
import { resolveConsultation, checkAuthorization,
|
|
30
|
+
import { resolveConsultation, checkAuthorization, CONSULTATION_TYPES, consultDrivers, withRevision } from './consult.js';
|
|
31
31
|
import { defaultStateDir } from './cache.js';
|
|
32
|
+
import { buildConsultOpts, loadConsultVd } from './consult-runtime.js';
|
|
32
33
|
import { buildReceipt, appendReceipt, receiptsCapFromEnv, readReceipts, markOutcome, USER_ACTIONS } from './receipts.js';
|
|
33
34
|
|
|
34
35
|
const BASE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
@@ -88,28 +89,6 @@ function withStaleAuthorityGuard(response, vd) {
|
|
|
88
89
|
return out;
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
// Load the fingerprint for a consult and fold in the act-as-me channel: delegation
|
|
92
|
-
// signals derived from this subject's own override history (§10.6 receipts). The
|
|
93
|
-
// origin-aware resolver then lets a `delegation` correction outrank a `self` signal on
|
|
94
|
-
// the same dimension. Best-effort — if receipts are unreadable, the consult still
|
|
95
|
-
// answers from the plain fingerprint (delegation learning never breaks a consultation).
|
|
96
|
-
async function loadConsultVd(baseDir, { preferCache = false } = {}) {
|
|
97
|
-
// preferCache (escalation): read the at-rest cache instead of a live phone pull, so the
|
|
98
|
-
// escalation doesn't add a fingerprint-read AUTHORIZE prompt on top of its decision card.
|
|
99
|
-
const vd = preferCache ? await loadFingerprintPreferCache(baseDir) : await loadFingerprint(baseDir);
|
|
100
|
-
try {
|
|
101
|
-
// Two delegation sources, both origin-aware: live override history (every override)
|
|
102
|
-
// + the subject's ENDORSED, portable delegation rules (authored_delegation). The
|
|
103
|
-
// authored rule thus shapes consult too, not only the §9 injection it already renders in.
|
|
104
|
-
const signals = deriveDelegationSignals(await readReceipts(defaultStateDir()))
|
|
105
|
-
.concat(authoredDelegationSignals(vd && vd.phaedo_fingerprint));
|
|
106
|
-
return withDelegationSignals(vd, signals);
|
|
107
|
-
} catch (e) {
|
|
108
|
-
process.stderr.write(`[phaedo-mcp] delegation derive skipped (consult unaffected): ${e.message}\n`);
|
|
109
|
-
return vd;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
92
|
// Act-as-me PROMOTION (delegation-sync.js): when the override history shows an
|
|
114
93
|
// ESTABLISHED pattern that isn't already authored/staged, deposit a delegation
|
|
115
94
|
// SUGGESTION across the sync boundary (relay agent_to_ext) for the subject to review +
|
|
@@ -184,7 +163,9 @@ async function maybeDepositDelegationPromotions(vd) {
|
|
|
184
163
|
}
|
|
185
164
|
}
|
|
186
165
|
|
|
187
|
-
|
|
166
|
+
// Env config + the subject's deference policies + this install's paired-client id —
|
|
167
|
+
// built in consult-runtime.js, the one copy the Claude Code ship hook shares.
|
|
168
|
+
const CONSULT_OPTS = buildConsultOpts(BASE_DIR, process.env);
|
|
188
169
|
// Real-time escalation window (docs/roadmap/escalation-and-push.md §crux): how long a
|
|
189
170
|
// blocking decision waits for the subject before the agent applies the safe-default
|
|
190
171
|
// (hold). Configurable; bounded by the relay's own MAX_WINDOW_MS.
|
|
@@ -192,20 +173,6 @@ const ESCALATION_WINDOW_MS = (() => {
|
|
|
192
173
|
const n = Number(process.env.PHAEDO_ESCALATION_WINDOW_MS);
|
|
193
174
|
return Number.isFinite(n) && n > 0 ? n : 3 * 60 * 1000;
|
|
194
175
|
})();
|
|
195
|
-
// Deference policies: PHAEDO_CONSULT_POLICIES if set, else the default file beside
|
|
196
|
-
// the server (mcp/consult-policies.json — what policy-editor.html produces). Zero
|
|
197
|
-
// config: drop the file in and restart the client. (Portable vault policies in
|
|
198
|
-
// phaedo_consult_policies always apply too, regardless of this file.)
|
|
199
|
-
const POLICIES_PATH = process.env.PHAEDO_CONSULT_POLICIES || join(BASE_DIR, 'consult-policies.json');
|
|
200
|
-
if (existsSync(POLICIES_PATH)) {
|
|
201
|
-
try { CONSULT_OPTS.policies = JSON.parse(readFileSync(POLICIES_PATH, 'utf8')); }
|
|
202
|
-
catch (e) { process.stderr.write(`[phaedo-mcp] ignoring malformed consult policies (${POLICIES_PATH}): ${e.message}\n`); }
|
|
203
|
-
}
|
|
204
|
-
// §10.5 `agents`: this process's OWN paired-client identity, resolved once at startup.
|
|
205
|
-
// One server serves one paired client, so it never asks a caller who is calling — it
|
|
206
|
-
// reads who it IS. Null (unpaired / unreadable) makes `agents`-scoped RELAXING rules
|
|
207
|
-
// inert, which is the fail-safe direction; the server still answers everything else.
|
|
208
|
-
CONSULT_OPTS.clientId = readServingClientId(BASE_DIR);
|
|
209
176
|
|
|
210
177
|
// Escalation PUSH policy (which signals wake the subject): the subject-configurable knob
|
|
211
178
|
// over the stakes-gated default. Local file (PHAEDO_ESCALATION_POLICY, else
|
|
@@ -223,7 +190,18 @@ if (existsSync(ESCALATION_POLICY_PATH)) {
|
|
|
223
190
|
// been the package's for two months. One source of truth; test-package-closure.mjs
|
|
224
191
|
// asserts the two agree.
|
|
225
192
|
const PKG_VERSION = JSON.parse(readFileSync(join(BASE_DIR, 'package.json'), 'utf8')).version;
|
|
226
|
-
|
|
193
|
+
// The one place a client is told how the tools fit together. Without it an app saw eight
|
|
194
|
+
// tool descriptions and nothing saying "load the profile first" or "consult before you ship" —
|
|
195
|
+
// so whether Phaedo was used at all depended on the model guessing. Short on purpose: clients
|
|
196
|
+
// put this in the model's context on every turn.
|
|
197
|
+
const SERVER_INSTRUCTIONS = [
|
|
198
|
+
'Phaedo holds the user\'s profile: how they like answers written, and how they want decisions handled when an AI acts for them.',
|
|
199
|
+
'- At the start of a session, call phaedo_request_injection once and treat the returned projection as background on the user\'s style.',
|
|
200
|
+
'- Before a consequential action for the user (merging, deploying, publishing, sending a message, spending money, deleting, changing an account or setting), call phaedo_consult and follow its signal. If you are acting on your own and the decision should be the user\'s, use phaedo_escalate and do not proceed until it resolves.',
|
|
201
|
+
'- When the user reacts to an action you consulted on, call phaedo_record_outcome with its receipt_id: record what they chose, never what you inferred.',
|
|
202
|
+
'- If a tool says no Phaedo profile is set up, tell the user once, plainly, and carry on normally. Do not retry.',
|
|
203
|
+
].join('\n');
|
|
204
|
+
const server = new McpServer({ name: 'phaedo', version: PKG_VERSION }, { instructions: SERVER_INSTRUCTIONS });
|
|
227
205
|
|
|
228
206
|
// ── Tool: phaedo_request_injection (binding §3.1) ─────────────────────────────
|
|
229
207
|
server.registerTool(
|
|
@@ -285,8 +263,8 @@ const ACTION_BINDING_SCHEMA = z
|
|
|
285
263
|
.optional()
|
|
286
264
|
.describe(
|
|
287
265
|
'Binds this consultation to the concrete call it describes. The action_descriptor is your ACCOUNT of the action; ' +
|
|
288
|
-
'this is the action. Recorded on the audit receipt so a later mismatch is provable, shown to the
|
|
289
|
-
'escalation reaches their phone, and REQUIRED by any standing rule the
|
|
266
|
+
'this is the action. Recorded on the audit receipt so a later mismatch is provable, shown to the user when an ' +
|
|
267
|
+
'escalation reaches their phone, and REQUIRED by any standing rule the user marked require_binding before it ' +
|
|
290
268
|
'will auto-proceed.',
|
|
291
269
|
);
|
|
292
270
|
|
|
@@ -299,9 +277,18 @@ server.registerTool(
|
|
|
299
277
|
{
|
|
300
278
|
title: 'Consult the Phaedo fingerprint at a decision point',
|
|
301
279
|
description:
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
280
|
+
// Names the MOMENTS. "Use at agent decision points" told no agent when it was at one: not
|
|
281
|
+
// one consultation was written between 2026-07-28 and 2026-10-18. And it closes the loop —
|
|
282
|
+
// the receipt_id is the only handle that attributes the user's reaction to THIS action.
|
|
283
|
+
'Ask how the user would want an action handled, before you take it on their behalf — without ' +
|
|
284
|
+
'reading their profile. Call it BEFORE any consequential action you are about to take for the ' +
|
|
285
|
+
'user: merging or deploying, publishing or posting, sending a message, paying or committing money, ' +
|
|
286
|
+
'deleting, or changing an account or setting. Describe the action in action_descriptor (domain, ' +
|
|
287
|
+
'reversible, magnitude, a short summary; amount when money moves) and bind it to the exact call ' +
|
|
288
|
+
'with action_binding. Returns a signal (proceed | proceed_with_note | clarify | escalate | decline | ' +
|
|
289
|
+
'insufficient_signal), a confidence, a short rationale_hint, a deference_level (how strongly to ' +
|
|
290
|
+
'weight it) and a receipt_id. Keep the receipt_id: when the user reacts to what you did — confirms ' +
|
|
291
|
+
'it, overrides it, or wants it done differently — record that with phaedo_record_outcome.',
|
|
305
292
|
inputSchema: {
|
|
306
293
|
consultation_type: z.enum(CONSULTATION_TYPES),
|
|
307
294
|
agent_id: z.string().optional(),
|
|
@@ -334,12 +321,12 @@ server.registerTool(
|
|
|
334
321
|
// the two tools an autonomous agent actually uses answered from a possibly-revoked cached
|
|
335
322
|
// profile with no downgrade and no provenance. That is the 2026-07-25 device defect (a
|
|
336
323
|
// revoked $500 grant honoured from a 22h cache) still live on the paths that matter most.
|
|
337
|
-
const
|
|
338
|
-
|
|
324
|
+
const raw = await resolveConsultation(vd, args || {}, CONSULT_OPTS);
|
|
325
|
+
const response = withStaleAuthorityGuard(raw, vd);
|
|
339
326
|
// Provenance for the local audit receipt only — the cues that drove this
|
|
340
327
|
// (never in `response`; §10.4). Best-effort: a failure must not block the answer.
|
|
341
328
|
let drivers = [];
|
|
342
|
-
try { drivers = consultDrivers(vd, args || {}); } catch { /* provenance is non-critical */ }
|
|
329
|
+
try { drivers = withRevision(consultDrivers(vd, args || {}, CONSULT_OPTS), raw); } catch { /* provenance is non-critical */ }
|
|
343
330
|
const receiptId = await emitReceipt('phaedo_consult', args, response, drivers); // every resolution, abstains included
|
|
344
331
|
if (receiptId) response.receipt_id = receiptId; // the handle for phaedo_record_outcome
|
|
345
332
|
await maybeDepositDelegationPromotions(vd); // act-as-me promotion (best-effort)
|
|
@@ -372,7 +359,7 @@ server.registerTool(
|
|
|
372
359
|
title: 'Check standing authorizations for an action',
|
|
373
360
|
description:
|
|
374
361
|
"Deterministic pre-flight: check whether one of the user's standing authorizations " +
|
|
375
|
-
'
|
|
362
|
+
'already decides this action — escalate, decline, or pre-approve — without running ' +
|
|
376
363
|
'the full consultation. Returns authorization_matched plus the authorized signal and the ' +
|
|
377
364
|
'rule that fired. If no rule matches, fall back to phaedo_consult or your own default.',
|
|
378
365
|
inputSchema: {
|
|
@@ -450,10 +437,13 @@ server.registerTool(
|
|
|
450
437
|
{
|
|
451
438
|
title: 'Record what the subject decided about a consulted action',
|
|
452
439
|
description:
|
|
453
|
-
'
|
|
454
|
-
'
|
|
455
|
-
'
|
|
456
|
-
'
|
|
440
|
+
'Record what the USER decided about an action you consulted Phaedo on: approved | rejected | ' +
|
|
441
|
+
'modified | unknown. Call it when the user reacts to that action — they confirm it (approved), ' +
|
|
442
|
+
'refuse or undo it (rejected), or want it done differently (modified). Always pass the receipt_id ' +
|
|
443
|
+
'that phaedo_consult (or a Phaedo hook) gave you; without it the most recent receipt is marked, ' +
|
|
444
|
+
'which may be a different action. Record what the user chose, never whether you proceeded: ' +
|
|
445
|
+
'silence is not approval, and an approval you inferred teaches nothing. A rejected/modified ' +
|
|
446
|
+
'outcome teaches Phaedo how the user wants delegated work handled (the act-as-me channel).',
|
|
457
447
|
inputSchema: {
|
|
458
448
|
outcome: z.enum(USER_ACTIONS),
|
|
459
449
|
receipt_id: z.string().optional(),
|
|
@@ -547,10 +537,10 @@ server.registerTool(
|
|
|
547
537
|
// UNCONDITIONALLY, so without the guard it always reasoned from a possibly-stale profile
|
|
548
538
|
// and then told the agent the signal was not blocking — i.e. "just proceed", and never
|
|
549
539
|
// wake the subject, on authority that may have been revoked hours earlier.
|
|
550
|
-
const
|
|
551
|
-
|
|
540
|
+
const raw = await resolveConsultation(vd, req, CONSULT_OPTS);
|
|
541
|
+
const response = withStaleAuthorityGuard(raw, vd);
|
|
552
542
|
let drivers = [];
|
|
553
|
-
try { drivers = consultDrivers(vd, req); } catch { /* non-critical */ }
|
|
543
|
+
try { drivers = withRevision(consultDrivers(vd, req, CONSULT_OPTS), raw); } catch { /* non-critical */ }
|
|
554
544
|
const escReceiptId = await emitReceipt('phaedo_escalate', req, response, drivers); // the decision being escalated
|
|
555
545
|
await maybeDepositReceiptsDigest(); // G1+G2 audit channel
|
|
556
546
|
|
|
@@ -687,10 +677,10 @@ server.registerTool(
|
|
|
687
677
|
{
|
|
688
678
|
title: 'Ask the subject to sign a payment authorization (AP2 Intent Mandate)',
|
|
689
679
|
description:
|
|
690
|
-
'Request the
|
|
680
|
+
'Request the user\'s cryptographic signature on an AP2 Intent Mandate built from their ' +
|
|
691
681
|
'standing authorizations — a credential a merchant or payments network can verify. Returns a ' +
|
|
692
682
|
'request_id and the unsigned mandate immediately; then poll phaedo_sign_status with BOTH, and ' +
|
|
693
|
-
'DO NOT treat the mandate as authorized until it returns signed. The
|
|
683
|
+
'DO NOT treat the mandate as authorized until it returns signed. The user signs on their ' +
|
|
694
684
|
'phone (Face ID, key in the Secure Enclave); they may also decline. Use only when you need a ' +
|
|
695
685
|
'verifiable payment credential — not for ordinary consultations.',
|
|
696
686
|
inputSchema: {
|
|
@@ -740,7 +730,7 @@ server.registerTool(
|
|
|
740
730
|
mandate,
|
|
741
731
|
message: 'Sent to the subject to sign. Poll phaedo_sign_status with this request_id AND this ' +
|
|
742
732
|
'mandate; do not treat it as authorized until it returns signed. If it expires or the ' +
|
|
743
|
-
'
|
|
733
|
+
'user declines, the mandate stays an unsigned proposal.',
|
|
744
734
|
}
|
|
745
735
|
: {
|
|
746
736
|
request_type: 'sign_mandate', status: 'unreachable', reason: dep.reason,
|
|
@@ -766,9 +756,9 @@ server.registerTool(
|
|
|
766
756
|
title: 'Check whether the subject has signed the mandate',
|
|
767
757
|
description:
|
|
768
758
|
'Poll a request from phaedo_sign_mandate. Pass the request_id AND the mandate that call returned. ' +
|
|
769
|
-
'Returns signed + the mandate carrying the verified signature once the
|
|
759
|
+
'Returns signed + the mandate carrying the verified signature once the user signs, refused if ' +
|
|
770
760
|
'they declined, pending while you should keep waiting, or expired once the window lapsed. A signed ' +
|
|
771
|
-
'result is verified against the
|
|
761
|
+
'result is verified against the user\'s published signing key before it is returned.',
|
|
772
762
|
inputSchema: {
|
|
773
763
|
request_id: z.string(),
|
|
774
764
|
mandate: z.record(z.any()),
|
package/server.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.phaedo-labs/phaedo-mcp",
|
|
4
|
-
"description": "
|
|
5
|
-
"version": "0.3.
|
|
4
|
+
"description": "Make AI apps answer your way, check with you before acting, and get approvals on your phone.",
|
|
5
|
+
"version": "0.3.2",
|
|
6
6
|
"repository": {
|
|
7
7
|
"url": "https://github.com/phaedo-labs/phaedo-mcp",
|
|
8
8
|
"source": "github",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "phaedo-mcp",
|
|
16
|
-
"version": "0.3.
|
|
16
|
+
"version": "0.3.2",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
19
19
|
}
|