phaedo-mcp 0.3.13 → 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 +7 -3
- package/hooks/pre-ship.mjs +114 -10
- package/install.mjs +8 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/vendor/trait-phrases.js +2 -0
package/README.md
CHANGED
|
@@ -435,9 +435,13 @@ ask me before posting in #eng". When the answer is no, it stays quiet.
|
|
|
435
435
|
- **Outcomes are yours, never inferred.** The hook records `approved` only when it has
|
|
436
436
|
evidence you were asked: Claude Code fired PermissionRequest for that call, and the
|
|
437
437
|
call then ran. In auto mode that counts only when Phaedo itself asked (the prompt it forced
|
|
438
|
-
is yours; any other is the classifier's); in dontAsk, never. Decline and
|
|
439
|
-
|
|
440
|
-
|
|
438
|
+
is yours; any other is the classifier's); in dontAsk, never. **Decline and that is recorded too:**
|
|
439
|
+
Claude Code fires no hook for a decline, but it marks it in the session's own transcript, so
|
|
440
|
+
at your next message (or when the turn ends) the hook reads the end of the transcript for its
|
|
441
|
+
open calls and records `rejected` — or `modified` if you said what to do instead. Only a
|
|
442
|
+
person's no counts (auto mode's classifier saying no, or quitting the app, records nothing),
|
|
443
|
+
and an outcome the agent already recorded is kept. No prompt shown, nothing recorded; the
|
|
444
|
+
agent is told so, and records your reaction only if you give one.
|
|
441
445
|
- **What a merge changes.** Before `gh pr merge` the hook reads the PR from GitHub (read-only):
|
|
442
446
|
failing or unfinished checks, a large diff (> 1,000 code lines or > 20 files), or a sensitive
|
|
443
447
|
path (CI/deploy config, auth/crypto/secrets by name, or your repo's `.phaedo-sensitive` list,
|
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.
|
|
@@ -43,6 +49,7 @@ import { createHash } from 'crypto';
|
|
|
43
49
|
import { execFile } from 'child_process';
|
|
44
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
|
|
|
@@ -62,6 +69,13 @@ export const ASKING_SIGNALS = new Set(['escalate', 'clarify', 'decline']);
|
|
|
62
69
|
// reviewer's whole point, so a yes on it is the subject's and is recorded.
|
|
63
70
|
const NO_HUMAN_MODES = new Set(['auto', 'dontAsk']);
|
|
64
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
|
+
|
|
65
79
|
// The profile is read from the at-rest cache, which only a live read refreshes. Past this
|
|
66
80
|
// age the agent is told once a day, so the subject hears about it in plain words.
|
|
67
81
|
export const STALE_PROFILE_DAYS = 14;
|
|
@@ -248,9 +262,14 @@ export function promptReason(signal, summary, why, personal = '') {
|
|
|
248
262
|
|
|
249
263
|
// What the agent reads before the command. It carries the receipt id — the one handle
|
|
250
264
|
// that attributes an outcome to THIS decision rather than whatever receipt is newest.
|
|
251
|
-
export function preContext({ summary, signal, receiptId, asked, hint }) {
|
|
265
|
+
export function preContext({ summary, signal, receiptId, asked, hint, recordsNo = false }) {
|
|
252
266
|
const head = `Phaedo was consulted before this action (${summary}) and answered "${signal}"` +
|
|
253
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
|
+
}
|
|
254
273
|
if (asked) {
|
|
255
274
|
return `${head} Phaedo flagged it for the user, so they may be asked to confirm. If they decline, ` +
|
|
256
275
|
`call phaedo_record_outcome with this receipt_id and "rejected" (or "modified" if they want it ` +
|
|
@@ -276,18 +295,64 @@ export function staleContext(days, mcpDir) {
|
|
|
276
295
|
`\`cd ${mcpDir} && npm run pull\` and approving on their phone.`;
|
|
277
296
|
}
|
|
278
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
|
+
|
|
279
323
|
/**
|
|
280
324
|
* One hook invocation. Pure apart from injected deps, so the whole flow is testable
|
|
281
325
|
* without a vault, a receipt store or Claude Code.
|
|
282
326
|
*
|
|
283
327
|
* deps: { consult(req) → { response, drivers, source? }, writeReceipt({request,response,drivers}) → id,
|
|
284
|
-
* 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) }
|
|
285
330
|
* Returns the JSON object to print, or null for no output.
|
|
286
331
|
*/
|
|
287
332
|
export async function runShipHook(input, deps) {
|
|
288
|
-
if (!reviewable(input)) return null;
|
|
289
333
|
const now = deps.now ?? Date.now();
|
|
290
|
-
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;
|
|
291
356
|
|
|
292
357
|
if (ev === 'PreToolUse') {
|
|
293
358
|
const ship = classifyToolCall(input.tool_name, input.tool_input);
|
|
@@ -320,12 +385,12 @@ export async function runShipHook(input, deps) {
|
|
|
320
385
|
const state = readState(deps.statePath);
|
|
321
386
|
if (input.tool_use_id) {
|
|
322
387
|
state.pending[input.tool_use_id] = {
|
|
323
|
-
receipt_id: receiptId, cmd_hash: callHash(input),
|
|
388
|
+
receipt_id: receiptId, cmd_hash: callHash(input), session_id: input.session_id || null,
|
|
324
389
|
asked, prompt_shown: false, at: new Date(now).toISOString(),
|
|
325
390
|
};
|
|
326
391
|
}
|
|
327
392
|
let context = preContext({
|
|
328
|
-
summary: ship.summary, signal, receiptId, asked,
|
|
393
|
+
summary: ship.summary, signal, receiptId, asked, recordsNo: Boolean(deps.readTranscript),
|
|
329
394
|
hint: typeof response?.rationale_hint === 'string' ? response.rationale_hint : '',
|
|
330
395
|
});
|
|
331
396
|
if (raised) context += ` GitHub shows ${why}, so Phaedo treated this merge as high-stakes.`;
|
|
@@ -388,7 +453,7 @@ export async function runShipHook(input, deps) {
|
|
|
388
453
|
export async function realDeps({ agentId = AGENT_ID, statePath } = {}) {
|
|
389
454
|
const { loadConsultVd, buildConsultOpts } = await import('../consult-runtime.js');
|
|
390
455
|
const { resolveConsultation, consultDrivers, withRevision } = await import('../consult.js');
|
|
391
|
-
const { buildReceipt, appendReceipt, receiptsCapFromEnv
|
|
456
|
+
const { buildReceipt, appendReceipt, receiptsCapFromEnv } = await import('../receipts.js');
|
|
392
457
|
const { sourceOf } = await import('../fingerprint-source.js');
|
|
393
458
|
const { defaultStateDir } = await import('../cache.js');
|
|
394
459
|
const stateDir = defaultStateDir();
|
|
@@ -411,7 +476,42 @@ export async function realDeps({ agentId = AGENT_ID, statePath } = {}) {
|
|
|
411
476
|
await appendReceipt(stateDir, r, { cap: receiptsCapFromEnv(process.env) });
|
|
412
477
|
return r.receipt_id;
|
|
413
478
|
},
|
|
414
|
-
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); },
|
|
415
515
|
};
|
|
416
516
|
}
|
|
417
517
|
|
|
@@ -419,6 +519,10 @@ async function main() {
|
|
|
419
519
|
let raw = '';
|
|
420
520
|
for await (const chunk of process.stdin) raw += chunk;
|
|
421
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
|
+
}
|
|
422
526
|
// Cheap exit before importing the vault stack: most Bash calls are not ship actions.
|
|
423
527
|
if (!reviewable(input)) return;
|
|
424
528
|
if (input.hook_event_name === 'PreToolUse' && !classifyToolCall(input.tool_name, input.tool_input)) return;
|
package/install.mjs
CHANGED
|
@@ -49,6 +49,9 @@ const SHIP_HOOK_MARKER = 'hooks/pre-ship.mjs'; // identifies our ship-
|
|
|
49
49
|
// it ran or failed (to record that person's yes). Its own budget is 12s; the timeout is
|
|
50
50
|
// the backstop.
|
|
51
51
|
export const SHIP_HOOK_EVENTS = ['PreToolUse', 'PermissionRequest', 'PostToolUse', 'PostToolUseFailure'];
|
|
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'];
|
|
52
55
|
// Shell commands, plus connected-tool calls whose name says they send, pay, delete or merge
|
|
53
56
|
// (ship-actions.js MCP_HOOK_MATCHER) — the personal reviewer covers both.
|
|
54
57
|
export const SHIP_HOOK_MATCHER = `Bash|${MCP_HOOK_MATCHER}`;
|
|
@@ -245,7 +248,7 @@ export function applyCodeHook({ action, dryRun = false, home = homedir() } = {})
|
|
|
245
248
|
const stop = removeHookEntry(both.settings, 'Stop', STOP_CAPTURE_HOOK_MARKER);
|
|
246
249
|
let s = stop.settings;
|
|
247
250
|
let shipChanged = false;
|
|
248
|
-
for (const ev of SHIP_HOOK_EVENTS) {
|
|
251
|
+
for (const ev of [...SHIP_HOOK_EVENTS, ...SHIP_TURN_EVENTS]) {
|
|
249
252
|
const r = removeHookEntry(s, ev, SHIP_HOOK_MARKER);
|
|
250
253
|
s = r.settings; shipChanged = shipChanged || r.changed;
|
|
251
254
|
}
|
|
@@ -282,6 +285,10 @@ export function applyCodeHook({ action, dryRun = false, home = homedir() } = {})
|
|
|
282
285
|
const r = mergeHookEntry(settings, ev, hookCommand({ hookPath: SHIP_HOOK_PATH }), SHIP_HOOK_MARKER, SHIP_HOOK_ENTRY);
|
|
283
286
|
settings = r.settings; shipChanged = shipChanged || r.changed;
|
|
284
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
|
+
}
|
|
285
292
|
const changed = start.changed || captureChanged || shipChanged;
|
|
286
293
|
if (!changed) return { name, status: 'unchanged', detail: `already configured${captureNote}` };
|
|
287
294
|
if (!dryRun) { if (read.existed) backup(path); writeConfig(path, settings); }
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.phaedo-labs/phaedo-mcp",
|
|
4
4
|
"description": "Make AI apps answer your way, check with you before acting, and get approvals on your phone.",
|
|
5
|
-
"version": "0.3.
|
|
5
|
+
"version": "0.3.14",
|
|
6
6
|
"repository": {
|
|
7
7
|
"url": "https://github.com/phaedo-labs/phaedo-mcp",
|
|
8
8
|
"source": "github",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "phaedo-mcp",
|
|
16
|
-
"version": "0.3.
|
|
16
|
+
"version": "0.3.14",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
19
19
|
}
|
package/vendor/trait-phrases.js
CHANGED
|
@@ -297,6 +297,8 @@
|
|
|
297
297
|
ask: 'When things are unclear, ask me before deciding.',
|
|
298
298
|
delay: 'When things are unclear, wait rather than commit too early.',
|
|
299
299
|
},
|
|
300
|
+
// The request-level words (ambiguity_response) for "guess and carry on" mean be decisive here.
|
|
301
|
+
aliases: { assume_state_proceed: 'default', assume_silent: 'default', assume_state: 'default' },
|
|
300
302
|
},
|
|
301
303
|
evidence_threshold: {
|
|
302
304
|
topic: 'how sure you need to be before committing',
|