phaedo-mcp 0.3.7 → 0.3.14
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 +102 -13
- package/agent-hooks.js +160 -0
- package/consult-core.js +44 -7
- package/escalation.js +28 -0
- package/fingerprint-source.js +14 -4
- package/history-import.js +202 -0
- package/hooks/agent-hook.mjs +160 -0
- package/hooks/pre-ship.mjs +155 -26
- package/hooks/session-start.mjs +8 -2
- package/install.mjs +31 -2
- package/learned.js +227 -0
- 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 +11 -6
- package/server.json +2 -2
- package/ship-actions.js +151 -2
- package/ship-readout.js +12 -2
- package/vendor/trait-phrases.js +2 -0
- package/weekly.js +63 -0
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Phaedo — Claude Code ship hook (PreToolUse, PermissionRequest, PostToolUse,
|
|
3
|
-
// PostToolUseFailure on Bash).
|
|
3
|
+
// PostToolUseFailure on Bash; UserPromptSubmit and Stop to catch a "no").
|
|
4
4
|
//
|
|
5
5
|
// WHY THIS EXISTS. The precedent track is starved: no consultation has been written since
|
|
6
6
|
// 2026-07-28, so agreement, calibration and override learning have nothing to read. The
|
|
@@ -25,7 +25,13 @@
|
|
|
25
25
|
// consult answered, and in any permission mode — a yes on a `proceed` is the scoreable
|
|
26
26
|
// case agreement needs.
|
|
27
27
|
// - prompt shown, then declined → Claude Code fires NO hook for a manual denial
|
|
28
|
-
// (documented)
|
|
28
|
+
// (documented). It does write the denial into the session transcript, marked
|
|
29
|
+
// `toolDenialKind: "user-rejected"` on the call's tool_result. So at the subject's next
|
|
30
|
+
// prompt (UserPromptSubmit), or when the turn ends (Stop — a "no, do this instead"
|
|
31
|
+
// continues the turn), the hook reads the transcript's tail for its own open calls and
|
|
32
|
+
// records `rejected`, or `modified` when the subject said what to do instead. A denial by
|
|
33
|
+
// auto mode's classifier or a permission rule carries a different kind and records
|
|
34
|
+
// nothing: only a person's no counts. An outcome the agent already recorded is kept.
|
|
29
35
|
// - no prompt (bypass mode, an allow rule) → nothing recorded. After the run the agent is
|
|
30
36
|
// told so, and records the subject's reaction only if they give one. Silence is never
|
|
31
37
|
// approval.
|
|
@@ -41,8 +47,9 @@ import { dirname, resolve, join } from 'path';
|
|
|
41
47
|
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
|
|
42
48
|
import { createHash } from 'crypto';
|
|
43
49
|
import { execFile } from 'child_process';
|
|
44
|
-
import { classifyShipCommand } from '../ship-actions.js';
|
|
50
|
+
import { classifyShipCommand, classifyToolCall } from '../ship-actions.js';
|
|
45
51
|
import { isMainModule } from '../lib/is-main.js';
|
|
52
|
+
import { isDirectoryEdition } from '../lib/config-dir.js';
|
|
46
53
|
|
|
47
54
|
const MCP_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
48
55
|
|
|
@@ -55,9 +62,20 @@ export const BUDGET_MS = 12000;
|
|
|
55
62
|
export const ASKING_SIGNALS = new Set(['escalate', 'clarify', 'decline']);
|
|
56
63
|
|
|
57
64
|
// Permission modes in which a PermissionRequest is not evidence that a PERSON answered:
|
|
58
|
-
// auto mode's classifier and dontAsk decide without one.
|
|
65
|
+
// auto mode's classifier and dontAsk decide without one. With ONE exception, in auto mode: when
|
|
66
|
+
// this hook itself returned `ask`, Claude Code shows the subject a real prompt ("a hook's `ask`
|
|
67
|
+
// also forces a permission prompt in auto mode: the classifier can still deny the tool call,
|
|
68
|
+
// but it can't approve the call silently" — hooks reference). That prompt is the personal
|
|
69
|
+
// reviewer's whole point, so a yes on it is the subject's and is recorded.
|
|
59
70
|
const NO_HUMAN_MODES = new Set(['auto', 'dontAsk']);
|
|
60
71
|
|
|
72
|
+
// The turn events that look for a "no". Every prompt fires one, so they stay cheap: a small
|
|
73
|
+
// state read, and the transcript only when this session has a consult still open.
|
|
74
|
+
export const TURN_EVENTS = new Set(['UserPromptSubmit', 'Stop']);
|
|
75
|
+
// How a person's denial is marked in the transcript (Claude Code), and how far back to read.
|
|
76
|
+
export const USER_DENIAL = 'user-rejected';
|
|
77
|
+
export const TRANSCRIPT_TAIL_BYTES = 4 * 1024 * 1024;
|
|
78
|
+
|
|
61
79
|
// The profile is read from the at-rest cache, which only a live read refreshes. Past this
|
|
62
80
|
// age the agent is told once a day, so the subject hears about it in plain words.
|
|
63
81
|
export const STALE_PROFILE_DAYS = 14;
|
|
@@ -70,6 +88,10 @@ const PENDING_TTL_MS = DAY_MS;
|
|
|
70
88
|
export const AGENT_ID = 'claude-code/ship-hook';
|
|
71
89
|
|
|
72
90
|
export const commandHash = (cmd) => createHash('sha256').update(String(cmd ?? '')).digest('hex');
|
|
91
|
+
// The call's identity across its hook events: the command for Bash, the input for a tool.
|
|
92
|
+
export const callHash = (input) => commandHash(input?.tool_name === 'Bash' ? input?.tool_input?.command : `${input?.tool_name}:${JSON.stringify(input?.tool_input ?? null)}`);
|
|
93
|
+
// The calls this hook reviews: shell commands, and connected tools (MCP).
|
|
94
|
+
const reviewable = (input) => input?.tool_name === 'Bash' || String(input?.tool_name || '').startsWith('mcp__');
|
|
73
95
|
|
|
74
96
|
// ── CI state for a merge (precedent track Phase 1b, B6) ───────────────────────────────
|
|
75
97
|
// Merging a PR whose checks are failing, or have not finished, is a bigger act than merging a
|
|
@@ -217,9 +239,22 @@ export function writeState(path, state, now) {
|
|
|
217
239
|
renameSync(tmp, path);
|
|
218
240
|
}
|
|
219
241
|
|
|
242
|
+
// THE PERSONAL PART OF THE PROMPT. Auto mode's reviewer judges an action by its type; this says
|
|
243
|
+
// why THIS person would want to see it — their own rule when one decided it, else what is at
|
|
244
|
+
// stake. Built only from the agent's descriptor and the consult answer (never vault content).
|
|
245
|
+
export function personalWhy(descriptor = {}, response = {}) {
|
|
246
|
+
const rule = /^Per your standing rule: (.+?)\.?$/.exec(String(response?.rationale_hint || '').trim());
|
|
247
|
+
if (rule) return `your rule: ${rule[1]}`;
|
|
248
|
+
const w = [];
|
|
249
|
+
if (descriptor.reversible === false) w.push("it can't be undone");
|
|
250
|
+
if (typeof descriptor.amount === 'number' && descriptor.amount > 0) w.push(`it spends $${descriptor.amount.toLocaleString('en-US', { maximumFractionDigits: 2 })}`);
|
|
251
|
+
return w.join(' and ');
|
|
252
|
+
}
|
|
253
|
+
|
|
220
254
|
// What the subject reads on the prompt. Plain words, no numbers, no protocol terms.
|
|
221
|
-
export function promptReason(signal, summary, why) {
|
|
222
|
-
const
|
|
255
|
+
export function promptReason(signal, summary, why, personal = '') {
|
|
256
|
+
const reasons = [why, personal].filter(Boolean).join('; ');
|
|
257
|
+
const what = reasons ? `${summary} — ${reasons}` : summary;
|
|
223
258
|
if (signal === 'decline') return `Phaedo thinks you'd say no to this one: ${what}. Go ahead anyway?`;
|
|
224
259
|
if (signal === 'clarify') return `Phaedo isn't sure how you'd want this handled: ${what}. Go ahead?`;
|
|
225
260
|
return `Phaedo thinks you'd want to OK this yourself: ${what}.`;
|
|
@@ -227,9 +262,14 @@ export function promptReason(signal, summary, why) {
|
|
|
227
262
|
|
|
228
263
|
// What the agent reads before the command. It carries the receipt id — the one handle
|
|
229
264
|
// that attributes an outcome to THIS decision rather than whatever receipt is newest.
|
|
230
|
-
export function preContext({ summary, signal, receiptId, asked, hint }) {
|
|
231
|
-
const head = `Phaedo was consulted before this
|
|
265
|
+
export function preContext({ summary, signal, receiptId, asked, hint, recordsNo = false }) {
|
|
266
|
+
const head = `Phaedo was consulted before this action (${summary}) and answered "${signal}"` +
|
|
232
267
|
(hint ? ` — ${hint}` : '') + `. receipt_id: ${receiptId}.`;
|
|
268
|
+
if (asked && recordsNo) {
|
|
269
|
+
return `${head} Phaedo flagged it for the user, so they may be asked to confirm. A yes or a no on ` +
|
|
270
|
+
`the prompt is recorded automatically. If they later say they wanted it done differently, call ` +
|
|
271
|
+
`phaedo_record_outcome with this receipt_id and "modified".`;
|
|
272
|
+
}
|
|
233
273
|
if (asked) {
|
|
234
274
|
return `${head} Phaedo flagged it for the user, so they may be asked to confirm. If they decline, ` +
|
|
235
275
|
`call phaedo_record_outcome with this receipt_id and "rejected" (or "modified" if they want it ` +
|
|
@@ -243,7 +283,7 @@ export function preContext({ summary, signal, receiptId, asked, hint }) {
|
|
|
243
283
|
|
|
244
284
|
// After the command, when Phaedo had flagged it and no prompt was shown.
|
|
245
285
|
export function unpromptedContext(receiptId) {
|
|
246
|
-
return `No confirmation prompt was shown for this
|
|
286
|
+
return `No confirmation prompt was shown for this action (receipt_id: ${receiptId}), though Phaedo ` +
|
|
247
287
|
`had flagged it for the user. Tell them in one line that it ran and Phaedo had flagged it, and ` +
|
|
248
288
|
`record their reaction with phaedo_record_outcome and this receipt_id if they give one. Silence ` +
|
|
249
289
|
`is not approval.`;
|
|
@@ -255,21 +295,67 @@ export function staleContext(days, mcpDir) {
|
|
|
255
295
|
`\`cd ${mcpDir} && npm run pull\` and approving on their phone.`;
|
|
256
296
|
}
|
|
257
297
|
|
|
298
|
+
/**
|
|
299
|
+
* What the transcript says became of each call in `ids`: Map id → { denied }, where `denied` is
|
|
300
|
+
* `rejected` or `modified` for a person's no (modified when they said what to do instead), and
|
|
301
|
+
* null for any other result (it ran, failed, or something other than a person refused it).
|
|
302
|
+
* A call with no result yet is absent. Reads only the tool_result entries. PURE.
|
|
303
|
+
*/
|
|
304
|
+
export function callResults(jsonl, ids) {
|
|
305
|
+
const want = new Set(ids);
|
|
306
|
+
const out = new Map();
|
|
307
|
+
if (!want.size) return out;
|
|
308
|
+
const words = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map((p) => (typeof p?.text === 'string' ? p.text : '')).join('\n') : '');
|
|
309
|
+
for (const line of String(jsonl || '').split('\n')) {
|
|
310
|
+
if (!line.includes('tool_use_id')) continue;
|
|
311
|
+
let j;
|
|
312
|
+
try { j = JSON.parse(line); } catch { continue; }
|
|
313
|
+
for (const c of Array.isArray(j?.message?.content) ? j.message.content : []) {
|
|
314
|
+
if (c?.type !== 'tool_result' || !want.has(c.tool_use_id)) continue;
|
|
315
|
+
// Quitting the app mid-prompt is marked as a denial too; it is not a decision.
|
|
316
|
+
const byPerson = j.toolDenialKind === USER_DENIAL && !j.interruptedByShutdown;
|
|
317
|
+
out.set(c.tool_use_id, { denied: byPerson ? (/the user said:/i.test(words(c.content)) ? 'modified' : 'rejected') : null });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
258
323
|
/**
|
|
259
324
|
* One hook invocation. Pure apart from injected deps, so the whole flow is testable
|
|
260
325
|
* without a vault, a receipt store or Claude Code.
|
|
261
326
|
*
|
|
262
327
|
* deps: { consult(req) → { response, drivers, source? }, writeReceipt({request,response,drivers}) → id,
|
|
263
|
-
* markOutcome(id, action), statePath, now, mcpDir
|
|
328
|
+
* markOutcome(id, action, { keepExisting }?), statePath, now, mcpDir?,
|
|
329
|
+
* readTranscript(path)? — present only where a transcript records denials (Claude Code) }
|
|
264
330
|
* Returns the JSON object to print, or null for no output.
|
|
265
331
|
*/
|
|
266
332
|
export async function runShipHook(input, deps) {
|
|
267
|
-
if (!input || input.tool_name !== 'Bash') return null;
|
|
268
333
|
const now = deps.now ?? Date.now();
|
|
269
|
-
const ev = input
|
|
334
|
+
const ev = input?.hook_event_name;
|
|
335
|
+
|
|
336
|
+
if (TURN_EVENTS.has(ev)) {
|
|
337
|
+
// A denied prompt leaves its call open: no PostToolUse ever comes. Close this session's.
|
|
338
|
+
const state = readState(deps.statePath);
|
|
339
|
+
const open = Object.entries(state.pending).filter(([, e]) => e?.session_id && e.session_id === input.session_id);
|
|
340
|
+
if (!open.length || !input.transcript_path || !deps.readTranscript) return null;
|
|
341
|
+
let text = '';
|
|
342
|
+
try { text = await deps.readTranscript(input.transcript_path); } catch { return null; }
|
|
343
|
+
const results = callResults(text, open.map(([id]) => id));
|
|
344
|
+
let closed = false;
|
|
345
|
+
for (const [id, entry] of open) {
|
|
346
|
+
const r = results.get(id);
|
|
347
|
+
if (!r) continue; // still running, or not written yet — the next event looks again
|
|
348
|
+
delete state.pending[id]; closed = true;
|
|
349
|
+
if (r.denied) await deps.markOutcome(entry.receipt_id, r.denied, { keepExisting: true });
|
|
350
|
+
}
|
|
351
|
+
if (closed) writeState(deps.statePath, state, now);
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (!reviewable(input)) return null;
|
|
270
356
|
|
|
271
357
|
if (ev === 'PreToolUse') {
|
|
272
|
-
const ship =
|
|
358
|
+
const ship = classifyToolCall(input.tool_name, input.tool_input);
|
|
273
359
|
if (!ship) return null;
|
|
274
360
|
// B6 + B3: one GitHub lookup per merge — check state, size, paths. Each can only raise.
|
|
275
361
|
let ci = null, stakes = null;
|
|
@@ -281,7 +367,7 @@ export async function runShipHook(input, deps) {
|
|
|
281
367
|
const raised = why !== '';
|
|
282
368
|
const request = {
|
|
283
369
|
consultation_type: 'action_approval',
|
|
284
|
-
agent_id: AGENT_ID,
|
|
370
|
+
agent_id: deps.agentId || AGENT_ID,
|
|
285
371
|
action_descriptor: raised ? { ...ship.action_descriptor, magnitude: 'high' } : ship.action_descriptor,
|
|
286
372
|
action_binding: ship.action_binding,
|
|
287
373
|
};
|
|
@@ -299,12 +385,12 @@ export async function runShipHook(input, deps) {
|
|
|
299
385
|
const state = readState(deps.statePath);
|
|
300
386
|
if (input.tool_use_id) {
|
|
301
387
|
state.pending[input.tool_use_id] = {
|
|
302
|
-
receipt_id: receiptId, cmd_hash:
|
|
388
|
+
receipt_id: receiptId, cmd_hash: callHash(input), session_id: input.session_id || null,
|
|
303
389
|
asked, prompt_shown: false, at: new Date(now).toISOString(),
|
|
304
390
|
};
|
|
305
391
|
}
|
|
306
392
|
let context = preContext({
|
|
307
|
-
summary: ship.summary, signal, receiptId, asked,
|
|
393
|
+
summary: ship.summary, signal, receiptId, asked, recordsNo: Boolean(deps.readTranscript),
|
|
308
394
|
hint: typeof response?.rationale_hint === 'string' ? response.rationale_hint : '',
|
|
309
395
|
});
|
|
310
396
|
if (raised) context += ` GitHub shows ${why}, so Phaedo treated this merge as high-stakes.`;
|
|
@@ -317,7 +403,7 @@ export async function runShipHook(input, deps) {
|
|
|
317
403
|
return {
|
|
318
404
|
hookSpecificOutput: {
|
|
319
405
|
hookEventName: 'PreToolUse',
|
|
320
|
-
...(asked ? { permissionDecision: 'ask', permissionDecisionReason: promptReason(signal, ship.summary, why) } : {}),
|
|
406
|
+
...(asked ? { permissionDecision: 'ask', permissionDecisionReason: promptReason(signal, ship.summary, why, personalWhy(request.action_descriptor, response)) } : {}),
|
|
321
407
|
additionalContext: context,
|
|
322
408
|
},
|
|
323
409
|
};
|
|
@@ -326,13 +412,15 @@ export async function runShipHook(input, deps) {
|
|
|
326
412
|
if (ev === 'PermissionRequest') {
|
|
327
413
|
// Evidence that a prompt is about to be shown for this command. PermissionRequest has
|
|
328
414
|
// no tool_use_id (documented), so match the newest pending consult on the same command.
|
|
329
|
-
if (
|
|
330
|
-
const h =
|
|
415
|
+
if (input.permission_mode === 'dontAsk') return null;
|
|
416
|
+
const h = callHash(input);
|
|
331
417
|
const state = readState(deps.statePath);
|
|
332
418
|
const hit = Object.values(state.pending)
|
|
333
419
|
.filter((e) => e.cmd_hash === h && !e.prompt_shown)
|
|
334
420
|
.sort((a, b) => Date.parse(b.at) - Date.parse(a.at))[0];
|
|
335
421
|
if (!hit) return null;
|
|
422
|
+
// Auto mode: only the prompt Phaedo forced is a person's; anything else is the classifier.
|
|
423
|
+
if (NO_HUMAN_MODES.has(input.permission_mode) && !hit.asked) return null;
|
|
336
424
|
hit.prompt_shown = true;
|
|
337
425
|
writeState(deps.statePath, state, now);
|
|
338
426
|
return null; // never decide here — the subject answers the prompt
|
|
@@ -361,17 +449,19 @@ export async function runShipHook(input, deps) {
|
|
|
361
449
|
}
|
|
362
450
|
|
|
363
451
|
// ── Real deps: the same consult path as the MCP server (consult-runtime.js). ──────────
|
|
364
|
-
|
|
452
|
+
// `agentId` names the agent on the receipt; other agents' hooks (agent-hook.mjs) pass their own.
|
|
453
|
+
export async function realDeps({ agentId = AGENT_ID, statePath } = {}) {
|
|
365
454
|
const { loadConsultVd, buildConsultOpts } = await import('../consult-runtime.js');
|
|
366
455
|
const { resolveConsultation, consultDrivers, withRevision } = await import('../consult.js');
|
|
367
|
-
const { buildReceipt, appendReceipt, receiptsCapFromEnv
|
|
456
|
+
const { buildReceipt, appendReceipt, receiptsCapFromEnv } = await import('../receipts.js');
|
|
368
457
|
const { sourceOf } = await import('../fingerprint-source.js');
|
|
369
458
|
const { defaultStateDir } = await import('../cache.js');
|
|
370
459
|
const stateDir = defaultStateDir();
|
|
371
460
|
const quiet = () => {};
|
|
372
461
|
const opts = buildConsultOpts(MCP_DIR, process.env, { warn: quiet });
|
|
373
462
|
return {
|
|
374
|
-
statePath: join(stateDir, 'ship-hook.json'),
|
|
463
|
+
statePath: statePath || join(stateDir, 'ship-hook.json'),
|
|
464
|
+
agentId,
|
|
375
465
|
mcpDir: MCP_DIR,
|
|
376
466
|
prFacts: (ship, cwd) => lookupPrFacts(ship, cwd),
|
|
377
467
|
async consult(request) {
|
|
@@ -382,11 +472,46 @@ async function realDeps() {
|
|
|
382
472
|
return { response, drivers, source: sourceOf(vd) };
|
|
383
473
|
},
|
|
384
474
|
async writeReceipt({ request, response, drivers }) {
|
|
385
|
-
const r = buildReceipt({ via: 'phaedo_consult', agentId
|
|
475
|
+
const r = buildReceipt({ via: 'phaedo_consult', agentId, clientId: opts.clientId, request, response, drivers });
|
|
386
476
|
await appendReceipt(stateDir, r, { cap: receiptsCapFromEnv(process.env) });
|
|
387
477
|
return r.receipt_id;
|
|
388
478
|
},
|
|
389
|
-
async markOutcome(id, action) { await
|
|
479
|
+
async markOutcome(id, action, opts) { await markOutcomeKeeping(stateDir, id, action, opts); },
|
|
480
|
+
// Only Claude Code's transcript marks a person's denial; other agents' hooks have none. The
|
|
481
|
+
// directory edition never reads chat history, so it does not wire the turn events either.
|
|
482
|
+
...(agentId === AGENT_ID && !isDirectoryEdition(MCP_DIR) ? { readTranscript: readTail } : {}),
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// The hook's own outcomes never overwrite one already recorded (the agent's, or a yes).
|
|
487
|
+
async function markOutcomeKeeping(stateDir, id, action, { keepExisting = false } = {}) {
|
|
488
|
+
const { readReceipts, markOutcome } = await import('../receipts.js');
|
|
489
|
+
if (keepExisting && (await readReceipts(stateDir)).some((r) => r.receipt_id === id && r.user_action)) return;
|
|
490
|
+
await markOutcome(stateDir, id, action);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// The last few MB of the transcript — a denial is near the end; the first, cut line is dropped.
|
|
494
|
+
async function readTail(path, max = TRANSCRIPT_TAIL_BYTES) {
|
|
495
|
+
const { openSync, fstatSync, readSync, closeSync } = await import('fs');
|
|
496
|
+
const fd = openSync(path, 'r');
|
|
497
|
+
try {
|
|
498
|
+
const size = fstatSync(fd).size;
|
|
499
|
+
const len = Math.min(size, max);
|
|
500
|
+
const buf = Buffer.alloc(len);
|
|
501
|
+
readSync(fd, buf, 0, len, size - len);
|
|
502
|
+
const text = buf.toString('utf8');
|
|
503
|
+
return len < size ? text.slice(text.indexOf('\n') + 1) : text;
|
|
504
|
+
} finally { closeSync(fd); }
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// The turn events' deps: no consult and no vault — only the state, the transcript, the receipts.
|
|
508
|
+
export async function turnDeps({ statePath } = {}) {
|
|
509
|
+
const { defaultStateDir } = await import('../cache.js');
|
|
510
|
+
const stateDir = defaultStateDir();
|
|
511
|
+
return {
|
|
512
|
+
statePath: statePath || join(stateDir, 'ship-hook.json'),
|
|
513
|
+
readTranscript: readTail,
|
|
514
|
+
async markOutcome(id, action, opts) { await markOutcomeKeeping(stateDir, id, action, opts); },
|
|
390
515
|
};
|
|
391
516
|
}
|
|
392
517
|
|
|
@@ -394,9 +519,13 @@ async function main() {
|
|
|
394
519
|
let raw = '';
|
|
395
520
|
for await (const chunk of process.stdin) raw += chunk;
|
|
396
521
|
const input = JSON.parse(raw);
|
|
522
|
+
if (TURN_EVENTS.has(input.hook_event_name)) {
|
|
523
|
+
await runShipHook(input, await turnDeps());
|
|
524
|
+
return; // never says anything on a prompt or a stop
|
|
525
|
+
}
|
|
397
526
|
// Cheap exit before importing the vault stack: most Bash calls are not ship actions.
|
|
398
|
-
if (input
|
|
399
|
-
if (input.hook_event_name === 'PreToolUse' && !
|
|
527
|
+
if (!reviewable(input)) return;
|
|
528
|
+
if (input.hook_event_name === 'PreToolUse' && !classifyToolCall(input.tool_name, input.tool_input)) return;
|
|
400
529
|
const out = await runShipHook(input, await realDeps());
|
|
401
530
|
if (out) process.stdout.write(JSON.stringify(out));
|
|
402
531
|
}
|
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,13 @@ 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
|
+
// The same hook on the turn events, to record a "no" from the transcript (full install only:
|
|
53
|
+
// the directory edition does not read chat history). No matcher — these events have no tool.
|
|
54
|
+
export const SHIP_TURN_EVENTS = ['UserPromptSubmit', 'Stop'];
|
|
55
|
+
// Shell commands, plus connected-tool calls whose name says they send, pay, delete or merge
|
|
56
|
+
// (ship-actions.js MCP_HOOK_MATCHER) — the personal reviewer covers both.
|
|
57
|
+
export const SHIP_HOOK_MATCHER = `Bash|${MCP_HOOK_MATCHER}`;
|
|
58
|
+
const SHIP_HOOK_ENTRY = { matcher: SHIP_HOOK_MATCHER, timeout: 15 };
|
|
50
59
|
|
|
51
60
|
// ── Client registry ──────────────────────────────────────────────────────────
|
|
52
61
|
// Every supported client stores MCP servers as a top-level `mcpServers` object,
|
|
@@ -239,7 +248,7 @@ export function applyCodeHook({ action, dryRun = false, home = homedir() } = {})
|
|
|
239
248
|
const stop = removeHookEntry(both.settings, 'Stop', STOP_CAPTURE_HOOK_MARKER);
|
|
240
249
|
let s = stop.settings;
|
|
241
250
|
let shipChanged = false;
|
|
242
|
-
for (const ev of SHIP_HOOK_EVENTS) {
|
|
251
|
+
for (const ev of [...SHIP_HOOK_EVENTS, ...SHIP_TURN_EVENTS]) {
|
|
243
252
|
const r = removeHookEntry(s, ev, SHIP_HOOK_MARKER);
|
|
244
253
|
s = r.settings; shipChanged = shipChanged || r.changed;
|
|
245
254
|
}
|
|
@@ -276,6 +285,10 @@ export function applyCodeHook({ action, dryRun = false, home = homedir() } = {})
|
|
|
276
285
|
const r = mergeHookEntry(settings, ev, hookCommand({ hookPath: SHIP_HOOK_PATH }), SHIP_HOOK_MARKER, SHIP_HOOK_ENTRY);
|
|
277
286
|
settings = r.settings; shipChanged = shipChanged || r.changed;
|
|
278
287
|
}
|
|
288
|
+
for (const ev of SHIP_TURN_EVENTS) {
|
|
289
|
+
const r = mergeHookEntry(settings, ev, hookCommand({ hookPath: SHIP_HOOK_PATH }), SHIP_HOOK_MARKER, { timeout: SHIP_HOOK_ENTRY.timeout });
|
|
290
|
+
settings = r.settings; shipChanged = shipChanged || r.changed;
|
|
291
|
+
}
|
|
279
292
|
const changed = start.changed || captureChanged || shipChanged;
|
|
280
293
|
if (!changed) return { name, status: 'unchanged', detail: `already configured${captureNote}` };
|
|
281
294
|
if (!dryRun) { if (read.existed) backup(path); writeConfig(path, settings); }
|
|
@@ -412,6 +425,7 @@ function parseArgs(argv) {
|
|
|
412
425
|
else if (a === '--dry-run') opts.dryRun = true;
|
|
413
426
|
else if (a === '--only') opts.only = argv[++i];
|
|
414
427
|
else if (a === '--code-hook') opts.codeHook = true;
|
|
428
|
+
else if (a === '--agent-hooks') opts.agentHooks = true;
|
|
415
429
|
else if (a === '--consult-model') opts.consultModel = argv[++i];
|
|
416
430
|
else if (a === '--consult-url') opts.consultUrl = argv[++i];
|
|
417
431
|
else if (a === '--no-model-judge') opts.noModelJudge = true;
|
|
@@ -430,6 +444,8 @@ const HELP = `Phaedo MCP installer
|
|
|
430
444
|
node install.mjs --pair configure, then run the one-time pairing
|
|
431
445
|
node install.mjs --code-hook also add the Claude Code SessionStart hook
|
|
432
446
|
(true auto-injection; ~/.claude/settings.json)
|
|
447
|
+
node install.mjs --agent-hooks also add Phaedo's merge check to Cursor, Copilot CLI,
|
|
448
|
+
Codex and Gemini CLI (each one found on this computer)
|
|
433
449
|
node install.mjs --capture-model <name|off> turn the capture hook's local
|
|
434
450
|
model detector on (e.g. llama3.1:8b) or off.
|
|
435
451
|
Writes <state dir>/capture-model.json, which
|
|
@@ -507,6 +523,19 @@ async function main(argv) {
|
|
|
507
523
|
if (opts.codeHook || action === 'uninstall') {
|
|
508
524
|
results.push(applyCodeHook({ action, dryRun: opts.dryRun }));
|
|
509
525
|
}
|
|
526
|
+
// The merge check in other agents (agent-hooks.js): opt-in on install, always cleaned up.
|
|
527
|
+
if (opts.agentHooks || action === 'uninstall') {
|
|
528
|
+
results.push(...applyAgentHooks({ action, dryRun: opts.dryRun }));
|
|
529
|
+
}
|
|
530
|
+
// Other agents' instruction files (rules-files.js): with the hook gone nothing would keep
|
|
531
|
+
// them current, so an uninstall always takes Phaedo's block back out.
|
|
532
|
+
if (action === 'uninstall') {
|
|
533
|
+
for (const t of Object.values(rulesTargets())) {
|
|
534
|
+
const r = await removeFromFile(t.file, { dryRun: opts.dryRun }).catch((e) => ({ status: 'error', error: e.message }));
|
|
535
|
+
if (r.status === 'removed') results.push({ name: t.label, status: opts.dryRun ? 'would-remove' : 'removed', detail: `took Phaedo's section out of ${t.file}` });
|
|
536
|
+
else if (r.status === 'error') results.push({ name: t.label, status: 'error', detail: `${r.error} (${t.file})` });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
510
539
|
// Capture-model on-switch (state-dir config file; see applyCaptureModelConfig).
|
|
511
540
|
if (action === 'install' && opts.captureModel !== undefined) {
|
|
512
541
|
const r = await applyCaptureModelConfig(opts.captureModel, { url: opts.captureUrl, dryRun: opts.dryRun });
|