phaedo-mcp 0.3.0 → 0.3.1
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 +138 -17
- package/agreement.js +48 -2
- package/consult-core.js +77 -3
- package/consult-runtime.js +60 -0
- package/consult.js +22 -1
- package/delegation-sync.js +6 -0
- package/fingerprint-source.js +34 -9
- package/hooks/pre-ship.mjs +409 -0
- package/hooks/session-end.mjs +6 -2
- package/hooks/stop-capture.mjs +140 -0
- package/install.mjs +71 -24
- package/package.json +6 -2
- package/phone-source.js +6 -1
- package/receipts.js +1 -1
- package/server.js +32 -53
- package/server.json +3 -3
- package/ship-actions.js +164 -0
- package/ship-readout.js +106 -0
- package/vendor/capture/detectors.js +4 -4
- package/vendor/capture/transcript.js +34 -3
- package/vendor/extraction/persist.js +7 -2
- package/vendor/extraction/reconcile.js +46 -5
- package/vendor/extraction/rule-promotion.js +6 -40
- package/vendor/signal-ledger.js +402 -0
- package/vendor/trait-phrases.js +656 -0
- package/vendored-files.mjs +4 -0
|
@@ -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
|
+
}
|
package/hooks/session-end.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|