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 +96 -11
- package/agent-hooks.js +160 -0
- package/consult-core.js +44 -7
- package/escalation.js +28 -0
- package/fingerprint-source.js +31 -11
- package/history-import.js +202 -0
- package/hooks/agent-hook.mjs +160 -0
- package/hooks/pre-ship.mjs +43 -18
- package/hooks/session-start.mjs +8 -2
- package/install.mjs +23 -1
- package/learned.js +227 -0
- package/lib/config-dir.js +23 -6
- package/package.json +15 -3
- package/precedent.js +267 -0
- package/pull.js +14 -1
- package/receipts-sync.js +6 -1
- package/rules-files.js +278 -0
- package/server.js +21 -10
- package/server.json +2 -2
- package/ship-actions.js +151 -2
- package/ship-readout.js +12 -2
- package/weekly.js +63 -0
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
|
-
###
|
|
360
|
-
|
|
361
|
-
`npm run setup:code-hook`
|
|
362
|
-
PermissionRequest, PostToolUse
|
|
363
|
-
(
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
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.
|
package/fingerprint-source.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|