phaedo-mcp 0.3.13 → 0.3.15

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 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 the agent records `rejected` (Claude Code fires no hook for a
439
- decline). No prompt shown, nothing recorded; the agent is told so, and records your
440
- reaction only if you give one.
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,
@@ -524,8 +528,15 @@ Quick readout on your own machine:
524
528
  cd mcp && npm run receipts # decrypts locally, lists the last 50 (with a short id per line)
525
529
  node receipts.js mark <id|prefix|latest> approved # record what YOU did → approved | rejected | modified | unknown
526
530
  npm run agreement # per-domain agreement rate (oracle vs your actual decisions)
531
+ npm run asks # are you being asked less? last 4 weeks vs the 4 before
532
+ npm run asks -- --weeks 12 --table # …week by week
527
533
  ```
528
534
 
535
+ `npm run asks` is the number Phaedo is judged by: how often it brought an action to you, whether
536
+ that is falling, how often you reversed what it decided alone, and which asks were about things
537
+ that could be undone and you approved as-is (the asks it should learn to drop). `/phaedo:status`
538
+ adds the headline once each half of the window holds 5 checks.
539
+
529
540
  `user_action` is **subject-authored**: set it from what *you* decided, not from whether an
530
541
  agent proceeded. Until receipts carry one, `npm run agreement` shows `—`
531
542
  (no data ≠ 0%); each one you mark makes the rate live. Metric definition:
package/agreement.js CHANGED
@@ -30,7 +30,7 @@
30
30
  export const DECISIVE = ['proceed', 'proceed_with_note', 'decline'];
31
31
  export const DEFAULT_WINDOW_DAYS = 30;
32
32
 
33
- function agrees(signal, userAction) {
33
+ export function agrees(signal, userAction) {
34
34
  if (userAction === 'approved') return signal === 'proceed' || signal === 'proceed_with_note';
35
35
  if (userAction === 'rejected') return signal === 'decline';
36
36
  return false; // 'modified' — the action was not accepted as-judged
package/asks.js ADDED
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ // "Are you being asked less?" — the number Phaedo's purpose is judged by.
3
+ //
4
+ // Phaedo's promise: agents stop interrupting you for things you'd have said yes to, and still
5
+ // bring you the irreversible, high-stakes ones. weekly.js counts one week; this reads the local
6
+ // decision records (receipts.js) week by week so the trend shows, together with the two checks
7
+ // that keep a falling number honest:
8
+ //
9
+ // checked — every action an agent brought to Phaedo
10
+ // asked — Phaedo sent it to you (escalate / clarify)
11
+ // quiet — Phaedo decided without asking (proceed / proceed_with_note / decline)
12
+ // overruled — a quiet call you then reversed (agreement.js scoring). The cost of asking less.
13
+ // confirmed — a quiet call you then agreed with
14
+ // avoidable — an ask about something that could be undone, which you approved as-is: the asks
15
+ // Phaedo should learn to stop making. An ask about an irreversible action is never
16
+ // avoidable — those are the subject's to decide.
17
+ // unsure — insufficient_signal: neither an ask nor a call
18
+ //
19
+ // The trend compares the ask rate of the recent half of the window with the half before it, and
20
+ // says nothing until each half holds MIN_TO_COMPARE checks — a rate over a handful is noise.
21
+ // Pure over the receipts list; the CLI reads the local store.
22
+ //
23
+ // npm run asks last 8 weeks, in plain lines
24
+ // npm run asks -- --weeks 12 --table week by week
25
+
26
+ import { readReceipts } from './receipts.js';
27
+ import { defaultStateDir } from './cache.js';
28
+ import { DECISIVE, agrees } from './agreement.js';
29
+ import { isMainModule } from './lib/is-main.js';
30
+
31
+ export const DEFAULT_WEEKS = 8;
32
+ export const MIN_TO_COMPARE = 5;
33
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
34
+ const ASKED = new Set(['escalate', 'clarify']);
35
+
36
+ const empty = () => ({ checked: 0, asked: 0, quiet: 0, overruled: 0, confirmed: 0, avoidable: 0, unsure: 0 });
37
+
38
+ function tally(row, r) {
39
+ const signal = r?.response?.signal;
40
+ const action = r?.user_action;
41
+ if (ASKED.has(signal)) {
42
+ row.checked++; row.asked++;
43
+ if (action === 'approved' && r?.request?.action_descriptor?.reversible === true) row.avoidable++;
44
+ } else if (DECISIVE.includes(signal)) {
45
+ row.checked++; row.quiet++;
46
+ if (action && action !== 'unknown') { if (agrees(signal, action)) row.confirmed++; else row.overruled++; }
47
+ } else if (signal === 'insufficient_signal') {
48
+ row.checked++; row.unsure++;
49
+ }
50
+ }
51
+
52
+ function sum(rows) {
53
+ const out = empty();
54
+ for (const row of rows) for (const k of Object.keys(out)) out[k] += row[k];
55
+ out.ask_rate = out.checked ? out.asked / out.checked : null;
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * (receipts, { now, weeks }) -> { weeks: [row…] oldest first, recent, earlier, half_weeks, total }.
61
+ * `recent` is the newest half of the window, `earlier` the equal span before it (an odd oldest week
62
+ * is left out of the comparison, never folded into one side).
63
+ */
64
+ export function asksByWeek(receipts, { now = Date.now(), weeks = DEFAULT_WEEKS } = {}) {
65
+ const n = Math.max(2, Math.floor(Number(weeks)) || DEFAULT_WEEKS);
66
+ const start = now - n * WEEK_MS;
67
+ const rows = Array.from({ length: n }, (_, i) => ({ start: new Date(start + i * WEEK_MS).toISOString(), ...empty() }));
68
+ for (const r of Array.isArray(receipts) ? receipts : []) {
69
+ const t = Date.parse(r?.created_at);
70
+ if (!Number.isFinite(t) || t < start || t > now) continue;
71
+ tally(rows[Math.min(n - 1, Math.floor((t - start) / WEEK_MS))], r);
72
+ }
73
+ for (const row of rows) row.ask_rate = row.checked ? row.asked / row.checked : null;
74
+ const half = Math.floor(n / 2);
75
+ return {
76
+ weeks: rows,
77
+ half_weeks: half,
78
+ recent: sum(rows.slice(n - half)),
79
+ earlier: sum(rows.slice(n - 2 * half, n - half)),
80
+ total: sum(rows),
81
+ };
82
+ }
83
+
84
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
85
+ const pct = (x) => `${Math.round(x * 100)}%`;
86
+
87
+ /** The headline: how often Phaedo asked lately, and whether that is falling. */
88
+ export function headline(rep) {
89
+ const { recent: r, earlier: e, half_weeks: h } = rep;
90
+ const span = plural(h, 'week');
91
+ if (!r.checked) return `No actions came to Phaedo in the last ${span}.`;
92
+ const first = `In the last ${span} Phaedo checked ${plural(r.checked, 'action')} and asked you about ${r.asked} (${pct(r.ask_rate)}).`;
93
+ if (r.checked < MIN_TO_COMPARE || e.checked < MIN_TO_COMPARE) return `${first} Not enough yet to say whether that is falling.`;
94
+ const diff = Math.round(r.ask_rate * 100) - Math.round(e.ask_rate * 100);
95
+ if (diff === 0) return `${first} That's the same as the ${span} before.`;
96
+ return `${first} That's ${diff < 0 ? 'down' : 'up'} from ${pct(e.ask_rate)} the ${span} before.`;
97
+ }
98
+
99
+ /** Plain lines for a person: the headline, what asking less cost, and the asks it could drop. */
100
+ export function formatAsks(rep) {
101
+ const r = rep.recent;
102
+ const lines = [headline(rep)];
103
+ if (r.quiet) {
104
+ const answered = r.confirmed + r.overruled;
105
+ // An answer on a quiet call means the subject was in the loop anyway — from the ship and
106
+ // agent hooks, only ever because the app showed its own approval prompt. Said plainly: it
107
+ // is the next interruption to remove, not a win to count.
108
+ lines.push(answered
109
+ ? `It decided ${plural(r.quiet, 'time')} without asking. You still had to answer ${answered} of those yourself, and reversed ${r.overruled}.`
110
+ : `It decided ${plural(r.quiet, 'time')} without asking; none of those has an answer from you yet.`);
111
+ }
112
+ if (r.avoidable) {
113
+ lines.push(`${r.avoidable === 1 ? 'One ask was' : `${r.avoidable} asks were`} about something that could be undone and you said yes as-is — ${r.avoidable === 1 ? 'that is' : 'those are'} what it should learn to stop asking.`);
114
+ }
115
+ return lines.join('\n');
116
+ }
117
+
118
+ /** For /phaedo:status: the headline only once there is a trend to show; '' until then. */
119
+ export function trendLine(rep) {
120
+ return rep.recent.checked >= MIN_TO_COMPARE && rep.earlier.checked >= MIN_TO_COMPARE ? headline(rep) : '';
121
+ }
122
+
123
+ /** Week by week, for the curious: `--table`. */
124
+ export function formatTable(rep) {
125
+ return rep.weeks.map((w) => {
126
+ const day = new Date(w.start).toLocaleDateString('en-US', { month: 'short', day: '2-digit', timeZone: 'UTC' });
127
+ if (!w.checked) return `${day} —`;
128
+ return `${day} ${String(w.checked).padStart(3)} checked ${String(w.asked).padStart(3)} asked (${pct(w.ask_rate).padStart(4)}) ${w.overruled} reversed ${w.avoidable} could have gone ahead`;
129
+ }).join('\n');
130
+ }
131
+
132
+ if (isMainModule(import.meta.url)) {
133
+ const args = process.argv.slice(2);
134
+ const i = args.indexOf('--weeks');
135
+ const weeks = i >= 0 ? Number(args[i + 1]) : DEFAULT_WEEKS;
136
+ const receipts = await readReceipts(defaultStateDir()).catch(() => []);
137
+ const rep = asksByWeek(receipts, { weeks });
138
+ console.log(formatAsks(rep));
139
+ if (args.includes('--table')) console.log(`\n${formatTable(rep)}`);
140
+ }
@@ -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), so the agent is told the receipt id up front and records `rejected`.
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.hook_event_name;
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, markOutcome } = await import('../receipts.js');
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 markOutcome(stateDir, id, action); },
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phaedo-mcp",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
4
4
  "private": false,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -51,6 +51,7 @@
51
51
  "receipts-sync.js",
52
52
  "agreement.js",
53
53
  "weekly.js",
54
+ "asks.js",
54
55
  "precedent.js",
55
56
  "rules-files.js",
56
57
  "learned.js",
@@ -99,7 +100,7 @@
99
100
  "unsetup": "node install.mjs --uninstall",
100
101
  "pair": "node pair.js",
101
102
  "pull": "node pull.js",
102
- "test": "node test-smoke.mjs && node test-no-profile.mjs && node test-phone-boundary.mjs && node test-cache.mjs && node test-pairing.mjs && node test-revoke.mjs && node test-install.mjs && node test-consult.mjs && node test-consult-policy.mjs && node test-amount-bands.mjs && node test-resolver-revision.mjs && node test-ship-actions.mjs && node test-ship-hook.mjs && node test-ship-readout.mjs && node test-action-binding.mjs && node test-autonomy-promotion.mjs && node test-ap2.mjs && node test-delegation-sync.mjs && node test-delegation-relay.mjs && node test-capture-sync.mjs && node test-capture-hook.mjs && node test-stop-capture.mjs && node test-capture-local.mjs && node test-capture-a11y.mjs && node test-vendored-capture.mjs && node test-escalation.mjs && node test-domain.mjs && node test-receipts.mjs && node test-receipts-sync.mjs && node test-agreement.mjs && node test-weekly.mjs && node test-precedent.mjs && node test-rules-files.mjs && node test-agent-hook.mjs && node test-learned.mjs && node test-history-import.mjs && node test-calibration.mjs && node test-identity.mjs && node test-signing.mjs && node test-sign-request.mjs && node test-signing-audit.mjs && node test-delegation-domain-fragmentation.mjs && node test-stale-authority.mjs && node test-config-home-fold.mjs && node test-precedence-and-ceiling.mjs && node test-package-closure.mjs && node test-plugin.mjs",
103
+ "test": "node test-smoke.mjs && node test-no-profile.mjs && node test-phone-boundary.mjs && node test-cache.mjs && node test-pairing.mjs && node test-revoke.mjs && node test-install.mjs && node test-consult.mjs && node test-consult-policy.mjs && node test-amount-bands.mjs && node test-resolver-revision.mjs && node test-ship-actions.mjs && node test-ship-hook.mjs && node test-ship-readout.mjs && node test-action-binding.mjs && node test-autonomy-promotion.mjs && node test-ap2.mjs && node test-delegation-sync.mjs && node test-delegation-relay.mjs && node test-capture-sync.mjs && node test-capture-hook.mjs && node test-stop-capture.mjs && node test-capture-local.mjs && node test-capture-a11y.mjs && node test-vendored-capture.mjs && node test-escalation.mjs && node test-domain.mjs && node test-receipts.mjs && node test-receipts-sync.mjs && node test-agreement.mjs && node test-weekly.mjs && node test-asks.mjs && node test-precedent.mjs && node test-rules-files.mjs && node test-agent-hook.mjs && node test-learned.mjs && node test-history-import.mjs && node test-calibration.mjs && node test-identity.mjs && node test-signing.mjs && node test-sign-request.mjs && node test-signing-audit.mjs && node test-delegation-domain-fragmentation.mjs && node test-stale-authority.mjs && node test-config-home-fold.mjs && node test-precedence-and-ceiling.mjs && node test-package-closure.mjs && node test-plugin.mjs",
103
104
  "receipts": "node receipts.js list",
104
105
  "agreement": "node agreement.js",
105
106
  "ship-readout": "node ship-readout.js",
@@ -123,6 +124,7 @@
123
124
  "smoke:autonomy": "node test-autonomy-promotion.mjs",
124
125
  "smoke:ap2": "node test-ap2.mjs",
125
126
  "week": "node weekly.js",
127
+ "asks": "node asks.js",
126
128
  "rules": "node rules-files.js",
127
129
  "learned": "node learned.js",
128
130
  "import": "node history-import.js"
package/pull.js CHANGED
@@ -11,12 +11,18 @@ import { loadFingerprint, sourceOf, summarizeStarter } from './fingerprint-sourc
11
11
  import { readReceipts } from './receipts.js';
12
12
  import { defaultStateDir } from './cache.js';
13
13
  import { weeklyHandled, formatWeekly } from './weekly.js';
14
+ import { asksByWeek, trendLine } from './asks.js';
14
15
  import { learnedItems, readLearnedState, reviewLine } from './learned.js';
15
16
 
16
17
  const BASE_DIR = dirname(fileURLToPath(import.meta.url));
17
18
 
18
- // Every outcome ends with what Phaedo did for the subject this week — the reason to have it.
19
- const week = async () => ` ${formatWeekly(weeklyHandled(await readReceipts(defaultStateDir()).catch(() => [])))}\n`;
19
+ // Every outcome ends with what Phaedo did for the subject this week — the reason to have it —
20
+ // and, once there is enough to compare, whether it is asking them less than before.
21
+ const week = async () => {
22
+ const receipts = await readReceipts(defaultStateDir()).catch(() => []);
23
+ const trend = trendLine(asksByWeek(receipts));
24
+ return ` ${formatWeekly(weeklyHandled(receipts))}\n${trend ? ` ${trend}\n` : ''}`;
25
+ };
20
26
  // …and, when there is any, what Phaedo learned that the subject hasn't looked at yet.
21
27
  const review = (vd) => { const line = reviewLine(learnedItems(vd), readLearnedState(defaultStateDir())); return line ? ` ${line}\n` : ''; };
22
28
 
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.13",
5
+ "version": "0.3.15",
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.13",
16
+ "version": "0.3.15",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  }
@@ -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',