phaedo-mcp 0.1.1 → 0.2.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/server.js CHANGED
@@ -16,11 +16,16 @@ 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 } from './fingerprint-source.js';
19
+ import { loadFingerprint, loadFingerprintPreferCache } from './fingerprint-source.js';
20
+ import { hasPairingRecord, loadPairingRecord } from './phone-source.js';
21
+ import { depositDelegationSuggestions } from './delegation-sync.js';
22
+ import { depositReceiptsDigest, drainAndApplyMarks } from './receipts-sync.js';
23
+ import { buildDelegationPromotions } from './consult-core.js';
24
+ import { depositEscalation, pollEscalationOnce, shouldEscalate, loadEscalationPolicy } from './escalation.js';
20
25
  import { buildInjectionResponse, errorResponse, PhaedoError } from './projection.js';
21
- import { resolveConsultation, checkAuthorization, consultOptsFromEnv, CONSULTATION_TYPES } from './consult.js';
26
+ import { resolveConsultation, checkAuthorization, consultOptsFromEnv, CONSULTATION_TYPES, consultDrivers, deriveDelegationSignals, withDelegationSignals, authoredDelegationSignals } from './consult.js';
22
27
  import { defaultStateDir } from './cache.js';
23
- import { buildReceipt, appendReceipt, receiptsCapFromEnv } from './receipts.js';
28
+ import { buildReceipt, appendReceipt, receiptsCapFromEnv, readReceipts, markOutcome, USER_ACTIONS } from './receipts.js';
24
29
 
25
30
  const BASE_DIR = dirname(fileURLToPath(import.meta.url));
26
31
 
@@ -28,16 +33,109 @@ const BASE_DIR = dirname(fileURLToPath(import.meta.url));
28
33
  // on-device receipt. Written AFTER resolution (the resolver stays pure) and
29
34
  // FAIL-SAFE: a receipt-write failure warns on stderr but never breaks the answer.
30
35
  const RECEIPTS_CAP = receiptsCapFromEnv(process.env);
31
- async function emitReceipt(via, args, response) {
36
+ async function emitReceipt(via, args, response, drivers) {
32
37
  try {
33
- const receipt = buildReceipt({ via, agentId: args?.agent_id, request: args, response });
38
+ const receipt = buildReceipt({ via, agentId: args?.agent_id, request: args, response, drivers });
34
39
  await appendReceipt(defaultStateDir(), receipt, { cap: RECEIPTS_CAP });
35
40
  } catch (e) {
36
41
  process.stderr.write(`[phaedo-mcp] receipt write failed (consultation still answered): ${e.message}\n`);
37
42
  }
38
43
  }
39
44
 
45
+ // Load the fingerprint for a consult and fold in the act-as-me channel: delegation
46
+ // signals derived from this subject's own override history (§10.6 receipts). The
47
+ // origin-aware resolver then lets a `delegation` correction outrank a `self` signal on
48
+ // the same dimension. Best-effort — if receipts are unreadable, the consult still
49
+ // answers from the plain fingerprint (delegation learning never breaks a consultation).
50
+ async function loadConsultVd(baseDir, { preferCache = false } = {}) {
51
+ // preferCache (escalation): read the at-rest cache instead of a live phone pull, so the
52
+ // escalation doesn't add a fingerprint-read AUTHORIZE prompt on top of its decision card.
53
+ const vd = preferCache ? await loadFingerprintPreferCache(baseDir) : await loadFingerprint(baseDir);
54
+ try {
55
+ // Two delegation sources, both origin-aware: live override history (every override)
56
+ // + the subject's ENDORSED, portable delegation rules (authored_delegation). The
57
+ // authored rule thus shapes consult too, not only the §9 injection it already renders in.
58
+ const signals = deriveDelegationSignals(await readReceipts(defaultStateDir()))
59
+ .concat(authoredDelegationSignals(vd && vd.phaedo_fingerprint));
60
+ return withDelegationSignals(vd, signals);
61
+ } catch (e) {
62
+ process.stderr.write(`[phaedo-mcp] delegation derive skipped (consult unaffected): ${e.message}\n`);
63
+ return vd;
64
+ }
65
+ }
66
+
67
+ // Act-as-me PROMOTION (delegation-sync.js): when the override history shows an
68
+ // ESTABLISHED pattern that isn't already authored/staged, deposit a delegation
69
+ // SUGGESTION across the sync boundary (relay agent_to_ext) for the subject to review +
70
+ // endorse in the extension. Best-effort and isolated — never throws into the consult.
71
+ // Only proposes a suggestion; the subject still authors (the authored-only invariant).
72
+ let _lastDepositSig = null; // dedup: the promo set last deposited THIS process (extension-side
73
+ // endorsement never round-trips into the agent's fp, so without this
74
+ // every consult would re-encrypt + re-POST the identical suggestion)
75
+ // §10.6 receipts audit channel (receipts-sync.js): deposit the latest receipts to the
76
+ // phone's audit slot (agent_to_phone) so the subject can mark outcomes in the flow
77
+ // rather than via `node receipts.js mark`. Dedup on the latest receipt_id +
78
+ // user_action set: identical state → skip the round-trip. Best-effort; a deposit
79
+ // failure NEVER throws into the tool that triggered it.
80
+ let _lastReceiptsSig = null;
81
+ async function maybeDepositReceiptsDigest() {
82
+ try {
83
+ if (!hasPairingRecord(BASE_DIR)) return;
84
+ const receipts = await readReceipts(defaultStateDir());
85
+ if (!receipts.length) return;
86
+ // Signature: latest id + a tally of marked states. Catches "new receipt arrived"
87
+ // AND "an existing receipt got marked" (so the next phone drain reflects the mark).
88
+ const latest = receipts[receipts.length - 1];
89
+ const tally = receipts.reduce((acc, r) => { acc[r.user_action ?? 'null'] = (acc[r.user_action ?? 'null'] || 0) + 1; return acc; }, {});
90
+ const sig = `${latest.receipt_id}|${JSON.stringify(tally)}`;
91
+ if (sig === _lastReceiptsSig) return;
92
+ const res = await depositReceiptsDigest(await loadPairingRecord(BASE_DIR), receipts);
93
+ if (res && res.deposited) _lastReceiptsSig = sig;
94
+ else if (res && !res.deposited && res.reason !== 'no-receipts')
95
+ process.stderr.write(`[phaedo-mcp] receipts digest deposit skipped: ${res.reason || 'http ' + res.status}\n`);
96
+ } catch (e) {
97
+ process.stderr.write(`[phaedo-mcp] receipts digest deposit skipped: ${e.message}\n`);
98
+ }
99
+ }
100
+
101
+ // Drain phone-deposited outcome marks and apply them via markOutcome. Best-effort;
102
+ // called before tools that read receipts (consult uses delegation derivation; the
103
+ // outcome tool may default to "latest") so a recent phone-side mark is reflected
104
+ // before the next operation. Idempotent on the MCP side.
105
+ async function maybePullPhoneMarks() {
106
+ try {
107
+ if (!hasPairingRecord(BASE_DIR)) return { applied: 0, skipped: 0 };
108
+ return await drainAndApplyMarks(await loadPairingRecord(BASE_DIR), defaultStateDir());
109
+ } catch (e) {
110
+ process.stderr.write(`[phaedo-mcp] phone marks drain skipped: ${e.message}\n`);
111
+ return { applied: 0, skipped: 0, reason: e.message };
112
+ }
113
+ }
114
+
115
+ async function maybeDepositDelegationPromotions(vd) {
116
+ try {
117
+ if (!hasPairingRecord(BASE_DIR)) return; // no extension to deposit to
118
+ const promos = buildDelegationPromotions(await readReceipts(defaultStateDir()), (vd && vd.phaedo_fingerprint) || {});
119
+ if (!promos.length) return;
120
+ const sig = promos.map(p => `${p.decision?.dimension}|${p.decision?.domain || ''}|${p.decision?.value}`).sort().join(',');
121
+ if (sig === _lastDepositSig) return; // identical set already deposited — skip the relay round-trip
122
+ const res = await depositDelegationSuggestions(await loadPairingRecord(BASE_DIR), promos);
123
+ if (res && res.deposited) _lastDepositSig = sig; // latch only on success, so a failed deposit retries next consult
124
+ else if (res && !res.deposited) // promotions existed but didn't land — say why (don't fail silently)
125
+ process.stderr.write(`[phaedo-mcp] ${promos.length} delegation promotion(s) ready but deposit skipped: ${res.reason || 'http ' + res.status}${res.reason === 'no-relay-device-id' ? ' — pairing has no relay lane (local-only pairing)' : ''}\n`);
126
+ } catch (e) {
127
+ process.stderr.write(`[phaedo-mcp] delegation promotion deposit skipped: ${e.message}\n`);
128
+ }
129
+ }
130
+
40
131
  const CONSULT_OPTS = consultOptsFromEnv(process.env);
132
+ // Real-time escalation window (docs/roadmap/escalation-and-push.md §crux): how long a
133
+ // blocking decision waits for the subject before the agent applies the safe-default
134
+ // (hold). Configurable; bounded by the relay's own MAX_WINDOW_MS.
135
+ const ESCALATION_WINDOW_MS = (() => {
136
+ const n = Number(process.env.PHAEDO_ESCALATION_WINDOW_MS);
137
+ return Number.isFinite(n) && n > 0 ? n : 3 * 60 * 1000;
138
+ })();
41
139
  // Deference policies: PHAEDO_CONSULT_POLICIES if set, else the default file beside
42
140
  // the server (mcp/consult-policies.json — what policy-editor.html produces). Zero
43
141
  // config: drop the file in and restart the client. (Portable vault policies in
@@ -48,7 +146,18 @@ if (existsSync(POLICIES_PATH)) {
48
146
  catch (e) { process.stderr.write(`[phaedo-mcp] ignoring malformed consult policies (${POLICIES_PATH}): ${e.message}\n`); }
49
147
  }
50
148
 
51
- const server = new McpServer({ name: 'phaedo', version: '0.1.0' });
149
+ // Escalation PUSH policy (which signals wake the subject): the subject-configurable knob
150
+ // over the stakes-gated default. Local file (PHAEDO_ESCALATION_POLICY, else
151
+ // mcp/escalation-policy.json beside the server) overrides; the portable vault policy
152
+ // (phaedo_fingerprint.escalation_policy) is merged in per-call (loadEscalationPolicy).
153
+ const ESCALATION_POLICY_PATH = process.env.PHAEDO_ESCALATION_POLICY || join(BASE_DIR, 'escalation-policy.json');
154
+ let ESCALATION_POLICY_FILE = null;
155
+ if (existsSync(ESCALATION_POLICY_PATH)) {
156
+ try { ESCALATION_POLICY_FILE = JSON.parse(readFileSync(ESCALATION_POLICY_PATH, 'utf8')); }
157
+ catch (e) { process.stderr.write(`[phaedo-mcp] ignoring malformed escalation policy (${ESCALATION_POLICY_PATH}): ${e.message}\n`); }
158
+ }
159
+
160
+ const server = new McpServer({ name: 'phaedo', version: '0.2.1' });
52
161
 
53
162
  // ── Tool: phaedo_request_injection (binding §3.1) ─────────────────────────────
54
163
  server.registerTool(
@@ -72,6 +181,8 @@ server.registerTool(
72
181
  requested_layers: z.array(z.string()).optional(),
73
182
  consumer_id: z.string().optional(),
74
183
  },
184
+ // Read-only: returns the projection, writes nothing. Safe to auto-allow.
185
+ annotations: { readOnlyHint: true, openWorldHint: false },
75
186
  },
76
187
  async (args) => {
77
188
  try {
@@ -109,6 +220,12 @@ server.registerTool(
109
220
  inputSchema: {
110
221
  consultation_type: z.enum(CONSULTATION_TYPES),
111
222
  agent_id: z.string().optional(),
223
+ // RESERVED for v0.3 authenticated agent identity (§10.5 enforcement / OQ9).
224
+ // Today `agent_id` is self-asserted and unverified; v0.3 will carry a signed
225
+ // per-agent credential here so a subject can honestly scope `agents`. Declared
226
+ // now (optional, ignored) so the v0.3 addition is backward-compatible — an
227
+ // agent sending it against this server is simply unaffected.
228
+ agent_credential: z.string().optional(),
112
229
  action_descriptor: z
113
230
  .object({
114
231
  domain: z.string().optional(),
@@ -120,12 +237,21 @@ server.registerTool(
120
237
  .optional(),
121
238
  context: z.record(z.any()).optional(),
122
239
  },
240
+ // Not read-only: appends a local §10.6 audit receipt and may best-effort deposit a
241
+ // delegation suggestion. Non-destructive and device-local (no external entities).
242
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
123
243
  },
124
244
  async (args) => {
125
245
  try {
126
- const vd = await loadFingerprint(BASE_DIR);
246
+ const vd = await loadConsultVd(BASE_DIR); // fingerprint + act-as-me delegation overlay
127
247
  const response = await resolveConsultation(vd, args || {}, CONSULT_OPTS);
128
- await emitReceipt('phaedo_consult', args, response); // every resolution, abstains included
248
+ // Provenance for the local audit receipt only — the cues that drove this
249
+ // (never in `response`; §10.4). Best-effort: a failure must not block the answer.
250
+ let drivers = [];
251
+ try { drivers = consultDrivers(vd, args || {}); } catch { /* provenance is non-critical */ }
252
+ await emitReceipt('phaedo_consult', args, response, drivers); // every resolution, abstains included
253
+ await maybeDepositDelegationPromotions(vd); // act-as-me promotion (best-effort)
254
+ await maybeDepositReceiptsDigest(); // G1+G2 audit channel — refresh the phone's view
129
255
  return {
130
256
  content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
131
257
  structuredContent: response,
@@ -160,6 +286,9 @@ server.registerTool(
160
286
  inputSchema: {
161
287
  consultation_type: z.enum(CONSULTATION_TYPES).optional(),
162
288
  agent_id: z.string().optional(),
289
+ // RESERVED for v0.3 authenticated agent identity (§10.5) — see phaedo_consult.
290
+ // Optional + ignored today; declared so v0.3 enforcement is additive.
291
+ agent_credential: z.string().optional(),
163
292
  action_descriptor: z
164
293
  .object({
165
294
  domain: z.string().optional(),
@@ -170,6 +299,8 @@ server.registerTool(
170
299
  })
171
300
  .optional(),
172
301
  },
302
+ // Deterministic pre-flight; appends a local audit receipt. Non-destructive, device-local.
303
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
173
304
  },
174
305
  async (args) => {
175
306
  try {
@@ -181,6 +312,7 @@ server.registerTool(
181
312
  // filling with probes; the follow-up phaedo_consult writes its own).
182
313
  if (response.authorization_matched) {
183
314
  await emitReceipt('phaedo_check_authorization', req, { signal: response.signal, confidence: 1.0, deference_level: 'high' });
315
+ await maybeDepositReceiptsDigest(); // G1+G2 audit channel — only on a matched preflight (no-match writes no receipt)
184
316
  }
185
317
  return {
186
318
  content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
@@ -197,6 +329,232 @@ server.registerTool(
197
329
  }
198
330
  );
199
331
 
332
+ // ── Tool: phaedo_record_outcome (spec §10.6 — the act-as-me loop closer) ──────
333
+ // After consulting Phaedo and the SUBJECT decided what to do, record their outcome on
334
+ // the consultation receipt. This is the source of delegation learning: a `rejected` or
335
+ // `modified` outcome — the subject CORRECTING an agent that acted on their behalf —
336
+ // teaches Phaedo how they want delegated work handled (a `delegation`-origin signal that
337
+ // outranks their own `self` pattern in future consults). Honesty rail: record what the
338
+ // SUBJECT chose, never whether YOU (the agent) proceeded — `approved` self-reported by an
339
+ // obedient agent teaches nothing, by design, so the agreement metric can't collapse into
340
+ // "agent obedience". §10.4-safe: returns only {matched, receipt_id, user_action}.
341
+ server.registerTool(
342
+ 'phaedo_record_outcome',
343
+ {
344
+ title: 'Record what the subject decided about a consulted action',
345
+ description:
346
+ 'After you consulted Phaedo and the USER decided what to do, record their outcome on the receipt: ' +
347
+ 'approved | rejected | modified | unknown. Record what the USER chose, NOT whether you proceeded. ' +
348
+ 'A rejected/modified outcome teaches Phaedo how the user wants delegated work handled (the act-as-me ' +
349
+ 'channel). Defaults to the most recent receipt; pass receipt_id to mark a specific one.',
350
+ inputSchema: {
351
+ outcome: z.enum(USER_ACTIONS),
352
+ receipt_id: z.string().optional(),
353
+ // RESERVED for v0.3 learning-loop integrity (OQ16). This is the act-as-me
354
+ // training channel: an adversary inside the same MCP boundary could poison
355
+ // delegation preferences with forged outcome calls. v0.3 will require the
356
+ // recording agent to be authenticated (agent_id + signed agent_credential),
357
+ // rate-limited per dimension, and anomaly-checked. Declared now (optional,
358
+ // ignored) so that enforcement is a backward-compatible addition.
359
+ agent_id: z.string().optional(),
360
+ agent_credential: z.string().optional(),
361
+ },
362
+ // Writes the subject's decision onto a local receipt (the act-as-me learning signal).
363
+ // Non-destructive + idempotent (re-recording the same outcome is a no-op); device-local.
364
+ // Recommend "Always allow" so the learning isn't gated behind a per-call prompt.
365
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
366
+ },
367
+ async (args) => {
368
+ try {
369
+ // Drain any phone-deposited marks first (G1+G2 audit channel): if the subject
370
+ // already marked outcomes from the phone, they should land before the agent's
371
+ // own self-record so "latest" resolves against the freshest state. Best-effort.
372
+ await maybePullPhoneMarks();
373
+ const res = await markOutcome(defaultStateDir(), args?.receipt_id || 'latest', args.outcome);
374
+ // Refresh the phone's view so the just-applied mark is reflected on next drain.
375
+ await maybeDepositReceiptsDigest();
376
+ const body = res.matched
377
+ ? { request_type: 'outcome_record', recorded: true, receipt_id: res.receipt_id, user_action: res.user_action }
378
+ : { request_type: 'outcome_record', recorded: false, reason: res.reason };
379
+ return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
380
+ } catch (err) {
381
+ const code = err instanceof PhaedoError ? err.code : 'request_malformed';
382
+ return {
383
+ content: [{ type: 'text', text: `Phaedo record-outcome error [${code}]: ${err.message}` }],
384
+ isError: true,
385
+ };
386
+ }
387
+ }
388
+ );
389
+
390
+ // ── Tool: phaedo_escalate (docs/roadmap/escalation-and-push.md) ───────────────
391
+ // REAL-TIME escalation for an AUTONOMOUS agent: when an agent acting on the subject's
392
+ // behalf hits a decision it can't make alone, wake the subject's phone for a live
393
+ // approve/deny/modify instead of guessing. Self-gating: it runs the consult itself and
394
+ // only wakes the subject when the signal is genuinely blocking (escalate/clarify) — a
395
+ // proceed* signal returns "just proceed", so the subject isn't pinged for low-stakes
396
+ // calls. Non-blocking: deposits + wakes, returns a request_id immediately; the agent
397
+ // then polls phaedo_escalation_status and HOLDS until it resolves (the safe default if
398
+ // the window lapses is do-not-proceed). §10.4-safe: only the agent's own action
399
+ // descriptor + the consult signal/rationale cross the wire (encrypted), never layer
400
+ // content.
401
+ server.registerTool(
402
+ 'phaedo_escalate',
403
+ {
404
+ title: 'Escalate a blocking decision to the subject in real time',
405
+ description:
406
+ "When you're acting autonomously for the user and hit a decision you shouldn't make alone, " +
407
+ 'escalate it to them live instead of guessing. Runs the consultation and, only if the signal is ' +
408
+ 'blocking (escalate/clarify), wakes the user\'s phone for an approve/deny/modify. Returns a ' +
409
+ 'request_id immediately — then poll phaedo_escalation_status and DO NOT PROCEED until it resolves ' +
410
+ '(if the user doesn\'t answer in time, hold). Use for consequential, hard-to-undo actions.',
411
+ inputSchema: {
412
+ consultation_type: z.enum(CONSULTATION_TYPES).optional(),
413
+ agent_id: z.string().optional(),
414
+ // RESERVED for v0.3 authenticated agent identity (§10.5) — see phaedo_consult.
415
+ // Optional + ignored today; declared so v0.3 enforcement is additive.
416
+ agent_credential: z.string().optional(),
417
+ action_descriptor: z
418
+ .object({
419
+ domain: z.string().optional(),
420
+ reversible: z.boolean().optional(),
421
+ magnitude: z.enum(['low', 'medium', 'high']).optional(),
422
+ amount: z.number().optional(),
423
+ summary: z.string().optional(),
424
+ })
425
+ .optional(),
426
+ context: z.record(z.any()).optional(),
427
+ },
428
+ // Opens a channel to an EXTERNAL entity (the subject's device, via the relay) and
429
+ // writes a local receipt — not read-only, not destructive, but openWorld (it reaches off-device).
430
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
431
+ },
432
+ async (args) => {
433
+ try {
434
+ const req = { consultation_type: 'action_approval', ...(args || {}) };
435
+ // Cache-preferred read: escalation shouldn't trigger a fingerprint-read authorize
436
+ // prompt on top of the decision card the subject already answers (one prompt, not two).
437
+ const vd = await loadConsultVd(BASE_DIR, { preferCache: true });
438
+ const response = await resolveConsultation(vd, req, CONSULT_OPTS);
439
+ let drivers = [];
440
+ try { drivers = consultDrivers(vd, req); } catch { /* non-critical */ }
441
+ await emitReceipt('phaedo_escalate', req, response, drivers); // the decision being escalated
442
+ await maybeDepositReceiptsDigest(); // G1+G2 audit channel
443
+
444
+ // Self-gate: only wake the subject for a genuinely blocking signal. `escalate`
445
+ // always; `clarify` per the subject's push policy (default: only when the action is
446
+ // consequential — irreversible / high magnitude). A low-stakes clarify is the
447
+ // agent's to resolve in its own flow.
448
+ const pushPolicy = loadEscalationPolicy(vd, { escalationPolicy: ESCALATION_POLICY_FILE });
449
+ if (!shouldEscalate(response.signal, req.action_descriptor, pushPolicy)) {
450
+ const subThresholdClarify = response.signal === 'clarify';
451
+ const body = {
452
+ request_type: 'escalation', escalated: false, signal: response.signal,
453
+ rationale_hint: response.rationale_hint, confidence: response.confidence,
454
+ ...(subThresholdClarify ? { reason: 'clarify_below_threshold' } : {}),
455
+ message: subThresholdClarify
456
+ ? 'Low-stakes clarification — handle it in your own flow (ask your clarifying question, or make a reasonable assumption and state it). It will surface in the user\'s review queue; no need to wake them. Raise an explicit escalate if you truly need a live decision.'
457
+ : 'Signal is not blocking — act on it with your own judgment; no need to interrupt the user.',
458
+ };
459
+ return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
460
+ }
461
+
462
+ const pairing = await loadPairingRecord(BASE_DIR);
463
+ const dep = await depositEscalation(pairing, req, response, { windowMs: ESCALATION_WINDOW_MS });
464
+ const body = dep.deposited
465
+ ? {
466
+ request_type: 'escalation', escalated: true, status: 'pending',
467
+ request_id: dep.request_id, window_ms: dep.window_ms, expires_at: dep.expires_at,
468
+ signal: response.signal, rationale_hint: response.rationale_hint,
469
+ // The relay's push-to-wake result for a BACKGROUNDED phone: { sent, reason }.
470
+ // sent:true → APNs accepted the wake; reason 'not_configured' → relay APNS_* unset,
471
+ // 'no_token'/'not_registered' → phone never registered its APNs token, 'apns_*' →
472
+ // Apple rejected (e.g. BadDeviceToken = sandbox/prod mismatch). A FOREGROUNDED phone
473
+ // is woken over the WS regardless, so a non-sent wake is not necessarily a failure.
474
+ woke: dep.woke,
475
+ message: 'Escalated to the user. Poll phaedo_escalation_status with this request_id; DO NOT PROCEED until it resolves. If it expires unanswered, hold (do not act).',
476
+ }
477
+ : {
478
+ request_type: 'escalation', escalated: false, status: 'unreachable', reason: dep.reason,
479
+ signal: response.signal, rationale_hint: response.rationale_hint,
480
+ message: dep.reason === 'no-relay-device-id' || dep.reason === 'unpaired'
481
+ ? 'No real-time channel to the user (no paired device with a relay lane). Hold or fall back to surfacing the decision in your own conversation.'
482
+ : 'Could not reach the user right now. Hold (do not act) or surface the decision in your own conversation.',
483
+ };
484
+ return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
485
+ } catch (err) {
486
+ const body = errorResponse(err, 'escalation');
487
+ const code = err instanceof PhaedoError ? err.code : 'internal_error';
488
+ return { content: [{ type: 'text', text: `Phaedo escalation error [${code}]: ${JSON.stringify(body)}` }], isError: true };
489
+ }
490
+ }
491
+ );
492
+
493
+ // ── Tool: phaedo_escalation_status ────────────────────────────────────────────
494
+ // Poll a pending escalation (from phaedo_escalate). Returns the subject's decision once
495
+ // they answer — approve/deny/modify — and AUTOMATICALLY records it as the §10.6 outcome
496
+ // (roadmap Q7: a real-time approve/deny IS the strongest act-as-me signal, so it feeds the
497
+ // same override-learning loop as phaedo_record_outcome). While unanswered it returns
498
+ // pending (keep holding); past the window it returns expired (apply the safe-default: hold).
499
+ server.registerTool(
500
+ 'phaedo_escalation_status',
501
+ {
502
+ title: 'Check whether the subject has decided an escalation',
503
+ description:
504
+ 'Poll an escalation you opened with phaedo_escalate. Returns resolved + the decision (approve | deny | ' +
505
+ 'modify) once the user answers, pending while you should keep waiting, or expired once the window lapsed ' +
506
+ '(then HOLD — do not proceed). A resolved decision is recorded automatically as the act-as-me outcome.',
507
+ inputSchema: {
508
+ request_id: z.string(),
509
+ expires_at: z.number().optional(),
510
+ receipt_id: z.string().optional(),
511
+ },
512
+ // Reaches the relay (openWorld) and, on resolution, writes the outcome onto a local receipt.
513
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
514
+ },
515
+ async (args) => {
516
+ try {
517
+ const pairing = await loadPairingRecord(BASE_DIR);
518
+ const r = await pollEscalationOnce(pairing, args.request_id, { expiresAt: args.expires_at });
519
+ if (r.resolved) {
520
+ // The live decision IS an outcome — record it on the consult receipt automatically.
521
+ let recorded = false;
522
+ try {
523
+ const m = await markOutcome(defaultStateDir(), args.receipt_id || 'latest', r.outcome);
524
+ recorded = !!(m && m.matched);
525
+ await maybeDepositReceiptsDigest(); // G1+G2: phone sees the recorded outcome on next drain
526
+ } catch { /* outcome-record is best-effort; the decision still returns */ }
527
+ const body = {
528
+ request_type: 'escalation_status', status: 'resolved',
529
+ decision: r.decision, proceed: r.proceed, outcome: r.outcome,
530
+ ...(r.note ? { note: r.note } : {}), ...(r.modified != null ? { modified: r.modified } : {}),
531
+ recorded,
532
+ message: !r.proceed
533
+ ? 'The user declined — do not proceed.'
534
+ : (r.decision === 'modify'
535
+ ? (r.modified != null
536
+ ? 'The user approved with changes — proceed using the modified parameters.'
537
+ : `The user wants a change before you proceed${r.note ? `: "${r.note}"` : ''}. Apply their instruction; do not proceed as originally planned.`)
538
+ : 'The user approved — proceed.'),
539
+ };
540
+ return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
541
+ }
542
+ const expired = r.status === 'expired';
543
+ const body = {
544
+ request_type: 'escalation_status', status: r.status,
545
+ ...(expired ? { proceed: false } : {}),
546
+ message: expired
547
+ ? 'The decision window lapsed with no answer. Apply the safe default: hold — do not proceed.'
548
+ : 'Not answered yet. Keep holding; poll again shortly.',
549
+ };
550
+ return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
551
+ } catch (err) {
552
+ const code = err instanceof PhaedoError ? err.code : 'request_malformed';
553
+ return { content: [{ type: 'text', text: `Phaedo escalation-status error [${code}]: ${err.message}` }], isError: true };
554
+ }
555
+ }
556
+ );
557
+
200
558
  // ── Resource: phaedo://fingerprint/projection (binding §3.1) ──────────────────
201
559
  // Default projection (all layers, standard mode) for resource-only clients.
202
560
  server.registerResource(
package/server.json CHANGED
@@ -1,18 +1,19 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
- "name": "io.github.galavoxx/phaedo-mcp",
4
- "description": "Carry your Phaedo cognitive fingerprint into any MCP client: inject your style at session start, and consult your decision pattern (proceed/clarify/escalate/decline) at agent decision points. Local, user-owned, privacy-preserving.",
5
- "version": "0.1.1",
3
+ "name": "io.github.phaedo-labs/phaedo-mcp",
4
+ "description": "Carry your Phaedo cognitive fingerprint into any MCP client: style injection, decision consults + real-time escalation.",
5
+ "version": "0.2.1",
6
6
  "repository": {
7
- "url": "https://github.com/galavoxx/phaedo-mcp",
8
- "source": "github"
7
+ "url": "https://github.com/phaedo-labs/phaedo",
8
+ "source": "github",
9
+ "subfolder": "mcp"
9
10
  },
10
11
  "packages": [
11
12
  {
12
13
  "registryType": "npm",
13
14
  "registryBaseUrl": "https://registry.npmjs.org",
14
15
  "identifier": "phaedo-mcp",
15
- "version": "0.1.1",
16
+ "version": "0.2.1",
16
17
  "transport": {
17
18
  "type": "stdio"
18
19
  }