phaedo-mcp 0.3.19 → 0.3.21

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/cache.js CHANGED
@@ -58,6 +58,17 @@ export async function clearCache(stateDir) {
58
58
  try { await rm(join(stateDir, 'cache.enc'), { force: true }); } catch { /* nothing to clear */ }
59
59
  }
60
60
 
61
+ // When the at-rest copy was made (ms), without decrypting it; null when there is none. The phone
62
+ // shows this age (receipts digest `copy_at`), so the subject can see the copy keeping up.
63
+ export async function readCacheTime(stateDir) {
64
+ try {
65
+ const { cached_at } = JSON.parse(await readFile(join(stateDir, 'cache.enc'), 'utf8'));
66
+ return Number.isFinite(cached_at) && cached_at > 0 ? cached_at : null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
61
72
  // Decrypt the cache → { vault, cachedAt } or null (absent/corrupt/undecryptable
62
73
  // → treated as no cache, since it's recoverable from the phone).
63
74
  export async function readCache(stateDir) {
@@ -15,6 +15,7 @@ import { resolve, dirname } from 'path';
15
15
  import { fileURLToPath } from 'url';
16
16
  import { hasPairingRecord, loadPairingRecord, loadFingerprintFromPhone, PhoneError } from './phone-source.js';
17
17
  import { defaultStateDir, writeCache, readCache, clearCache } from './cache.js';
18
+ import { maybeDrainProfileCopy } from './profile-inbox.js';
18
19
  import { foldLocalCapture, isLocalCaptureOwner, localCaptureSessionCount } from './capture-local.js';
19
20
  import { Capabilities, AuthoredSource } from './lib/phaedo-crypto.js';
20
21
  import { PhaedoError } from './errors.js';
@@ -307,8 +308,11 @@ async function loadFingerprintUnfiltered(baseDir) {
307
308
  await clearCache(stateDir);
308
309
  throw err;
309
310
  }
311
+ // Phone unreachable → the copy the phone last pushed for us, if it is newer (profile-inbox.js),
312
+ // then the encrypted cache (may be stale).
313
+ await maybeDrainProfileCopy(async () => rec, stateDir);
310
314
  const cached = await readCache(stateDir);
311
- if (cached) return stamp(foldConfigHome(normalizeIdentity(cached.vault)), cached); // phone unreachable → encrypted cache (may be stale)
315
+ if (cached) return stamp(foldConfigHome(normalizeIdentity(cached.vault)), cached);
312
316
  throw err; // no phone + no cache → cannot serve
313
317
  }
314
318
  }
@@ -371,6 +375,9 @@ async function loadFingerprintUnfiltered(baseDir) {
371
375
  // escalation in flight.
372
376
  export async function loadFingerprintPreferCache(baseDir, opts) {
373
377
  if (!process.env.PHAEDO_FINGERPRINT && hasPairingRecord(baseDir)) {
378
+ // The phone pushes a fresh copy whenever its app opens or the profile changes; take it first
379
+ // (throttled, fail-fast — this is the ship hook's path). See profile-inbox.js.
380
+ await maybeDrainProfileCopy(() => loadPairingRecord(baseDir), defaultStateDir());
374
381
  const cached = await readCache(defaultStateDir());
375
382
  if (cached) return forgetting(stamp(foldConfigHome(normalizeIdentity(cached.vault)), cached), opts);
376
383
  }
@@ -328,8 +328,8 @@ export function unpromptedContext(receiptId) {
328
328
 
329
329
  export function staleContext(days, mcpDir) {
330
330
  return `Also: Phaedo's copy of the user's profile is ${days} days old (their phone has not been ` +
331
- `reached since). Mention once, in one line, that they can refresh it by running ` +
332
- `\`cd ${mcpDir} && npm run pull\` and approving on their phone.`;
331
+ `reached since). Mention once, in one line, that opening the Phaedo app on their phone refreshes ` +
332
+ `it within minutes, or they can run \`cd ${mcpDir} && npm run pull\` and approve on their phone.`;
333
333
  }
334
334
 
335
335
  /**
@@ -421,8 +421,10 @@ export async function runShipHook(input, deps) {
421
421
  const extra = [];
422
422
  if (ci) extra.push({ cue: 'ciState', value: ci, confidence: null, basis: ci === 'unknown' ? null : 'observed' });
423
423
  if (ci && deps.prFacts) extra.push({ cue: 'diffStakes', value: stakesValue(stakes), confidence: null, basis: stakes ? 'observed' : null });
424
- if (grant) extra.push({ cue: 'standingGrant', value: granted ? 'answered' : `held:${grant.why}`, confidence: null, basis: 'observed' });
425
- const allDrivers = extra.length ? [...(drivers || []), ...extra] : drivers;
424
+ // First, not last: buildReceipt keeps only the first 16 drivers, and the weekly summary on the
425
+ // phone counts this one ("your standing rule decided N of them", weekly.js).
426
+ const lead = grant ? [{ cue: 'standingGrant', value: granted ? 'answered' : `held:${grant.why}`, confidence: null, basis: 'observed' }] : [];
427
+ const allDrivers = (extra.length || lead.length) ? [...lead, ...(drivers || []), ...extra] : drivers;
426
428
  const receiptId = await deps.writeReceipt({ request, response, drivers: allDrivers });
427
429
  if (!receiptId) return null; // no receipt = nothing to learn from; stay out of the way
428
430
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phaedo-mcp",
3
- "version": "0.3.19",
3
+ "version": "0.3.21",
4
4
  "private": false,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -36,6 +36,7 @@
36
36
  "server.js",
37
37
  "server.json",
38
38
  "fingerprint-source.js",
39
+ "profile-inbox.js",
39
40
  "projection.js",
40
41
  "errors.js",
41
42
  "consult.js",
@@ -100,7 +101,7 @@
100
101
  "unsetup": "node install.mjs --uninstall",
101
102
  "pair": "node pair.js",
102
103
  "pull": "node pull.js",
103
- "test": "node test-smoke.mjs && node test-no-profile.mjs && node test-phone-boundary.mjs && node test-cache.mjs && node test-pairing.mjs && node test-revoke.mjs && node test-install.mjs && node test-consult.mjs && node test-consult-policy.mjs && node test-amount-bands.mjs && node test-resolver-revision.mjs && node test-ship-actions.mjs && node test-ship-hook.mjs && node test-ship-readout.mjs && node test-action-binding.mjs && node test-autonomy-promotion.mjs && node test-ap2.mjs && node test-delegation-sync.mjs && node test-delegation-relay.mjs && node test-capture-sync.mjs && node test-capture-hook.mjs && node test-stop-capture.mjs && node test-capture-local.mjs && node test-capture-a11y.mjs && node test-vendored-capture.mjs && node test-escalation.mjs && node test-domain.mjs && node test-receipts.mjs && node test-receipts-sync.mjs && node test-agreement.mjs && node test-weekly.mjs && node test-asks.mjs && node test-precedent.mjs && node test-rules-files.mjs && node test-agent-hook.mjs && node test-learned.mjs && node test-history-import.mjs && node test-calibration.mjs && node test-identity.mjs && node test-signing.mjs && node test-sign-request.mjs && node test-signing-audit.mjs && node test-delegation-domain-fragmentation.mjs && node test-stale-authority.mjs && node test-config-home-fold.mjs && node test-precedence-and-ceiling.mjs && node test-package-closure.mjs && node test-plugin.mjs",
104
+ "test": "node test-smoke.mjs && node test-no-profile.mjs && node test-phone-boundary.mjs && node test-cache.mjs && node test-profile-inbox.mjs && node test-pairing.mjs && node test-revoke.mjs && node test-install.mjs && node test-consult.mjs && node test-consult-policy.mjs && node test-amount-bands.mjs && node test-resolver-revision.mjs && node test-ship-actions.mjs && node test-ship-hook.mjs && node test-ship-readout.mjs && node test-action-binding.mjs && node test-autonomy-promotion.mjs && node test-ap2.mjs && node test-delegation-sync.mjs && node test-delegation-relay.mjs && node test-capture-sync.mjs && node test-capture-hook.mjs && node test-stop-capture.mjs && node test-capture-local.mjs && node test-capture-a11y.mjs && node test-vendored-capture.mjs && node test-escalation.mjs && node test-domain.mjs && node test-receipts.mjs && node test-receipts-sync.mjs && node test-agreement.mjs && node test-weekly.mjs && node test-asks.mjs && node test-precedent.mjs && node test-rules-files.mjs && node test-agent-hook.mjs && node test-learned.mjs && node test-history-import.mjs && node test-calibration.mjs && node test-identity.mjs && node test-signing.mjs && node test-sign-request.mjs && node test-signing-audit.mjs && node test-delegation-domain-fragmentation.mjs && node test-stale-authority.mjs && node test-config-home-fold.mjs && node test-precedence-and-ceiling.mjs && node test-package-closure.mjs && node test-plugin.mjs",
104
105
  "receipts": "node receipts.js list",
105
106
  "agreement": "node agreement.js",
106
107
  "ship-readout": "node ship-readout.js",
@@ -0,0 +1,114 @@
1
+ // The phone's pushed profile copy (relay lane `profile_to_agent/<client_id>`, 2026-11-02).
2
+ //
3
+ // The live read (phone-source.js) needs the phone's LAN server up and a Face ID tap, so on the
4
+ // reference Mac it almost never completed and the at-rest copy went days stale — past the 7 days
5
+ // a standing rule may run on (lib/rule-leeway.js). The phone now leaves a fresh copy for this
6
+ // agent whenever its app comes to the foreground or the profile changes (mobile
7
+ // agentProfile.js / agentProfilePush.ts), sealed under this agent's own fp_sync_key. Here we pick
8
+ // it up and make it the at-rest copy.
9
+ //
10
+ // What keeps this safe:
11
+ // - It refreshes the CACHE only. A pushed copy is never "live" (sourceOf stays live:false), so
12
+ // the stale-authority guard and the rule leeway treat it exactly as they treat any cache.
13
+ // - Its age is the PHONE's `generated_at`, not when we drained it, so a copy that sat on the
14
+ // relay for days is days old here too. A time in the future is clamped to now.
15
+ // - Newest wins: a copy no newer than the one we hold is ignored, so the relay cannot roll the
16
+ // profile back to before a rule was removed by replaying an older deposit.
17
+ // - Sealed + tagged: only this agent's pair key opens it, and `kind: 'agent_profile'` stops
18
+ // anything else sealed under that key (our own receipts digest) being read as a profile.
19
+ //
20
+ // Best-effort and throttled (CHECK_GAP_MS, shared across processes through a small state file,
21
+ // since the ship hook runs as a fresh process per tool call): a relay failure leaves the copy we
22
+ // have and never fails the read that asked.
23
+
24
+ import { readFile, writeFile, mkdir } from 'fs/promises';
25
+ import { join } from 'path';
26
+ import { Phaedo, b64uDec } from './lib/phaedo-crypto.js';
27
+ import { readCache, writeCache } from './cache.js';
28
+ import { toVaultPayload } from './phone-source.js';
29
+ import { relayBaseFromEnv } from './receipts-sync.js';
30
+
31
+ const { Kdf, Envelope } = Phaedo;
32
+ export const AGENT_PROFILE_KIND = 'agent_profile';
33
+ export const CHECK_GAP_MS = 10 * 60 * 1000;
34
+ const SEGMENT_RE = /^[A-Za-z0-9_.-]{1,128}$/; // relay/mailbox.js LANE_SUB_RE
35
+ const STATE_FILE = 'profile-inbox.json';
36
+ const TIMEOUT_MS = 3000; // on the ship hook's path: fail fast
37
+
38
+ /** Open a pushed copy → { vault, generatedAt } or null (wrong key, wrong kind, malformed). */
39
+ export async function parseAgentProfileDeposit(pairKeyB64u, envelope) {
40
+ if (!envelope || !pairKeyB64u) return null;
41
+ const pairKey = b64uDec(pairKeyB64u);
42
+ let key;
43
+ try { key = await Kdf.deriveFpSyncKey(pairKey); } finally { pairKey.fill(0); }
44
+ try {
45
+ const plaintext = await Envelope.unwrap(envelope, key);
46
+ let inner;
47
+ try { inner = JSON.parse(new TextDecoder().decode(plaintext)); } finally { if (plaintext.fill) plaintext.fill(0); }
48
+ if (!inner || inner.kind !== AGENT_PROFILE_KIND || !inner.data || typeof inner.data !== 'object') return null;
49
+ const generatedAt = Date.parse(inner.generated_at);
50
+ if (!Number.isFinite(generatedAt)) return null;
51
+ return { vault: toVaultPayload(inner), generatedAt };
52
+ } catch {
53
+ return null;
54
+ } finally {
55
+ if (key && key.fill) key.fill(0);
56
+ }
57
+ }
58
+
59
+ /** Should a pushed copy replace the cache? Only a strictly newer one. Returns the time to stamp. */
60
+ export function acceptCopy(generatedAt, cachedAt, now = Date.now()) {
61
+ if (!Number.isFinite(generatedAt)) return null;
62
+ const at = Math.min(generatedAt, now); // a phone clock ahead of ours cannot buy freshness
63
+ if (Number.isFinite(cachedAt) && at <= cachedAt) return null;
64
+ return at;
65
+ }
66
+
67
+ /**
68
+ * Fetch this agent's pushed copy and, when it is newer than the cache, make it the cache.
69
+ * → { updated: true, generatedAt } | { updated: false, reason }.
70
+ */
71
+ export async function drainProfileCopy(pairing, stateDir, deps = {}) {
72
+ const fetchFn = deps.fetch || globalThis.fetch;
73
+ const now = deps.now ? deps.now() : Date.now();
74
+ if (!pairing || !pairing.pair_key) return { updated: false, reason: 'unpaired' };
75
+ if (!pairing.relay_device_id) return { updated: false, reason: 'no-relay-device-id' };
76
+ if (typeof pairing.client_id !== 'string' || !SEGMENT_RE.test(pairing.client_id)) return { updated: false, reason: 'no-client-id' };
77
+ try {
78
+ const url = `${deps.relayBase || relayBaseFromEnv()}/fp-mailbox/${encodeURIComponent(pairing.relay_device_id)}/profile_to_agent/${pairing.client_id}`;
79
+ const authHeader = pairing.relay_device_secret
80
+ ? { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` }
81
+ : {};
82
+ const res = await fetchFn(url, { method: 'GET', headers: authHeader, signal: AbortSignal.timeout(deps.timeoutMs || TIMEOUT_MS) });
83
+ // 404 = nothing pushed yet; 400 = a relay that predates the lane.
84
+ if (!res || !res.ok) return { updated: false, reason: `http-${res && res.status}` };
85
+ const body = await res.json().catch(() => null);
86
+ const copy = await parseAgentProfileDeposit(pairing.pair_key, body && body.envelope);
87
+ if (!copy) return { updated: false, reason: 'unreadable' };
88
+ const cached = await readCache(stateDir);
89
+ const at = acceptCopy(copy.generatedAt, cached ? Number(cached.cachedAt) : NaN, now);
90
+ if (at == null) return { updated: false, reason: 'not-newer' };
91
+ await writeCache(stateDir, copy.vault, at);
92
+ return { updated: true, generatedAt: at };
93
+ } catch (e) {
94
+ return { updated: false, reason: String(e && e.message || e) };
95
+ }
96
+ }
97
+
98
+ /** Throttled `drainProfileCopy`: at most once per CHECK_GAP_MS across every process on this Mac. */
99
+ export async function maybeDrainProfileCopy(loadPairing, stateDir, deps = {}) {
100
+ const now = deps.now ? deps.now() : Date.now();
101
+ const path = join(stateDir, STATE_FILE);
102
+ try {
103
+ const last = Number(JSON.parse(await readFile(path, 'utf8'))?.checked_at);
104
+ if (Number.isFinite(last) && now >= last && now - last < CHECK_GAP_MS) return { updated: false, reason: 'recent' };
105
+ } catch { /* first check, or an unreadable file: check now */ }
106
+ try {
107
+ await mkdir(stateDir, { recursive: true, mode: 0o700 });
108
+ // Claimed BEFORE the request, so the processes that start together do not all ask.
109
+ await writeFile(path, JSON.stringify({ checked_at: now }), { mode: 0o600 });
110
+ } catch { /* a read-only state dir still gets its check */ }
111
+ let pairing = null;
112
+ try { pairing = await loadPairing(); } catch { return { updated: false, reason: 'unpaired' }; }
113
+ return drainProfileCopy(pairing, stateDir, deps);
114
+ }
package/pull.js CHANGED
@@ -58,7 +58,8 @@ loadFingerprint(BASE_DIR)
58
58
  `⚠ Could NOT reach your phone — served a CACHED copy${hours != null ? ` about ${hours}h old` : ''} ` +
59
59
  `(fingerprint ${fp.fingerprint_id || '(no id)'}, ${layers} layer(s)).\n` +
60
60
  ` Anything you changed since then — including a revoked authorization — is NOT reflected.\n` +
61
- ` Open Phaedo on your phone and approve the card, then run this again.\n`
61
+ ` Opening Phaedo on your phone sends this computer a fresh copy (picked up within 10 minutes);\n` +
62
+ ` to read it live instead, keep the app open, approve the card, and run this again.\n`
62
63
  );
63
64
  process.stdout.write(await week() + review(vd));
64
65
  process.exit(2); // distinct from 1 (hard failure) — stale, not broken
package/receipts-sync.js CHANGED
@@ -78,7 +78,7 @@ function digestFields(r) {
78
78
  // Pure-crypto: take the local receipts list, build a §10.4-safe digest of the most
79
79
  // recent `cap` entries, and seal it under fp_sync_key. Returns `{ envelope }` — the
80
80
  // same shape `agent_to_ext` (delegation-sync) uses, so the relay's mailbox accepts it.
81
- export async function buildReceiptsDeposit(pairKeyB64u, receipts, { cap = DIGEST_CAP, now = () => new Date().toISOString() } = {}) {
81
+ export async function buildReceiptsDeposit(pairKeyB64u, receipts, { cap = DIGEST_CAP, now = () => new Date().toISOString(), copyAt = null } = {}) {
82
82
  const list = Array.isArray(receipts) ? receipts : [];
83
83
  // Most-recent N (receipts are append-only, ordered by creation in the store).
84
84
  const head = list.slice(-cap).map(digestFields).filter(Boolean);
@@ -100,7 +100,12 @@ export async function buildReceiptsDeposit(pairKeyB64u, receipts, { cap = DIGEST
100
100
  // plus the sentence the MCP already shows in /phaedo:status, so both surfaces say the same.
101
101
  const w = weeklyHandled(list, { now: Number.isFinite(agreementAt) ? agreementAt : Date.now() });
102
102
  const week = { ...w, summary: formatWeekly(w) };
103
- const inner = { kind: 'receipts_digest', data: { receipts: head, agreement, week, generated_at: generatedAt } };
103
+ // How fresh this computer's profile copy is (cache.js readCacheTime), so the phone can show it.
104
+ // Omitted when there is no copy; never later than this digest.
105
+ const copy = Number.isFinite(copyAt) && copyAt > 0
106
+ ? { copy_at: new Date(Math.min(copyAt, Number.isFinite(agreementAt) ? agreementAt : Date.now())).toISOString() }
107
+ : {};
108
+ const inner = { kind: 'receipts_digest', data: { receipts: head, agreement, week, generated_at: generatedAt, ...copy } };
104
109
  const plaintext = new TextEncoder().encode(JSON.stringify(inner));
105
110
  const envelope = await Envelope.wrap(plaintext, fpSyncKey, { artifact_kind: 'receipts_digest', created_at: now() });
106
111
  plaintext.fill(0);
package/server.js CHANGED
@@ -29,8 +29,9 @@ import { depositSignRequest, pollSignOnce } from './signing-request.js';
29
29
  import { buildSigningAuditRecord, appendSigningAudit } from './signing-audit.js';
30
30
  import { buildInjectionResponse, errorResponse, PhaedoError } from './projection.js';
31
31
  import { resolveConsultation, checkAuthorization, standingRuleRelaxes, CONSULTATION_TYPES, consultDrivers, withRevision } from './consult.js';
32
- import { defaultStateDir } from './cache.js';
32
+ import { defaultStateDir, readCacheTime } from './cache.js';
33
33
  import { ruleLeeway, RULE_FRESH_DAYS } from './lib/rule-leeway.js';
34
+ import { standingRuleDriver } from './weekly.js';
34
35
  import { buildConsultOpts, loadConsultVd } from './consult-runtime.js';
35
36
  import { buildReceipt, appendReceipt, receiptsCapFromEnv, readReceipts, markOutcome, USER_ACTIONS } from './receipts.js';
36
37
 
@@ -102,6 +103,13 @@ async function guardFacts(vd, req, ruleDecided = ruleRelaxes(vd, req)) {
102
103
  }
103
104
  }
104
105
 
106
+ // The receipt's note that one of the subject's standing rules let this go ahead (weekly.js counts
107
+ // it for the phone). First in the list, since buildReceipt keeps only the first 16 drivers.
108
+ function ruleAnsweredDriver(vd, req, response) {
109
+ if (response?.signal !== 'proceed') return [];
110
+ try { return standingRuleDriver(checkAuthorization(vd, req, CONSULT_OPTS), response); } catch { return []; }
111
+ }
112
+
105
113
  function withStaleAuthorityGuard(response, vd, { ruleDecided = true, leeway = false } = {}) {
106
114
  const src = sourceOf(vd);
107
115
  const out = { ...response };
@@ -151,9 +159,11 @@ async function maybeDepositReceiptsDigest() {
151
159
  // AND "an existing receipt got marked" (so the next phone drain reflects the mark).
152
160
  const latest = receipts[receipts.length - 1];
153
161
  const tally = receipts.reduce((acc, r) => { acc[r.user_action ?? 'null'] = (acc[r.user_action ?? 'null'] || 0) + 1; return acc; }, {});
154
- const sig = `${latest.receipt_id}|${JSON.stringify(tally)}`;
162
+ // The copy's time rides along, so a fresher copy alone is worth a new digest.
163
+ const copyAt = await readCacheTime(defaultStateDir());
164
+ const sig = `${latest.receipt_id}|${JSON.stringify(tally)}|${copyAt}`;
155
165
  if (sig === _lastReceiptsSig) return;
156
- const res = await depositReceiptsDigest(await loadPairingRecord(BASE_DIR), receipts);
166
+ const res = await depositReceiptsDigest(await loadPairingRecord(BASE_DIR), receipts, { copyAt });
157
167
  if (res && res.deposited) _lastReceiptsSig = sig;
158
168
  else if (res && !res.deposited && res.reason !== 'no-receipts')
159
169
  process.stderr.write(`[phaedo-mcp] receipts digest deposit skipped: ${res.reason || 'http ' + res.status}\n`);
@@ -377,6 +387,7 @@ server.registerTool(
377
387
  // (never in `response`; §10.4). Best-effort: a failure must not block the answer.
378
388
  let drivers = [];
379
389
  try { drivers = withRevision(consultDrivers(vd, args || {}, CONSULT_OPTS), raw); } catch { /* provenance is non-critical */ }
390
+ drivers = [...ruleAnsweredDriver(vd, args || {}, response), ...drivers];
380
391
  const receiptId = await emitReceipt('phaedo_consult', args, response, drivers); // every resolution, abstains included
381
392
  if (receiptId) response.receipt_id = receiptId; // the handle for phaedo_record_outcome
382
393
  await maybeDepositDelegationPromotions(vd); // act-as-me promotion (best-effort)
@@ -454,7 +465,8 @@ server.registerTool(
454
465
  // receipt. A no-match is not a decision — no receipt (keeps the store from
455
466
  // filling with probes; the follow-up phaedo_consult writes its own).
456
467
  if (matchedPreGuard) {
457
- const rid = await emitReceipt('phaedo_check_authorization', req, { signal: response.signal, confidence: 1.0, deference_level: 'high' });
468
+ const rid = await emitReceipt('phaedo_check_authorization', req, { signal: response.signal, confidence: 1.0, deference_level: 'high' },
469
+ ruleAnsweredDriver(vd, req, response));
458
470
  if (rid) response.receipt_id = rid;
459
471
  await maybeDepositReceiptsDigest(); // G1+G2 audit channel — only on a matched preflight (no-match writes no receipt)
460
472
  }
@@ -591,6 +603,7 @@ server.registerTool(
591
603
  const response = withStaleAuthorityGuard(raw, vd, await guardFacts(vd, req));
592
604
  let drivers = [];
593
605
  try { drivers = withRevision(consultDrivers(vd, req, CONSULT_OPTS), raw); } catch { /* non-critical */ }
606
+ drivers = [...ruleAnsweredDriver(vd, req, response), ...drivers];
594
607
  const escReceiptId = await emitReceipt('phaedo_escalate', req, response, drivers); // the decision being escalated
595
608
  await maybeDepositReceiptsDigest(); // G1+G2 audit channel
596
609
 
package/server.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.phaedo-labs/phaedo-mcp",
4
4
  "description": "Make AI apps answer your way, check with you before acting, and get approvals on your phone.",
5
- "version": "0.3.19",
5
+ "version": "0.3.21",
6
6
  "repository": {
7
7
  "url": "https://github.com/phaedo-labs/phaedo-mcp",
8
8
  "source": "github",
@@ -13,7 +13,7 @@
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "phaedo-mcp",
16
- "version": "0.3.19",
16
+ "version": "0.3.21",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  }
package/weekly.js CHANGED
@@ -10,7 +10,10 @@
10
10
  // came_to_you — Phaedo said check with you (escalate / clarify)
11
11
  // unsure — Phaedo didn't know (insufficient_signal); the agent fell back to its own default
12
12
  //
13
- // "Handled" is went_ahead + stopped. Pure over the receipts list; the CLI reads the local store.
13
+ // "Handled" is went_ahead + stopped. `by_rule` is the part of went_ahead that one of the subject's
14
+ // standing rules decided — the receipt carries a `standingRule` (server) or `standingGrant` (ship
15
+ // hook) driver saying so — which is how they see a rule they accepted actually being used.
16
+ // Pure over the receipts list; the CLI reads the local store.
14
17
  //
15
18
  // npm run week
16
19
 
@@ -27,14 +30,30 @@ const BUCKET = {
27
30
  insufficient_signal: 'unsure',
28
31
  };
29
32
 
33
+ // The driver cues that mean "a standing rule answered this" (server.js ruleAnsweredDriver,
34
+ // hooks/pre-ship.mjs). A held grant is recorded as `held:<why>`, so only 'answered' counts.
35
+ export const RULE_CUES = new Set(['standingRule', 'standingGrant']);
36
+ export function answeredByRule(r) {
37
+ return Array.isArray(r?.drivers) && r.drivers.some((d) => RULE_CUES.has(d?.cue) && d?.value === 'answered');
38
+ }
39
+
40
+ /** The server's driver for a receipt a standing rule decided: an authorization that matched and
41
+ * said go ahead, AND an answer that still says go ahead after the stale-authority guard — one the
42
+ * guard withheld went to the subject instead, so the rule did not decide it. */
43
+ export function standingRuleDriver(auth, response) {
44
+ return auth?.authorization_matched === true && auth.signal === 'proceed' && response?.signal === 'proceed'
45
+ ? [{ cue: 'standingRule', value: 'answered', confidence: 1, basis: 'authored' }] : [];
46
+ }
47
+
30
48
  export function weeklyHandled(receipts, { now = Date.now(), days = WEEK_DAYS } = {}) {
31
49
  const since = now - days * DAY_MS;
32
- const out = { days, went_ahead: 0, stopped: 0, came_to_you: 0, unsure: 0 };
50
+ const out = { days, went_ahead: 0, by_rule: 0, stopped: 0, came_to_you: 0, unsure: 0 };
33
51
  for (const r of Array.isArray(receipts) ? receipts : []) {
34
52
  const t = Date.parse(r?.created_at);
35
53
  if (!Number.isFinite(t) || t < since || t > now) continue;
36
54
  const b = BUCKET[r?.response?.signal];
37
55
  if (b) out[b]++;
56
+ if (b === 'went_ahead' && answeredByRule(r)) out.by_rule++;
38
57
  }
39
58
  out.handled = out.went_ahead + out.stopped;
40
59
  out.total = out.handled + out.came_to_you + out.unsure;
@@ -53,8 +72,9 @@ export function formatWeekly(w) {
53
72
  ? `This week Phaedo handled ${plural(w.handled, 'decision')} without interrupting you (${parts.join(', ')})`
54
73
  : 'This week Phaedo handled no decisions on its own';
55
74
  const asked = w.came_to_you ? ` and brought ${w.came_to_you} to you` : '';
75
+ const rule = w.by_rule ? ` Your standing rule decided ${w.handled === 1 ? 'it' : `${w.by_rule === 1 ? 'one' : w.by_rule} of them`}.` : '';
56
76
  const unsure = w.unsure ? ` ${w.unsure === 1 ? 'One more was' : `${w.unsure} more were`} too unclear to call.` : '';
57
- return `${handled}${asked}.${unsure}`;
77
+ return `${handled}${asked}.${rule}${unsure}`;
58
78
  }
59
79
 
60
80
  if (isMainModule(import.meta.url)) {