phaedo-mcp 0.3.6 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -123,6 +123,51 @@ will then tell you there's no profile until you pair your phone.
123
123
  You can disconnect this computer at any time from the **Agents** screen on your
124
124
  phone.
125
125
 
126
+ ### Learn from your ChatGPT or Claude history
127
+
128
+ ```bash
129
+ npm run import -- ~/Downloads/your-export.zip
130
+ ```
131
+
132
+ Get the file from ChatGPT (Settings → Data controls → Export data) or Claude (Settings → Privacy →
133
+ Export data); each emails a zip. Phaedo reads the last year of conversations (`--all` for
134
+ everything, `--dry-run` to count first) through the same on-device detectors as Claude Code
135
+ capture, keeps only the counts, and discards the text — delete the export afterwards. Importing
136
+ the same file again replaces, never doubles. Paired: the patterns go to your phone. Not paired:
137
+ they become your starter profile. In the Claude Code plugin, it's `/phaedo:import`.
138
+
139
+ ### See what Phaedo learned — keep it or remove it
140
+
141
+ ```bash
142
+ npm run learned
143
+ ```
144
+
145
+ Lists what Phaedo inferred from how you work (not your interview answers or your rules), one
146
+ plain sentence each, and flags pairs that disagree ("favor speed" vs "favor quality").
147
+ `npm run learned -- --remove 3,5` stops those shaping answers and decisions on this computer —
148
+ the list is applied every time the profile loads, so a pattern your sessions keep showing stays
149
+ removed. `--keep all` marks the rest reviewed; `--forgotten` and `--restore <n>` undo.
150
+ `/phaedo:status` says when there are new things to look at. To remove something from the phone's
151
+ profile itself, use the Phaedo app. In the Claude Code plugin, it's `/phaedo:learned`.
152
+
153
+ ### Give agents without MCP the same profile
154
+
155
+ Codex, Copilot CLI, Gemini CLI and Antigravity each read a personal instructions file at the
156
+ start of every session. One command writes a short version of your profile (your rules and how
157
+ you like answers) into each one found on this computer:
158
+
159
+ ```bash
160
+ npm run rules
161
+ ```
162
+
163
+ It goes into `~/.codex/AGENTS.md`, `~/.copilot/copilot-instructions.md` and
164
+ `~/.gemini/GEMINI.md` (shared by Gemini CLI and Antigravity), inside a marked block; the rest
165
+ of each file is left alone. Cursor has no personal file, so name a project:
166
+ `npm run rules -- --project ~/code/app` writes `.cursor/rules/phaedo.mdc` and keeps it out of
167
+ git. Phaedo refreshes these files whenever a Claude Code session starts with Phaedo on, and
168
+ only those you set up. `npm run rules -- --dry-run` shows the text first;
169
+ `npm run rules -- --remove` takes it out. In the Claude Code plugin, it's `/phaedo:rules`.
170
+
126
171
  ---
127
172
 
128
173
  ## Reference
@@ -356,22 +401,41 @@ detectors, and a model call can never break or stall a session (30s budget, hook
356
401
  exits 0). Prefer the `setup:capture-model` config file over an env var — the file reaches
357
402
  Dock-launched Claude Desktop, a shell `export` does not.
358
403
 
359
- ### Consult before shipping, from Claude Code (`hooks/pre-ship.mjs`)
360
-
361
- `npm run setup:code-hook` also installs a **ship hook** on Bash (PreToolUse,
362
- PermissionRequest, PostToolUse, PostToolUseFailure). Before an agent merges a PR
363
- (`gh pr merge`), deploys (`fly deploy`), builds or submits a phone app (`eas build` /
364
- `eas submit`) or pushes straight to `main`, the hook runs a full `phaedo_consult` on it
365
- (`ship-actions.js` names these; every other command is ignored in ~50 ms) and writes a
366
- §10.6 receipt, `agent_id: claude-code/ship-hook`.
404
+ ### Your personal reviewer in Claude Code (`hooks/pre-ship.mjs`)
405
+
406
+ `npm run setup:code-hook` (or the Claude plugin) installs this hook on PreToolUse,
407
+ PermissionRequest, PostToolUse and PostToolUseFailure, for shell commands and for
408
+ connected-app (MCP) tool calls. Before an agent does something people disagree about, the
409
+ hook runs a full `phaedo_consult` on it and writes a §10.6 receipt, `agent_id:
410
+ claude-code/ship-hook`. `ship-actions.js` names what counts:
411
+
412
+ - **Shell:** merge a PR (`gh pr merge`), deploy (`fly deploy`, `vercel --prod`, `netlify deploy
413
+ --prod`, `firebase deploy`, `terraform apply|destroy`, `kubectl apply|delete`), build or
414
+ submit a phone app (`eas build` / `eas submit`), push straight to `main`, force-push any
415
+ branch, delete a remote branch, open a PR, comment on or review a PR or issue, publish a
416
+ package (`npm`/`pnpm`/`yarn`/`bun publish`, `cargo publish`, `twine upload`, `gem push`;
417
+ never `--dry-run`), create a GitHub release.
418
+ - **Connected apps:** any tool whose name says it sends, replies, forwards, posts, publishes,
419
+ comments, shares or invites; pays, purchases, transfers or checks out; deletes, purges,
420
+ destroys or trashes; or merges — through any MCP server except Phaedo's own. The receipt
421
+ records who or what the call targets (recipient, channel, subject, amount), never a message
422
+ body.
423
+
424
+ Every other command or tool is ignored in about 50 ms.
425
+
426
+ **Why it matters in auto mode.** Auto mode's reviewer judges an action by its type and lets
427
+ routine ones through. Phaedo asks the other question — would *this* person want to see this
428
+ one? — and when the answer is yes it returns `ask`, which forces a real prompt even in auto
429
+ mode, with the reason in your terms: "it can't be undone", "it spends $250", or "your rule:
430
+ ask me before posting in #eng". When the answer is no, it stays quiet.
367
431
 
368
432
  - **It only ever adds friction.** It never returns `allow`. A permissive answer leaves
369
433
  Claude Code's own permission flow untouched; `escalate` / `clarify` / `decline` returns
370
434
  `ask`, so the action comes to you with one plain line saying why.
371
435
  - **Outcomes are yours, never inferred.** The hook records `approved` only when it has
372
- evidence you were asked: Claude Code fired PermissionRequest for that command, and the
373
- command then ran. That holds in every mode except auto / dontAsk, where no person
374
- answers. Decline and the agent records `rejected` (Claude Code fires no hook for a
436
+ evidence you were asked: Claude Code fired PermissionRequest for that call, and the
437
+ call then ran. In auto mode that counts only when Phaedo itself asked (the prompt it forced
438
+ is yours; any other is the classifier's); in dontAsk, never. Decline and the agent records `rejected` (Claude Code fires no hook for a
375
439
  decline). No prompt shown, nothing recorded; the agent is told so, and records your
376
440
  reaction only if you give one.
377
441
  - **What a merge changes.** Before `gh pr merge` the hook reads the PR from GitHub (read-only):
@@ -385,6 +449,27 @@ PermissionRequest, PostToolUse, PostToolUseFailure). Before an agent merges a PR
385
449
  - **Fail-safe:** any error, timeout (12 s) or unusable profile → no output, the command
386
450
  runs as if the hook weren't there.
387
451
 
452
+ ### The same check in Cursor, Copilot CLI, Codex and Gemini CLI (`hooks/agent-hook.mjs`)
453
+
454
+ ```bash
455
+ cd ~/phaedo/mcp && npm run setup:agent-hooks
456
+ ```
457
+
458
+ This adds Phaedo's check to each of these agents found on this computer, beside any hooks you
459
+ already have: `~/.cursor/hooks.json`, `~/.copilot/hooks/phaedo.json`, `~/.codex/hooks.json`
460
+ and `~/.gemini/settings.json`. The same actions are reviewed, with the same consult and the
461
+ same decision records (each names the agent that asked).
462
+
463
+ - **Cursor and Copilot CLI** can show a confirmation prompt, so you get one, with the reason.
464
+ - **Codex and Gemini CLI** can't prompt from a hook, so Phaedo pauses the action once with the
465
+ reason; the agent asks you in the chat, and the same command run again within 15 minutes goes
466
+ through. It never blocks twice in a row and never says yes on your behalf.
467
+ - **Codex** skips new hooks until you trust them: run `/hooks` in Codex once and trust Phaedo's.
468
+ - **Antigravity** has no hooks, so it gets your profile (`npm run rules`) but not this check.
469
+ - Outcomes aren't recorded automatically in these agents yet; `npm run ship-readout` shows
470
+ what they asked.
471
+ - `npm run unsetup` (uninstall) takes them all out.
472
+
388
473
  ## Consult at decision points (`phaedo_consult`, §10)
389
474
 
390
475
  Beyond injecting style, an agent can **ask the fingerprint how the user's pattern
package/agent-hooks.js ADDED
@@ -0,0 +1,160 @@
1
+ // Phaedo — wire the merge check (hooks/agent-hook.mjs) into Cursor, Copilot CLI, Codex and
2
+ // Gemini CLI. Each keeps its hooks in its own user-level file; Phaedo's entry is recognised by
3
+ // the script path, merged in beside whatever is there, and removed on uninstall.
4
+ //
5
+ // Cursor ~/.cursor/hooks.json beforeShellExecution + beforeMCPExecution
6
+ // Copilot ~/.copilot/hooks/phaedo.json preToolUse (a file of Phaedo's own)
7
+ // Codex ~/.codex/hooks.json PreToolUse (new hooks need trusting in /hooks)
8
+ // Gemini ~/.gemini/settings.json hooks.BeforeTool
9
+ //
10
+ // Antigravity documents no hooks, so it gets the profile (rules-files.js) but no merge check.
11
+ // The merge functions are PURE (config in, config out); applyAgentHooks does the file I/O.
12
+
13
+ import { homedir } from 'os';
14
+ import { join, resolve, dirname } from 'path';
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, rmSync } from 'fs';
16
+ import { fileURLToPath } from 'url';
17
+ import { MCP_HOOK_MATCHER } from './ship-actions.js';
18
+
19
+ export const AGENT_HOOK_PATH = join(dirname(fileURLToPath(import.meta.url)), 'hooks', 'agent-hook.mjs');
20
+ export const MARKER = 'hooks/agent-hook.mjs';
21
+ export const TIMEOUT_S = 15; // above the hook's own 12 s budget
22
+ const SHIP_MATCHER = `Bash|${MCP_HOOK_MATCHER}`;
23
+ // Gemini names MCP tools mcp_<server>_<tool> (one underscore), so the same verbs, one prefix.
24
+ const GEMINI_MATCHER = `run_shell_command|${MCP_HOOK_MATCHER.replace(/^mcp__/, 'mcp_')}`;
25
+
26
+ const norm = (s) => String(s ?? '').replace(/\\/g, '/');
27
+ const ours = (cmd) => norm(cmd).includes(MARKER);
28
+ export const agentCommand = (agent, { nodePath = process.execPath, hookPath = AGENT_HOOK_PATH } = {}) => `"${nodePath}" "${hookPath}" ${agent}`;
29
+
30
+ /** Where each agent keeps its hooks, for this home and environment. */
31
+ export function hookFiles({ home = homedir(), env = process.env } = {}) {
32
+ const codexHome = env.CODEX_HOME ? resolve(env.CODEX_HOME) : join(home, '.codex');
33
+ const copilotHome = env.COPILOT_HOME ? resolve(env.COPILOT_HOME) : join(home, '.copilot');
34
+ return {
35
+ cursor: { label: 'Cursor', dir: join(home, '.cursor'), file: join(home, '.cursor', 'hooks.json') },
36
+ copilot: { label: 'Copilot CLI', dir: copilotHome, file: join(copilotHome, 'hooks', 'phaedo.json'), ownFile: true },
37
+ codex: { label: 'Codex', dir: codexHome, file: join(codexHome, 'hooks.json'), note: 'In Codex, run /hooks once and trust the Phaedo hook — Codex skips new hooks until you do.' },
38
+ gemini: { label: 'Gemini CLI', dir: join(home, '.gemini'), file: join(home, '.gemini', 'settings.json') },
39
+ };
40
+ }
41
+
42
+ const clone = (o) => JSON.parse(JSON.stringify(o ?? {}));
43
+ const arr = (v) => (Array.isArray(v) ? v : []);
44
+
45
+ // Cursor and Copilot: { hooks: { event: [ {command…} ] } } — one flat entry per hook.
46
+ function mergeFlat(config, events, entry, cmdOf) {
47
+ const c = clone(config);
48
+ c.version ??= 1;
49
+ c.hooks = c.hooks && typeof c.hooks === 'object' ? c.hooks : {};
50
+ for (const ev of events) {
51
+ const rest = arr(c.hooks[ev]).filter((h) => !ours(cmdOf(h)));
52
+ c.hooks[ev] = [...rest, entry];
53
+ }
54
+ return c;
55
+ }
56
+ function removeFlat(config, events, cmdOf) {
57
+ const c = clone(config);
58
+ if (!c.hooks || typeof c.hooks !== 'object') return c;
59
+ for (const ev of events) {
60
+ if (!Array.isArray(c.hooks[ev])) continue;
61
+ c.hooks[ev] = c.hooks[ev].filter((h) => !ours(cmdOf(h)));
62
+ if (!c.hooks[ev].length) delete c.hooks[ev];
63
+ }
64
+ return c;
65
+ }
66
+
67
+ // Codex and Gemini: { hooks: { Event: [ { matcher, hooks: [ {command…} ] } ] } } — Claude-style groups.
68
+ function mergeGrouped(container, event, group) {
69
+ const c = clone(container);
70
+ c.hooks = c.hooks && typeof c.hooks === 'object' ? c.hooks : {};
71
+ const rest = removeGrouped({ hooks: { [event]: arr(c.hooks[event]) } }, event).hooks[event] || [];
72
+ c.hooks[event] = [...rest, group];
73
+ return c;
74
+ }
75
+ function removeGrouped(container, event) {
76
+ const c = clone(container);
77
+ if (!c.hooks || !Array.isArray(c.hooks[event])) return c;
78
+ c.hooks[event] = c.hooks[event]
79
+ .map((g) => ({ ...g, hooks: arr(g?.hooks).filter((h) => !ours(h?.command)) }))
80
+ .filter((g) => g.hooks.length);
81
+ if (!c.hooks[event].length) delete c.hooks[event];
82
+ return c;
83
+ }
84
+
85
+ const CURSOR_EVENTS = ['beforeShellExecution', 'beforeMCPExecution'];
86
+
87
+ /** The agent's config with Phaedo's hook in (replacing any older Phaedo entry). PURE. */
88
+ export function mergeAgentHook(agent, config, opts = {}) {
89
+ const cmd = agentCommand(agent, opts);
90
+ switch (agent) {
91
+ case 'cursor':
92
+ return mergeFlat(config, CURSOR_EVENTS, { command: cmd, timeout: TIMEOUT_S }, (h) => h?.command);
93
+ case 'copilot':
94
+ return mergeFlat(config, ['preToolUse'], { type: 'command', bash: cmd, powershell: `& ${cmd}`, timeoutSec: TIMEOUT_S }, (h) => h?.bash ?? h?.command ?? h?.powershell);
95
+ case 'codex':
96
+ return mergeGrouped(config, 'PreToolUse', { matcher: SHIP_MATCHER, hooks: [{ type: 'command', command: cmd, timeout: TIMEOUT_S, statusMessage: 'Checking with Phaedo' }] });
97
+ case 'gemini':
98
+ return mergeGrouped(config, 'BeforeTool', { matcher: GEMINI_MATCHER, hooks: [{ type: 'command', command: cmd, timeout: TIMEOUT_S * 1000, name: 'phaedo-merge-check' }] });
99
+ default: throw new Error(`unknown agent: ${agent}`);
100
+ }
101
+ }
102
+
103
+ /** The agent's config with every Phaedo hook entry taken out. PURE. */
104
+ export function removeAgentHook(agent, config) {
105
+ switch (agent) {
106
+ case 'cursor': return removeFlat(config, CURSOR_EVENTS, (h) => h?.command);
107
+ case 'copilot': return removeFlat(config, ['preToolUse'], (h) => h?.bash ?? h?.command ?? h?.powershell);
108
+ case 'codex': return removeGrouped(config, 'PreToolUse');
109
+ case 'gemini': return removeGrouped(config, 'BeforeTool');
110
+ default: throw new Error(`unknown agent: ${agent}`);
111
+ }
112
+ }
113
+
114
+ function readJson(path) {
115
+ if (!existsSync(path)) return { config: {}, existed: false };
116
+ const raw = readFileSync(path, 'utf8');
117
+ if (!raw.trim()) return { config: {}, existed: true };
118
+ const config = JSON.parse(raw); // throws on comments or damage — the caller refuses to touch it
119
+ if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('not a JSON object');
120
+ return { config, existed: true };
121
+ }
122
+
123
+ /**
124
+ * Install or remove the merge check for every agent found (or `only`). Returns result
125
+ * records in install.mjs's shape: { name, status, detail }.
126
+ */
127
+ export function applyAgentHooks({ action, dryRun = false, home = homedir(), env = process.env, only = null, nodePath, hookPath } = {}) {
128
+ const out = [];
129
+ for (const [agent, t] of Object.entries(hookFiles({ home, env }))) {
130
+ if (only && !only.includes(agent)) continue;
131
+ const name = `${t.label} merge check`;
132
+ if (action === 'install' && !existsSync(t.dir)) continue; // not installed here
133
+ let read;
134
+ try { read = readJson(t.file); } catch (e) {
135
+ out.push({ name, status: 'error', detail: `${t.file} isn't plain JSON (${e.message}); not touched` });
136
+ continue;
137
+ }
138
+ if (action === 'uninstall') {
139
+ if (!read.existed) continue;
140
+ const next = removeAgentHook(agent, read.config);
141
+ if (JSON.stringify(next) === JSON.stringify(read.config)) continue;
142
+ const empty = t.ownFile && !Object.keys(next.hooks || {}).length;
143
+ if (!dryRun) {
144
+ if (empty) rmSync(t.file);
145
+ else { copyFileSync(t.file, `${t.file}.phaedo.bak`); writeFileSync(t.file, JSON.stringify(next, null, 2) + '\n'); }
146
+ }
147
+ out.push({ name, status: dryRun ? 'would-remove' : 'removed', detail: t.file });
148
+ continue;
149
+ }
150
+ const next = mergeAgentHook(agent, read.config, { nodePath, hookPath });
151
+ if (JSON.stringify(next) === JSON.stringify(read.config)) { out.push({ name, status: 'unchanged', detail: 'already configured' }); continue; }
152
+ if (!dryRun) {
153
+ if (read.existed) copyFileSync(t.file, `${t.file}.phaedo.bak`);
154
+ mkdirSync(dirname(t.file), { recursive: true });
155
+ writeFileSync(t.file, JSON.stringify(next, null, 2) + '\n');
156
+ }
157
+ out.push({ name, status: dryRun ? 'would-write' : 'configured', detail: t.file, ...(t.note ? { restartHint: t.note } : {}) });
158
+ }
159
+ return out;
160
+ }
package/consult-core.js CHANGED
@@ -489,16 +489,53 @@ function resolveActionApproval(vd, n, suppressed) {
489
489
  } else {
490
490
  signal = p.score >= 0.7 ? 'proceed_with_note' : 'proceed';
491
491
  }
492
- const domain = n.domain ? ` in the ${n.domain} domain` : '';
493
- const posture = p.score >= 0.55 ? 'favors caution and human review'
494
- : p.score <= 0.4 ? 'favors acting and adjusting'
495
- : 'weighs the main risks before committing';
496
- const hint = irreversible
497
- ? `On irreversible${mag >= 2 ? ', high-magnitude' : ''} actions${domain}, the subject ${posture}.`
498
- : `On reversible actions${domain}, the subject ${posture}.`;
492
+ const hint = actionApprovalHint({ signal, score: p.score, irreversible, mag, thinEvidence, domain: n.domain });
499
493
  return { signal, hint, coverage: p.coverage, found: p.found, withheld: p.withheld, signalConfidence: p.signalConfidence };
500
494
  }
501
495
 
496
+ // The rationale must explain the SIGNAL, not just the subject's general posture. It used to
497
+ // state the posture alone, so a cautious subject's low-stakes, reversible action came back
498
+ // "proceed_with_note — the subject favors caution and human review": an answer and a reason
499
+ // pointing opposite ways, which an agent can't act on and a person can't trust. Now the sentence
500
+ // says how this person leans in this situation, then what that means for this action.
501
+ export function actionApprovalHint({ signal, score, irreversible, mag, thinEvidence, domain }) {
502
+ const where = domain ? ` in the ${domain} domain` : '';
503
+ const situation = irreversible
504
+ ? `On irreversible${mag >= 2 ? ', high-stakes' : ''} actions${where}`
505
+ : mag >= 2 ? `On high-stakes actions${where}`
506
+ : domain ? `In the ${domain} domain` : 'In general'; // the ending names the low stakes, once
507
+ const lean = score >= 0.55 ? 'cautious' : score <= 0.4 ? 'acting' : 'balanced';
508
+ const posture = lean === 'cautious' ? 'favors caution and human review'
509
+ : lean === 'acting' ? 'favors acting and adjusting'
510
+ : 'weighs the main risks before committing';
511
+ const lowStakes = !irreversible && mag < 2;
512
+ // Each ending carries its own connector: "but" when the answer goes against the lean.
513
+ let consequence;
514
+ switch (signal) {
515
+ case 'proceed':
516
+ consequence = lean === 'cautious' ? ', but this one is reversible and low-stakes, so going ahead fits'
517
+ : lean === 'balanced' ? ', and this one is reversible and low-stakes, so going ahead fits'
518
+ : ', so going ahead fits';
519
+ break;
520
+ case 'proceed_with_note':
521
+ consequence = lean !== 'cautious' ? ', so go ahead and tell them what you did'
522
+ : lowStakes ? ', but this one is reversible and low-stakes, so go ahead and tell them what you did'
523
+ : ', but the evidence you gave is enough to go ahead; tell them what you did';
524
+ break;
525
+ case 'clarify':
526
+ consequence = thinEvidence ? `, ${lean === 'acting' ? 'but' : 'and'} no evidence was offered, so ask before acting`
527
+ : lean === 'acting' ? ', but this one is unclear enough to ask before acting'
528
+ : ', so ask before acting';
529
+ break;
530
+ case 'escalate':
531
+ consequence = lean === 'acting' ? ', but the stakes here call for checking with them first' : ', so check with them first';
532
+ break;
533
+ default:
534
+ consequence = '';
535
+ }
536
+ return `${situation}, the subject ${posture}${consequence}.`;
537
+ }
538
+
502
539
  function resolveDomainRiskCheck(vd, n, suppressed) {
503
540
  const p = profile(vd, RELEVANT_CUES.domain_risk_check, false, n.domain, suppressed);
504
541
  const high = p.score >= 0.55;
package/escalation.js CHANGED
@@ -181,9 +181,37 @@ export function buildEscalationPayload(consultRequest = {}, consultResponse = {}
181
181
  },
182
182
  signal: clip(consultResponse.signal, 40),
183
183
  rationale_hint: clip(consultResponse.rationale_hint, 280),
184
+ why_asked: whyAsked(consultRequest, consultResponse),
184
185
  };
185
186
  }
186
187
 
188
+ // WHY YOU'RE SEEING THIS — one plain line for the person on the phone, not the agent.
189
+ // `rationale_hint` is written to the agent ("the subject favors caution… so check with them
190
+ // first"); shown to the subject it reads as Phaedo talking about them in the third person, and it
191
+ // never says what TRIGGERED the interruption. Pushary's default prompt rate flip-flopped twice in
192
+ // October because users couldn't tell why they were being asked; a visible reason on every card
193
+ // is the fix. PURE, and built only from what is already in the payload: the agent's descriptor,
194
+ // the signal, and — when a standing rule decided it — that rule's own note. Never vault content.
195
+ export function whyAsked(consultRequest = {}, consultResponse = {}) {
196
+ const ad = (consultRequest.action_descriptor && typeof consultRequest.action_descriptor === 'object') ? consultRequest.action_descriptor : {};
197
+ const hint = typeof consultResponse.rationale_hint === 'string' ? consultResponse.rationale_hint : '';
198
+ const rule = /^Per your standing rule: (.+?)\.?$/.exec(hint.trim());
199
+ if (rule) return `One of your rules says to check with you: ${rule[1]}.`.slice(0, 200);
200
+ const reasons = [];
201
+ if (ad.reversible === false) reasons.push("it can't be undone");
202
+ if (typeof ad.amount === 'number' && isFinite(ad.amount) && ad.amount > 0) {
203
+ reasons.push(`it spends $${ad.amount.toLocaleString('en-US', { maximumFractionDigits: 2 })}`);
204
+ } else if (ad.magnitude === 'high') reasons.push("it's high-stakes");
205
+ const what = reasons.length ? reasons.join(' and ') : null;
206
+ const signal = String(consultResponse.signal || '');
207
+ if (signal === 'clarify') {
208
+ return what ? `Phaedo asked because ${what}, and it isn't clear enough what you'd want.`
209
+ : "Phaedo asked because it isn't clear enough what you'd want here.";
210
+ }
211
+ return what ? `Phaedo asked because ${what}, and you usually want to see those yourself.`
212
+ : "Phaedo asked because it wasn't confident you'd want this done without you.";
213
+ }
214
+
187
215
  // Seal the payload under fp_sync_key into the deposit body { request_id, envelope,
188
216
  // window_ms }. PURE crypto (no network) so the round-trip is testable in isolation,
189
217
  // mirroring delegation-sync.js buildDelegationDeposit.
@@ -18,7 +18,8 @@ import { defaultStateDir, writeCache, readCache, clearCache } from './cache.js';
18
18
  import { foldLocalCapture, isLocalCaptureOwner, localCaptureSessionCount } from './capture-local.js';
19
19
  import { Capabilities, AuthoredSource } from './lib/phaedo-crypto.js';
20
20
  import { PhaedoError } from './errors.js';
21
- import { configDir } from './lib/config-dir.js';
21
+ import { configDir, isDirectoryEdition } from './lib/config-dir.js';
22
+ import { applyForgotten, readLearnedState } from './learned.js';
22
23
 
23
24
  // §4.1 protocol-identity normalization (consumer defense-in-depth, 2026-06-16).
24
25
  // A stored fingerprint from a producer that predates identity stamping (or whose
@@ -118,7 +119,14 @@ export const PLUGIN_NO_PROFILE_MESSAGE =
118
119
  'loads at the start of the next one. For your full profile, pair the Phaedo phone app with ' +
119
120
  '/phaedo:pair (the app is not open to the public yet; https://phaedo.so to hear when it is).';
120
121
 
121
- export function noProfileMessage(env = process.env) {
122
+ // The directory edition learns nothing from sessions, so it can't promise a starter profile.
123
+ export const DIRECTORY_NO_PROFILE_MESSAGE =
124
+ 'No Phaedo profile is set up yet, so Phaedo has not changed how the assistant responds. Your ' +
125
+ 'profile comes from the Phaedo phone app: pair it with /phaedo:pair (the app is not open to the ' +
126
+ 'public yet; https://phaedo.so to hear when it is).';
127
+
128
+ export function noProfileMessage(env = process.env, baseDir = null) {
129
+ if (baseDir && isDirectoryEdition(baseDir)) return DIRECTORY_NO_PROFILE_MESSAGE;
122
130
  return env.CLAUDE_PLUGIN_ROOT ? PLUGIN_NO_PROFILE_MESSAGE : NO_PROFILE_MESSAGE;
123
131
  }
124
132
 
@@ -127,6 +135,7 @@ const NO_PROFILE_LOG =
127
135
  'no profile is set up on this computer, so Phaedo will not change how the assistant responds. ' +
128
136
  'Pair the Phaedo phone app, install the Claude Code hook to start one from your own sessions, ' +
129
137
  'or set PHAEDO_FINGERPRINT=sample to try the example.';
138
+ const DIRECTORY_NO_PROFILE_LOG = 'no profile yet; pair the Phaedo phone app with /phaedo:pair.';
130
139
  const PLUGIN_NO_PROFILE_LOG =
131
140
  'no profile yet; one starts from your Claude Code sessions on this computer, or pair the phone app with /phaedo:pair.';
132
141
 
@@ -144,7 +153,8 @@ const PLUGIN_NO_PROFILE_LOG =
144
153
  // phone later replaces it (the phone inherits the cumulative counts — capture-local.js §5).
145
154
  export const STARTER_FINGERPRINT_ID = 'starter-local';
146
155
 
147
- async function loadStarterProfile(now = new Date().toISOString()) {
156
+ async function loadStarterProfile(now = new Date().toISOString(), baseDir = null) {
157
+ if (baseDir && isDirectoryEdition(baseDir)) return null; // nothing is learned from sessions there
148
158
  if (!await isLocalCaptureOwner(false)) return null;
149
159
  const base = { phaedo_fingerprint: { fingerprint_id: STARTER_FINGERPRINT_ID, updated_at: now, layers: {} } };
150
160
  const vd = await foldLocalCapture(base, { now });
@@ -189,13 +199,14 @@ export async function profileStatusLine(baseDir) {
189
199
  if (env) return null;
190
200
  if (hasPairingRecord(baseDir)) return null;
191
201
  if (existsSync(resolve(configDir(baseDir), 'fingerprint.json'))) return null;
192
- const starter = await loadStarterProfile();
202
+ const starter = await loadStarterProfile(undefined, baseDir);
193
203
  if (starter) {
194
204
  const { learned, areas, sessions, stage } = await summarizeStarter(starter);
195
205
  return `no phone paired; using a starter profile from your own sessions on this computer: ` +
196
206
  `${learned} thing${learned === 1 ? '' : 's'} learned across ${areas} area${areas === 1 ? '' : 's'}` +
197
207
  `${sessions ? ` from ${sessions} session${sessions === 1 ? '' : 's'}` : ''} — ${stage}`;
198
208
  }
209
+ if (isDirectoryEdition(baseDir)) return DIRECTORY_NO_PROFILE_LOG;
199
210
  return process.env.CLAUDE_PLUGIN_ROOT ? PLUGIN_NO_PROFILE_LOG : NO_PROFILE_LOG;
200
211
  }
201
212
 
@@ -245,7 +256,16 @@ export function sourceOf(vd) {
245
256
  return s || { live: true, cachedAt: null, ageMs: 0 };
246
257
  }
247
258
 
248
- export async function loadFingerprint(baseDir) {
259
+ // What the subject removed in `npm run learned` is taken out on EVERY read, so injection,
260
+ // consult and the rules files all stop using it — even when the sessions keep showing it.
261
+ // `unfiltered` is for that review screen alone, which has to list what was removed.
262
+ const forgetting = (vd, { unfiltered = false } = {}) => (unfiltered ? vd : applyForgotten(vd, readLearnedState(defaultStateDir())));
263
+
264
+ export async function loadFingerprint(baseDir, opts) {
265
+ return forgetting(await loadFingerprintUnfiltered(baseDir), opts);
266
+ }
267
+
268
+ async function loadFingerprintUnfiltered(baseDir) {
249
269
  // Explicit local source wins over phone pairing: if PHAEDO_FINGERPRINT names a
250
270
  // file, honor it and skip the phone entirely. Pointing at a concrete fingerprint
251
271
  // is an unambiguous "use THIS" — it lets a user force a local file and makes the
@@ -287,9 +307,9 @@ export async function loadFingerprint(baseDir) {
287
307
  // Only the default location may be absent quietly. A path the user NAMED that doesn't
288
308
  // read is their mistake to see, not "no profile".
289
309
  if (!process.env.PHAEDO_FINGERPRINT && err?.code === 'ENOENT') {
290
- const starter = await loadStarterProfile();
310
+ const starter = await loadStarterProfile(undefined, baseDir);
291
311
  if (starter) return starter;
292
- throw new PhaedoError('decryption_failed', noProfileMessage());
312
+ throw new PhaedoError('decryption_failed', noProfileMessage(process.env, baseDir));
293
313
  }
294
314
  throw new PhaedoError('decryption_failed', `Could not read the Phaedo profile at ${path} (${err?.code || err?.message || err}).`);
295
315
  }
@@ -304,7 +324,7 @@ export async function loadFingerprint(baseDir) {
304
324
  // persistence, idempotent). Owner-gated: a paired setup never reaches this
305
325
  // branch, and an explicit PHAEDO_FINGERPRINT ("use THIS file") stays verbatim —
306
326
  // it is the hermetic-test escape hatch.
307
- if (!process.env.PHAEDO_FINGERPRINT && await isLocalCaptureOwner(hasPairingRecord(baseDir))) {
327
+ if (!process.env.PHAEDO_FINGERPRINT && !isDirectoryEdition(baseDir) && await isLocalCaptureOwner(hasPairingRecord(baseDir))) {
308
328
  return foldConfigHome(normalizeIdentity(await foldLocalCapture(vd)));
309
329
  }
310
330
  return foldConfigHome(normalizeIdentity(vd));
@@ -335,10 +355,10 @@ export async function loadFingerprint(baseDir) {
335
355
  // there.) Falls through to a live pull ONLY when no cache exists at all (first run on this
336
356
  // machine), which seeds it; run one phaedo_consult after pairing to seed without an
337
357
  // escalation in flight.
338
- export async function loadFingerprintPreferCache(baseDir) {
358
+ export async function loadFingerprintPreferCache(baseDir, opts) {
339
359
  if (!process.env.PHAEDO_FINGERPRINT && hasPairingRecord(baseDir)) {
340
360
  const cached = await readCache(defaultStateDir());
341
- if (cached) return stamp(foldConfigHome(normalizeIdentity(cached.vault)), cached);
361
+ if (cached) return forgetting(stamp(foldConfigHome(normalizeIdentity(cached.vault)), cached), opts);
342
362
  }
343
- return loadFingerprint(baseDir);
363
+ return loadFingerprint(baseDir, opts);
344
364
  }