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
|
@@ -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
|
+
}
|
package/hooks/pre-ship.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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:
|
|
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 (
|
|
330
|
-
const h =
|
|
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
|
-
|
|
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
|
|
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
|
|
399
|
-
if (input.hook_event_name === 'PreToolUse' && !
|
|
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
|
}
|
package/hooks/session-start.mjs
CHANGED
|
@@ -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.
|
|
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)
|
|
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
|
-
|
|
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 });
|