phaedo-mcp 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,409 @@
1
+ #!/usr/bin/env node
2
+ // Phaedo — Claude Code ship hook (PreToolUse, PermissionRequest, PostToolUse,
3
+ // PostToolUseFailure on Bash).
4
+ //
5
+ // WHY THIS EXISTS. The precedent track is starved: no consultation has been written since
6
+ // 2026-07-28, so agreement, calibration and override learning have nothing to read. The
7
+ // decisions this subject actually delegates, every week, are the ones a coding agent takes
8
+ // for them — merging a PR, deploying the relay, cutting a phone build. This hook consults
9
+ // Phaedo before those run (ship-actions.js names them) and writes a §10.6 receipt, so the
10
+ // receipts accumulate in normal use instead of waiting for an agent to remember a tool.
11
+ //
12
+ // WHAT IT MAY DO — ONE RULE. The hook only ever ADDS friction; it never removes it. It
13
+ // never returns `allow`, so it cannot pre-approve anything Claude Code would otherwise
14
+ // have asked about. On a permissive answer it returns no decision at all (Claude Code's
15
+ // own permission flow applies unchanged); on a cautious one it returns `ask`, which puts
16
+ // the action in front of the subject. That is why it reads the at-rest profile cache and
17
+ // skips the server's stale-authority guard: the guard exists to stop a stale read from
18
+ // EXPANDING autonomy, and nothing here can.
19
+ //
20
+ // WHERE OUTCOMES COME FROM — SUBJECT-AUTHORED ONLY (§10.6 honesty rail). The one thing the
21
+ // hook records itself is a yes the subject gave on a prompt, and it records that only on
22
+ // EVIDENCE that the prompt was shown: Claude Code fires PermissionRequest when it is about
23
+ // to show one. So, per call:
24
+ // - prompt shown, then the command ran → the subject said yes → `approved`. Whatever the
25
+ // consult answered, and in any permission mode — a yes on a `proceed` is the scoreable
26
+ // case agreement needs.
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`.
29
+ // - no prompt (bypass mode, an allow rule) → nothing recorded. After the run the agent is
30
+ // told so, and records the subject's reaction only if they give one. Silence is never
31
+ // approval.
32
+ // This replaces a guess: whether a hook's `ask` still prompts under bypassPermissions is
33
+ // not documented, and no longer needs to be.
34
+ //
35
+ // FAIL-SAFE: any error, timeout or unrecognised input exits 0 with no output, so a broken
36
+ // Phaedo never blocks or slows a merge beyond the budget. Wire it with
37
+ // `npm run setup:code-hook` (install.mjs adds all four events).
38
+
39
+ import { fileURLToPath } from 'url';
40
+ import { dirname, resolve, join } from 'path';
41
+ import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'fs';
42
+ import { createHash } from 'crypto';
43
+ import { execFile } from 'child_process';
44
+ import { classifyShipCommand } from '../ship-actions.js';
45
+ import { isMainModule } from '../lib/is-main.js';
46
+
47
+ const MCP_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
48
+
49
+ // Never hold a command up for long. A consult with a model judge configured can take its
50
+ // own timeout (PHAEDO_CONSULT_TIMEOUT_MS, default 15s); past this the hook says nothing.
51
+ export const BUDGET_MS = 12000;
52
+
53
+ // Signals that hand the action to the subject. `insufficient_signal` is not one: an
54
+ // abstain is Phaedo saying it does not know, which is no reason to interrupt anyone.
55
+ export const ASKING_SIGNALS = new Set(['escalate', 'clarify', 'decline']);
56
+
57
+ // Permission modes in which a PermissionRequest is not evidence that a PERSON answered:
58
+ // auto mode's classifier and dontAsk decide without one.
59
+ const NO_HUMAN_MODES = new Set(['auto', 'dontAsk']);
60
+
61
+ // The profile is read from the at-rest cache, which only a live read refreshes. Past this
62
+ // age the agent is told once a day, so the subject hears about it in plain words.
63
+ export const STALE_PROFILE_DAYS = 14;
64
+ const DAY_MS = 24 * 60 * 60 * 1000;
65
+ const PENDING_TTL_MS = DAY_MS;
66
+
67
+ // The caller label on the receipt. `via` stays `phaedo_consult` — the hook runs a full
68
+ // consultation through the same path, and the receipt schema's `via` enum is closed — so
69
+ // the free-text, self-asserted `agent_id` is where "this came from the ship hook" lives.
70
+ export const AGENT_ID = 'claude-code/ship-hook';
71
+
72
+ export const commandHash = (cmd) => createHash('sha256').update(String(cmd ?? '')).digest('hex');
73
+
74
+ // ── CI state for a merge (precedent track Phase 1b, B6) ───────────────────────────────
75
+ // Merging a PR whose checks are failing, or have not finished, is a bigger act than merging a
76
+ // green one — and nothing the agent declares says so. CAUTION-ONLY, by measurement: on the
77
+ // dev Mac's profile a normal merge answers `proceed` with or without `evidence_provided`, and
78
+ // on an admin merge green evidence only SOFTENS the answer (clarify → proceed_with_note). So
79
+ // green is RECORDED and changes nothing; failing or unfinished raises the merge to `high`.
80
+ // The state comes from GitHub, read by the hook itself — never from the agent.
81
+ export const CI_LOOKUP_MS = 4000;
82
+
83
+ /** statusCheckRollup → 'red' | 'pending' | 'green' | 'none' (no checks configured). */
84
+ export function ciStateFromRollup(rollup) {
85
+ const list = Array.isArray(rollup) ? rollup : [];
86
+ if (!list.length) return 'none';
87
+ const FAIL = new Set(['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'STALE']);
88
+ const OK = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
89
+ let pending = false;
90
+ for (const c of list) {
91
+ // CheckRun: status + conclusion. StatusContext: state.
92
+ const v = String(c?.conclusion || c?.state || '').toUpperCase();
93
+ if (FAIL.has(v)) return 'red';
94
+ const done = c?.__typename === 'StatusContext' ? v && v !== 'PENDING' && v !== 'EXPECTED' : String(c?.status || '').toUpperCase() === 'COMPLETED';
95
+ if (!done || !OK.has(v)) pending = true;
96
+ }
97
+ return pending ? 'pending' : 'green';
98
+ }
99
+
100
+ // How each state changes the consult, and the words the subject reads on the prompt.
101
+ export const CI_RAISES = new Set(['red', 'pending']);
102
+ const CI_WORDS = { red: 'its checks are failing', pending: "its checks haven't finished" };
103
+
104
+ // ── What a merge changes (precedent track Phase 1b, B3) ───────────────────────────────
105
+ // The classifier calls every merge `medium` — a 2-line typo fix and a 2,000-line rewrite of
106
+ // the rule engine alike. Two facts from the PR raise it to `high`; nothing lowers it (a
107
+ // lowered magnitude could let a §10.5 grant capped at `low` fire — the widening direction).
108
+ //
109
+ // LARGE: more than LARGE_LINES changed lines or LARGE_FILES files, markdown excluded. Set from
110
+ // the dev repo's last 60 merged PRs (code-only p50 135 lines / 3 files, p90 848 / 14): about
111
+ // 1 PR in 12 crosses either line.
112
+ export const LARGE_LINES = 1000;
113
+ export const LARGE_FILES = 20;
114
+ // SENSITIVE: a changed path that matters more than its size. Generic rules for any repo (CI
115
+ // and deploy config, migrations, auth / crypto / secrets / security / permissions by name —
116
+ // test files excluded from the name rule), plus the repo's own list in `.phaedo-sensitive`
117
+ // (one path prefix or `*` glob per line, # comments). The list is read from the BASE branch,
118
+ // so a PR cannot shrink it for itself, and a PR that edits the list is sensitive by definition.
119
+ export const SENSITIVE_FILE = '.phaedo-sensitive';
120
+ const GENERIC_SENSITIVE = [
121
+ /^\.github\/workflows\//, /(^|\/)Dockerfile$/, /(^|\/)fly\.toml$/, /\.tf$/, /(^|\/)migrations?\//,
122
+ ];
123
+ const SENSITIVE_NAME = /(^|[\/_.-])(auth|crypto|secrets?|security|permissions?)([\/_.-]|$)/i;
124
+ const TEST_PATH = /(^|\/)(tests?|__tests__)\/|(^|\/)test[-_]|[._-]test\.|\.spec\./i;
125
+
126
+ /** Parse `.phaedo-sensitive` text → patterns. */
127
+ export function parseSensitiveList(text) {
128
+ return String(text || '').split(/\r?\n/).map((l) => l.replace(/#.*$/, '').trim()).filter(Boolean);
129
+ }
130
+ function patternMatches(pattern, path) {
131
+ if (!pattern.includes('*')) return path === pattern || path.startsWith(pattern);
132
+ const re = new RegExp('^' + pattern.split('*').map((x) => x.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*'));
133
+ return re.test(path);
134
+ }
135
+ export function isSensitivePath(path, repoPatterns = []) {
136
+ const p = String(path || '');
137
+ if (!p) return false;
138
+ if (p === SENSITIVE_FILE) return true;
139
+ if (GENERIC_SENSITIVE.some((re) => re.test(p))) return true;
140
+ if (!TEST_PATH.test(p) && SENSITIVE_NAME.test(p)) return true;
141
+ return repoPatterns.some((pat) => patternMatches(pat, p));
142
+ }
143
+
144
+ /** (PR facts, repo patterns) → { large, sensitive, lines, files } — PURE. */
145
+ export function diffStakes(facts, repoPatterns = []) {
146
+ const all = Array.isArray(facts?.files) ? facts.files : [];
147
+ const code = all.filter((f) => !/\.md$/i.test(String(f?.path || '')));
148
+ const lines = code.reduce((n, f) => n + (Number(f?.additions) || 0) + (Number(f?.deletions) || 0), 0);
149
+ // gh lists at most 100 files; past that, the count it reports is the floor of the truth.
150
+ const truncated = Number(facts?.changedFiles) > all.length;
151
+ const files = truncated ? Number(facts.changedFiles) : code.length;
152
+ const large = lines > LARGE_LINES || files > LARGE_FILES || truncated;
153
+ const sensitive = all.some((f) => isSensitivePath(f?.path, repoPatterns));
154
+ return { large, sensitive, lines, files };
155
+ }
156
+ export const stakesValue = (st) => (!st ? 'unknown' : st.large && st.sensitive ? 'large+sensitive' : st.large ? 'large' : st.sensitive ? 'sensitive' : 'normal');
157
+
158
+ function run(cmd, args, cwd, timeoutMs) {
159
+ return new Promise((res) => {
160
+ try {
161
+ execFile(cmd, args, { cwd: cwd || undefined, timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 },
162
+ (err, stdout) => res(err ? null : String(stdout)));
163
+ } catch { res(null); }
164
+ });
165
+ }
166
+
167
+ /**
168
+ * One read-only GitHub lookup for everything the hook needs about a merge, via the gh CLI the
169
+ * agent itself uses: { ci, stakes } — ci 'unknown' and stakes null on any failure.
170
+ */
171
+ export async function lookupPrFacts(ship, cwd, {
172
+ gh = process.env.PHAEDO_SHIP_GH || 'gh', git = process.env.PHAEDO_SHIP_GIT || 'git', timeoutMs = CI_LOOKUP_MS,
173
+ } = {}) {
174
+ const args = ['pr', 'view', ...(ship.pr ? [ship.pr] : []), ...(ship.repo ? ['--repo', ship.repo] : []),
175
+ '--json', 'statusCheckRollup,files,changedFiles,baseRefName'];
176
+ const out = await run(gh, args, cwd, timeoutMs);
177
+ let j = null;
178
+ try { j = out ? JSON.parse(out) : null; } catch { j = null; }
179
+ if (!j || typeof j !== 'object') return { ci: 'unknown', stakes: null };
180
+ // The repo's own list, from the base branch. Only when the merge targets THIS checkout
181
+ // (no --repo); a list we cannot read is an empty list — the generic rules still apply.
182
+ let patterns = [];
183
+ if (!ship.repo && typeof j.baseRefName === 'string' && /^[\w./-]+$/.test(j.baseRefName)) {
184
+ const text = await run(git, ['show', `origin/${j.baseRefName}:${SENSITIVE_FILE}`], cwd, 2000);
185
+ patterns = parseSensitiveList(text);
186
+ }
187
+ return { ci: ciStateFromRollup(j.statusCheckRollup), stakes: diffStakes(j, patterns) };
188
+ }
189
+
190
+ /** Back-compat: the CI state alone. */
191
+ export async function lookupCiState(ship, cwd, opts) { return (await lookupPrFacts(ship, cwd, opts)).ci; }
192
+
193
+ // The words the subject reads, joined into one line.
194
+ export function raiseWords(ci, stakes) {
195
+ const w = [];
196
+ if (CI_RAISES.has(ci)) w.push(CI_WORDS[ci]);
197
+ if (stakes?.large) w.push(`it's a large change (${stakes.lines.toLocaleString('en-US')} lines across ${stakes.files} files)`);
198
+ if (stakes?.sensitive) w.push('it touches sensitive files');
199
+ return w.join('; ');
200
+ }
201
+
202
+ // ── Hook state: tool_use_id → its consult, so later events can find it. ───────────────
203
+ // Ids, a command DIGEST and flags only — never the command. Entries pruned after a day.
204
+ export function readState(path) {
205
+ try {
206
+ const j = JSON.parse(readFileSync(path, 'utf8'));
207
+ if (j && typeof j === 'object' && j.pending && typeof j.pending === 'object') return j;
208
+ } catch { /* missing or corrupt → fresh */ }
209
+ return { pending: {} };
210
+ }
211
+ export function writeState(path, state, now) {
212
+ const pending = {};
213
+ for (const [k, v] of Object.entries(state.pending || {})) if (now - Date.parse(v?.at) < PENDING_TTL_MS) pending[k] = v;
214
+ mkdirSync(dirname(path), { recursive: true });
215
+ const tmp = `${path}.${process.pid}.tmp`;
216
+ writeFileSync(tmp, JSON.stringify({ ...state, pending }), { mode: 0o600 });
217
+ renameSync(tmp, path);
218
+ }
219
+
220
+ // What the subject reads on the prompt. Plain words, no numbers, no protocol terms.
221
+ export function promptReason(signal, summary, why) {
222
+ const what = why ? `${summary} — ${why}` : summary;
223
+ if (signal === 'decline') return `Phaedo thinks you'd say no to this one: ${what}. Go ahead anyway?`;
224
+ if (signal === 'clarify') return `Phaedo isn't sure how you'd want this handled: ${what}. Go ahead?`;
225
+ return `Phaedo thinks you'd want to OK this yourself: ${what}.`;
226
+ }
227
+
228
+ // What the agent reads before the command. It carries the receipt id — the one handle
229
+ // 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 command (${summary}) and answered "${signal}"` +
232
+ (hint ? ` — ${hint}` : '') + `. receipt_id: ${receiptId}.`;
233
+ if (asked) {
234
+ return `${head} Phaedo flagged it for the user, so they may be asked to confirm. If they decline, ` +
235
+ `call phaedo_record_outcome with this receipt_id and "rejected" (or "modified" if they want it ` +
236
+ `done differently). A yes on the prompt is recorded automatically.`;
237
+ }
238
+ return `${head} If the user reacts to this specific action when you report it — says it was right, ` +
239
+ `objects, or wants it changed — call phaedo_record_outcome with this receipt_id and "approved", ` +
240
+ `"rejected" or "modified". Record only what they actually said about it; silence is not approval, ` +
241
+ `and do not record anything because you proceeded.`;
242
+ }
243
+
244
+ // After the command, when Phaedo had flagged it and no prompt was shown.
245
+ export function unpromptedContext(receiptId) {
246
+ return `No confirmation prompt was shown for this command (receipt_id: ${receiptId}), though Phaedo ` +
247
+ `had flagged it for the user. Tell them in one line that it ran and Phaedo had flagged it, and ` +
248
+ `record their reaction with phaedo_record_outcome and this receipt_id if they give one. Silence ` +
249
+ `is not approval.`;
250
+ }
251
+
252
+ export function staleContext(days, mcpDir) {
253
+ return `Also: Phaedo's copy of the user's profile is ${days} days old (their phone has not been ` +
254
+ `reached since). Mention once, in one line, that they can refresh it by running ` +
255
+ `\`cd ${mcpDir} && npm run pull\` and approving on their phone.`;
256
+ }
257
+
258
+ /**
259
+ * One hook invocation. Pure apart from injected deps, so the whole flow is testable
260
+ * without a vault, a receipt store or Claude Code.
261
+ *
262
+ * deps: { consult(req) → { response, drivers, source? }, writeReceipt({request,response,drivers}) → id,
263
+ * markOutcome(id, action), statePath, now, mcpDir? }
264
+ * Returns the JSON object to print, or null for no output.
265
+ */
266
+ export async function runShipHook(input, deps) {
267
+ if (!input || input.tool_name !== 'Bash') return null;
268
+ const now = deps.now ?? Date.now();
269
+ const ev = input.hook_event_name;
270
+
271
+ if (ev === 'PreToolUse') {
272
+ const ship = classifyShipCommand(input.tool_input?.command);
273
+ if (!ship) return null;
274
+ // B6 + B3: one GitHub lookup per merge — check state, size, paths. Each can only raise.
275
+ let ci = null, stakes = null;
276
+ if (ship.class === 'merge_pr') {
277
+ if (deps.prFacts) ({ ci, stakes } = await deps.prFacts(ship, input.cwd));
278
+ else if (deps.ciState) ci = await deps.ciState(ship, input.cwd);
279
+ }
280
+ const why = raiseWords(ci, stakes);
281
+ const raised = why !== '';
282
+ const request = {
283
+ consultation_type: 'action_approval',
284
+ agent_id: AGENT_ID,
285
+ action_descriptor: raised ? { ...ship.action_descriptor, magnitude: 'high' } : ship.action_descriptor,
286
+ action_binding: ship.action_binding,
287
+ };
288
+ const { response, drivers, source } = await deps.consult(request);
289
+ const signal = String(response?.signal || '');
290
+ // Provenance: what the hook saw, beside what drove the answer. Local receipt only.
291
+ const extra = [];
292
+ if (ci) extra.push({ cue: 'ciState', value: ci, confidence: null, basis: ci === 'unknown' ? null : 'observed' });
293
+ if (ci && deps.prFacts) extra.push({ cue: 'diffStakes', value: stakesValue(stakes), confidence: null, basis: stakes ? 'observed' : null });
294
+ const allDrivers = extra.length ? [...(drivers || []), ...extra] : drivers;
295
+ const receiptId = await deps.writeReceipt({ request, response, drivers: allDrivers });
296
+ if (!receiptId) return null; // no receipt = nothing to learn from; stay out of the way
297
+ const asked = ASKING_SIGNALS.has(signal);
298
+
299
+ const state = readState(deps.statePath);
300
+ if (input.tool_use_id) {
301
+ state.pending[input.tool_use_id] = {
302
+ receipt_id: receiptId, cmd_hash: commandHash(input.tool_input?.command),
303
+ asked, prompt_shown: false, at: new Date(now).toISOString(),
304
+ };
305
+ }
306
+ let context = preContext({
307
+ summary: ship.summary, signal, receiptId, asked,
308
+ hint: typeof response?.rationale_hint === 'string' ? response.rationale_hint : '',
309
+ });
310
+ if (raised) context += ` GitHub shows ${why}, so Phaedo treated this merge as high-stakes.`;
311
+ const ageDays = source && source.live === false && Number.isFinite(source.ageMs) ? Math.floor(source.ageMs / DAY_MS) : 0;
312
+ if (ageDays >= STALE_PROFILE_DAYS && !(now - Date.parse(state.stale_noted_at) < DAY_MS)) {
313
+ context += ' ' + staleContext(ageDays, deps.mcpDir || MCP_DIR);
314
+ state.stale_noted_at = new Date(now).toISOString();
315
+ }
316
+ writeState(deps.statePath, state, now);
317
+ return {
318
+ hookSpecificOutput: {
319
+ hookEventName: 'PreToolUse',
320
+ ...(asked ? { permissionDecision: 'ask', permissionDecisionReason: promptReason(signal, ship.summary, why) } : {}),
321
+ additionalContext: context,
322
+ },
323
+ };
324
+ }
325
+
326
+ if (ev === 'PermissionRequest') {
327
+ // Evidence that a prompt is about to be shown for this command. PermissionRequest has
328
+ // no tool_use_id (documented), so match the newest pending consult on the same command.
329
+ if (NO_HUMAN_MODES.has(input.permission_mode)) return null;
330
+ const h = commandHash(input.tool_input?.command);
331
+ const state = readState(deps.statePath);
332
+ const hit = Object.values(state.pending)
333
+ .filter((e) => e.cmd_hash === h && !e.prompt_shown)
334
+ .sort((a, b) => Date.parse(b.at) - Date.parse(a.at))[0];
335
+ if (!hit) return null;
336
+ hit.prompt_shown = true;
337
+ writeState(deps.statePath, state, now);
338
+ return null; // never decide here — the subject answers the prompt
339
+ }
340
+
341
+ if (ev === 'PostToolUse' || ev === 'PostToolUseFailure') {
342
+ if (!input.tool_use_id) return null;
343
+ const state = readState(deps.statePath);
344
+ const entry = state.pending[input.tool_use_id];
345
+ if (!entry) return null;
346
+ delete state.pending[input.tool_use_id];
347
+ writeState(deps.statePath, state, now);
348
+ // An interrupted command is the subject changing their mind mid-run, which is not a
349
+ // clean yes — record nothing.
350
+ if (ev === 'PostToolUseFailure' && input.is_interrupt) return null;
351
+ if (entry.prompt_shown) {
352
+ // The command started after a prompt, so the subject said yes. A failed command is
353
+ // still an approved decision.
354
+ await deps.markOutcome(entry.receipt_id, 'approved');
355
+ return null;
356
+ }
357
+ if (!entry.asked) return null; // permissive + unprompted: the pre-context already said how
358
+ return { hookSpecificOutput: { hookEventName: ev, additionalContext: unpromptedContext(entry.receipt_id) } };
359
+ }
360
+ return null;
361
+ }
362
+
363
+ // ── Real deps: the same consult path as the MCP server (consult-runtime.js). ──────────
364
+ async function realDeps() {
365
+ const { loadConsultVd, buildConsultOpts } = await import('../consult-runtime.js');
366
+ const { resolveConsultation, consultDrivers, withRevision } = await import('../consult.js');
367
+ const { buildReceipt, appendReceipt, receiptsCapFromEnv, markOutcome } = await import('../receipts.js');
368
+ const { sourceOf } = await import('../fingerprint-source.js');
369
+ const { defaultStateDir } = await import('../cache.js');
370
+ const stateDir = defaultStateDir();
371
+ const quiet = () => {};
372
+ const opts = buildConsultOpts(MCP_DIR, process.env, { warn: quiet });
373
+ return {
374
+ statePath: join(stateDir, 'ship-hook.json'),
375
+ mcpDir: MCP_DIR,
376
+ prFacts: (ship, cwd) => lookupPrFacts(ship, cwd),
377
+ async consult(request) {
378
+ const vd = await loadConsultVd(MCP_DIR, { preferCache: true, warn: quiet });
379
+ const response = await resolveConsultation(vd, request, opts);
380
+ let drivers = [];
381
+ try { drivers = withRevision(consultDrivers(vd, request, opts), response); } catch { /* provenance is non-critical */ }
382
+ return { response, drivers, source: sourceOf(vd) };
383
+ },
384
+ async writeReceipt({ request, response, drivers }) {
385
+ const r = buildReceipt({ via: 'phaedo_consult', agentId: AGENT_ID, clientId: opts.clientId, request, response, drivers });
386
+ await appendReceipt(stateDir, r, { cap: receiptsCapFromEnv(process.env) });
387
+ return r.receipt_id;
388
+ },
389
+ async markOutcome(id, action) { await markOutcome(stateDir, id, action); },
390
+ };
391
+ }
392
+
393
+ async function main() {
394
+ let raw = '';
395
+ for await (const chunk of process.stdin) raw += chunk;
396
+ const input = JSON.parse(raw);
397
+ // Cheap exit before importing the vault stack: most Bash calls are not ship actions.
398
+ if (input?.tool_name !== 'Bash') return;
399
+ if (input.hook_event_name === 'PreToolUse' && !classifyShipCommand(input.tool_input?.command)) return;
400
+ const out = await runShipHook(input, await realDeps());
401
+ if (out) process.stdout.write(JSON.stringify(out));
402
+ }
403
+
404
+ if (isMainModule(import.meta.url)) {
405
+ const timeout = new Promise((res) => setTimeout(res, BUDGET_MS));
406
+ Promise.race([main().catch(() => {}), timeout])
407
+ .then(() => process.exit(0))
408
+ .catch(() => process.exit(0)); // a hook must never break the session
409
+ }
@@ -20,7 +20,8 @@
20
20
  // The raw transcript is read, counted, and DISCARDED — nothing derived from it
21
21
  // but signal names + integers is ever persisted or transmitted (the discard-raw
22
22
  // contract). Wire it up with `npm run setup:code-hook` (installs SessionStart
23
- // injection + this SessionEnd capture together), or by hand in
23
+ // injection + this SessionEnd capture + the throttled Stop capture in
24
+ // stop-capture.mjs, which re-runs this file for sessions that stay open), or by hand in
24
25
  // ~/.claude/settings.json:
25
26
  // { "hooks": { "SessionEnd": [ { "hooks": [
26
27
  // { "type": "command", "command": "<node> <abs path to this file>" }
@@ -228,9 +229,12 @@ if (isMain) {
228
229
  process.stdin.on('end', () => res(data));
229
230
  process.stdin.on('error', () => res(''));
230
231
  });
232
+ // Input comes on stdin from Claude Code (SessionEnd), or in PHAEDO_CAPTURE_INPUT when
233
+ // the Stop hook (stop-capture.mjs) launches this file detached for an open session.
231
234
  const run = async () => {
232
235
  let input = {};
233
- try { input = JSON.parse(await readStdin()); } catch { /* no/bad input → no-op */ }
236
+ const handed = process.env.PHAEDO_CAPTURE_INPUT;
237
+ try { input = JSON.parse(handed !== undefined ? handed : await readStdin()); } catch { /* no/bad input → no-op */ }
234
238
  await runCaptureHook(input);
235
239
  };
236
240
  const timeout = new Promise((res) => setTimeout(res, BUDGET_MS));
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ // Phaedo — Claude Code Stop hook: capture a session that is still OPEN.
3
+ //
4
+ // The SessionEnd hook (session-end.mjs) only runs when a session ends, and the
5
+ // Claude desktop app keeps sessions open for days — measured 2026-10-20, no Claude
6
+ // Code session had been captured since 2026-09-20 although ~39 had happened. Stop
7
+ // fires at the end of every assistant turn, so this hook re-runs the SAME capture on
8
+ // the live transcript, at most once per session per CAPTURE_INTERVAL_MIN.
9
+ //
10
+ // That is safe to repeat: capture/state.js applySession REPLACES a session's counts
11
+ // (never adds), so capturing a session at turn 10, turn 40 and again at SessionEnd
12
+ // leaves exactly one contribution — the latest. The relay deposit is a cumulative
13
+ // snapshot (delta-lift), so a later deposit supersedes an earlier one.
14
+ //
15
+ // Cost on the common path (inside the window): read one small JSON file, exit. No
16
+ // transcript read, no capture modules loaded. When a capture IS due, the window is
17
+ // claimed first and the capture runs in a DETACHED child (session-end.mjs, input via
18
+ // PHAEDO_CAPTURE_INPUT) — the turn never waits for the transcript parse or the relay.
19
+ //
20
+ // FAIL-SAFE: any error exits 0 with NO output. Stop hooks can block a turn from
21
+ // ending by printing a decision — this one never prints anything.
22
+ //
23
+ // Wired by `npm run setup:code-hook` beside SessionStart/SessionEnd, or by hand in
24
+ // ~/.claude/settings.json:
25
+ // { "hooks": { "Stop": [ { "hooks": [
26
+ // { "type": "command", "command": "<node> <abs path to this file>" }
27
+ // ] } ] } }
28
+
29
+ import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
30
+ import { spawn as nodeSpawn } from 'node:child_process';
31
+ import { dirname, join, resolve } from 'node:path';
32
+ import { homedir } from 'node:os';
33
+ import { fileURLToPath } from 'node:url';
34
+ import { isMainModule } from '../lib/is-main.js';
35
+
36
+ const CAPTURE_HOOK_PATH = resolve(dirname(fileURLToPath(import.meta.url)), 'session-end.mjs');
37
+ export const DEFAULT_INTERVAL_MIN = 15;
38
+ // Throttle entries older than this are dropped on write, so the file stays a few lines.
39
+ const KEEP_MS = 7 * 86400 * 1000;
40
+ // The whole hook is a file read + maybe a spawn; never hold a turn longer than this.
41
+ const BUDGET_MS = 2000;
42
+
43
+ // Same directory capture/state.js uses (defaultStatePath) — inlined so the common
44
+ // path loads nothing from the capture stack.
45
+ export function defaultThrottlePath(env = process.env) {
46
+ return join(env.PHAEDO_MCP_STATE_DIR || join(homedir(), '.phaedo-mcp'), 'capture-throttle.json');
47
+ }
48
+
49
+ export function intervalMs(env = process.env) {
50
+ const min = Number(env.PHAEDO_CAPTURE_INTERVAL_MIN);
51
+ return (Number.isFinite(min) && min >= 1 ? min : DEFAULT_INTERVAL_MIN) * 60 * 1000;
52
+ }
53
+
54
+ // Missing or corrupt → {} (a broken throttle file means "capture now", never "never").
55
+ function loadThrottle(path) {
56
+ try {
57
+ const t = JSON.parse(readFileSync(path, 'utf8'));
58
+ return t && typeof t === 'object' && !Array.isArray(t) ? t : {};
59
+ } catch { return {}; }
60
+ }
61
+
62
+ // Due unless this session was captured within the window. A stamp in the FUTURE
63
+ // (clock moved back) counts as due — a bad clock must not silence capture.
64
+ export function isDue(throttle, sessionId, nowMs, windowMs) {
65
+ const last = Number(throttle[sessionId]);
66
+ if (!Number.isFinite(last)) return true;
67
+ return !(last <= nowMs && nowMs - last < windowMs);
68
+ }
69
+
70
+ // Two sessions stopping at the same instant can each overwrite the other's stamp;
71
+ // the loser just captures again on its next Stop (harmless — capture is idempotent).
72
+ function saveThrottle(path, throttle, sessionId, nowMs) {
73
+ const next = {};
74
+ for (const [id, at] of Object.entries(throttle)) {
75
+ if (Number.isFinite(Number(at)) && nowMs - Number(at) < KEEP_MS && Number(at) <= nowMs) next[id] = Number(at);
76
+ }
77
+ next[sessionId] = nowMs;
78
+ mkdirSync(dirname(path), { recursive: true });
79
+ const tmp = `${path}.${process.pid}.tmp`;
80
+ writeFileSync(tmp, JSON.stringify(next) + '\n', 'utf8');
81
+ renameSync(tmp, path);
82
+ }
83
+
84
+ // Launch session-end.mjs detached, so it outlives this hook and the turn ends now.
85
+ function spawnCapture(input, { spawn = nodeSpawn, hookPath = CAPTURE_HOOK_PATH, env = process.env } = {}) {
86
+ const child = spawn(process.execPath, [hookPath], {
87
+ detached: true,
88
+ stdio: 'ignore',
89
+ windowsHide: true,
90
+ env: { ...env, PHAEDO_CAPTURE_INPUT: JSON.stringify(input) },
91
+ });
92
+ if (child && typeof child.on === 'function') child.on('error', () => {});
93
+ if (child && typeof child.unref === 'function') child.unref();
94
+ }
95
+
96
+ // The Stop pass, dependency-injectable for tests.
97
+ // input — the hook's stdin JSON ({ session_id, transcript_path, ... }).
98
+ // deps — { now (ms), throttlePath, windowMs, spawn, hookPath, env }.
99
+ // Returns { spawned, reason } (never throws).
100
+ export function runStopHook(input, deps = {}) {
101
+ try {
102
+ const env = deps.env || process.env;
103
+ const sessionId = input && input.session_id;
104
+ const transcriptPath = input && input.transcript_path;
105
+ if (!sessionId || !transcriptPath) return { spawned: false, reason: 'no-session' };
106
+ const nowMs = deps.now !== undefined ? deps.now : Date.now();
107
+ const path = deps.throttlePath || defaultThrottlePath(env);
108
+ const windowMs = deps.windowMs || intervalMs(env);
109
+ const throttle = loadThrottle(path);
110
+ if (!isDue(throttle, sessionId, nowMs, windowMs)) return { spawned: false, reason: 'throttled' };
111
+ // Claim the window BEFORE spawning: a second Stop a moment later must not start a
112
+ // second capture. If the claim can't be written, don't capture either — otherwise
113
+ // an unwritable state dir would mean a full capture on every turn.
114
+ saveThrottle(path, throttle, sessionId, nowMs);
115
+ spawnCapture({ session_id: sessionId, transcript_path: transcriptPath, hook_event_name: 'Stop' },
116
+ { spawn: deps.spawn, hookPath: deps.hookPath, env });
117
+ return { spawned: true, reason: 'due' };
118
+ } catch {
119
+ return { spawned: false, reason: 'error' };
120
+ }
121
+ }
122
+
123
+ // ── script entry (Claude Code invokes this file directly) ────────────────────
124
+ if (isMainModule(import.meta.url)) {
125
+ const readStdin = () => new Promise((res) => {
126
+ let data = '';
127
+ process.stdin.on('data', (c) => { data += c; });
128
+ process.stdin.on('end', () => res(data));
129
+ process.stdin.on('error', () => res(''));
130
+ });
131
+ const run = async () => {
132
+ let input = {};
133
+ try { input = JSON.parse(await readStdin()); } catch { /* no/bad input → no-op */ }
134
+ runStopHook(input);
135
+ };
136
+ const timeout = new Promise((res) => setTimeout(res, BUDGET_MS));
137
+ Promise.race([run().catch(() => {}), timeout])
138
+ .then(() => process.exit(0))
139
+ .catch(() => process.exit(0)); // a hook must never break the session
140
+ }