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/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 (on by default) ────────────────────────────────
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
- // Picks the first installed general-purpose model (skips the phaedo extraction
92
- // fine-tune). Falls back to the default string + reachable:false on any failure.
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 general = names.find((n) => !/phaedo|extract/i.test(n));
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
- export function mergeHookEntry(settings, event, command, marker) {
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 already = !!existing && existing.hooks.some((h) => h.command === command);
171
- next.hooks[event] = [...others, { hooks: [{ type: 'command', command }] }];
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
- if (!start.changed && !both.changed) return { name, status: 'absent', detail: 'no phaedo hook to remove' };
212
- if (!dryRun) { backup(path); writeConfig(path, both.settings); }
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 hook
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
- settings = both.settings; captureChanged = both.changed;
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
- settings = rm.settings; captureChanged = rm.changed;
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
- const changed = start.changed || captureChanged;
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 (ON by default, local Ollama, falls back to the rule engine):
400
- node install.mjs --consult-model <id> pin the model (e.g. llama3.1:8b)
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 (no model env)
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: ON by default. Pin it if --consult-model is given,
431
- // otherwise probe local Ollama and pick an installed general model. Off with
432
- // --no-model-judge. Always falls back to the rule engine at runtime, so a
433
- // missing model never breaks consultation.
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 (rule engine only)';
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.0",
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
- const m = inner.metrics || {};
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, loadFingerprintPreferCache, sourceOf } from './fingerprint-source.js';
20
- import { hasPairingRecord, loadPairingRecord, readServingClientId } from './phone-source.js';
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, consultOptsFromEnv, CONSULTATION_TYPES, consultDrivers, deriveDelegationSignals, withDelegationSignals, authoredDelegationSignals } from './consult.js';
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
- const CONSULT_OPTS = consultOptsFromEnv(process.env);
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
- "Ask how the user's cognitive pattern bears on a decision — without ingesting the fingerprint. " +
303
- 'Returns a signal (proceed | proceed_with_note | clarify | escalate | decline), a confidence, a ' +
304
- 'short rationale_hint, and a deference_level (how strongly to weight it). Use at agent decision points.',
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 response = withStaleAuthorityGuard(
338
- await resolveConsultation(vd, args || {}, CONSULT_OPTS), vd);
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
- 'After you consulted Phaedo and the USER decided what to do, record their outcome on the receipt: ' +
454
- 'approved | rejected | modified | unknown. Record what the USER chose, NOT whether you proceeded. ' +
455
- 'A rejected/modified outcome teaches Phaedo how the user wants delegated work handled (the act-as-me ' +
456
- 'channel). Defaults to the most recent receipt; pass receipt_id to mark a specific one.',
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 response = withStaleAuthorityGuard(
551
- await resolveConsultation(vd, req, CONSULT_OPTS), vd);
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: injection, consults, escalation, mandate signing.",
5
- "version": "0.3.0",
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.0",
16
+ "version": "0.3.1",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  }
@@ -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
+ }