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.
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+ // Phaedo — learn from your ChatGPT or Claude history, from the export file each one gives you.
3
+ //
4
+ // npm run import -- ~/Downloads/chatgpt-export.zip
5
+ // npm run import -- ~/Downloads/claude-export.zip --all # every conversation, not just the last year
6
+ // npm run import -- conversations.json --dry-run # count what it would read, change nothing
7
+ //
8
+ // Get the file: ChatGPT → Settings → Data controls → Export data; Claude → Settings → Privacy →
9
+ // Export data. Each emails a zip; its conversations.json is all this reads.
10
+ //
11
+ // Each conversation becomes {role, text} turns and goes through the SAME spine the Claude Code
12
+ // hook and the Mac reader use (capture/exchanges.js → capture-core.js: the deterministic
13
+ // detectors → per-session state → delta lift), as its own session keyed by the conversation's id,
14
+ // so importing the same export twice replaces rather than doubles. One deposit to the phone at
15
+ // the end when paired; a starter profile on this computer when not.
16
+ //
17
+ // DISCARD-RAW, as everywhere in capture: the text is read in memory, counted, and dropped.
18
+ // Nothing derived from it but signal names and integers is kept or sent. Not in the directory
19
+ // edition (its policy forbids reading chat history).
20
+
21
+ import { execFileSync } from 'child_process';
22
+ import { readFileSync, existsSync } from 'fs';
23
+ import { resolve, dirname, extname } from 'path';
24
+ import { fileURLToPath } from 'url';
25
+ import { isMainModule } from './lib/is-main.js';
26
+
27
+ export const DEFAULT_DAYS = 365;
28
+ export const SURFACES = Object.freeze({ chatgpt: 'chatgpt-import', claude: 'claude-import' });
29
+ const DAY = 24 * 60 * 60 * 1000;
30
+
31
+ const text = (v) => (typeof v === 'string' ? v : '');
32
+ const iso = (t) => {
33
+ if (typeof t === 'number' && Number.isFinite(t)) return new Date(t < 1e12 ? t * 1000 : t).toISOString();
34
+ const p = Date.parse(t);
35
+ return Number.isFinite(p) ? new Date(p).toISOString() : null;
36
+ };
37
+
38
+ /** Which service an export came from, by shape. PURE. */
39
+ export function detectFormat(list) {
40
+ const first = Array.isArray(list) ? list.find((c) => c && typeof c === 'object') : null;
41
+ if (!first) return null;
42
+ if (first.mapping && typeof first.mapping === 'object') return 'chatgpt';
43
+ if (Array.isArray(first.chat_messages)) return 'claude';
44
+ return null;
45
+ }
46
+
47
+ // ChatGPT stores each conversation as a tree (every regenerate or edit is a branch). The
48
+ // conversation the person actually had is the path from `current_node` up to the root.
49
+ function chatgptTurns(c) {
50
+ const map = c.mapping || {};
51
+ let id = c.current_node && map[c.current_node] ? c.current_node : null;
52
+ if (!id) { // no current_node: the deepest leaf of the first branch
53
+ id = Object.keys(map).find((k) => !map[k]?.parent) || null;
54
+ while (id && Array.isArray(map[id]?.children) && map[id].children.length) id = map[id].children[0];
55
+ }
56
+ const path = [];
57
+ const seen = new Set();
58
+ while (id && map[id] && !seen.has(id)) { seen.add(id); path.push(map[id]); id = map[id].parent; }
59
+ path.reverse();
60
+ const turns = [];
61
+ for (const node of path) {
62
+ const m = node.message;
63
+ const role = m?.author?.role;
64
+ if (role !== 'user' && role !== 'assistant') continue;
65
+ const ct = m.content?.content_type;
66
+ if (ct && ct !== 'text' && ct !== 'multimodal_text') continue; // code runs, browsing, tool output
67
+ const body = (Array.isArray(m.content?.parts) ? m.content.parts : []).map(text).join('\n').trim();
68
+ if (body) turns.push({ role, text: body, id: m.id || node.id, timestamp: iso(m.create_time) });
69
+ }
70
+ return turns;
71
+ }
72
+
73
+ function claudeTurns(c) {
74
+ const turns = [];
75
+ for (const m of Array.isArray(c.chat_messages) ? c.chat_messages : []) {
76
+ const role = m?.sender === 'human' ? 'user' : m?.sender === 'assistant' ? 'assistant' : null;
77
+ if (!role) continue;
78
+ const parts = Array.isArray(m.content) ? m.content.filter((p) => p?.type === 'text').map((p) => text(p.text)) : [];
79
+ const body = (parts.length ? parts.join('\n') : text(m.text)).trim();
80
+ if (body) turns.push({ role, text: body, id: m.uuid, timestamp: iso(m.created_at) });
81
+ }
82
+ return turns;
83
+ }
84
+
85
+ /**
86
+ * The export's conversations as [{ id, at, turns }], newest first, within `days` of `now`
87
+ * (null = all). Never throws on a malformed entry; skips it. PURE.
88
+ */
89
+ export function conversationsFrom(list, format, { now = Date.now(), days = DEFAULT_DAYS } = {}) {
90
+ const since = days == null ? -Infinity : now - days * DAY;
91
+ const out = [];
92
+ for (const c of Array.isArray(list) ? list : []) {
93
+ if (!c || typeof c !== 'object') continue;
94
+ const id = format === 'chatgpt' ? (c.conversation_id || c.id) : c.uuid;
95
+ const at = format === 'chatgpt' ? iso(c.update_time ?? c.create_time) : iso(c.updated_at ?? c.created_at);
96
+ if (!id || !at || Date.parse(at) < since || Date.parse(at) > now) continue;
97
+ const turns = format === 'chatgpt' ? chatgptTurns(c) : claudeTurns(c);
98
+ if (turns.some((t) => t.role === 'user') && turns.some((t) => t.role === 'assistant')) out.push({ id: String(id), at, turns });
99
+ }
100
+ return out.sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
101
+ }
102
+
103
+ /** conversations.json from a .zip (system unzip, else bsdtar) or a .json path. */
104
+ export function readExport(path) {
105
+ const p = resolve(path);
106
+ if (!existsSync(p)) throw Object.assign(new Error(`no file at ${p}`), { code: 'ENOENT' });
107
+ if (extname(p).toLowerCase() !== '.zip') return JSON.parse(readFileSync(p, 'utf8'));
108
+ const opts = { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] };
109
+ for (const [cmd, args] of [['unzip', ['-p', p, 'conversations.json']], ['tar', ['-xOf', p, 'conversations.json']]]) {
110
+ try { const raw = execFileSync(cmd, args, opts); if (raw.trim()) return JSON.parse(raw); } catch { /* try the next */ }
111
+ }
112
+ throw new Error("couldn't find conversations.json in that zip — is it the ChatGPT or Claude data export?");
113
+ }
114
+
115
+ /**
116
+ * Fold every conversation through the capture spine and deposit once. deps: { fold, deposit,
117
+ * sourceId, statePath, now } — the real ones in the CLI, fakes in tests. Returns a summary.
118
+ */
119
+ export async function importConversations(convos, format, deps) {
120
+ const surface = SURFACES[format];
121
+ let signals = [];
122
+ let messages = 0;
123
+ for (const c of convos) {
124
+ const r = await deps.fold(c, surface);
125
+ messages += r.counted || 0;
126
+ if (Array.isArray(r.signals)) signals = r.signals; // cumulative: the last fold holds them all
127
+ }
128
+ const deposit = signals.length && deps.deposit ? await deps.deposit(signals, surface) : null;
129
+ return { conversations: convos.length, messages, signals: signals.length, deposited: Boolean(deposit?.deposited), deposit };
130
+ }
131
+
132
+ const plural = (n, w) => `${n.toLocaleString('en-US')} ${w}${n === 1 ? '' : 's'}`;
133
+
134
+ /** The result in plain words. PURE. */
135
+ export function summaryText(r, { service, days, dryRun = false, paired = false }) {
136
+ const span = days == null ? '' : ` from the last ${days === 365 ? 'year' : `${days} days`}`;
137
+ if (!r.conversations) return `No ${service} conversations${span} to read in that file.`;
138
+ const head = `${dryRun ? 'Would read' : 'Read'} ${plural(r.conversations, 'conversation')}${span} from ${service} (${plural(r.messages, 'message')} of yours).`;
139
+ if (dryRun) return `${head} Nothing was changed.`;
140
+ const learned = r.signals
141
+ ? `Phaedo found ${plural(r.signals, 'pattern')} in how you work${paired ? (r.deposited ? ' and sent them to your phone' : '; they\'ll reach your phone with the next sync') : ''}.`
142
+ : 'Phaedo found nothing new in how you work from these.';
143
+ return `${head} ${learned} Only counts were kept; you can delete the export file now. To see and prune what it learned: npm run learned`;
144
+ }
145
+
146
+ // ── CLI ───────────────────────────────────────────────────────────────────────
147
+
148
+ async function main(argv) {
149
+ const file = argv.find((a) => !a.startsWith('--'));
150
+ const say = (s) => process.stdout.write(s + '\n');
151
+ if (!file) {
152
+ say('Usage: npm run import -- <ChatGPT or Claude export .zip, or its conversations.json> [--all] [--days N] [--dry-run]');
153
+ return 1;
154
+ }
155
+ const opt = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : null; };
156
+ const days = argv.includes('--all') ? null : (Number(opt('--days')) > 0 ? Number(opt('--days')) : DEFAULT_DAYS);
157
+ const dryRun = argv.includes('--dry-run');
158
+
159
+ let list;
160
+ try { list = readExport(file); } catch (e) { say(`✗ ${e.code === 'ENOENT' ? `No file at ${file}.` : e.message}`); return 1; }
161
+ const format = detectFormat(list);
162
+ if (!format) { say("✗ That doesn't look like a ChatGPT or Claude data export (no conversations in the shape either one uses)."); return 1; }
163
+ const service = format === 'chatgpt' ? 'ChatGPT' : 'Claude';
164
+ const convos = conversationsFrom(list, format, { days });
165
+ list = null; // the raw export is not needed past this point
166
+
167
+ if (dryRun) {
168
+ const messages = convos.reduce((n, c) => n + c.turns.filter((t) => t.role === 'user').length, 0);
169
+ say(summaryText({ conversations: convos.length, messages, signals: 0 }, { service, days, dryRun: true }));
170
+ return 0;
171
+ }
172
+
173
+ const MCP_DIR = dirname(fileURLToPath(import.meta.url));
174
+ const { loadCaptureModule } = await import('./lib/capture-home.js');
175
+ const [{ turnsToBatch }, { foldBatchToSignals }, { deriveSourceDeviceId }] = await Promise.all(
176
+ ['capture/exchanges.js', 'capture/capture-core.js', 'capture/source-id.js'].map(loadCaptureModule));
177
+ const { loadPairingRecord } = await import('./phone-source.js');
178
+ const { buildCaptureDelta, depositCaptureDelta } = await import('./capture-sync.js');
179
+ let pairing = null;
180
+ try { pairing = await loadPairingRecord(MCP_DIR); } catch { pairing = null; }
181
+ const now = new Date().toISOString();
182
+ const sourceId = deriveSourceDeviceId(pairing, SURFACES[format]);
183
+
184
+ const r = await importConversations(convos, format, {
185
+ async fold(c, surface) {
186
+ const batch = turnsToBatch(c.turns, { sessionId: `${surface}:${c.id}`, platform: surface });
187
+ // Regex detectors only: a local model over a whole history would take hours.
188
+ const out = await foldBatchToSignals(batch, { sourceId, surface, sessionLocalId: `${surface}:${c.id}`, now, captureModel: null });
189
+ return { signals: out.signals, counted: batch.user_prompts.length };
190
+ },
191
+ deposit: pairing ? async (signals, surface) => {
192
+ const delta = buildCaptureDelta(signals, { sourceSurface: surface, sourceDeviceId: sourceId, capturedAt: now });
193
+ return depositCaptureDelta(pairing, delta, { now: () => now });
194
+ } : null,
195
+ });
196
+ say(summaryText(r, { service, days, paired: Boolean(pairing) }));
197
+ return 0;
198
+ }
199
+
200
+ if (isMainModule(import.meta.url)) {
201
+ main(process.argv.slice(2)).then((c) => process.exit(c), (e) => { process.stderr.write(`✗ ${e.message}\n`); process.exit(1); });
202
+ }
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ // Phaedo — the merge check for agents other than Claude Code.
3
+ //
4
+ // node hooks/agent-hook.mjs <cursor|copilot|codex|gemini>
5
+ //
6
+ // Each agent's pre-action hook sends its own JSON on stdin. This turns it into the shape the
7
+ // Claude Code ship hook (pre-ship.mjs) already handles, runs the SAME classification, consult
8
+ // and receipt path, and answers in the agent's own format. Same rule as pre-ship: it only ever
9
+ // adds friction — it never returns "allow" — and it stays silent for anything that isn't a
10
+ // merge, deploy, publish, message, payment, delete or the like.
11
+ //
12
+ // agent hook can it show a prompt?
13
+ // Cursor ~/.cursor/hooks.json beforeShellExecution, yes — permission "ask"
14
+ // beforeMCPExecution
15
+ // Copilot ~/.copilot/hooks/phaedo.json preToolUse yes — permissionDecision "ask"
16
+ // Codex ~/.codex/hooks.json PreToolUse no — "ask" is unsupported
17
+ // Gemini ~/.gemini/settings.json BeforeTool no — allow / deny only
18
+ //
19
+ // Where the agent can't prompt, Phaedo pauses the action once with the reason, so the agent
20
+ // stops and asks in the chat; the same call again within RETRY_WINDOW_MS goes through (the
21
+ // subject has answered) and is not consulted twice. A pause, never a wall — and never a loop.
22
+ //
23
+ // FAIL-SAFE: any error, and anything past BUDGET_MS, exits 0 with no output. That matters most
24
+ // for Copilot, where a hook that crashes DENIES the tool call.
25
+
26
+ import { join } from 'path';
27
+ import { classifyToolCall } from '../ship-actions.js';
28
+ import { runShipHook, callHash, readState, writeState, BUDGET_MS } from './pre-ship.mjs';
29
+ import { isMainModule } from '../lib/is-main.js';
30
+
31
+ export const AGENTS = Object.freeze({
32
+ cursor: { id: 'cursor/ship-hook', canAsk: true },
33
+ copilot: { id: 'copilot-cli/ship-hook', canAsk: true },
34
+ codex: { id: 'codex/ship-hook', canAsk: false },
35
+ gemini: { id: 'gemini-cli/ship-hook', canAsk: false },
36
+ });
37
+ export const RETRY_WINDOW_MS = 15 * 60 * 1000;
38
+
39
+ const parseMaybe = (v) => {
40
+ if (typeof v !== 'string') return v && typeof v === 'object' ? v : {};
41
+ try { const o = JSON.parse(v); return o && typeof o === 'object' ? o : {}; } catch { return {}; }
42
+ };
43
+ const shell = (command, cwd) => (typeof command === 'string' && command.trim()
44
+ ? { tool_name: 'Bash', tool_input: { command }, cwd, hook_event_name: 'PreToolUse' } : null);
45
+ const mcp = (server, tool, args, cwd) => (server && tool
46
+ ? { tool_name: `mcp__${server}__${tool}`, tool_input: parseMaybe(args), cwd, hook_event_name: 'PreToolUse' } : null);
47
+
48
+ /** The agent's hook input as the Claude Code PreToolUse shape, or null when it isn't a tool call. PURE. */
49
+ export function normalize(agent, raw) {
50
+ const r = raw && typeof raw === 'object' ? raw : {};
51
+ const cwd = r.cwd || (Array.isArray(r.workspace_roots) ? r.workspace_roots[0] : undefined);
52
+ switch (agent) {
53
+ case 'cursor':
54
+ if (typeof r.mcp_server_name === 'string') return mcp(r.mcp_server_name, r.tool_name, r.tool_input, cwd);
55
+ return shell(r.command, cwd);
56
+ case 'copilot': {
57
+ // camelCase `preToolUse` (toolName/toolArgs), or the PascalCase variant (tool_name/tool_input).
58
+ const name = String(r.toolName ?? r.tool_name ?? '');
59
+ const args = parseMaybe(r.toolArgs ?? r.tool_input);
60
+ if (/^(bash|shell|powershell)$/i.test(name)) return shell(args.command, cwd);
61
+ if (name.startsWith('mcp__')) return { tool_name: name, tool_input: args, cwd, hook_event_name: 'PreToolUse' };
62
+ return null;
63
+ }
64
+ case 'codex':
65
+ // Codex's PreToolUse is already Claude-shaped: Bash + tool_input.command, mcp__server__tool.
66
+ if (r.tool_name === 'Bash') return shell(r.tool_input?.command, cwd);
67
+ if (typeof r.tool_name === 'string' && r.tool_name.startsWith('mcp__')) return { tool_name: r.tool_name, tool_input: parseMaybe(r.tool_input), cwd, hook_event_name: 'PreToolUse' };
68
+ return null;
69
+ case 'gemini': {
70
+ const name = String(r.tool_name || '');
71
+ if (name === 'run_shell_command') return shell(r.tool_input?.command, cwd);
72
+ const ctx = r.mcp_context && typeof r.mcp_context === 'object' ? r.mcp_context : {};
73
+ const server = ctx.server_name;
74
+ if (server && name.startsWith(`mcp_${server}_`)) return mcp(server, name.slice(`mcp_${server}_`.length), r.tool_input, cwd);
75
+ const m = /^mcp_([^_]+)_(.+)$/.exec(name);
76
+ return m ? mcp(m[1], m[2], r.tool_input, cwd) : null;
77
+ }
78
+ default: return null;
79
+ }
80
+ }
81
+
82
+ const PAUSE = " This agent can't show a confirmation prompt, so Phaedo paused it: ask the user, and run the same command again only if they say yes.";
83
+
84
+ /**
85
+ * The ship hook's answer in the agent's own format, or null for no output. PURE.
86
+ * Never "allow": a quiet answer is no output, so the agent's own permission flow applies.
87
+ */
88
+ export function render(agent, out) {
89
+ const h = out?.hookSpecificOutput || {};
90
+ const asked = h.permissionDecision === 'ask';
91
+ const reason = h.permissionDecisionReason || '';
92
+ const context = h.additionalContext || '';
93
+ switch (agent) {
94
+ case 'cursor':
95
+ return asked ? { permission: 'ask', user_message: reason, agent_message: context } : null;
96
+ case 'copilot':
97
+ return asked ? { permissionDecision: 'ask', permissionDecisionReason: reason } : null;
98
+ case 'codex':
99
+ if (asked) return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason + PAUSE } };
100
+ return context ? { hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: context } } : null;
101
+ case 'gemini':
102
+ return asked ? { decision: 'deny', reason: reason + PAUSE, systemMessage: reason } : null;
103
+ default: return null;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * One invocation. deps: the pre-ship deps (consult, writeReceipt, statePath, now…) plus
109
+ * `pausePath` for the agents that can't prompt. Returns what to print, or null.
110
+ */
111
+ export async function runAgentHook(agent, raw, deps) {
112
+ const spec = AGENTS[agent];
113
+ if (!spec) return null;
114
+ const input = normalize(agent, raw);
115
+ if (!input || !classifyToolCall(input.tool_name, input.tool_input)) return null;
116
+ const now = deps.now ?? Date.now();
117
+ const hash = callHash(input);
118
+
119
+ if (!spec.canAsk) {
120
+ const paused = readState(deps.pausePath);
121
+ paused.pending ||= {};
122
+ for (const [k, v] of Object.entries(paused.pending)) if (!(now - Date.parse(v?.at) < RETRY_WINDOW_MS)) delete paused.pending[k];
123
+ const key = `${agent}:${hash}`;
124
+ if (paused.pending[key]) {
125
+ // The retry after a pause: the subject answered in the chat. Let it through, once.
126
+ delete paused.pending[key];
127
+ writeState(deps.pausePath, paused, now);
128
+ return null;
129
+ }
130
+ const out = await runShipHook(input, { ...deps, agentId: spec.id });
131
+ if (out?.hookSpecificOutput?.permissionDecision === 'ask') {
132
+ paused.pending[key] = { at: new Date(now).toISOString() };
133
+ writeState(deps.pausePath, paused, now);
134
+ }
135
+ return render(agent, out);
136
+ }
137
+ return render(agent, await runShipHook(input, { ...deps, agentId: spec.id }));
138
+ }
139
+
140
+ async function main(agent) {
141
+ let raw = '';
142
+ for await (const chunk of process.stdin) raw += chunk;
143
+ let parsed;
144
+ try { parsed = JSON.parse(raw); } catch { return; }
145
+ // Cheap exit before importing the vault stack: most tool calls are not ship actions.
146
+ const input = normalize(agent, parsed);
147
+ if (!input || !classifyToolCall(input.tool_name, input.tool_input)) return;
148
+ const { realDeps } = await import('./pre-ship.mjs');
149
+ const { defaultStateDir } = await import('../cache.js');
150
+ const deps = await realDeps({ agentId: AGENTS[agent]?.id });
151
+ const out = await runAgentHook(agent, parsed, { ...deps, pausePath: join(defaultStateDir(), 'agent-hook-paused.json') });
152
+ if (out) process.stdout.write(JSON.stringify(out));
153
+ }
154
+
155
+ if (isMainModule(import.meta.url)) {
156
+ const timeout = new Promise((res) => setTimeout(res, BUDGET_MS));
157
+ Promise.race([main(process.argv[2]).catch(() => {}), timeout])
158
+ .then(() => process.exit(0))
159
+ .catch(() => process.exit(0)); // never break, and never deny by crashing (Copilot fails closed)
160
+ }
@@ -41,7 +41,7 @@ import { dirname, resolve, join } from 'path';
41
41
  import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
42
42
  import { createHash } from 'crypto';
43
43
  import { execFile } from 'child_process';
44
- import { classifyShipCommand } from '../ship-actions.js';
44
+ import { classifyShipCommand, classifyToolCall } from '../ship-actions.js';
45
45
  import { isMainModule } from '../lib/is-main.js';
46
46
 
47
47
  const MCP_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -55,7 +55,11 @@ export const BUDGET_MS = 12000;
55
55
  export const ASKING_SIGNALS = new Set(['escalate', 'clarify', 'decline']);
56
56
 
57
57
  // Permission modes in which a PermissionRequest is not evidence that a PERSON answered:
58
- // auto mode's classifier and dontAsk decide without one.
58
+ // auto mode's classifier and dontAsk decide without one. With ONE exception, in auto mode: when
59
+ // this hook itself returned `ask`, Claude Code shows the subject a real prompt ("a hook's `ask`
60
+ // also forces a permission prompt in auto mode: the classifier can still deny the tool call,
61
+ // but it can't approve the call silently" — hooks reference). That prompt is the personal
62
+ // reviewer's whole point, so a yes on it is the subject's and is recorded.
59
63
  const NO_HUMAN_MODES = new Set(['auto', 'dontAsk']);
60
64
 
61
65
  // The profile is read from the at-rest cache, which only a live read refreshes. Past this
@@ -70,6 +74,10 @@ const PENDING_TTL_MS = DAY_MS;
70
74
  export const AGENT_ID = 'claude-code/ship-hook';
71
75
 
72
76
  export const commandHash = (cmd) => createHash('sha256').update(String(cmd ?? '')).digest('hex');
77
+ // The call's identity across its hook events: the command for Bash, the input for a tool.
78
+ export const callHash = (input) => commandHash(input?.tool_name === 'Bash' ? input?.tool_input?.command : `${input?.tool_name}:${JSON.stringify(input?.tool_input ?? null)}`);
79
+ // The calls this hook reviews: shell commands, and connected tools (MCP).
80
+ const reviewable = (input) => input?.tool_name === 'Bash' || String(input?.tool_name || '').startsWith('mcp__');
73
81
 
74
82
  // ── CI state for a merge (precedent track Phase 1b, B6) ───────────────────────────────
75
83
  // Merging a PR whose checks are failing, or have not finished, is a bigger act than merging a
@@ -217,9 +225,22 @@ export function writeState(path, state, now) {
217
225
  renameSync(tmp, path);
218
226
  }
219
227
 
228
+ // THE PERSONAL PART OF THE PROMPT. Auto mode's reviewer judges an action by its type; this says
229
+ // why THIS person would want to see it — their own rule when one decided it, else what is at
230
+ // stake. Built only from the agent's descriptor and the consult answer (never vault content).
231
+ export function personalWhy(descriptor = {}, response = {}) {
232
+ const rule = /^Per your standing rule: (.+?)\.?$/.exec(String(response?.rationale_hint || '').trim());
233
+ if (rule) return `your rule: ${rule[1]}`;
234
+ const w = [];
235
+ if (descriptor.reversible === false) w.push("it can't be undone");
236
+ if (typeof descriptor.amount === 'number' && descriptor.amount > 0) w.push(`it spends $${descriptor.amount.toLocaleString('en-US', { maximumFractionDigits: 2 })}`);
237
+ return w.join(' and ');
238
+ }
239
+
220
240
  // What the subject reads on the prompt. Plain words, no numbers, no protocol terms.
221
- export function promptReason(signal, summary, why) {
222
- const what = why ? `${summary} — ${why}` : summary;
241
+ export function promptReason(signal, summary, why, personal = '') {
242
+ const reasons = [why, personal].filter(Boolean).join('; ');
243
+ const what = reasons ? `${summary} — ${reasons}` : summary;
223
244
  if (signal === 'decline') return `Phaedo thinks you'd say no to this one: ${what}. Go ahead anyway?`;
224
245
  if (signal === 'clarify') return `Phaedo isn't sure how you'd want this handled: ${what}. Go ahead?`;
225
246
  return `Phaedo thinks you'd want to OK this yourself: ${what}.`;
@@ -228,7 +249,7 @@ export function promptReason(signal, summary, why) {
228
249
  // What the agent reads before the command. It carries the receipt id — the one handle
229
250
  // that attributes an outcome to THIS decision rather than whatever receipt is newest.
230
251
  export function preContext({ summary, signal, receiptId, asked, hint }) {
231
- const head = `Phaedo was consulted before this command (${summary}) and answered "${signal}"` +
252
+ const head = `Phaedo was consulted before this action (${summary}) and answered "${signal}"` +
232
253
  (hint ? ` — ${hint}` : '') + `. receipt_id: ${receiptId}.`;
233
254
  if (asked) {
234
255
  return `${head} Phaedo flagged it for the user, so they may be asked to confirm. If they decline, ` +
@@ -243,7 +264,7 @@ export function preContext({ summary, signal, receiptId, asked, hint }) {
243
264
 
244
265
  // After the command, when Phaedo had flagged it and no prompt was shown.
245
266
  export function unpromptedContext(receiptId) {
246
- return `No confirmation prompt was shown for this command (receipt_id: ${receiptId}), though Phaedo ` +
267
+ return `No confirmation prompt was shown for this action (receipt_id: ${receiptId}), though Phaedo ` +
247
268
  `had flagged it for the user. Tell them in one line that it ran and Phaedo had flagged it, and ` +
248
269
  `record their reaction with phaedo_record_outcome and this receipt_id if they give one. Silence ` +
249
270
  `is not approval.`;
@@ -264,12 +285,12 @@ export function staleContext(days, mcpDir) {
264
285
  * Returns the JSON object to print, or null for no output.
265
286
  */
266
287
  export async function runShipHook(input, deps) {
267
- if (!input || input.tool_name !== 'Bash') return null;
288
+ if (!reviewable(input)) return null;
268
289
  const now = deps.now ?? Date.now();
269
290
  const ev = input.hook_event_name;
270
291
 
271
292
  if (ev === 'PreToolUse') {
272
- const ship = classifyShipCommand(input.tool_input?.command);
293
+ const ship = classifyToolCall(input.tool_name, input.tool_input);
273
294
  if (!ship) return null;
274
295
  // B6 + B3: one GitHub lookup per merge — check state, size, paths. Each can only raise.
275
296
  let ci = null, stakes = null;
@@ -281,7 +302,7 @@ export async function runShipHook(input, deps) {
281
302
  const raised = why !== '';
282
303
  const request = {
283
304
  consultation_type: 'action_approval',
284
- agent_id: AGENT_ID,
305
+ agent_id: deps.agentId || AGENT_ID,
285
306
  action_descriptor: raised ? { ...ship.action_descriptor, magnitude: 'high' } : ship.action_descriptor,
286
307
  action_binding: ship.action_binding,
287
308
  };
@@ -299,7 +320,7 @@ export async function runShipHook(input, deps) {
299
320
  const state = readState(deps.statePath);
300
321
  if (input.tool_use_id) {
301
322
  state.pending[input.tool_use_id] = {
302
- receipt_id: receiptId, cmd_hash: commandHash(input.tool_input?.command),
323
+ receipt_id: receiptId, cmd_hash: callHash(input),
303
324
  asked, prompt_shown: false, at: new Date(now).toISOString(),
304
325
  };
305
326
  }
@@ -317,7 +338,7 @@ export async function runShipHook(input, deps) {
317
338
  return {
318
339
  hookSpecificOutput: {
319
340
  hookEventName: 'PreToolUse',
320
- ...(asked ? { permissionDecision: 'ask', permissionDecisionReason: promptReason(signal, ship.summary, why) } : {}),
341
+ ...(asked ? { permissionDecision: 'ask', permissionDecisionReason: promptReason(signal, ship.summary, why, personalWhy(request.action_descriptor, response)) } : {}),
321
342
  additionalContext: context,
322
343
  },
323
344
  };
@@ -326,13 +347,15 @@ export async function runShipHook(input, deps) {
326
347
  if (ev === 'PermissionRequest') {
327
348
  // Evidence that a prompt is about to be shown for this command. PermissionRequest has
328
349
  // no tool_use_id (documented), so match the newest pending consult on the same command.
329
- if (NO_HUMAN_MODES.has(input.permission_mode)) return null;
330
- const h = commandHash(input.tool_input?.command);
350
+ if (input.permission_mode === 'dontAsk') return null;
351
+ const h = callHash(input);
331
352
  const state = readState(deps.statePath);
332
353
  const hit = Object.values(state.pending)
333
354
  .filter((e) => e.cmd_hash === h && !e.prompt_shown)
334
355
  .sort((a, b) => Date.parse(b.at) - Date.parse(a.at))[0];
335
356
  if (!hit) return null;
357
+ // Auto mode: only the prompt Phaedo forced is a person's; anything else is the classifier.
358
+ if (NO_HUMAN_MODES.has(input.permission_mode) && !hit.asked) return null;
336
359
  hit.prompt_shown = true;
337
360
  writeState(deps.statePath, state, now);
338
361
  return null; // never decide here — the subject answers the prompt
@@ -361,7 +384,8 @@ export async function runShipHook(input, deps) {
361
384
  }
362
385
 
363
386
  // ── Real deps: the same consult path as the MCP server (consult-runtime.js). ──────────
364
- async function realDeps() {
387
+ // `agentId` names the agent on the receipt; other agents' hooks (agent-hook.mjs) pass their own.
388
+ export async function realDeps({ agentId = AGENT_ID, statePath } = {}) {
365
389
  const { loadConsultVd, buildConsultOpts } = await import('../consult-runtime.js');
366
390
  const { resolveConsultation, consultDrivers, withRevision } = await import('../consult.js');
367
391
  const { buildReceipt, appendReceipt, receiptsCapFromEnv, markOutcome } = await import('../receipts.js');
@@ -371,7 +395,8 @@ async function realDeps() {
371
395
  const quiet = () => {};
372
396
  const opts = buildConsultOpts(MCP_DIR, process.env, { warn: quiet });
373
397
  return {
374
- statePath: join(stateDir, 'ship-hook.json'),
398
+ statePath: statePath || join(stateDir, 'ship-hook.json'),
399
+ agentId,
375
400
  mcpDir: MCP_DIR,
376
401
  prFacts: (ship, cwd) => lookupPrFacts(ship, cwd),
377
402
  async consult(request) {
@@ -382,7 +407,7 @@ async function realDeps() {
382
407
  return { response, drivers, source: sourceOf(vd) };
383
408
  },
384
409
  async writeReceipt({ request, response, drivers }) {
385
- const r = buildReceipt({ via: 'phaedo_consult', agentId: AGENT_ID, clientId: opts.clientId, request, response, drivers });
410
+ const r = buildReceipt({ via: 'phaedo_consult', agentId, clientId: opts.clientId, request, response, drivers });
386
411
  await appendReceipt(stateDir, r, { cap: receiptsCapFromEnv(process.env) });
387
412
  return r.receipt_id;
388
413
  },
@@ -395,8 +420,8 @@ async function main() {
395
420
  for await (const chunk of process.stdin) raw += chunk;
396
421
  const input = JSON.parse(raw);
397
422
  // Cheap exit before importing the vault stack: most Bash calls are not ship actions.
398
- if (input?.tool_name !== 'Bash') return;
399
- if (input.hook_event_name === 'PreToolUse' && !classifyShipCommand(input.tool_input?.command)) return;
423
+ if (!reviewable(input)) return;
424
+ if (input.hook_event_name === 'PreToolUse' && !classifyToolCall(input.tool_name, input.tool_input)) return;
400
425
  const out = await runShipHook(input, await realDeps());
401
426
  if (out) process.stdout.write(JSON.stringify(out));
402
427
  }
@@ -16,12 +16,15 @@
16
16
  //
17
17
  // FAIL-SAFE: any error (no pairing, phone unreachable + no cache, render failure)
18
18
  // exits 0 with NO output, so a missing fingerprint never blocks or slows your
19
- // session beyond a short timeout. It never writes anything.
19
+ // session beyond a short timeout. The one thing it writes: other agents' instruction files
20
+ // that already carry Phaedo's block (rules-files.js) get the same profile, refreshed — never
21
+ // a file the subject didn't opt in with `npm run rules`.
20
22
 
21
23
  import { fileURLToPath } from 'url';
22
24
  import { dirname, resolve } from 'path';
23
25
  import { loadFingerprintPreferCache } from '../fingerprint-source.js';
24
26
  import { buildInjectionResponse } from '../projection.js';
27
+ import { refreshExisting } from '../rules-files.js';
25
28
 
26
29
  // hooks/ lives one level under mcp/ — the sources resolve against mcp/.
27
30
  const MCP_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -46,7 +49,10 @@ async function run() {
46
49
  // Same pattern that #76 applied to escalation (cache-preferred fingerprint read).
47
50
  const vd = await loadFingerprintPreferCache(MCP_DIR);
48
51
  const response = buildInjectionResponse(vd, { requested_layers: ['all'], mode: 'standard' });
49
- if (response && response.projection) emitContext(response.projection);
52
+ if (response && response.projection) {
53
+ emitContext(response.projection);
54
+ await refreshExisting(response.projection).catch(() => {});
55
+ }
50
56
  }
51
57
 
52
58
  const timeout = new Promise((res) => setTimeout(res, BUDGET_MS));
package/install.mjs CHANGED
@@ -27,6 +27,9 @@ import { dirname, join, resolve } from 'path';
27
27
  import { homedir } from 'os';
28
28
  import { fileURLToPath } from 'url';
29
29
  import { spawnSync } from 'child_process';
30
+ import { MCP_HOOK_MATCHER } from './ship-actions.js';
31
+ import { targets as rulesTargets, removeFromFile } from './rules-files.js';
32
+ import { applyAgentHooks } from './agent-hooks.js';
30
33
 
31
34
  const BASE_DIR = dirname(fileURLToPath(import.meta.url));
32
35
  const SERVER_PATH = join(BASE_DIR, 'server.js');
@@ -46,7 +49,10 @@ const SHIP_HOOK_MARKER = 'hooks/pre-ship.mjs'; // identifies our ship-
46
49
  // it ran or failed (to record that person's yes). Its own budget is 12s; the timeout is
47
50
  // the backstop.
48
51
  export const SHIP_HOOK_EVENTS = ['PreToolUse', 'PermissionRequest', 'PostToolUse', 'PostToolUseFailure'];
49
- const SHIP_HOOK_ENTRY = { matcher: 'Bash', timeout: 15 };
52
+ // Shell commands, plus connected-tool calls whose name says they send, pay, delete or merge
53
+ // (ship-actions.js MCP_HOOK_MATCHER) — the personal reviewer covers both.
54
+ export const SHIP_HOOK_MATCHER = `Bash|${MCP_HOOK_MATCHER}`;
55
+ const SHIP_HOOK_ENTRY = { matcher: SHIP_HOOK_MATCHER, timeout: 15 };
50
56
 
51
57
  // ── Client registry ──────────────────────────────────────────────────────────
52
58
  // Every supported client stores MCP servers as a top-level `mcpServers` object,
@@ -412,6 +418,7 @@ function parseArgs(argv) {
412
418
  else if (a === '--dry-run') opts.dryRun = true;
413
419
  else if (a === '--only') opts.only = argv[++i];
414
420
  else if (a === '--code-hook') opts.codeHook = true;
421
+ else if (a === '--agent-hooks') opts.agentHooks = true;
415
422
  else if (a === '--consult-model') opts.consultModel = argv[++i];
416
423
  else if (a === '--consult-url') opts.consultUrl = argv[++i];
417
424
  else if (a === '--no-model-judge') opts.noModelJudge = true;
@@ -430,6 +437,8 @@ const HELP = `Phaedo MCP installer
430
437
  node install.mjs --pair configure, then run the one-time pairing
431
438
  node install.mjs --code-hook also add the Claude Code SessionStart hook
432
439
  (true auto-injection; ~/.claude/settings.json)
440
+ node install.mjs --agent-hooks also add Phaedo's merge check to Cursor, Copilot CLI,
441
+ Codex and Gemini CLI (each one found on this computer)
433
442
  node install.mjs --capture-model <name|off> turn the capture hook's local
434
443
  model detector on (e.g. llama3.1:8b) or off.
435
444
  Writes <state dir>/capture-model.json, which
@@ -507,6 +516,19 @@ async function main(argv) {
507
516
  if (opts.codeHook || action === 'uninstall') {
508
517
  results.push(applyCodeHook({ action, dryRun: opts.dryRun }));
509
518
  }
519
+ // The merge check in other agents (agent-hooks.js): opt-in on install, always cleaned up.
520
+ if (opts.agentHooks || action === 'uninstall') {
521
+ results.push(...applyAgentHooks({ action, dryRun: opts.dryRun }));
522
+ }
523
+ // Other agents' instruction files (rules-files.js): with the hook gone nothing would keep
524
+ // them current, so an uninstall always takes Phaedo's block back out.
525
+ if (action === 'uninstall') {
526
+ for (const t of Object.values(rulesTargets())) {
527
+ const r = await removeFromFile(t.file, { dryRun: opts.dryRun }).catch((e) => ({ status: 'error', error: e.message }));
528
+ if (r.status === 'removed') results.push({ name: t.label, status: opts.dryRun ? 'would-remove' : 'removed', detail: `took Phaedo's section out of ${t.file}` });
529
+ else if (r.status === 'error') results.push({ name: t.label, status: 'error', detail: `${r.error} (${t.file})` });
530
+ }
531
+ }
510
532
  // Capture-model on-switch (state-dir config file; see applyCaptureModelConfig).
511
533
  if (action === 'install' && opts.captureModel !== undefined) {
512
534
  const r = await applyCaptureModelConfig(opts.captureModel, { url: opts.captureUrl, dryRun: opts.dryRun });