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 +138 -17
- 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 +6 -2
- package/phone-source.js +6 -1
- package/receipts.js +1 -1
- package/server.js +32 -53
- 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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "phaedo-mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
"consult.js",
|
|
33
33
|
"consult-core.js",
|
|
34
34
|
"consult-policy.js",
|
|
35
|
+
"consult-runtime.js",
|
|
36
|
+
"ship-actions.js",
|
|
37
|
+
"ship-readout.js",
|
|
35
38
|
"action-binding.js",
|
|
36
39
|
"autonomy-promotion.js",
|
|
37
40
|
"ap2.js",
|
|
@@ -80,9 +83,10 @@
|
|
|
80
83
|
"unsetup": "node install.mjs --uninstall",
|
|
81
84
|
"pair": "node pair.js",
|
|
82
85
|
"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",
|
|
86
|
+
"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
87
|
"receipts": "node receipts.js list",
|
|
85
88
|
"agreement": "node agreement.js",
|
|
89
|
+
"ship-readout": "node ship-readout.js",
|
|
86
90
|
"calibration": "node calibration.js",
|
|
87
91
|
"health": "node health.js",
|
|
88
92
|
"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
|
|
@@ -299,9 +266,18 @@ server.registerTool(
|
|
|
299
266
|
{
|
|
300
267
|
title: 'Consult the Phaedo fingerprint at a decision point',
|
|
301
268
|
description:
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
269
|
+
// Names the MOMENTS. "Use at agent decision points" told no agent when it was at one: not
|
|
270
|
+
// one consultation was written between 2026-07-28 and 2026-10-18. And it closes the loop —
|
|
271
|
+
// the receipt_id is the only handle that attributes the user's reaction to THIS action.
|
|
272
|
+
'Ask how the user would want an action handled, before you take it on their behalf — without ' +
|
|
273
|
+
'reading their profile. Call it BEFORE any consequential action you are about to take for the ' +
|
|
274
|
+
'user: merging or deploying, publishing or posting, sending a message, paying or committing money, ' +
|
|
275
|
+
'deleting, or changing an account or setting. Describe the action in action_descriptor (domain, ' +
|
|
276
|
+
'reversible, magnitude, a short summary; amount when money moves) and bind it to the exact call ' +
|
|
277
|
+
'with action_binding. Returns a signal (proceed | proceed_with_note | clarify | escalate | decline | ' +
|
|
278
|
+
'insufficient_signal), a confidence, a short rationale_hint, a deference_level (how strongly to ' +
|
|
279
|
+
'weight it) and a receipt_id. Keep the receipt_id: when the user reacts to what you did — confirms ' +
|
|
280
|
+
'it, overrides it, or wants it done differently — record that with phaedo_record_outcome.',
|
|
305
281
|
inputSchema: {
|
|
306
282
|
consultation_type: z.enum(CONSULTATION_TYPES),
|
|
307
283
|
agent_id: z.string().optional(),
|
|
@@ -334,12 +310,12 @@ server.registerTool(
|
|
|
334
310
|
// the two tools an autonomous agent actually uses answered from a possibly-revoked cached
|
|
335
311
|
// profile with no downgrade and no provenance. That is the 2026-07-25 device defect (a
|
|
336
312
|
// revoked $500 grant honoured from a 22h cache) still live on the paths that matter most.
|
|
337
|
-
const
|
|
338
|
-
|
|
313
|
+
const raw = await resolveConsultation(vd, args || {}, CONSULT_OPTS);
|
|
314
|
+
const response = withStaleAuthorityGuard(raw, vd);
|
|
339
315
|
// Provenance for the local audit receipt only — the cues that drove this
|
|
340
316
|
// (never in `response`; §10.4). Best-effort: a failure must not block the answer.
|
|
341
317
|
let drivers = [];
|
|
342
|
-
try { drivers = consultDrivers(vd, args || {}); } catch { /* provenance is non-critical */ }
|
|
318
|
+
try { drivers = withRevision(consultDrivers(vd, args || {}, CONSULT_OPTS), raw); } catch { /* provenance is non-critical */ }
|
|
343
319
|
const receiptId = await emitReceipt('phaedo_consult', args, response, drivers); // every resolution, abstains included
|
|
344
320
|
if (receiptId) response.receipt_id = receiptId; // the handle for phaedo_record_outcome
|
|
345
321
|
await maybeDepositDelegationPromotions(vd); // act-as-me promotion (best-effort)
|
|
@@ -450,10 +426,13 @@ server.registerTool(
|
|
|
450
426
|
{
|
|
451
427
|
title: 'Record what the subject decided about a consulted action',
|
|
452
428
|
description:
|
|
453
|
-
'
|
|
454
|
-
'
|
|
455
|
-
'
|
|
456
|
-
'
|
|
429
|
+
'Record what the USER decided about an action you consulted Phaedo on: approved | rejected | ' +
|
|
430
|
+
'modified | unknown. Call it when the user reacts to that action — they confirm it (approved), ' +
|
|
431
|
+
'refuse or undo it (rejected), or want it done differently (modified). Always pass the receipt_id ' +
|
|
432
|
+
'that phaedo_consult (or a Phaedo hook) gave you; without it the most recent receipt is marked, ' +
|
|
433
|
+
'which may be a different action. Record what the user chose, never whether you proceeded: ' +
|
|
434
|
+
'silence is not approval, and an approval you inferred teaches nothing. A rejected/modified ' +
|
|
435
|
+
'outcome teaches Phaedo how the user wants delegated work handled (the act-as-me channel).',
|
|
457
436
|
inputSchema: {
|
|
458
437
|
outcome: z.enum(USER_ACTIONS),
|
|
459
438
|
receipt_id: z.string().optional(),
|
|
@@ -547,10 +526,10 @@ server.registerTool(
|
|
|
547
526
|
// UNCONDITIONALLY, so without the guard it always reasoned from a possibly-stale profile
|
|
548
527
|
// and then told the agent the signal was not blocking — i.e. "just proceed", and never
|
|
549
528
|
// wake the subject, on authority that may have been revoked hours earlier.
|
|
550
|
-
const
|
|
551
|
-
|
|
529
|
+
const raw = await resolveConsultation(vd, req, CONSULT_OPTS);
|
|
530
|
+
const response = withStaleAuthorityGuard(raw, vd);
|
|
552
531
|
let drivers = [];
|
|
553
|
-
try { drivers = consultDrivers(vd, req); } catch { /* non-critical */ }
|
|
532
|
+
try { drivers = withRevision(consultDrivers(vd, req, CONSULT_OPTS), raw); } catch { /* non-critical */ }
|
|
554
533
|
const escReceiptId = await emitReceipt('phaedo_escalate', req, response, drivers); // the decision being escalated
|
|
555
534
|
await maybeDepositReceiptsDigest(); // G1+G2 audit channel
|
|
556
535
|
|
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": "Carry your Phaedo cognitive fingerprint into any MCP client:
|
|
5
|
-
"version": "0.3.
|
|
4
|
+
"description": "Carry your Phaedo cognitive fingerprint into any MCP client: inject, consult, escalate, sign.",
|
|
5
|
+
"version": "0.3.1",
|
|
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.1",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
19
19
|
}
|
package/ship-actions.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Ship actions — which shell commands are a decision the subject would want consulted on.
|
|
2
|
+
//
|
|
3
|
+
// The precedent track's Phase 1 (docs/roadmap/precedent-and-sync-design-2026-09-13.md) is
|
|
4
|
+
// starved of outcome data because nothing consults: no receipt has been written since
|
|
5
|
+
// 2026-07-28. The decisions the subject actually delegates, every week, are the ones a
|
|
6
|
+
// coding agent takes on their behalf — merging a PR, deploying the relay, cutting a phone
|
|
7
|
+
// build. This module names those, so the PreToolUse hook (hooks/pre-ship.mjs) can consult
|
|
8
|
+
// before they run. Everything else a shell does is not a decision and is ignored.
|
|
9
|
+
//
|
|
10
|
+
// PURE: (command string) -> classification | null. No I/O.
|
|
11
|
+
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
// One entry per class. `match` sees one simple command, already split out of a compound
|
|
15
|
+
// line and stripped of env assignments and `npx`. The descriptor is what the consult
|
|
16
|
+
// reasons about — magnitude is the stakes, reversible is whether it can be walked back.
|
|
17
|
+
const CLASSES = [
|
|
18
|
+
{
|
|
19
|
+
id: 'merge_pr',
|
|
20
|
+
// `gh pr merge` — reversible (a revert), medium. `--admin` skips required checks, which
|
|
21
|
+
// is a different act: high.
|
|
22
|
+
match: (w) => w[0] === 'gh' && w[1] === 'pr' && w[2] === 'merge',
|
|
23
|
+
describe: (w) => {
|
|
24
|
+
const num = w.slice(3).find((x) => /^#?\d+$/.test(x));
|
|
25
|
+
const admin = w.includes('--admin');
|
|
26
|
+
// `-R/--repo` names the repository when the agent is not inside it; the hook passes it
|
|
27
|
+
// through when it asks GitHub for the PR's check state (B6).
|
|
28
|
+
const ri = w.findIndex((x) => x === '-R' || x === '--repo');
|
|
29
|
+
const repo = ri >= 0 ? w[ri + 1] : (w.find((x) => x.startsWith('--repo='))?.split('=')[1]);
|
|
30
|
+
return {
|
|
31
|
+
magnitude: admin ? 'high' : 'medium',
|
|
32
|
+
reversible: true,
|
|
33
|
+
summary: `${admin ? 'admin-merge' : 'merge'} ${num ? `PR #${num.replace('#', '')}` : "this branch's PR"}`,
|
|
34
|
+
pr: num ? num.replace('#', '') : null,
|
|
35
|
+
repo: repo || null,
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'deploy',
|
|
41
|
+
// `fly deploy` — production for every paired phone. Reversible (`fly releases` rollback)
|
|
42
|
+
// but high: a bad relay breaks sync for everyone until it is rolled back.
|
|
43
|
+
match: (w) => (w[0] === 'fly' || w[0] === 'flyctl') && w[1] === 'deploy',
|
|
44
|
+
describe: (w) => {
|
|
45
|
+
const i = w.findIndex((x) => x === '-a' || x === '--app');
|
|
46
|
+
const app = i >= 0 ? w[i + 1] : null;
|
|
47
|
+
return { magnitude: 'high', reversible: true, summary: `deploy${app ? ` ${app}` : ''} to Fly` };
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'app_build',
|
|
52
|
+
// `eas build` — a build artifact, nothing reaches a device yet. Low.
|
|
53
|
+
match: (w) => isEas(w[0]) && w[1] === 'build',
|
|
54
|
+
describe: (w) => ({ magnitude: 'low', reversible: true, summary: `phone build${platform(w)}` }),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'app_submit',
|
|
58
|
+
// `eas submit` (or `eas build --auto-submit`) — the build goes to TestFlight / the store.
|
|
59
|
+
match: (w) => isEas(w[0]) && (w[1] === 'submit' || (w[1] === 'build' && w.includes('--auto-submit'))),
|
|
60
|
+
describe: (w) => ({ magnitude: 'medium', reversible: true, summary: `submit phone build${platform(w)}` }),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'push_main',
|
|
64
|
+
// A direct push to the default branch, skipping review. A force-push there rewrites
|
|
65
|
+
// shared history: high, and not cleanly reversible.
|
|
66
|
+
match: (w) => w[0] === 'git' && w[1] === 'push' && w.slice(2).some((x) => /^\+?(?:[^:]*:)?(?:refs\/heads\/)?(?:main|master)$/.test(x)),
|
|
67
|
+
describe: (w) => {
|
|
68
|
+
const force = w.some((x) => x === '-f' || x === '--force' || x.startsWith('--force-with-lease') || x.startsWith('+'));
|
|
69
|
+
return force
|
|
70
|
+
? { magnitude: 'high', reversible: false, summary: 'force-push to main' }
|
|
71
|
+
: { magnitude: 'medium', reversible: true, summary: 'push directly to main' };
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
// Order matters for one pair: `eas build --auto-submit` is a submit, not a build.
|
|
77
|
+
const ORDER = ['merge_pr', 'deploy', 'app_submit', 'app_build', 'push_main'];
|
|
78
|
+
|
|
79
|
+
function isEas(w0) { return w0 === 'eas' || w0 === 'eas-cli'; }
|
|
80
|
+
function platform(w) {
|
|
81
|
+
const i = w.findIndex((x) => x === '--platform' || x === '-p');
|
|
82
|
+
const p = i >= 0 ? w[i + 1] : (w.find((x) => x.startsWith('--platform='))?.split('=')[1]);
|
|
83
|
+
return p ? ` (${p})` : '';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Split a shell line into simple commands on && || ; | and newlines. Not a shell parser —
|
|
87
|
+
// quotes are honoured only enough that `git commit -m "a && b"` does not split.
|
|
88
|
+
export function splitCommands(line) {
|
|
89
|
+
const out = [];
|
|
90
|
+
let cur = '';
|
|
91
|
+
let q = null;
|
|
92
|
+
for (let i = 0; i < line.length; i++) {
|
|
93
|
+
const c = line[i];
|
|
94
|
+
if (q) { cur += c; if (c === q) q = null; continue; }
|
|
95
|
+
if (c === '"' || c === "'") { q = c; cur += c; continue; }
|
|
96
|
+
if (c === '\n' || c === ';' || c === '|' || (c === '&' && line[i + 1] === '&')) {
|
|
97
|
+
if (c === '&' || (c === '|' && line[i + 1] === '|')) i++;
|
|
98
|
+
out.push(cur); cur = '';
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
cur += c;
|
|
102
|
+
}
|
|
103
|
+
out.push(cur);
|
|
104
|
+
return out.map((s) => s.trim()).filter(Boolean);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Words of one simple command, with leading env assignments, `npx`/`npx --yes`, and a
|
|
108
|
+
// quoted-string's quotes removed.
|
|
109
|
+
export function words(cmd) {
|
|
110
|
+
const w = (cmd.match(/"[^"]*"|'[^']*'|\S+/g) || []).map((x) => x.replace(/^["']|["']$/g, ''));
|
|
111
|
+
while (w.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0])) w.shift();
|
|
112
|
+
if (w[0] === 'npx') { w.shift(); while (w[0] && w[0].startsWith('-')) w.shift(); }
|
|
113
|
+
// A path to the binary (`/opt/homebrew/bin/gh`, `./node_modules/.bin/eas`) is the binary.
|
|
114
|
+
if (w[0]) w[0] = w[0].split('/').pop();
|
|
115
|
+
return w;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Stakes order for a line that chains several ship actions: irreversible first, then by
|
|
119
|
+
// magnitude. A line that merges and then deploys is consulted on the deploy — consulting on
|
|
120
|
+
// the merge would let the riskier half ride on the safer half's answer.
|
|
121
|
+
const MAG_RANK = { low: 0, medium: 1, high: 2 };
|
|
122
|
+
const stakes = (d) => (d.reversible === false ? 10 : 0) + (MAG_RANK[d.magnitude] ?? 1);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Classify a Bash command. Returns null when no part of it is a ship action, else the
|
|
126
|
+
* highest-stakes ship action in it (ties: the first), with `multiple` set when the line
|
|
127
|
+
* chains more than one.
|
|
128
|
+
*
|
|
129
|
+
* @returns {null | { class, summary, multiple, action_descriptor, action_binding }}
|
|
130
|
+
*/
|
|
131
|
+
export function classifyShipCommand(command) {
|
|
132
|
+
if (typeof command !== 'string' || !command.trim()) return null;
|
|
133
|
+
const hits = [];
|
|
134
|
+
for (const seg of splitCommands(command)) {
|
|
135
|
+
const w = words(seg);
|
|
136
|
+
if (!w.length) continue;
|
|
137
|
+
for (const id of ORDER) {
|
|
138
|
+
const k = CLASSES.find((c) => c.id === id);
|
|
139
|
+
if (k.match(w)) { hits.push({ id, seg, d: k.describe(w) }); break; }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!hits.length) return null;
|
|
143
|
+
const first = hits.reduce((best, h) => (stakes(h.d) > stakes(best.d) ? h : best));
|
|
144
|
+
return {
|
|
145
|
+
class: first.id,
|
|
146
|
+
summary: first.d.summary,
|
|
147
|
+
multiple: hits.length > 1,
|
|
148
|
+
// merge_pr only: which PR, and in which repo (null = the current branch's, here).
|
|
149
|
+
...(first.id === 'merge_pr' ? { pr: first.d.pr, repo: first.d.repo } : {}),
|
|
150
|
+
action_descriptor: {
|
|
151
|
+
domain: 'engineering',
|
|
152
|
+
reversible: first.d.reversible,
|
|
153
|
+
magnitude: first.d.magnitude,
|
|
154
|
+
summary: first.d.summary,
|
|
155
|
+
},
|
|
156
|
+
// §10.2 binding: the concrete call this descriptor claims to describe — a digest of the
|
|
157
|
+
// whole line, never the line itself beyond a short preview.
|
|
158
|
+
action_binding: {
|
|
159
|
+
tool_name: 'Bash',
|
|
160
|
+
arguments_hash: createHash('sha256').update(command).digest('hex'),
|
|
161
|
+
arguments_preview: first.seg.slice(0, 120),
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|