New:Socket for Asana Is Now Available.Learn more
Sign In

phaedo-mcp

Package Overview
Dependencies
Maintainers
1
Versions
5
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

phaedo-mcp - npm Package Compare versions

Comparing version
0.1.1
to
0.2.0
+107
calibration.js
// Phaedo — calibration reliability curve (build-now tier, slice 2).
//
// agreement.js answers "how often was the oracle right, per domain." This answers a
// DIFFERENT and (per the design thread) more important question: is the oracle's
// CONFIDENCE honest? When it says 0.8, does that decision actually hold ~80% of the
// time? A well-calibrated 0.6 is worth more to an agent deciding act-vs-escalate than
// an overconfident 0.9. So we bucket decisive consult predictions by confidence band
// and compare the band's MEAN PREDICTED confidence to its REALIZED agreement rate.
//
// PURE: (receipts, opts) -> report. No I/O, no globals — same discipline as
// agreement.js / consult-core.js. Reuses agreement.js's scoring contract (DECISIVE
// signals + agrees()) so "right" means exactly what it means there.
//
// DELIBERATELY NOT WIRED to anything. This is a READOUT (`npm run calibration`).
// Recalibrating confidence or auto-adjusting deference from this curve is a product
// decision for a later session (closing the loop), not an engineering default.
//
// Metric: for each scored receipt (decisive signal + known user_action, in window),
// bucket by response.confidence into equal-width bins. Per bin:
// predicted_mean = mean(confidence), realized_rate = agreements / scored,
// gap = predicted_mean - realized_rate (positive = OVERconfident).
// Expected Calibration Error (ECE) = sum over bins of (bin_scored/total_scored)·|gap|.
// A low ECE means the numbers can be trusted as probabilities.
import { DECISIVE, DEFAULT_WINDOW_DAYS } from './agreement.js';
function agrees(signal, userAction) {
if (userAction === 'approved') return signal === 'proceed' || signal === 'proceed_with_note';
if (userAction === 'rejected') return signal === 'decline';
return false; // 'modified' — not accepted as-judged
}
const clamp01 = (x) => Math.max(0, Math.min(1, x));
// (receipts, { windowDays, bins, now }) -> {
// window: { days, since, now, total_receipts, in_window },
// scored, ece,
// bins: [ { lo, hi, scored, agreements, predicted_mean, realized_rate, gap } ],
// }
// ece / a bin's realized_rate are null when nothing was scored (no data ≠ 0%).
export function computeCalibration(receipts, { windowDays = DEFAULT_WINDOW_DAYS, bins = 5, now = Date.now() } = {}) {
const nBins = Math.max(1, Math.floor(bins));
const since = now - windowDays * 24 * 60 * 60 * 1000;
const slot = (conf) => Math.min(nBins - 1, Math.floor(clamp01(conf) * nBins)); // 1.0 → last bin
const table = Array.from({ length: nBins }, (_, i) => ({
lo: Math.round((i / nBins) * 100) / 100,
hi: Math.round(((i + 1) / nBins) * 100) / 100,
scored: 0, agreements: 0, _confSum: 0, predicted_mean: null, realized_rate: null, gap: null,
}));
let inWindow = 0, totalScored = 0;
for (const r of Array.isArray(receipts) ? receipts : []) {
const t = Date.parse(r?.created_at);
if (!Number.isFinite(t) || t < since || t > now) continue;
inWindow++;
const signal = r.response?.signal;
const action = r.user_action;
const conf = r.response?.confidence;
if (!DECISIVE.includes(signal)) continue; // non-directional: no prediction to calibrate
if (action == null || action === 'unknown') continue; // not yet scorable
if (typeof conf !== 'number' || !Number.isFinite(conf)) continue;
const b = table[slot(conf)];
b.scored++; b._confSum += clamp01(conf);
if (agrees(signal, action)) b.agreements++;
totalScored++;
}
let eceSum = 0;
for (const b of table) {
if (b.scored > 0) {
b.predicted_mean = Math.round((b._confSum / b.scored) * 1000) / 1000;
b.realized_rate = Math.round((b.agreements / b.scored) * 1000) / 1000;
b.gap = Math.round((b.predicted_mean - b.realized_rate) * 1000) / 1000;
eceSum += (b.scored / totalScored) * Math.abs(b.predicted_mean - b.realized_rate);
}
delete b._confSum;
}
return {
window: { days: windowDays, since: new Date(since).toISOString(), now: new Date(now).toISOString(), total_receipts: Array.isArray(receipts) ? receipts.length : 0, in_window: inWindow },
scored: totalScored,
ece: totalScored > 0 ? Math.round(eceSum * 1000) / 1000 : null,
bins: table,
};
}
// ── Dev CLI: `node calibration.js [windowDays] [bins]` / `npm run calibration` ──
// Local readout only — decrypts the local receipt store with the local key; nothing
// leaves the device. NOT product UX, NOT fed back into confidence/deference.
if (process.argv[1] && process.argv[1].endsWith('calibration.js')) {
const days = Number(process.argv[2]) || DEFAULT_WINDOW_DAYS;
const nBins = Number(process.argv[3]) || 5;
Promise.all([import('./receipts.js'), import('./cache.js')]).then(async ([{ readReceipts }, { defaultStateDir }]) => {
const report = computeCalibration(await readReceipts(defaultStateDir()), { windowDays: days, bins: nBins });
const { window: w } = report;
console.log(`Calibration over the last ${w.days}d — ${report.scored} scored of ${w.in_window}/${w.total_receipts} receipts in window`);
console.log(`ECE (expected calibration error): ${report.ece === null ? ' — ' : report.ece.toFixed(3)} (lower = confidence is honest)\n`);
console.log('band scored predicted realized gap');
for (const b of report.bins) {
const f = (x) => x === null ? ' — ' : (x * 100).toFixed(0).padStart(4) + '%';
const gap = b.gap === null ? ' — ' : (b.gap > 0 ? '+' : '') + (b.gap * 100).toFixed(0) + '%';
console.log(`${(b.lo.toFixed(1) + '–' + b.hi.toFixed(1)).padEnd(12)} ${String(b.scored).padStart(5)} ${f(b.predicted_mean)} ${f(b.realized_rate)} ${gap.padStart(6)}`);
}
if (report.scored === 0) console.log('\nNo scored decisive receipts yet — set user_action as you accept/reject agent actions.');
else console.log('\npositive gap = overconfident (predicted > realized); negative = underconfident.');
}).catch((e) => { console.error(`calibration: ${e.message}`); process.exit(1); });
}
+363
-36

@@ -42,2 +42,10 @@ // Phaedo §10 Agent Consultation — the PURE resolver core.

// §4.7: distinct from "no coverage" — coverage EXISTS but the dimension(s) this action
// turns on are under an open conflict the subject hasn't settled, so the oracle withholds
// a signal (the consult-side mirror of injection suppression). Escalate, don't assume.
const CONTESTED_HINT =
'The subject is actively reconciling a contradiction on the decision dimension this ' +
'action turns on, so the oracle withholds a signal until they settle it — escalate to ' +
'the subject rather than assume a default.';
// ── vd contract guard (brief M0.4) ────────────────────────────────────────────

@@ -98,2 +106,7 @@ // The consultation boundary's mirror of context-block.js validateVaultData (which

const BASIS_WEIGHT = { observed: 1.0, corroborated: 1.0, self_reported: 0.9, text_scan: 0.75 };
// The act-as-me channel: a `delegation`-origin cue (how the subject wants work done FOR
// them) reads as MORE authoritative for a consult — an agent is about to act on their
// behalf — so it is boosted above a `self`-origin one. `self` is the neutral 1.0, so
// today's all-self fingerprints are unchanged; the per-cue product is clamped to 1.0.
const ORIGIN_WEIGHT = { delegation: 1.15, self: 1.0 };

@@ -108,3 +121,35 @@ // Does a signal's §4.2.7 domain tag pertain to the action's domain? Same

// Resolve a relevant cue to { text, confidence, basis }, preferring a structured
// ── Open-conflict suppression (§4.7, the consult-side mirror of injection) ──────
// When a dimension has an OPEN conflict_record (the subject's self-report and a fresh
// observation disagree, not yet settled), the injection projection withholds it from
// the layer summary (extraction/reconcile.js openConflictFieldKeys → renderLayerSummary
// suppress). Consult reads the raw signals, not the summary, so it must withhold the
// SAME contested dimensions itself — otherwise an agent gets a confident answer on a
// dimension the subject is still deciding. A withheld cue counts as "not found", so if
// every relevant cue is contested, coverage→0 and the resolver abstains (below). Field
// keys mirror reconcile.js: `field` (untagged → fingerprint-wide) or `field::domain`.
function openConflictKeys(vd) {
const recs = vd?.phaedo_fingerprint?.conflict_records;
const set = new Set();
for (const r of (Array.isArray(recs) ? recs : [])) {
if (r && r.status === 'open' && r.field) set.add(r.field + (typeof r.domain === 'string' ? `::${r.domain}` : ''));
}
return set;
}
// Is `field` contested for this action? An UNTAGGED open conflict suppresses it
// fingerprint-wide (matching untagged signals' fingerprint-wide reach); a domain-TAGGED
// conflict suppresses only when the action's domain matches.
function isFieldContested(suppressed, field, actionDomain) {
if (!suppressed || !suppressed.size) return false;
if (suppressed.has(field)) return true;
if (actionDomain) {
for (const key of suppressed) {
const i = key.indexOf('::');
if (i > 0 && key.slice(0, i) === field && domainMatches(key.slice(i + 2), actionDomain)) return true;
}
}
return false;
}
// Resolve a relevant cue to { text, confidence, basis, origin }, preferring a structured
// §4.2 signal (which carries a real per-signal confidence + provenance) over a

@@ -119,2 +164,9 @@ // dimensioned questionnaire response. Returns undefined when neither is present

// the action has no domain, tags are ignored (any field-matching signal serves).
//
// Origin preference (the act-as-me channel, §4.2 `origin`): a `delegation`-origin
// signal — learned from the subject CORRECTING an agent acting on their behalf —
// OUTRANKS a `self`-origin one for the same dimension. A consultation IS an agent
// about to act on the subject's behalf (§10), so how they want work done FOR them
// dominates how they do it themselves. Absent `origin` ⇒ `self`, so today's
// (all-self) fingerprints are unaffected — this only bites once delegation signals exist.
function signalCue(vd, layerId, fields, actionDomain) {

@@ -128,19 +180,29 @@ const L = layer(vd, layerId);

const basis = typeof s.evidence_basis === 'string' ? s.evidence_basis : evidenceBasis(s.source);
return { text: String(Array.isArray(s.value) ? s.value.join(' ') : s.value).toLowerCase(), confidence, basis };
const origin = s.origin === 'delegation' ? 'delegation' : 'self';
return { text: String(Array.isArray(s.value) ? s.value.join(' ') : s.value).toLowerCase(), confidence, basis, origin };
};
let untagged;
// Score each matching signal and keep the best. tier = domain relevance (a domain
// match outranks an untagged fallback, as before); within a tier, `delegation`
// outranks `self`. Ties keep document order (first wins) — so for today's all-self,
// mostly-untagged fingerprints the selection is identical to the prior first-match.
let best;
for (const s of (L.signals || [])) {
if (!(s && want.has(s.field) && (s.polarity ?? 'positive') !== 'negative' && s.value != null)) continue;
let tier;
if (typeof s.domain === 'string' && actionDomain) {
if (domainMatches(s.domain, actionDomain)) return asCue(s); // domain match wins outright
continue; // wrong domain — skip
if (!domainMatches(s.domain, actionDomain)) continue; // wrong domain — skip
tier = 2; // domain match
} else if (typeof s.domain === 'string') {
tier = 1; // tagged, action domain-free
} else {
tier = 0; // untagged fallback (today's norm)
}
if (typeof s.domain === 'string' && !actionDomain) return asCue(s); // no action domain → tags ignored
if (!untagged) untagged = asCue(s); // untagged fallback
const score = tier * 2 + (s.origin === 'delegation' ? 1 : 0);
if (!best || score > best.score) best = { score, cue: asCue(s) };
}
if (untagged) return untagged;
if (best) return best.cue;
for (const r of (L.responses || L.answers || [])) {
const dim = r?.dimension || r?.field;
const v = r?.value ?? r?.answer;
if (dim && want.has(dim) && v != null) return { text: String(v).toLowerCase(), confidence: 0.7, basis: 'self_reported' };
if (dim && want.has(dim) && v != null) return { text: String(v).toLowerCase(), confidence: 0.7, basis: 'self_reported', origin: 'self' };
}

@@ -204,3 +266,9 @@ return undefined;

function readCue(vd, spec, actionDomain) {
function readCue(vd, spec, actionDomain, suppressed) {
// §4.7: if any of this cue's fields is under an open conflict, withhold the whole cue
// (its field-aliases name the same contested dimension). `withheld` lets the resolver
// tell "abstaining because contested" apart from "abstaining for no coverage".
if (suppressed && spec.fields.some((f) => isFieldContested(suppressed, f, actionDomain))) {
return { found: false, lean: 0, confidence: 0, basis: null, withheld: true };
}
let cue;

@@ -213,8 +281,16 @@ for (const lid of spec.layers) { cue = signalCue(vd, lid, spec.fields, actionDomain); if (cue) break; } // a structured §4.2 signal wins

const lean = (c && !b) ? 1 : (b && !c) ? -1 : 0; // both/neither → present but neutral
// per-cue confidence + basis: a structured signal carries its own; a keyword hit
// in free text is the weakest evidence (text_scan).
return { found, lean, confidence: found ? (cue ? cue.confidence : 0.6) : 0, basis: found ? (cue ? cue.basis : 'text_scan') : null };
// per-cue confidence + basis + origin: a structured signal carries its own; a keyword
// hit in free text is the weakest evidence (text_scan), always self-origin.
return {
found, lean,
confidence: found ? (cue ? cue.confidence : 0.6) : 0,
basis: found ? (cue ? cue.basis : 'text_scan') : null,
origin: found ? (cue ? cue.origin : 'self') : null,
};
}
function readAmbiguity(vd, actionDomain) {
function readAmbiguity(vd, actionDomain, suppressed) {
if (suppressed && AMBIGUITY.fields.some((f) => isFieldContested(suppressed, f, actionDomain))) {
return { found: false, value: null, confidence: 0, basis: null, withheld: true };
}
let cue;

@@ -225,4 +301,5 @@ for (const lid of AMBIGUITY.layers) { cue = signalCue(vd, lid, AMBIGUITY.fields, actionDomain); if (cue) break; }

const basis = cue ? cue.basis : 'text_scan';
if (hit(text, AMBIGUITY.askFirst)) return { found: true, value: 'ask_first', confidence, basis };
if (hit(text, AMBIGUITY.proceed)) return { found: true, value: 'proceed', confidence, basis };
const origin = cue ? cue.origin : 'self';
if (hit(text, AMBIGUITY.askFirst)) return { found: true, value: 'ask_first', confidence, basis, origin };
if (hit(text, AMBIGUITY.proceed)) return { found: true, value: 'proceed', confidence, basis, origin };
return { found: false, value: null, confidence: 0, basis: null };

@@ -240,13 +317,16 @@ }

// the ambiguity verdict.
function profile(vd, relevantCues, includeAmbiguity, actionDomain) {
let score = 0.5, found = 0, total = relevantCues.length + (includeAmbiguity ? 1 : 0);
const effConfs = []; // per-cue confidence × evidence-basis weight, for the cues actually used
function profile(vd, relevantCues, includeAmbiguity, actionDomain, suppressed) {
let score = 0.5, found = 0, withheld = 0, total = relevantCues.length + (includeAmbiguity ? 1 : 0);
const effConfs = []; // per-cue confidence × evidence-basis × origin weight, for the cues used
const eff = (r) => Math.min(1, r.confidence * (BASIS_WEIGHT[r.basis] ?? 0.75) * (ORIGIN_WEIGHT[r.origin] ?? 1.0));
for (const key of relevantCues) {
const r = readCue(vd, CUES[key], actionDomain);
if (r.found) { found++; score += r.lean * 0.13; effConfs.push(r.confidence * (BASIS_WEIGHT[r.basis] ?? 0.75)); }
const r = readCue(vd, CUES[key], actionDomain, suppressed);
if (r.found) { found++; score += r.lean * 0.13; effConfs.push(eff(r)); }
else if (r.withheld) withheld++;
}
let ambiguity = { found: false, value: null };
if (includeAmbiguity) {
ambiguity = readAmbiguity(vd, actionDomain);
if (ambiguity.found) { found++; if (ambiguity.value === 'ask_first') score += 0.13; effConfs.push(ambiguity.confidence * (BASIS_WEIGHT[ambiguity.basis] ?? 0.75)); }
ambiguity = readAmbiguity(vd, actionDomain, suppressed);
if (ambiguity.found) { found++; if (ambiguity.value === 'ask_first') score += 0.13; effConfs.push(eff(ambiguity)); }
else if (ambiguity.withheld) withheld++;
}

@@ -257,2 +337,3 @@ return {

found,
withheld,
signalConfidence: effConfs.length ? effConfs.reduce((a, b) => a + b, 0) / effConfs.length : 0,

@@ -310,5 +391,15 @@ ambiguity,

// The Decision&Risk cues each consultation type weighs (single source of truth —
// the resolvers AND the provenance reader consultDrivers() read from here, so the
// recorded "what drove this" can never drift from what actually drove it).
const RELEVANT_CUES = {
action_approval: ['irreversibleCaution', 'decisionThreshold', 'evidenceBar'],
domain_risk_check: ['evidenceBar', 'speedQuality', 'decisionThreshold'],
escalation_default: ['decisionThreshold', 'irreversibleCaution'],
};
const USES_AMBIGUITY = new Set(['action_approval', 'escalation_default']);
// ── Per-type resolvers → { signal, hint, coverage, found } ────────────────────
function resolveActionApproval(vd, n) {
const p = profile(vd, ['irreversibleCaution', 'decisionThreshold', 'evidenceBar'], true, n.domain);
function resolveActionApproval(vd, n, suppressed) {
const p = profile(vd, RELEVANT_CUES.action_approval, true, n.domain, suppressed);
const irreversible = n.reversible === false;

@@ -337,7 +428,7 @@ const mag = magnitudeRank(n.magnitude);

: `On reversible actions${domain}, the subject ${posture}.`;
return { signal, hint, coverage: p.coverage, found: p.found, signalConfidence: p.signalConfidence };
return { signal, hint, coverage: p.coverage, found: p.found, withheld: p.withheld, signalConfidence: p.signalConfidence };
}
function resolveDomainRiskCheck(vd, n) {
const p = profile(vd, ['evidenceBar', 'speedQuality', 'decisionThreshold'], false, n.domain);
function resolveDomainRiskCheck(vd, n, suppressed) {
const p = profile(vd, RELEVANT_CUES.domain_risk_check, false, n.domain, suppressed);
const high = p.score >= 0.55;

@@ -349,7 +440,7 @@ const signal = high ? 'clarify' : p.score <= 0.4 ? 'proceed' : 'proceed_with_note';

: 'balances evidence against speed';
return { signal, hint: `Risk posture${where}: the subject ${posture}.`, coverage: p.coverage, found: p.found, signalConfidence: p.signalConfidence };
return { signal, hint: `Risk posture${where}: the subject ${posture}.`, coverage: p.coverage, found: p.found, withheld: p.withheld, signalConfidence: p.signalConfidence };
}
function resolveEscalationDefault(vd, n) {
const p = profile(vd, ['decisionThreshold', 'irreversibleCaution'], true, n.domain);
function resolveEscalationDefault(vd, n, suppressed) {
const p = profile(vd, RELEVANT_CUES.escalation_default, true, n.domain, suppressed);
let signal;

@@ -364,3 +455,3 @@ if (p.ambiguity.value === 'ask_first') signal = 'clarify';

: 'When uncertain on low-stakes calls, the subject defaults to a reasonable assumption, stated openly.';
return { signal, hint, coverage: p.coverage, found: p.found, signalConfidence: p.signalConfidence };
return { signal, hint, coverage: p.coverage, found: p.found, withheld: p.withheld, signalConfidence: p.signalConfidence };
}

@@ -379,2 +470,230 @@

// ── Provenance (local audit only — NOT the §10.3 response) ────────────────────
// The Decision&Risk cues that actually drove a consultation, for the §10.6 receipt:
// "I returned this because cues A,B,C fired at these confidences/bases." Reuses the
// resolver's exact relevance lists + readCue, so it can't drift from what drove the
// answer. Carries lean/value + confidence + evidence basis — NOT the raw layer text,
// and it lives ONLY in the local encrypted receipt, never in the response the agent
// sees (§10.4 holds: provenance is for the subject's audit/calibration, not the agent).
// Pure + defensive: bad/redirect/abstain inputs → []. Takes the raw request; normalizes
// internally so callers pass exactly what they passed to the consult.
export function consultDrivers(vd, request) {
let n;
try { n = normalizeRequest(request || {}); } catch { return []; }
const keys = RELEVANT_CUES[n.type];
if (!keys) return []; // voice_draft / unknown → no inferred basis
const suppressed = openConflictKeys(vd); // §4.7: withhold contested cues here too,
const drivers = []; // so the receipt matches what actually drove it
// A driver records `origin` only when it's `delegation` — the act-as-me case worth
// flagging in the audit; omitting it for `self` keeps today's receipts byte-identical.
const withOrigin = (d, origin) => (origin === 'delegation' ? { ...d, origin } : d);
for (const key of keys) {
const r = readCue(vd, CUES[key], n.domain, suppressed);
if (r.found) drivers.push(withOrigin({ cue: key, lean: r.lean, confidence: r.confidence, basis: r.basis }, r.origin));
}
if (USES_AMBIGUITY.has(n.type)) {
const a = readAmbiguity(vd, n.domain, suppressed);
if (a.found) drivers.push(withOrigin({ cue: 'ambiguityPosture', value: a.value, confidence: a.confidence, basis: a.basis }, a.origin));
}
return drivers;
}
// ── Delegation producer (the act-as-me SOURCE) ────────────────────────────────
// Derive `delegation`-origin signals from the consult OVERRIDE history (§10.6 receipts)
// — the act-as-me learning half that pairs with origin-aware resolution above.
//
// Honesty rail (docs/roadmap/in-claude-outcome-marking.md): ONLY an override teaches —
// `user_action` `rejected` or `modified`, the subject correcting the agent. `approved`
// (exactly what a rubber-stamping autonomous agent would self-report) teaches NOTHING,
// so the channel can't silently collapse into "agent obedience". The corrected DIRECTION
// is read from the oracle's own signal: overriding a PERMISSIVE signal (proceed*) means
// the subject wanted MORE caution on delegated work; overriding a CAUTIOUS one
// (escalate/decline/clarify) means LESS. Each driving cue of the overridden consult is
// nudged in that direction, aggregated across receipts (net direction wins; a tie is
// genuinely ambiguous → skipped). Pure; reuses the CUES catalog so the synthesized value
// reads back through the same keyword path. A delegated correction is scoped to the
// action's domain when it had one, so a financial override never reshapes writing.
const PERMISSIVE_SIGNAL = new Set(['proceed', 'proceed_with_note']);
const CAUTIOUS_SIGNAL = new Set(['escalate', 'decline', 'clarify']);
const OVERRIDE_WEIGHT = { rejected: 1.0, modified: 0.5 };
export function deriveDelegationSignals(receipts, { now = new Date().toISOString(), floor = 1 } = {}) {
const tally = new Map(); // `${cue}|${domain}` → { cue, domain, sum, n }
for (const r of (Array.isArray(receipts) ? receipts : [])) {
const w = OVERRIDE_WEIGHT[r && r.user_action];
if (!w) continue; // only rejected/modified teach
const sig = r?.response?.signal;
const correct = PERMISSIVE_SIGNAL.has(sig) ? 1 : CAUTIOUS_SIGNAL.has(sig) ? -1 : 0; // +1 cautious / -1 bold
if (!correct) continue; // abstain/unknown → no direction
const domain = (typeof r?.request?.action_descriptor?.domain === 'string') ? r.request.action_descriptor.domain : '';
for (const d of (Array.isArray(r.drivers) ? r.drivers : [])) {
if (!CUES[d && d.cue]) continue; // scalar caution cues only (ambiguity later)
const k = `${d.cue}|${domain}`;
const t = tally.get(k) || { cue: d.cue, domain, sum: 0, n: 0 };
t.sum += correct * w; t.n += 1;
tally.set(k, t);
}
}
const out = [];
for (const { cue, domain, sum, n } of tally.values()) {
if (n < 1 || Math.abs(sum) < floor) continue; // need a clear, sufficient net correction
const dir = sum > 0 ? 'cautious' : 'bold';
out.push({
layer: CUES[cue].layers[0],
field: CUES[cue].fields[0],
value: CUES[cue][dir][0], // a keyword the resolver reads as this direction
polarity: 'positive',
origin: 'delegation',
evidence_basis: 'observed',
source: 'delegation_override',
confidence: Math.min(0.9, 0.55 + 0.1 * n),
observation_count: n,
last_observed: now,
...(domain ? { domain } : {}),
});
}
return out;
}
// Merge derived delegation signals into a COPY of vd's layers so the origin-aware
// resolver sees them (delegation outranks self per dimension). Pure; clones only the
// layers it touches; never mutates the input vd or its arrays. The derived signals live
// only in this per-consult in-memory vd — never persisted to the fingerprint, never
// across §10.4 (they shape the SIGNAL, they don't cross as content).
export function withDelegationSignals(vd, signals) {
if (!Array.isArray(signals) || !signals.length || !vd || !vd.phaedo_fingerprint) return vd;
const fp = vd.phaedo_fingerprint;
const layers = { ...(fp.layers || {}) };
for (const s of signals) {
const lid = s.layer || 'decision_and_risk';
const L = layers[lid] ? { ...layers[lid] } : {};
const { layer, ...sig } = s; // drop the routing key from the stored signal
L.signals = [...(Array.isArray(L.signals) ? L.signals : []), sig];
layers[lid] = L;
}
return { ...vd, phaedo_fingerprint: { ...fp, layers } };
}
// ── Delegation PROMOTION (the reviewable, portable act-as-me step) ────────────
// `deriveDelegationSignals` above feeds the live resolver an in-memory overlay from
// EVERY override. Promotion is the durable, reviewable step: when an override pattern is
// ESTABLISHED (|net corrections| ≥ floor on a dimension) and not already authored, stage
// a `suggested_rules` candidate — the SAME shape the §E delta-promotion review uses
// (Proposal 0007), so it rides the existing popup card + endorse path. Endorsing it
// authors a portable, delegation-origin `standing_rules` entry (syncs, shapes injection
// + consult). Pure; honesty rail inherited (only overrides count). No-nag: skip a
// dimension already authored as a delegation rule, or already staged/dismissed.
export const DELEGATION_PROMOTION_FLOOR = 3; // matches §4.7 RESOLUTION_FLOOR / §E PROMOTION_FLOOR
// Each scalar caution cue → its canonical §4.2 Decision&Risk dimension + the catalog
// value token for each corrective direction, and a first-person *delegation* sentence
// (act-as-me framing — "when you act on my behalf"). Ambiguity is excluded (its driver
// carries a value, not a lean — handled when promotion grows past the scalar cues).
const CUE_DIMENSION = {
irreversibleCaution: 'reversible_vs_irreversible',
decisionThreshold: 'evidence_threshold',
evidenceBar: 'evidence_threshold',
speedQuality: 'speed_vs_quality',
};
const DIRECTION_VALUE = {
reversible_vs_irreversible: { cautious: 'more_cautious', bold: 'consistent_style' },
evidence_threshold: { cautious: 'high', bold: 'low' },
speed_vs_quality: { cautious: 'quality', bold: 'speed' },
};
const DELEGATION_TEXT = {
reversible_vs_irreversible: { cautious: 'When you act on my behalf, slow down and check with me on decisions that are hard to undo.',
bold: "When acting on my behalf, treat hard-to-undo decisions like any other — don't over-escalate them." },
evidence_threshold: { cautious: 'When acting on my behalf, make sure the main risks are covered before you commit.',
bold: "When acting on my behalf, act on the best available signal — don't over-gather evidence." },
speed_vs_quality: { cautious: 'When acting on my behalf, favor getting it right over getting it fast.',
bold: 'When acting on my behalf, favor moving fast over polishing.' },
};
function delegationText(dimension, dir, domain) {
const base = (DELEGATION_TEXT[dimension] && DELEGATION_TEXT[dimension][dir]) || `When acting on my behalf, handle ${dimension} as I've corrected you to.`;
return domain ? base.replace(/\.$/, ` — especially on ${domain} decisions.`) : base;
}
export function buildDelegationPromotions(receipts, fp, { now = new Date().toISOString(), floor = DELEGATION_PROMOTION_FLOOR } = {}) {
// Aggregate overrides per CANONICAL (dimension, domain): signed weight + count. Same
// honesty rail + direction rule as deriveDelegationSignals (only rejected/modified;
// direction read from the oracle's own signal).
const tally = new Map(); // `${dimension}|${domain}` → { dimension, domain, sum, n }
for (const r of (Array.isArray(receipts) ? receipts : [])) {
const w = OVERRIDE_WEIGHT[r && r.user_action];
if (!w) continue;
const sig = r?.response?.signal;
const correct = PERMISSIVE_SIGNAL.has(sig) ? 1 : CAUTIOUS_SIGNAL.has(sig) ? -1 : 0;
if (!correct) continue;
const domain = (typeof r?.request?.action_descriptor?.domain === 'string') ? r.request.action_descriptor.domain : '';
for (const d of (Array.isArray(r.drivers) ? r.drivers : [])) {
const dim = CUE_DIMENSION[d && d.cue];
if (!dim) continue;
const k = `${dim}|${domain}`;
const t = tally.get(k) || { dimension: dim, domain, sum: 0, n: 0 };
t.sum += correct * w; t.n += 1; tally.set(k, t);
}
}
const keyOf = (dim, domain) => `${dim}|${domain || ''}`;
const authored = new Set((Array.isArray(fp?.standing_rules) ? fp.standing_rules : [])
.filter((r) => r?.decision?.origin === 'delegation' && r?.decision?.dimension)
.map((r) => keyOf(r.decision.dimension, r.decision.domain)));
const staged = new Set((Array.isArray(fp?.suggested_rules) ? fp.suggested_rules : [])
.filter((s) => s?.source === 'delegation_promotion' && s?.decision?.dimension)
.map((s) => keyOf(s.decision.dimension, s.decision.domain)));
const out = [];
for (const { dimension, domain, sum, n } of tally.values()) {
if (Math.abs(sum) < floor) continue; // not yet established
if (authored.has(keyOf(dimension, domain)) || staged.has(keyOf(dimension, domain))) continue; // no-nag
const dir = sum > 0 ? 'cautious' : 'bold';
out.push({
suggestion_id: (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : `dlg-${Math.random().toString(36).slice(2, 10)}`,
status: 'suggested',
source: 'delegation_promotion',
proposed_text: delegationText(dimension, dir, domain),
decision: { dimension, polarity: 'positive', origin: 'delegation', value: DIRECTION_VALUE[dimension]?.[dir], ...(domain ? { domain } : {}) },
evidence: { observation_count: n, evidence_basis: 'observed' },
created: now,
});
}
return out;
}
// ── Honoring an AUTHORED delegation rule (the portable act-as-me preference) ──
// Once the subject ENDORSES a delegation suggestion (rule-review.js), it becomes a
// `standing_rules` instruction with `decision.origin:"delegation"` — portable: it syncs
// across devices and renders in the §9 injection "Operating rules" block verbatim. To
// also shape CONSULT, convert each such rule into a `delegation`-origin signal the
// origin-aware resolver already honors (it then OUTRANKS the subject's self pattern, and
// — authored — reads at full strength). The catalog `decision.value` token fixes the
// direction; mapped to the keyword the cue readers recognize. Pure.
const VALUE_DIRECTION = {
more_cautious: 'cautious', consistent_style: 'bold',
high: 'cautious', low: 'bold',
quality: 'cautious', accuracy: 'cautious', speed: 'bold',
};
const DIMENSION_KEYWORD = {
reversible_vs_irreversible: { cautious: 'more_cautious', bold: 'consistent_style' },
evidence_threshold: { cautious: 'high evidence', bold: 'good enough' },
speed_vs_quality: { cautious: 'quality over', bold: 'speed over' },
};
export function authoredDelegationSignals(fp) {
const rules = Array.isArray(fp && fp.standing_rules) ? fp.standing_rules : [];
const out = [];
for (const r of rules) {
const d = r && r.decision;
if (!d || d.origin !== 'delegation' || !d.dimension) continue;
const dir = VALUE_DIRECTION[d.value];
const kw = dir && DIMENSION_KEYWORD[d.dimension] && DIMENSION_KEYWORD[d.dimension][dir];
if (!kw) continue; // unmapped value → can't honor in-consult (still injects verbatim)
out.push({
layer: 'decision_and_risk',
field: d.dimension, value: kw, polarity: 'positive',
origin: 'delegation', evidence_basis: 'corroborated', source: 'authored_delegation',
confidence: 0.9, observation_count: 3,
...(typeof d.domain === 'string' ? { domain: d.domain } : {}),
});
}
return out;
}
// §10.3 response shape — the ONLY fields that cross the boundary (§10.4).

@@ -395,7 +714,10 @@ export function shape(type, signal, confidence, rationale_hint, deference_level) {

export function ruleResponse(vd, n) {
// §4.7 contested dimensions are withheld from the cue readers (the consult-side mirror
// of injection suppression). Built once per consult and threaded into every resolver.
const suppressed = openConflictKeys(vd);
let resolved;
switch (n.type) {
case 'action_approval': resolved = resolveActionApproval(vd, n); break;
case 'domain_risk_check': resolved = resolveDomainRiskCheck(vd, n); break;
case 'escalation_default': resolved = resolveEscalationDefault(vd, n); break;
case 'action_approval': resolved = resolveActionApproval(vd, n, suppressed); break;
case 'domain_risk_check': resolved = resolveDomainRiskCheck(vd, n, suppressed); break;
case 'escalation_default': resolved = resolveEscalationDefault(vd, n, suppressed); break;
case 'voice_draft': resolved = resolveVoiceDraft(); break;

@@ -410,3 +732,8 @@ }

if (n.type !== 'voice_draft' && resolved.found === 0) {
return shape(n.type, 'insufficient_signal', calibrate(vd, 0).confidence, ABSTAIN_HINT, 'low');
// Distinguish "no coverage" from "coverage exists but is contested": if every
// relevant cue was WITHHELD by an open conflict, tell the agent it is under review
// (so it escalates to the subject rather than treating the subject as a blank slate).
const contested = resolved.withheld > 0;
return shape(n.type, 'insufficient_signal', calibrate(vd, 0).confidence,
contested ? CONTESTED_HINT : ABSTAIN_HINT, 'low');
}

@@ -413,0 +740,0 @@ const cal = n.type === 'voice_draft'

@@ -52,2 +52,10 @@ // Phaedo §10.5 consultation — standing authorizations / deference policies

amountGt: typeof m.amount_gt === 'number' && isFinite(m.amount_gt) ? m.amount_gt : null,
// Conditional + revocable authority (§10.5). `expiresAt` is ENFORCED: a lapsed
// authorization stops firing (time-bound = revocable, no identity needed; the
// direction is always authority-NARROWING, so it's fail-safe). `agents` is
// RESERVED (parsed, not yet enforced): scoping authority to specific agents needs
// an AUTHENTICATED agent identity — a self-asserted agent_id would be false
// security — so enforcement waits for that (build-later).
expiresAt: typeof r.expires_at === 'string' && Number.isFinite(Date.parse(r.expires_at)) ? Date.parse(r.expires_at) : null,
agents: Array.isArray(r.agents) ? r.agents.map((a) => String(a).toLowerCase()).filter(Boolean) : [],
effect,

@@ -60,12 +68,46 @@ note: typeof r.note === 'string' ? r.note : null,

// Merge policy sources, most-portable first:
// 1. inside the fingerprint (`phaedo_fingerprint.consult_policies`) — rides
// `inner.data` on the phone pull, so it travels everywhere the fingerprint
// does (extension ↔ phone ↔ MCP) with NO profile-schema change. Preferred
// home for authored, synced policies.
// 2. a top-level vault sibling (`phaedo_consult_policies`) — local/extension use.
// 3. opts.policies — a loaded local file (server: mcp/consult-policies.json).
// Project the AUTHORIZATION-kind entries of `standing_rules` (Proposal 0007 — the
// single authored home) into the raw consult_policies rule shape normalizePolicies
// consumes. `kind:"instruction"` entries are verbatim injection rules, not gates —
// the resolver ignores them. `note` falls back to the injectable `text` so the
// rationale_hint still names the rule.
function authorizationsFromStandingRules(fp) {
const rules = Array.isArray(fp?.standing_rules) ? fp.standing_rules : [];
return rules
.filter((r) => r && r.kind === 'authorization' && r.effect)
.map((r) => ({ id: r.instruction_id || null, match: r.match || {}, effect: r.effect, note: r.note || r.text || null, expires_at: r.expires_at, agents: r.agents }));
}
// Project a bound `kind:"instruction"` (Proposal 0007 §D) into a consultation BIAS.
// An instruction carries prose + a `decision` dimension + polarity, but not a
// structured enum value, so it cannot resolve a dimension to a value the way an
// inferred signal does. v0.1 semantics, deliberately conservative: a NEGATIVE
// (anti-preference / "I won't…") bound rule nudges the matching-domain consultation
// one step cautious (bias_cautious) — authored rules may make the agent MORE
// deferential, never less. POSITIVE-polarity rules expand autonomy, the riskier
// direction, and are NOT projected in v0.1 (they still inject verbatim); that is
// gated behind structured per-dimension values. An unscoped (no decision.domain)
// rule biases all actions cautious — global by construction.
function biasesFromStandingRules(fp) {
const rules = Array.isArray(fp?.standing_rules) ? fp.standing_rules : [];
return rules
.filter((r) => r && r.kind === 'instruction' && r.decision && r.decision.polarity === 'negative')
.map((r) => ({ id: r.instruction_id || null, match: { domains: r.decision.domain ? [r.decision.domain] : [] }, effect: 'bias_cautious', note: r.text || null }));
}
// Merge policy sources, the authored home first:
// 1. `phaedo_fingerprint.standing_rules` — the Proposal 0007 authored home:
// kind:authorization → a deterministic gate; kind:instruction bound negative →
// a cautious bias (§D). Rides `inner.data` on the phone pull, travels everywhere
// the fingerprint does (extension ↔ phone ↔ MCP) with NO profile-schema change.
// 2. `phaedo_fingerprint.consult_policies` — DEPRECATED (0007) back-compat read path.
// 3. a top-level vault sibling (`phaedo_consult_policies`) — DEPRECATED, local/extension use.
// 4. opts.policies — a loaded local file (server: mcp/consult-policies.json).
// Force rules (authorizations) always take precedence over bias rules in applyPolicies,
// so ordering within the list only affects which same-class rule wins (first match).
// Defensive — never throws.
export function loadPolicies(vd, opts = {}) {
let list = [];
try { list = list.concat(normalizePolicies(authorizationsFromStandingRules(vd?.phaedo_fingerprint))); } catch { /* ignore */ }
try { list = list.concat(normalizePolicies(biasesFromStandingRules(vd?.phaedo_fingerprint))); } catch { /* ignore */ }
try { list = list.concat(normalizePolicies(vd?.phaedo_fingerprint?.consult_policies)); } catch { /* ignore */ }

@@ -99,5 +141,12 @@ try { list = list.concat(normalizePolicies(vd?.phaedo_consult_policies)); } catch { /* ignore */ }

// paying for a full consultation. voice_draft is exempt (a redirect, not a decision).
export function matchAuthorization(n, policies) {
// A rule is ACTIVE only if it has not lapsed (§10.5 revocable authority). `expiresAt`
// is enforced; `agents` scoping is reserved (see normalizePolicies). Authority-narrowing
// only — an expired rule simply stops applying.
function ruleActive(rule, now) {
return !(rule.expiresAt != null && now >= rule.expiresAt);
}
export function matchAuthorization(n, policies, { now = Date.now() } = {}) {
if (!policies || !policies.length || n.type === 'voice_draft') return null;
const matched = policies.filter((r) => ruleMatches(r, n));
const matched = policies.filter((r) => ruleActive(r, now) && ruleMatches(r, n));
const force = matched.find((r) => r.effect in FORCE);

@@ -113,6 +162,6 @@ if (!force) return null;

// never policy-overridden (it's a redirect, not a decision).
export function applyPolicies(response, n, policies) {
export function applyPolicies(response, n, policies, { now = Date.now() } = {}) {
if (!policies || !policies.length || n.type === 'voice_draft') return response;
const auth = matchAuthorization(n, policies);
const auth = matchAuthorization(n, policies, { now });
if (auth) {

@@ -128,3 +177,3 @@ return {

const matched = policies.filter((r) => ruleMatches(r, n));
const matched = policies.filter((r) => ruleActive(r, now) && ruleMatches(r, n));
const bias = matched[0];

@@ -131,0 +180,0 @@ if (!bias) return response;

+4
-2

@@ -17,3 +17,5 @@ // Phaedo §10 Agent Consultation — MCP-binding wrapper around the pure resolver.

resolveConsultationCore, buildConsultationResponse, checkAuthorization, normalizeRequest,
shape, SIGNALS, CONSULTATION_TYPES, validateVaultShape,
shape, SIGNALS, CONSULTATION_TYPES, validateVaultShape, consultDrivers,
deriveDelegationSignals, withDelegationSignals, buildDelegationPromotions, DELEGATION_PROMOTION_FLOOR,
authoredDelegationSignals,
} from './consult-core.js';

@@ -23,3 +25,3 @@

// spec/test-schemas.mjs) keep their `from './consult.js'` paths.
export { CONSULTATION_TYPES, buildConsultationResponse, resolveConsultationCore, checkAuthorization, validateVaultShape };
export { CONSULTATION_TYPES, buildConsultationResponse, resolveConsultationCore, checkAuthorization, validateVaultShape, consultDrivers, deriveDelegationSignals, withDelegationSignals, buildDelegationPromotions, DELEGATION_PROMOTION_FLOOR, authoredDelegationSignals };

@@ -26,0 +28,0 @@ // ── Model resolver (richest fingerprint, LOCAL compute — opt-in) ──────────────

@@ -16,2 +16,27 @@ // Fingerprint source for the Phaedo MCP server. The server depends only on

// §4.1 protocol-identity normalization (consumer defense-in-depth, 2026-06-16).
// A stored fingerprint from a producer that predates identity stamping (or whose
// phone copy was never re-persisted) can arrive missing the REQUIRED §4.1 fields
// — `npm run health` surfaced exactly this on a live fingerprint. The
// deterministic ones (phaedo_protocol_version, schema_revision) are safe for any
// consumer to ensure; subject_id falls back to the existing, stable, non-PII
// `fingerprint_id` when absent, rather than minting a value that would churn on
// every load. This makes the reference server serve §4.1-conformant fingerprints
// regardless of producer staleness. Source cleanup (re-persisting these on the
// phone) remains the producer's job. SEMANTIC hygiene (episodic/opaque content)
// is deliberately NOT masked here: health keeps surfacing it as an honest
// producer signal, and context-block's render guard protects the AI projection.
const IDENTITY_PROTOCOL_VERSION = '0.1';
const IDENTITY_SCHEMA_REVISION = 1;
function normalizeIdentity(vd) {
const fp = vd && vd.phaedo_fingerprint;
if (!fp || typeof fp !== 'object') return vd;
const patch = {};
if (fp.phaedo_protocol_version === undefined) patch.phaedo_protocol_version = IDENTITY_PROTOCOL_VERSION;
if (fp.schema_revision === undefined) patch.schema_revision = IDENTITY_SCHEMA_REVISION;
if (fp.subject_id === undefined && typeof fp.fingerprint_id === 'string' && fp.fingerprint_id) patch.subject_id = fp.fingerprint_id;
if (!Object.keys(patch).length) return vd;
return { ...vd, phaedo_fingerprint: { ...fp, ...patch } };
}
// Resolution order for the local fixture (1a):

@@ -48,3 +73,3 @@ // 1. PHAEDO_FINGERPRINT env var (absolute or cwd-relative path)

await writeCache(stateDir, vd);
return vd;
return normalizeIdentity(vd);
} catch (err) {

@@ -61,3 +86,3 @@ // A 4xx means the phone REACHED us and rejected this client (revoked /

const cached = await readCache(stateDir);
if (cached) return cached.vault; // phone unreachable → encrypted cache (may be stale)
if (cached) return normalizeIdentity(cached.vault); // phone unreachable → encrypted cache (may be stale)
throw err; // no phone + no cache → cannot serve

@@ -79,3 +104,36 @@ }

}
return vd;
return normalizeIdentity(vd);
}
// Like loadFingerprint, but PREFERS the at-rest encrypted cache over a live phone pull.
// Used by the escalation path so reading the fingerprint (to compute the consult signal)
// doesn't trigger a SECOND prompt — the fingerprint-read AUTHORIZE — on top of the
// escalation decision card the subject already answers. One prompt per escalation, not two.
//
// The cache is seeded + refreshed by the normal live-pull consults, so it stays current; a
// consult SIGNAL tolerates a slightly-stale fingerprint (it doesn't change minute to minute).
// Falls back to a live pull only when no cache exists yet (first run on this machine), which
// seeds it. Revocation safety is unaffected: a revoked MCP's escalation can't be decrypted by
// the phone anyway (its client entry is gone), so it never reaches the subject regardless of
// what the MCP read locally.
// Like loadFingerprint, but PREFERS the at-rest encrypted cache over a live phone pull.
// Used by the escalation path so reading the fingerprint (to compute the consult signal)
// NEVER triggers an interactive fingerprint-read AUTHORIZE on top of the escalation
// decision card. One prompt per escalation — the decision — not two, and critically no
// authorize modal racing the decision modal on the phone (that double-present crashed iOS).
//
// Uses ANY decryptable cache regardless of age. Revocation is enforced PHONE-SIDE for the
// escalation path: a revoked agent's escalation can't be decrypted by the phone (its client
// registry entry is gone), so reading a stale cache here yields nothing the agent can
// actually deliver to the subject. (The normal consult path — loadFingerprint — still does
// the live pull with its 4xx-purge revocation, so a revoked agent stops getting fresh reads
// there.) Falls through to a live pull ONLY when no cache exists at all (first run on this
// machine), which seeds it; run one phaedo_consult after pairing to seed without an
// escalation in flight.
export async function loadFingerprintPreferCache(baseDir) {
if (!process.env.PHAEDO_FINGERPRINT && hasPairingRecord(baseDir)) {
const cached = await readCache(defaultStateDir());
if (cached) return normalizeIdentity(cached.vault);
}
return loadFingerprint(baseDir);
}

@@ -23,3 +23,3 @@ #!/usr/bin/env node

import { dirname, resolve } from 'path';
import { loadFingerprint } from '../fingerprint-source.js';
import { loadFingerprintPreferCache } from '../fingerprint-source.js';
import { buildInjectionResponse } from '../projection.js';

@@ -41,3 +41,10 @@

async function run() {
const vd = await loadFingerprint(MCP_DIR);
// PREFER the at-rest encrypted cache over a live phone pull. Claude Code (and Claude
// Desktop's per-tab MCP spawn) runs SessionStart often — including on every tab bounce
// to a session that uses this hook — so a LIVE pull here surfaces a stray "authorize"
// sheet on the phone with no live caller behind it, since the user didn't initiate
// anything from the subject side. The cache is exactly what session-start should rely
// on: fast, non-interrupting, and refreshed by any real consult/escalate that follows.
// Same pattern that #76 applied to escalation (cache-preferred fingerprint read).
const vd = await loadFingerprintPreferCache(MCP_DIR);
const response = buildInjectionResponse(vd, { requested_layers: ['all'], mode: 'standard' });

@@ -44,0 +51,0 @@ if (response && response.projection) emitContext(response.projection);

@@ -359,2 +359,8 @@ #!/usr/bin/env node

}
console.log(' 4. Let Phaedo learn how you delegate — set `phaedo_record_outcome`');
console.log(' to "Always allow" in your client\'s tool-permission settings.');
console.log(' It records when you override an agent (the act-as-me signal); on');
console.log(' "Ask" it prompts every time, so the learning stays cold if skipped.');
console.log(' It only writes a local, encrypted audit receipt — nothing leaves the');
console.log(' device, and an authored rule still needs your explicit endorsement.');
}

@@ -361,0 +367,0 @@ if (opts.pair && !opts.dryRun && touched.length) return runPairing();

{
"name": "phaedo-mcp",
"version": "0.1.1",
"version": "0.2.0",
"private": false,

@@ -9,3 +9,3 @@ "license": "Apache-2.0",

"mcpName": "io.github.galavoxx/phaedo-mcp",
"homepage": "https://getphaedo.com",
"homepage": "https://phaedo.so",
"repository": {

@@ -37,2 +37,3 @@ "type": "git",

"agreement.js",
"calibration.js",
"phone-source.js",

@@ -59,2 +60,3 @@ "cache.js",

"prepack": "node build-package.mjs",
"pretest": "node build-package.mjs",
"setup": "node install.mjs",

@@ -66,5 +68,8 @@ "setup:pair": "node install.mjs --pair",

"pull": "node pull.js",
"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-receipts.mjs && node test-agreement.mjs",
"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-delegation-sync.mjs && node test-delegation-relay.mjs && node test-escalation.mjs && node test-receipts.mjs && node test-receipts-sync.mjs && node test-agreement.mjs && node test-calibration.mjs && node test-identity.mjs",
"receipts": "node receipts.js list",
"agreement": "node agreement.js",
"calibration": "node calibration.js",
"health": "node health.js",
"validate": "node ../spec/validate-fingerprint.mjs sample-fingerprint.json",
"smoke": "node test-smoke.mjs",

@@ -78,4 +83,6 @@ "smoke:phone": "node test-phone-boundary.mjs",

"smoke:policy": "node test-consult-policy.mjs",
"smoke:escalation": "node test-escalation.mjs",
"smoke:receipts": "node test-receipts.mjs",
"smoke:agreement": "node test-agreement.mjs"
"smoke:agreement": "node test-agreement.mjs",
"smoke:calibration": "node test-calibration.mjs"
},

@@ -82,0 +89,0 @@ "dependencies": {

@@ -140,2 +140,13 @@ // MCP server self-pairing (P3, plan: mcp-revocable-approval-plan.md).

// The relay lane id: prefer the one resolved during a relay rendezvous, but a LAN
// pairing ALSO needs it (the escalation channel + fp-mailbox deposit to the relay by
// this id). The phone returns its current relay_device_id in the /v1/pair response
// (localServer.ts), so capture it whichever rendezvous we used — without it, a LAN
// pairing has no relay lane and phaedo_escalate fails with "no-relay-device-id".
const relayId = relayDeviceId || body.relay_device_id || null;
// Per-device bearer (relay/auth.js): the phone shares the secret it minted with paired
// clients so they can authenticate to the relay AS this device. Without it,
// escalation/fp-mailbox deposits get 401.
const relaySecret = body.relay_device_secret || null;
return {

@@ -148,3 +159,4 @@ endpoint,

sas,
...(relayDeviceId ? { relay_device_id: relayDeviceId } : {}),
...(relayId ? { relay_device_id: relayId } : {}),
...(relaySecret ? { relay_device_secret: relaySecret } : {}),
};

@@ -151,0 +163,0 @@ }

@@ -17,2 +17,5 @@ # Phaedo MCP server — Phase 1 (push injection)

| tool | `phaedo_check_authorization` | deterministic-only pre-flight: does a standing authorization (§10.5) already decide this action? (no inference) |
| tool | `phaedo_escalate` | run the consult and, on a blocking signal (`escalate`, or a consequential `clarify`), wake the subject's paired device for a live **approve / deny / modify**; returns a `request_id` (non-blocking). Safe default on no answer: **hold**. |
| tool | `phaedo_escalation_status` | poll an escalation by `request_id`; on resolution records the subject's live decision as the §10.6 outcome automatically (a real-time approve/deny is the strongest act-as-me signal). |
| tool | `phaedo_record_outcome` | §10.6 — record what the SUBJECT decided about a consulted action (`approved`/`rejected`/`modified`/`unknown`). A `rejected`/`modified` outcome teaches the **act-as-me** channel (how the user wants delegated work handled). |

@@ -202,3 +205,3 @@ The projection is rendered by the **same** `context-block.js` the extension

- `consultation_type`: `action_approval` · `domain_risk_check` · `escalation_default` · `voice_draft`.
- `signal`: `proceed` · `proceed_with_note` · `clarify` · `escalate` · `decline` · `insufficient_signal` (abstain — the fingerprint has no coverage for this action; fall back to your own default).
- `signal`: `proceed` · `proceed_with_note` · `clarify` · `escalate` · `decline` · `insufficient_signal` (abstain — either the fingerprint has no coverage for this action, or the relevant decision dimension is under an open §4.7 conflict the subject is still reconciling and is withheld; fall back to your own default or escalate to the subject).
- `deference_level` (`high`/`medium`/`low`) = how strongly to weight it; sparse data → `low`.

@@ -205,0 +208,0 @@

@@ -90,4 +90,14 @@ // Phaedo §10.6 — consultation RECEIPTS (build brief M4).

// full consultations and pre-flight matches separately.
export function buildReceipt({ via, agentId, request, response, now = Date.now() }) {
export function buildReceipt({ via, agentId, request, response, drivers, now = Date.now() }) {
const ad = (request && typeof request.action_descriptor === 'object' && request.action_descriptor) || {};
// Provenance (optional, §10.4-safe — LOCAL audit only): the Decision&Risk cues that
// drove an inferred consult, each {cue, lean?/value?, confidence, basis}. No layer
// text; coerced to the known shape; absent for deterministic pre-flights.
const drv = Array.isArray(drivers) ? drivers.slice(0, 16).map((d) => ({
cue: typeof d?.cue === 'string' ? d.cue : null,
...(typeof d?.lean === 'number' ? { lean: d.lean } : {}),
...(d?.value != null ? { value: String(d.value) } : {}),
confidence: typeof d?.confidence === 'number' ? d.confidence : null,
basis: typeof d?.basis === 'string' ? d.basis : null,
})) : [];
return {

@@ -113,2 +123,3 @@ receipt_id: crypto.randomUUID(),

},
...(drv.length ? { drivers: drv } : {}),
user_action: null,

@@ -115,0 +126,0 @@ };

{
"phaedo_fingerprint": {
"phaedo_protocol_version": "0.1",
"fingerprint_id": "sample-0000-0000-0000-000000000000",
"subject_id": "sample-subj-0000-0000-000000000000",
"schema_revision": 1,
"updated_at": "2026-06-01T12:00:00.000Z",

@@ -5,0 +8,0 @@ "persona_strength": 0.58,

+366
-8

@@ -19,7 +19,12 @@ #!/usr/bin/env node

import { loadFingerprint } from './fingerprint-source.js';
import { loadFingerprint, loadFingerprintPreferCache } from './fingerprint-source.js';
import { hasPairingRecord, loadPairingRecord } from './phone-source.js';
import { depositDelegationSuggestions } from './delegation-sync.js';
import { depositReceiptsDigest, drainAndApplyMarks } from './receipts-sync.js';
import { buildDelegationPromotions } from './consult-core.js';
import { depositEscalation, pollEscalationOnce, shouldEscalate, loadEscalationPolicy } from './escalation.js';
import { buildInjectionResponse, errorResponse, PhaedoError } from './projection.js';
import { resolveConsultation, checkAuthorization, consultOptsFromEnv, CONSULTATION_TYPES } from './consult.js';
import { resolveConsultation, checkAuthorization, consultOptsFromEnv, CONSULTATION_TYPES, consultDrivers, deriveDelegationSignals, withDelegationSignals, authoredDelegationSignals } from './consult.js';
import { defaultStateDir } from './cache.js';
import { buildReceipt, appendReceipt, receiptsCapFromEnv } from './receipts.js';
import { buildReceipt, appendReceipt, receiptsCapFromEnv, readReceipts, markOutcome, USER_ACTIONS } from './receipts.js';

@@ -32,5 +37,5 @@ const BASE_DIR = dirname(fileURLToPath(import.meta.url));

const RECEIPTS_CAP = receiptsCapFromEnv(process.env);
async function emitReceipt(via, args, response) {
async function emitReceipt(via, args, response, drivers) {
try {
const receipt = buildReceipt({ via, agentId: args?.agent_id, request: args, response });
const receipt = buildReceipt({ via, agentId: args?.agent_id, request: args, response, drivers });
await appendReceipt(defaultStateDir(), receipt, { cap: RECEIPTS_CAP });

@@ -42,3 +47,96 @@ } catch (e) {

// Load the fingerprint for a consult and fold in the act-as-me channel: delegation
// signals derived from this subject's own override history (§10.6 receipts). The
// origin-aware resolver then lets a `delegation` correction outrank a `self` signal on
// the same dimension. Best-effort — if receipts are unreadable, the consult still
// answers from the plain fingerprint (delegation learning never breaks a consultation).
async function loadConsultVd(baseDir, { preferCache = false } = {}) {
// preferCache (escalation): read the at-rest cache instead of a live phone pull, so the
// escalation doesn't add a fingerprint-read AUTHORIZE prompt on top of its decision card.
const vd = preferCache ? await loadFingerprintPreferCache(baseDir) : await loadFingerprint(baseDir);
try {
// Two delegation sources, both origin-aware: live override history (every override)
// + the subject's ENDORSED, portable delegation rules (authored_delegation). The
// authored rule thus shapes consult too, not only the §9 injection it already renders in.
const signals = deriveDelegationSignals(await readReceipts(defaultStateDir()))
.concat(authoredDelegationSignals(vd && vd.phaedo_fingerprint));
return withDelegationSignals(vd, signals);
} catch (e) {
process.stderr.write(`[phaedo-mcp] delegation derive skipped (consult unaffected): ${e.message}\n`);
return vd;
}
}
// Act-as-me PROMOTION (delegation-sync.js): when the override history shows an
// ESTABLISHED pattern that isn't already authored/staged, deposit a delegation
// SUGGESTION across the sync boundary (relay agent_to_ext) for the subject to review +
// endorse in the extension. Best-effort and isolated — never throws into the consult.
// Only proposes a suggestion; the subject still authors (the authored-only invariant).
let _lastDepositSig = null; // dedup: the promo set last deposited THIS process (extension-side
// endorsement never round-trips into the agent's fp, so without this
// every consult would re-encrypt + re-POST the identical suggestion)
// §10.6 receipts audit channel (receipts-sync.js): deposit the latest receipts to the
// phone's audit slot (agent_to_phone) so the subject can mark outcomes in the flow
// rather than via `node receipts.js mark`. Dedup on the latest receipt_id +
// user_action set: identical state → skip the round-trip. Best-effort; a deposit
// failure NEVER throws into the tool that triggered it.
let _lastReceiptsSig = null;
async function maybeDepositReceiptsDigest() {
try {
if (!hasPairingRecord(BASE_DIR)) return;
const receipts = await readReceipts(defaultStateDir());
if (!receipts.length) return;
// Signature: latest id + a tally of marked states. Catches "new receipt arrived"
// AND "an existing receipt got marked" (so the next phone drain reflects the mark).
const latest = receipts[receipts.length - 1];
const tally = receipts.reduce((acc, r) => { acc[r.user_action ?? 'null'] = (acc[r.user_action ?? 'null'] || 0) + 1; return acc; }, {});
const sig = `${latest.receipt_id}|${JSON.stringify(tally)}`;
if (sig === _lastReceiptsSig) return;
const res = await depositReceiptsDigest(await loadPairingRecord(BASE_DIR), receipts);
if (res && res.deposited) _lastReceiptsSig = sig;
else if (res && !res.deposited && res.reason !== 'no-receipts')
process.stderr.write(`[phaedo-mcp] receipts digest deposit skipped: ${res.reason || 'http ' + res.status}\n`);
} catch (e) {
process.stderr.write(`[phaedo-mcp] receipts digest deposit skipped: ${e.message}\n`);
}
}
// Drain phone-deposited outcome marks and apply them via markOutcome. Best-effort;
// called before tools that read receipts (consult uses delegation derivation; the
// outcome tool may default to "latest") so a recent phone-side mark is reflected
// before the next operation. Idempotent on the MCP side.
async function maybePullPhoneMarks() {
try {
if (!hasPairingRecord(BASE_DIR)) return { applied: 0, skipped: 0 };
return await drainAndApplyMarks(await loadPairingRecord(BASE_DIR), defaultStateDir());
} catch (e) {
process.stderr.write(`[phaedo-mcp] phone marks drain skipped: ${e.message}\n`);
return { applied: 0, skipped: 0, reason: e.message };
}
}
async function maybeDepositDelegationPromotions(vd) {
try {
if (!hasPairingRecord(BASE_DIR)) return; // no extension to deposit to
const promos = buildDelegationPromotions(await readReceipts(defaultStateDir()), (vd && vd.phaedo_fingerprint) || {});
if (!promos.length) return;
const sig = promos.map(p => `${p.decision?.dimension}|${p.decision?.domain || ''}|${p.decision?.value}`).sort().join(',');
if (sig === _lastDepositSig) return; // identical set already deposited — skip the relay round-trip
const res = await depositDelegationSuggestions(await loadPairingRecord(BASE_DIR), promos);
if (res && res.deposited) _lastDepositSig = sig; // latch only on success, so a failed deposit retries next consult
else if (res && !res.deposited) // promotions existed but didn't land — say why (don't fail silently)
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`);
} catch (e) {
process.stderr.write(`[phaedo-mcp] delegation promotion deposit skipped: ${e.message}\n`);
}
}
const CONSULT_OPTS = consultOptsFromEnv(process.env);
// Real-time escalation window (docs/roadmap/escalation-and-push.md §crux): how long a
// blocking decision waits for the subject before the agent applies the safe-default
// (hold). Configurable; bounded by the relay's own MAX_WINDOW_MS.
const ESCALATION_WINDOW_MS = (() => {
const n = Number(process.env.PHAEDO_ESCALATION_WINDOW_MS);
return Number.isFinite(n) && n > 0 ? n : 3 * 60 * 1000;
})();
// Deference policies: PHAEDO_CONSULT_POLICIES if set, else the default file beside

@@ -54,4 +152,15 @@ // the server (mcp/consult-policies.json — what policy-editor.html produces). Zero

const server = new McpServer({ name: 'phaedo', version: '0.1.0' });
// Escalation PUSH policy (which signals wake the subject): the subject-configurable knob
// over the stakes-gated default. Local file (PHAEDO_ESCALATION_POLICY, else
// mcp/escalation-policy.json beside the server) overrides; the portable vault policy
// (phaedo_fingerprint.escalation_policy) is merged in per-call (loadEscalationPolicy).
const ESCALATION_POLICY_PATH = process.env.PHAEDO_ESCALATION_POLICY || join(BASE_DIR, 'escalation-policy.json');
let ESCALATION_POLICY_FILE = null;
if (existsSync(ESCALATION_POLICY_PATH)) {
try { ESCALATION_POLICY_FILE = JSON.parse(readFileSync(ESCALATION_POLICY_PATH, 'utf8')); }
catch (e) { process.stderr.write(`[phaedo-mcp] ignoring malformed escalation policy (${ESCALATION_POLICY_PATH}): ${e.message}\n`); }
}
const server = new McpServer({ name: 'phaedo', version: '0.2.0' });
// ── Tool: phaedo_request_injection (binding §3.1) ─────────────────────────────

@@ -79,2 +188,4 @@ server.registerTool(

},
// Read-only: returns the projection, writes nothing. Safe to auto-allow.
annotations: { readOnlyHint: true, openWorldHint: false },
},

@@ -117,2 +228,8 @@ async (args) => {

agent_id: z.string().optional(),
// RESERVED for v0.3 authenticated agent identity (§10.5 enforcement / OQ9).
// Today `agent_id` is self-asserted and unverified; v0.3 will carry a signed
// per-agent credential here so a subject can honestly scope `agents`. Declared
// now (optional, ignored) so the v0.3 addition is backward-compatible — an
// agent sending it against this server is simply unaffected.
agent_credential: z.string().optional(),
action_descriptor: z

@@ -129,8 +246,17 @@ .object({

},
// Not read-only: appends a local §10.6 audit receipt and may best-effort deposit a
// delegation suggestion. Non-destructive and device-local (no external entities).
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
},
async (args) => {
try {
const vd = await loadFingerprint(BASE_DIR);
const vd = await loadConsultVd(BASE_DIR); // fingerprint + act-as-me delegation overlay
const response = await resolveConsultation(vd, args || {}, CONSULT_OPTS);
await emitReceipt('phaedo_consult', args, response); // every resolution, abstains included
// Provenance for the local audit receipt only — the cues that drove this
// (never in `response`; §10.4). Best-effort: a failure must not block the answer.
let drivers = [];
try { drivers = consultDrivers(vd, args || {}); } catch { /* provenance is non-critical */ }
await emitReceipt('phaedo_consult', args, response, drivers); // every resolution, abstains included
await maybeDepositDelegationPromotions(vd); // act-as-me promotion (best-effort)
await maybeDepositReceiptsDigest(); // G1+G2 audit channel — refresh the phone's view
return {

@@ -170,2 +296,5 @@ content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],

agent_id: z.string().optional(),
// RESERVED for v0.3 authenticated agent identity (§10.5) — see phaedo_consult.
// Optional + ignored today; declared so v0.3 enforcement is additive.
agent_credential: z.string().optional(),
action_descriptor: z

@@ -181,2 +310,4 @@ .object({

},
// Deterministic pre-flight; appends a local audit receipt. Non-destructive, device-local.
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
},

@@ -193,2 +324,3 @@ async (args) => {

await emitReceipt('phaedo_check_authorization', req, { signal: response.signal, confidence: 1.0, deference_level: 'high' });
await maybeDepositReceiptsDigest(); // G1+G2 audit channel — only on a matched preflight (no-match writes no receipt)
}

@@ -210,2 +342,228 @@ return {

// ── Tool: phaedo_record_outcome (spec §10.6 — the act-as-me loop closer) ──────
// After consulting Phaedo and the SUBJECT decided what to do, record their outcome on
// the consultation receipt. This is the source of delegation learning: a `rejected` or
// `modified` outcome — the subject CORRECTING an agent that acted on their behalf —
// teaches Phaedo how they want delegated work handled (a `delegation`-origin signal that
// outranks their own `self` pattern in future consults). Honesty rail: record what the
// SUBJECT chose, never whether YOU (the agent) proceeded — `approved` self-reported by an
// obedient agent teaches nothing, by design, so the agreement metric can't collapse into
// "agent obedience". §10.4-safe: returns only {matched, receipt_id, user_action}.
server.registerTool(
'phaedo_record_outcome',
{
title: 'Record what the subject decided about a consulted action',
description:
'After you consulted Phaedo and the USER decided what to do, record their outcome on the receipt: ' +
'approved | rejected | modified | unknown. Record what the USER chose, NOT whether you proceeded. ' +
'A rejected/modified outcome teaches Phaedo how the user wants delegated work handled (the act-as-me ' +
'channel). Defaults to the most recent receipt; pass receipt_id to mark a specific one.',
inputSchema: {
outcome: z.enum(USER_ACTIONS),
receipt_id: z.string().optional(),
// RESERVED for v0.3 learning-loop integrity (OQ16). This is the act-as-me
// training channel: an adversary inside the same MCP boundary could poison
// delegation preferences with forged outcome calls. v0.3 will require the
// recording agent to be authenticated (agent_id + signed agent_credential),
// rate-limited per dimension, and anomaly-checked. Declared now (optional,
// ignored) so that enforcement is a backward-compatible addition.
agent_id: z.string().optional(),
agent_credential: z.string().optional(),
},
// Writes the subject's decision onto a local receipt (the act-as-me learning signal).
// Non-destructive + idempotent (re-recording the same outcome is a no-op); device-local.
// Recommend "Always allow" so the learning isn't gated behind a per-call prompt.
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
},
async (args) => {
try {
// Drain any phone-deposited marks first (G1+G2 audit channel): if the subject
// already marked outcomes from the phone, they should land before the agent's
// own self-record so "latest" resolves against the freshest state. Best-effort.
await maybePullPhoneMarks();
const res = await markOutcome(defaultStateDir(), args?.receipt_id || 'latest', args.outcome);
// Refresh the phone's view so the just-applied mark is reflected on next drain.
await maybeDepositReceiptsDigest();
const body = res.matched
? { request_type: 'outcome_record', recorded: true, receipt_id: res.receipt_id, user_action: res.user_action }
: { request_type: 'outcome_record', recorded: false, reason: res.reason };
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
} catch (err) {
const code = err instanceof PhaedoError ? err.code : 'request_malformed';
return {
content: [{ type: 'text', text: `Phaedo record-outcome error [${code}]: ${err.message}` }],
isError: true,
};
}
}
);
// ── Tool: phaedo_escalate (docs/roadmap/escalation-and-push.md) ───────────────
// REAL-TIME escalation for an AUTONOMOUS agent: when an agent acting on the subject's
// behalf hits a decision it can't make alone, wake the subject's phone for a live
// approve/deny/modify instead of guessing. Self-gating: it runs the consult itself and
// only wakes the subject when the signal is genuinely blocking (escalate/clarify) — a
// proceed* signal returns "just proceed", so the subject isn't pinged for low-stakes
// calls. Non-blocking: deposits + wakes, returns a request_id immediately; the agent
// then polls phaedo_escalation_status and HOLDS until it resolves (the safe default if
// the window lapses is do-not-proceed). §10.4-safe: only the agent's own action
// descriptor + the consult signal/rationale cross the wire (encrypted), never layer
// content.
server.registerTool(
'phaedo_escalate',
{
title: 'Escalate a blocking decision to the subject in real time',
description:
"When you're acting autonomously for the user and hit a decision you shouldn't make alone, " +
'escalate it to them live instead of guessing. Runs the consultation and, only if the signal is ' +
'blocking (escalate/clarify), wakes the user\'s phone for an approve/deny/modify. Returns a ' +
'request_id immediately — then poll phaedo_escalation_status and DO NOT PROCEED until it resolves ' +
'(if the user doesn\'t answer in time, hold). Use for consequential, hard-to-undo actions.',
inputSchema: {
consultation_type: z.enum(CONSULTATION_TYPES).optional(),
agent_id: z.string().optional(),
// RESERVED for v0.3 authenticated agent identity (§10.5) — see phaedo_consult.
// Optional + ignored today; declared so v0.3 enforcement is additive.
agent_credential: z.string().optional(),
action_descriptor: z
.object({
domain: z.string().optional(),
reversible: z.boolean().optional(),
magnitude: z.enum(['low', 'medium', 'high']).optional(),
amount: z.number().optional(),
summary: z.string().optional(),
})
.optional(),
context: z.record(z.any()).optional(),
},
// Opens a channel to an EXTERNAL entity (the subject's device, via the relay) and
// writes a local receipt — not read-only, not destructive, but openWorld (it reaches off-device).
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
},
async (args) => {
try {
const req = { consultation_type: 'action_approval', ...(args || {}) };
// Cache-preferred read: escalation shouldn't trigger a fingerprint-read authorize
// prompt on top of the decision card the subject already answers (one prompt, not two).
const vd = await loadConsultVd(BASE_DIR, { preferCache: true });
const response = await resolveConsultation(vd, req, CONSULT_OPTS);
let drivers = [];
try { drivers = consultDrivers(vd, req); } catch { /* non-critical */ }
await emitReceipt('phaedo_escalate', req, response, drivers); // the decision being escalated
await maybeDepositReceiptsDigest(); // G1+G2 audit channel
// Self-gate: only wake the subject for a genuinely blocking signal. `escalate`
// always; `clarify` per the subject's push policy (default: only when the action is
// consequential — irreversible / high magnitude). A low-stakes clarify is the
// agent's to resolve in its own flow.
const pushPolicy = loadEscalationPolicy(vd, { escalationPolicy: ESCALATION_POLICY_FILE });
if (!shouldEscalate(response.signal, req.action_descriptor, pushPolicy)) {
const subThresholdClarify = response.signal === 'clarify';
const body = {
request_type: 'escalation', escalated: false, signal: response.signal,
rationale_hint: response.rationale_hint, confidence: response.confidence,
...(subThresholdClarify ? { reason: 'clarify_below_threshold' } : {}),
message: subThresholdClarify
? '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.'
: 'Signal is not blocking — act on it with your own judgment; no need to interrupt the user.',
};
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
}
const pairing = await loadPairingRecord(BASE_DIR);
const dep = await depositEscalation(pairing, req, response, { windowMs: ESCALATION_WINDOW_MS });
const body = dep.deposited
? {
request_type: 'escalation', escalated: true, status: 'pending',
request_id: dep.request_id, window_ms: dep.window_ms, expires_at: dep.expires_at,
signal: response.signal, rationale_hint: response.rationale_hint,
// The relay's push-to-wake result for a BACKGROUNDED phone: { sent, reason }.
// sent:true → APNs accepted the wake; reason 'not_configured' → relay APNS_* unset,
// 'no_token'/'not_registered' → phone never registered its APNs token, 'apns_*' →
// Apple rejected (e.g. BadDeviceToken = sandbox/prod mismatch). A FOREGROUNDED phone
// is woken over the WS regardless, so a non-sent wake is not necessarily a failure.
woke: dep.woke,
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).',
}
: {
request_type: 'escalation', escalated: false, status: 'unreachable', reason: dep.reason,
signal: response.signal, rationale_hint: response.rationale_hint,
message: dep.reason === 'no-relay-device-id' || dep.reason === 'unpaired'
? '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.'
: 'Could not reach the user right now. Hold (do not act) or surface the decision in your own conversation.',
};
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
} catch (err) {
const body = errorResponse(err, 'escalation');
const code = err instanceof PhaedoError ? err.code : 'internal_error';
return { content: [{ type: 'text', text: `Phaedo escalation error [${code}]: ${JSON.stringify(body)}` }], isError: true };
}
}
);
// ── Tool: phaedo_escalation_status ────────────────────────────────────────────
// Poll a pending escalation (from phaedo_escalate). Returns the subject's decision once
// they answer — approve/deny/modify — and AUTOMATICALLY records it as the §10.6 outcome
// (roadmap Q7: a real-time approve/deny IS the strongest act-as-me signal, so it feeds the
// same override-learning loop as phaedo_record_outcome). While unanswered it returns
// pending (keep holding); past the window it returns expired (apply the safe-default: hold).
server.registerTool(
'phaedo_escalation_status',
{
title: 'Check whether the subject has decided an escalation',
description:
'Poll an escalation you opened with phaedo_escalate. Returns resolved + the decision (approve | deny | ' +
'modify) once the user answers, pending while you should keep waiting, or expired once the window lapsed ' +
'(then HOLD — do not proceed). A resolved decision is recorded automatically as the act-as-me outcome.',
inputSchema: {
request_id: z.string(),
expires_at: z.number().optional(),
receipt_id: z.string().optional(),
},
// Reaches the relay (openWorld) and, on resolution, writes the outcome onto a local receipt.
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
},
async (args) => {
try {
const pairing = await loadPairingRecord(BASE_DIR);
const r = await pollEscalationOnce(pairing, args.request_id, { expiresAt: args.expires_at });
if (r.resolved) {
// The live decision IS an outcome — record it on the consult receipt automatically.
let recorded = false;
try {
const m = await markOutcome(defaultStateDir(), args.receipt_id || 'latest', r.outcome);
recorded = !!(m && m.matched);
await maybeDepositReceiptsDigest(); // G1+G2: phone sees the recorded outcome on next drain
} catch { /* outcome-record is best-effort; the decision still returns */ }
const body = {
request_type: 'escalation_status', status: 'resolved',
decision: r.decision, proceed: r.proceed, outcome: r.outcome,
...(r.note ? { note: r.note } : {}), ...(r.modified != null ? { modified: r.modified } : {}),
recorded,
message: !r.proceed
? 'The user declined — do not proceed.'
: (r.decision === 'modify'
? (r.modified != null
? 'The user approved with changes — proceed using the modified parameters.'
: `The user wants a change before you proceed${r.note ? `: "${r.note}"` : ''}. Apply their instruction; do not proceed as originally planned.`)
: 'The user approved — proceed.'),
};
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
}
const expired = r.status === 'expired';
const body = {
request_type: 'escalation_status', status: r.status,
...(expired ? { proceed: false } : {}),
message: expired
? 'The decision window lapsed with no answer. Apply the safe default: hold — do not proceed.'
: 'Not answered yet. Keep holding; poll again shortly.',
};
return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], structuredContent: body };
} catch (err) {
const code = err instanceof PhaedoError ? err.code : 'request_malformed';
return { content: [{ type: 'text', text: `Phaedo escalation-status error [${code}]: ${err.message}` }], isError: true };
}
}
);
// ── Resource: phaedo://fingerprint/projection (binding §3.1) ──────────────────

@@ -212,0 +570,0 @@ // Default projection (all layers, standard mode) for resource-only clients.

{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.galavoxx/phaedo-mcp",
"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.",
"version": "0.1.1",
"description": "Carry your Phaedo cognitive fingerprint into any MCP client: style injection, decision consults + real-time escalation.",
"version": "0.2.0",
"repository": {

@@ -15,3 +15,3 @@ "url": "https://github.com/galavoxx/phaedo-mcp",

"identifier": "phaedo-mcp",
"version": "0.1.1",
"version": "0.2.0",
"transport": {

@@ -18,0 +18,0 @@ "type": "stdio"

@@ -326,6 +326,39 @@ // ─── Phaedo Context Block ─────────────────────────────────────────────────────

// Mutually-exclusive behavioral signal pairs. When both fire, injecting both
// produces a self-contradicting instruction (e.g. "Act without checking" AND
// "Confirm before acting" under Autonomy). Resolve to the dominant side by
// observation count; on an exact tie neither is a reliable directive, so drop
// both rather than inject a contradiction.
const OPPOSING_SIGNALS = [
['fewer_questions', 'wants_confirmation'],
['fewer_options', 'wants_options'],
['prefers_casual', 'prefers_formal'],
['avoid_bullets', 'prefer_bullets'],
['no_headers', 'use_headers'],
['skip_reasoning', 'show_reasoning'],
['conceptual_only', 'wants_implementation'],
['too_long', 'too_short'],
['no_examples', 'wants_examples'],
['stay_scoped', 'wants_proactive'],
['skip_meta', 'show_meta'],
];
function resolveOpposingSignals(flat) {
const out = { ...flat };
for (const [a, b] of OPPOSING_SIGNALS) {
const av = out[a] || 0, bv = out[b] || 0;
if (av > 0 && bv > 0) {
if (av > bv) delete out[b];
else if (bv > av) delete out[a];
else { delete out[a]; delete out[b]; } // tie → omit both
}
}
return out;
}
function synthesizeResponseInstructions(behavioralSignals, cognitiveProfile) {
const flat = {};
const raw = {};
for (const layer of Object.values(behavioralSignals))
for (const [k, v] of Object.entries(layer)) flat[k] = (flat[k] || 0) + v;
for (const [k, v] of Object.entries(layer)) raw[k] = (raw[k] || 0) + v;
const flat = resolveOpposingSignals(raw);

@@ -405,2 +438,79 @@ const inst = { format: [], tone: [], depth: [], autonomy: [], decisions: [], pace: [] };

// ─── Operating rules (Proposal 0007: authored standing_rules → §9 injection) ──
// Authored, user-confirmed rules render VERBATIM (§5.2 — never summarized) at the
// TOP of the profile, above inferred style, so a confirmed rule wins when it
// conflicts with an inferred habit (§4.3 conflict-priority). Sources every
// `standing_rules` entry carrying `text` (a kind:"instruction", or a
// kind:"authorization" that also chose to surface its rule). Ordered by §5.2
// `priority` (lower first), capped so rules never crowd out the rest of the persona.
// Raised 8 → 16 once free-form authoring shipped: a subject with the interview's
// decision rules (priorities 10–40) plus a few hand-authored rules exceeds 8, and
// the highest-priority-number (newest hand-authored) rule was being silently
// dropped from the injected block even though the vault + popup kept it.
const OPERATING_RULES_BUDGET = 16;
// A delta-promotion injects as a hard constraint only when its evidence-weighted
// priority (set by rule-review.js deltaPriority) is at/below this — i.e. strongly
// backed. Legacy flat-50 endorsements sit above it and stay out of injection.
const DELTA_INJECT_THRESHOLD = 45;
// Known-safe sources for injection (allow-list). Was a deny-list ("anything not
// delta_promotion"), which let any unknown / future / typo'd source through silently.
// New sources must be added here deliberately. `undefined` source is also allowed
// (legacy test fixtures + kind:'authorization' rules that don't stamp source — the
// existing behavior we don't want to regress); delta_promotion is conditional on the
// evidence-weighted priority gate above. Keep in sync with the source values
// producers actually write (see grep -E "source:\s*['\"]\w+['\"]"); adding a new
// producer source without listing it here = the rule silently won't inject.
const INJECT_SOURCE_ALLOW = new Set([
'interview', // popup.js addOperatingRule + edited questionnaire decision rules
'interview_template', // chip-picked-unedited questionnaire rule
'conflict_resolution', // conflict-review.js resolveConflict + revertOverride
'delegation_promotion', // act-as-me, §9 normative
]);
// Newlines/tabs collapse to a single space — the Operating Rules block is a
// one-line-per-rule block. Without scrub, a user-typed rule containing "\n" (or a
// future producer that emits multi-line text) would silently break out of its line
// and inject as extra bullets / pseudo-headings, derailing the block's structure.
// Render-time chokepoint catches every writer (popup, conflict-review, mobile,
// historical data already on disk) without chasing each call site.
function sanitizeRuleLine(s) {
return String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
}
function renderOperatingRules(fingerprint) {
const rules = Array.isArray(fingerprint && fingerprint.standing_rules) ? fingerprint.standing_rules : [];
const seen = new Set();
const chosen = rules
.filter(r => r && typeof r.text === 'string' && r.text.trim())
// Curation: the Operating-rules block is for DELIBERATE hard constraints —
// authored rules + interview-elicited decision rules + act-as-me (delegation) are
// ALWAYS injected. Auto-promoted style signals (source:'delta_promotion') inject
// ONLY when strongly evidenced — rule-review.js stamps them an evidence-weighted
// priority (lower = stronger), so a well-backed endorsement (≤ threshold) earns a
// slot while thin/legacy flat-50 ones stay out. Excluded promotions remain on the
// fingerprint and still drive §10 consult; they just don't dilute injection (the
// binding constraint is instruction-following fidelity, not tokens).
.filter(r => {
if (r.source == null) return true; // legacy / no-source — defensive
if (r.source === 'delta_promotion') { // priority-gated
return (Number.isFinite(r.priority) ? r.priority : 1e9) <= DELTA_INJECT_THRESHOLD;
}
return INJECT_SOURCE_ALLOW.has(r.source); // explicit allow-list
})
// Dedup by normalized text — defensive against near-duplicate authored rules.
.filter(r => { const k = r.text.trim().toLowerCase().replace(/\s+/g, ' '); if (seen.has(k)) return false; seen.add(k); return true; })
.sort((a, b) => (Number.isFinite(a.priority) ? a.priority : 1e9) - (Number.isFinite(b.priority) ? b.priority : 1e9))
.slice(0, OPERATING_RULES_BUDGET);
if (!chosen.length) return [];
const lines = [
'\n## Operating rules (user-confirmed)',
'Explicit rules this person has endorsed. Treat them as hard constraints on decisions',
'and recommendations, higher priority than inferred style. Apply them silently; do not',
'restate them or open replies with "as someone who...".',
];
for (const r of chosen) {
const exc = typeof r.exception === 'string' && r.exception.trim() ? ` (Exception: ${sanitizeRuleLine(r.exception)})` : '';
lines.push(`- ${sanitizeRuleLine(r.text)}${exc}`);
}
return lines;
}
// ─── Context block assembly ───────────────────────────────────────────────────

@@ -411,2 +521,20 @@

// ── Framing (2026-07-04): declares the block as background about the subject,
// not conversational history. Closes the confabulation defect surfaced by the
// persona-eval cross-judge (see docs/persona-extraction/persona-effectiveness-eval.md
// §7.4 Category A) where the model was writing "you mentioned…" attributing
// fingerprint content to prior turns the user never spoke.
lines.push(
'This is a persona model of the subject — background about how they',
'communicate and decide, learned across prior sessions. It is NOT part of',
'this conversation; the subject has NOT said any of what follows in this',
'session. Use it to calibrate tone, depth, format, and how decisions are',
'framed. Do NOT attribute anything below to the subject as if they said it',
'here (never "you mentioned…", "as you noted…", or "given your focus on X"',
'citing content from this profile) — those attributions would be fabrications.',
);
// ── 0. Operating rules (authored, user-confirmed — highest priority) ─────────
lines.push(...renderOperatingRules(fingerprint));
// ── 1. Response instructions (derived from observed behavioral corrections) ──

@@ -474,2 +602,19 @@ const inst = synthesizeResponseInstructions(behavioralSignals, cognitiveProfile);

lines.push(`\n### ${layer.label}`);
// A2 residual fix (2026-07-05): content-side softening of the
// Domain-and-expertise section. The persona-eval §7.4 residual
// showed the block front-loading a persona domain (e.g. immigration)
// on prompts that didn't name or invite it. A previous framing-
// directive iteration at the block-top level (A2, PR #115) net-
// regressed by making the model over-hedgy on unrelated prompts.
// This localized directive is scoped ONLY to the Domain-and-expertise
// section — the specific place where domain content actually gets
// read as topical steering. Concrete: for Randy this section says
// "Primary Expertise: Building operational frameworks... business
// immigration matters...", which the model was reading as "front-
// load immigration on any HR-adjacent prompt." The framing tells
// it to treat those answers as BACKGROUND about the subject rather
// than topics to steer generic prompts toward.
if (layerId === 'domain_and_expertise') {
lines.push('*Use as background context about who the subject is — do not steer general prompts toward these topics unless the prompt names them.*');
}
for (const { label, question, answer } of answered)

@@ -501,4 +646,7 @@ lines.push(`${label || question}: ${answer.trim()}`);

if (layer && typeof layer.summary === 'string' && layer.summary.trim()) {
learnedLines.push(`\n### ${layer.label || layerId.replace(/_/g, ' ')}`);
learnedLines.push(layer.summary.trim());
const clean = sanitizeSummary(layer.summary); // P0 hygiene: drop episodic/opaque/dup lines
if (clean) {
learnedLines.push(`\n### ${layer.label || layerId.replace(/_/g, ' ')}`);
learnedLines.push(clean);
}
}

@@ -577,2 +725,29 @@ }

// Projection hygiene (P0, 2026-06-16): defense-in-depth on the injected summary.
// The producer's renderLayerSummary guards at generation, but a stale or
// third-party producer could store a violating summary; drop episodic + opaque
// value lines and dedupe repeated dimensions so the AI never sees them. Regexes
// MUST stay in sync with checkSummary() in spec/validate-fingerprint.mjs.
var SUMMARY_EPISODIC_RE = /(https?:\/\/|\bwww\.)|[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}|\b[a-z0-9][a-z0-9-]*\.(com|org|net|io|so|co|ai|app|xyz|info)\b/i;
var SUMMARY_DIM_RE = /^[-*\s]*([^:]{1,60}):\s*(.+)$/;
var SUMMARY_OPAQUE_RE = /^[a-z]\d(?:[ _][a-z0-9]+)*$/i;
function sanitizeSummary(text) {
const out = [];
const seen = new Set();
for (const raw of String(text).split('\n')) {
const t = raw.trim();
if (!t) { out.push(raw); continue; }
if (SUMMARY_EPISODIC_RE.test(t)) continue; // drop episodic (§4.6)
const m = t.match(SUMMARY_DIM_RE);
if (m) {
const dim = m[1].trim().toLowerCase();
if (seen.has(dim)) continue; // drop duplicate dimension
if (SUMMARY_OPAQUE_RE.test(m[2].trim())) continue; // drop opaque value
seen.add(dim);
}
out.push(raw);
}
return out.join('\n').trim();
}
function renderContextBlock(vd, opts) {

@@ -579,0 +754,0 @@ const v = validateVaultData(vd);

@@ -129,3 +129,6 @@ // Encryption envelope per Phaedo Protocol spec §7.2.

globalThis.Phaedo = globalThis.Phaedo || {}
globalThis.Phaedo.Envelope = { wrap, unwrap }
// canonicalStringify is exposed for the conformance test vectors (spec §6 /
// spec/test-vectors): it is the only canonicalization v0.1 actually performs
// (the metadata AAD binding). See its doc-comment for the RFC 8785 subset note.
globalThis.Phaedo.Envelope = { wrap, unwrap, canonicalStringify }
})()

@@ -47,2 +47,17 @@ // Key derivation helpers using native WebCrypto HKDF-SHA-256.

/**
* Fingerprint-sync mailbox key (Proposal 0008). A distinct sub-key of pair_key,
* so the store-and-forward fingerprint envelope is decryptable by either device
* with NO live session or biometric — and never reuses pair_key's HMAC role or
* seed_key's. Re-derivable from the stored pair_key, so neither side holds state.
*/
async function deriveFpSyncKey(pairKey) {
return hkdfBytes(
pairKey,
new Uint8Array(0),
utf8(P.HKDF_INFO.FP_SYNC_KEY),
P.SIZES.AES_KEY,
)
}
async function deriveSessionKey(ephemeralShared, sessionToken) {

@@ -112,5 +127,5 @@ return hkdfBytes(

globalThis.Phaedo.Kdf = {
derivePairKey, deriveSeedKey, deriveSessionKey, deriveSAS, formatSAS,
derivePairKey, deriveSeedKey, deriveFpSyncKey, deriveSessionKey, deriveSAS, formatSAS,
authEphemeral, verifyAuthEphemeral,
}
})()

@@ -32,2 +32,6 @@ // Phaedo Protocol constants. Single source of truth on the extension side.

SEED_KEY: 'phaedo-seed-v0.1',
// Fingerprint-sync mailbox key (Proposal 0008). A distinct sub-key of
// pair_key so the store-and-forward fingerprint envelope is decryptable by
// either device with no live session — and never reuses any other role's key.
FP_SYNC_KEY: 'phaedo-fp-sync-v0.1',
},

@@ -34,0 +38,0 @@