phaedo-mcp 0.2.2 → 0.3.0

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.
Files changed (61) hide show
  1. package/NOTICE +17 -0
  2. package/README.md +51 -9
  3. package/action-binding.js +82 -0
  4. package/agreement.js +38 -0
  5. package/ap2.js +214 -0
  6. package/autonomy-promotion.js +280 -0
  7. package/build-package.mjs +25 -10
  8. package/capture-a11y.js +116 -0
  9. package/capture-local.js +85 -0
  10. package/capture-sync.js +99 -0
  11. package/consult-core.js +50 -6
  12. package/consult-policy.js +427 -33
  13. package/consult.js +8 -1
  14. package/delegation-sync.js +89 -0
  15. package/escalation-policy.sample.json +13 -0
  16. package/escalation.js +383 -0
  17. package/fingerprint-source.js +78 -4
  18. package/health.js +54 -0
  19. package/hooks/session-end.mjs +240 -0
  20. package/install.mjs +164 -21
  21. package/lib/capture-home.js +46 -0
  22. package/lib/domain.js +101 -0
  23. package/lib/is-main.js +40 -0
  24. package/lib/phaedo-crypto.js +17 -1
  25. package/package.json +25 -5
  26. package/phone-source.js +79 -11
  27. package/projection.js +4 -1
  28. package/pull.js +19 -3
  29. package/receipts-sync.js +197 -0
  30. package/receipts.js +33 -2
  31. package/server.js +305 -15
  32. package/server.json +3 -3
  33. package/signing-audit.js +111 -0
  34. package/signing-request.js +217 -0
  35. package/signing.js +176 -0
  36. package/vendor/authored-source.js +250 -0
  37. package/vendor/capabilities.js +203 -0
  38. package/vendor/capture/capture-core.js +67 -0
  39. package/vendor/capture/detectors.js +287 -0
  40. package/vendor/capture/exchanges.js +77 -0
  41. package/vendor/capture/model-detect.js +195 -0
  42. package/vendor/capture/owner.js +35 -0
  43. package/vendor/capture/package.json +4 -0
  44. package/vendor/capture/source-id.js +56 -0
  45. package/vendor/capture/state.js +148 -0
  46. package/vendor/capture/transcript.js +153 -0
  47. package/vendor/commitments.js +169 -0
  48. package/vendor/context-block.js +251 -18
  49. package/vendor/envelope.js +50 -4
  50. package/vendor/extraction/capture-combine.js +160 -0
  51. package/vendor/extraction/catalog.js +232 -0
  52. package/vendor/extraction/delta-lift.js +109 -0
  53. package/vendor/extraction/fingerprint-update.js +227 -0
  54. package/vendor/extraction/package.json +4 -0
  55. package/vendor/extraction/persist.js +40 -0
  56. package/vendor/extraction/reconcile.js +269 -0
  57. package/vendor/extraction/resolve.js +178 -0
  58. package/vendor/extraction/rule-promotion.js +137 -0
  59. package/vendor/kdf.js +20 -1
  60. package/vendor/protocol.js +7 -0
  61. package/vendored-files.mjs +82 -0
package/health.js ADDED
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ // Projection health report (P2) — `npm run health`, run during dogfooding.
3
+ //
4
+ // Loads the LIVE decrypted fingerprint (phone pairing / at-rest cache / local
5
+ // file, via the normal source resolution) and runs the §4.6/§4.7 + projection-
6
+ // hygiene checks over it, printing a MEASURED report: conformance + summary-
7
+ // hygiene errors (Proposal P0) and §4.7 unresolved dimensions (Proposal 0006).
8
+ // This is the "projection cleanliness" number for the end-of-window eval
9
+ // (docs/roadmap/dogfooding-eval-2026-06.md). Read-only — no network writes.
10
+ //
11
+ // Exit: 0 = clean · 1 = hygiene errors · 2 = could not load a fingerprint.
12
+
13
+ import { dirname } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { loadFingerprint } from './fingerprint-source.js';
16
+ import { validateFingerprint, resolveLayer } from '../spec/validate-fingerprint.mjs';
17
+
18
+ const baseDir = dirname(fileURLToPath(import.meta.url));
19
+
20
+ let vd;
21
+ try { vd = await loadFingerprint(baseDir); }
22
+ catch (e) { console.error(`Could not load a fingerprint: ${e.message}`); process.exit(2); }
23
+
24
+ const fp = vd && vd.phaedo_fingerprint;
25
+ if (!fp) { console.log('No fingerprint yet (empty vault) — nothing to check.'); process.exit(0); }
26
+
27
+ const layers = fp.layers || {};
28
+ const errs = validateFingerprint(fp); // structural + §4.6 + summary hygiene (P0)
29
+
30
+ // §4.7 (Proposal 0006): count dimensions and flag the ones a thin/conflicting
31
+ // evidence set leaves unresolved (which a consumer must treat conservatively).
32
+ let dimCount = 0;
33
+ const unresolved = [];
34
+ for (const [lid, L] of Object.entries(layers)) {
35
+ const res = resolveLayer(L.signals || []);
36
+ for (const [field, r] of Object.entries(res)) {
37
+ if (!r) continue;
38
+ dimCount++;
39
+ if (r.status === 'unresolved') unresolved.push(`${lid}.${field}`);
40
+ }
41
+ }
42
+ const sigCount = Object.values(layers).reduce((n, L) => n + ((L.signals || []).length), 0);
43
+
44
+ console.log(`\nPhaedo projection health — fingerprint ${fp.fingerprint_id || '(no id)'}`);
45
+ console.log(` layers ${Object.keys(layers).length} · signals ${sigCount} · resolved dimensions ${dimCount}`);
46
+ console.log(` conformance + projection-hygiene errors: ${errs.length}`);
47
+ for (const e of errs) console.log(` ✗ ${e}`);
48
+ console.log(` §4.7 unresolved dimensions (conflicting + thin evidence): ${unresolved.length}`);
49
+ for (const u of unresolved) console.log(` ⚠ ${u}`);
50
+
51
+ const clean = errs.length === 0;
52
+ console.log(`\n${clean ? '✓ projection clean' : `✗ ${errs.length} hygiene issue(s)`}` +
53
+ `${unresolved.length ? ` · ${unresolved.length} unresolved dimension(s)` : ''}\n`);
54
+ process.exit(clean ? 0 : 1);
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env node
2
+ // Phaedo — Claude Code SessionEnd capture hook (Track A, option (a)).
3
+ //
4
+ // Claude Code runs this when a session ends, passing hook input on stdin
5
+ // ({ session_id, transcript_path, ... }). The hook:
6
+ // 1. reads the session transcript JSONL (local file, never leaves the machine),
7
+ // 2. parses it to an exchange_batch (capture/transcript.js),
8
+ // 3. runs the deterministic Path-A detectors over the user messages
9
+ // (capture/detectors.js — the parity-tested mirror of the extension's tables),
10
+ // optionally enriched by a LOCAL model classifying the regex-miss messages
11
+ // against the same taxonomy (capture/model-detect.js, option (b) — opt-in
12
+ // via PHAEDO_CAPTURE_MODEL, e.g. `llama3.1:8b` on Ollama; fail-safe),
13
+ // 4. folds this session's counts into the durable per-surface state, REPLACING
14
+ // any prior contribution from the same session (capture/state.js — resume-safe),
15
+ // 5. lifts the CUMULATIVE counters into source='delta' §5.1 signals
16
+ // (extraction/delta-lift.js — idempotent snapshot semantics), and
17
+ // 6. deposits the signal delta to the relay's capture_to_owner lane
18
+ // (mcp/capture-sync.js — ciphertext under fp_sync_key, best-effort).
19
+ //
20
+ // The raw transcript is read, counted, and DISCARDED — nothing derived from it
21
+ // but signal names + integers is ever persisted or transmitted (the discard-raw
22
+ // contract). Wire it up with `npm run setup:code-hook` (installs SessionStart
23
+ // injection + this SessionEnd capture together), or by hand in
24
+ // ~/.claude/settings.json:
25
+ // { "hooks": { "SessionEnd": [ { "hooks": [
26
+ // { "type": "command", "command": "<node> <abs path to this file>" }
27
+ // ] } ] } }
28
+ //
29
+ // FAIL-SAFE: any error (no transcript, unpaired, relay down, corrupt state)
30
+ // exits 0 with NO output — capture must never break or slow a session's end.
31
+ // Unpaired machines still accumulate local state; deposits begin once paired.
32
+ //
33
+ // Packaging note: the capture/extraction modules load via lib/capture-home.js —
34
+ // repo root in the dev clone, mcp/vendor/ in the published npm tarball
35
+ // (build-package.mjs vendors the whole closure; test-vendored-capture.mjs proves
36
+ // the rootless layout works).
37
+
38
+ import { readFileSync, existsSync, statSync, openSync, readSync, closeSync } from 'node:fs';
39
+ import { fileURLToPath, pathToFileURL } from 'node:url';
40
+ import { dirname, resolve } from 'node:path';
41
+ import { loadCaptureModule } from '../lib/capture-home.js';
42
+ import { isMainModule } from '../lib/is-main.js';
43
+ import { loadPairingRecord } from '../phone-source.js';
44
+ import { buildCaptureDelta, depositCaptureDelta } from '../capture-sync.js';
45
+
46
+ // The capture stack loads through lib/capture-home.js so this hook runs BOTH
47
+ // from the dev clone (repo-root sources) and from the published npm tarball
48
+ // (build-package.mjs vendors them under mcp/vendor/). Top-level await: the
49
+ // install gate (install.mjs captureHookAvailable) guarantees these resolve
50
+ // wherever the hook is actually wired.
51
+ const [
52
+ { transcriptToBatch },
53
+ { accumulateBatchCounts },
54
+ { captureModelOpts, enrichCountsWithModel },
55
+ { deriveSourceDeviceId },
56
+ { defaultStatePath, loadState, saveState, applySession, cumulativeCounts },
57
+ { liftDeltaSignals },
58
+ ] = await Promise.all([
59
+ 'capture/transcript.js',
60
+ 'capture/detectors.js',
61
+ 'capture/model-detect.js',
62
+ 'capture/source-id.js',
63
+ 'capture/state.js',
64
+ 'extraction/delta-lift.js',
65
+ ].map(loadCaptureModule));
66
+
67
+ const MCP_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
68
+ const SURFACE = 'claude-code';
69
+ // Option (b): with a local capture model configured — env var OR the
70
+ // capture-model.json the installer writes — allow headroom for one
71
+ // classification call (its own 15s timeout) on top of the deterministic pass.
72
+ // Without one, the original tight budget stands.
73
+ const BUDGET_MS = captureModelOpts(process.env).model ? 30000 : 10000;
74
+ // Cap the transcript bytes we read+parse. The budget is a setTimeout race, which a
75
+ // setTimeout CANNOT preempt while the synchronous read/parse runs — so a huge transcript
76
+ // (long agentic sessions accumulate tool_result blobs) would block session-end past
77
+ // BUDGET_MS regardless. Recent behavior lives at the END of an append-only log, so on an
78
+ // oversized file we read only the last MAX_READ_BYTES and start parsing at the first
79
+ // whole line. 24 MB ≈ well under 100ms to parse — comfortably inside the budget.
80
+ const MAX_READ_BYTES = 24 * 1024 * 1024;
81
+
82
+ // Read the whole file, or just its tail when it exceeds MAX_READ_BYTES (dropping the
83
+ // leading partial line). Returns '' on any IO error (caller treats as unreadable).
84
+ function readTranscriptCapped(path) {
85
+ let size;
86
+ try { size = statSync(path).size; } catch { return readFileSync(path, 'utf8'); }
87
+ if (size <= MAX_READ_BYTES) return readFileSync(path, 'utf8');
88
+ const fd = openSync(path, 'r');
89
+ try {
90
+ const buf = Buffer.allocUnsafe(MAX_READ_BYTES);
91
+ readSync(fd, buf, 0, MAX_READ_BYTES, size - MAX_READ_BYTES);
92
+ const text = buf.toString('utf8');
93
+ const nl = text.indexOf('\n');
94
+ return nl >= 0 ? text.slice(nl + 1) : text; // drop the partial first line
95
+ } finally { closeSync(fd); }
96
+ }
97
+
98
+ // ── Capture pairing (whose key encrypts the deposit) ─────────────────────────
99
+ // The capture_to_owner envelope must be decryptable by the DRAINING owner. The
100
+ // PHONE is the tier-1 owner and its drain is BUILT (fpSync.drainCapture): it
101
+ // holds every client's pair_key and tries each until one decrypts, so the plain
102
+ // <mcp>/pairing.json — whatever pairing this machine rides — is the right
103
+ // default. The pairing.capture.json export (the extension's phaedo_local_pairing,
104
+ // copied by hand so the EXTENSION could decrypt) is RETIRED as a requirement:
105
+ // it remains a supported override for a phone app that predates the drain, or
106
+ // to pin the key explicitly. Resolution order:
107
+ // 1. PHAEDO_CAPTURE_PAIRING (explicit path)
108
+ // 2. <mcp>/pairing.capture.json — legacy/override (gitignored); delete it once
109
+ // the phone build with drainCapture is installed
110
+ // 3. <mcp>/pairing.json — the default
111
+ // Needs pair_key + relay_device_id (+ relay_device_secret for relay auth).
112
+ export class CapturePairingError extends Error {}
113
+
114
+ // Returns a valid capture pairing record, or null when NO capture pairing is
115
+ // configured (caller may fall back to mcp/pairing.json). THROWS CapturePairingError
116
+ // when a capture-pairing file is PRESENT but broken (unparseable, or missing
117
+ // pair_key/relay_device_id): silently falling back to a DIFFERENT pairing would
118
+ // encrypt every deposit under the wrong key to the wrong relay slot — the exact
119
+ // live-test failure the override exists to prevent, reinstated by a typo. The caller
120
+ // must skip the deposit instead. A record lacking relay_device_secret is returned but
121
+ // warned about (its deposits will 401 until the user re-pairs).
122
+ export function loadCapturePairing(mcpDir, env = process.env, warn = (m) => process.stderr.write(`[phaedo capture] ${m}\n`)) {
123
+ const candidates = [
124
+ { path: env.PHAEDO_CAPTURE_PAIRING && resolve(process.cwd(), env.PHAEDO_CAPTURE_PAIRING), explicit: true },
125
+ { path: resolve(mcpDir, 'pairing.capture.json'), explicit: false },
126
+ ].filter((c) => c.path);
127
+ for (const { path, explicit } of candidates) {
128
+ if (!existsSync(path)) {
129
+ if (explicit) throw new CapturePairingError(`PHAEDO_CAPTURE_PAIRING points at a missing file: ${path} — not depositing (would use the wrong key).`);
130
+ continue;
131
+ }
132
+ let rec;
133
+ try { rec = JSON.parse(readFileSync(path, 'utf8')); }
134
+ catch (e) { throw new CapturePairingError(`capture pairing ${path} is not valid JSON (${e.message}) — not depositing (would use the wrong key). Re-export the extension's pairing.`); }
135
+ if (!rec || !rec.pair_key || !rec.relay_device_id) {
136
+ throw new CapturePairingError(`capture pairing ${path} is missing pair_key/relay_device_id — not depositing (would use the wrong key). Re-export the extension's pairing.`);
137
+ }
138
+ if (!rec.relay_device_secret) warn(`capture pairing ${path} has no relay_device_secret — deposits will 401 until you re-pair the extension.`);
139
+ return rec;
140
+ }
141
+ return null;
142
+ }
143
+
144
+ // The full capture pass, dependency-injectable for tests.
145
+ // input — the hook's stdin JSON ({ session_id, transcript_path, ... }).
146
+ // deps — { readFile, statePath, pairing, fetch, relayBase, now, mcpDir }.
147
+ // Returns a result record (never throws).
148
+ export async function runCaptureHook(input, deps = {}) {
149
+ const read = deps.readFile || readTranscriptCapped;
150
+ const statePath = deps.statePath || defaultStatePath();
151
+ const nowIso = deps.now || new Date().toISOString();
152
+
153
+ const transcriptPath = input && input.transcript_path;
154
+ if (!transcriptPath) return { deposited: false, reason: 'no-transcript-path' };
155
+ let text;
156
+ try { text = read(transcriptPath); } catch { return { deposited: false, reason: 'transcript-unreadable' }; }
157
+
158
+ const batch = transcriptToBatch(text, { sessionId: input.session_id });
159
+ if (!batch.exchanges.length) return { deposited: false, reason: 'no-exchanges' };
160
+ let counts = accumulateBatchCounts(batch);
161
+
162
+ // Option (b) — model-backed detector enrichment (capture/model-detect.js):
163
+ // classify the regex-MISS messages against the same correction taxonomy and
164
+ // fold the hits into this session's counts BEFORE the per-session-replace
165
+ // state apply, so re-fires stay resume-safe and the deposit stays pure delta.
166
+ // Opt-in (PHAEDO_CAPTURE_MODEL env, or the installer-written
167
+ // capture-model.json); any failure leaves the regex counts intact.
168
+ let modelAdded = 0;
169
+ const modelOpts = deps.captureModel !== undefined ? deps.captureModel : captureModelOpts(deps.env);
170
+ if (modelOpts && modelOpts.model) {
171
+ const enriched = await enrichCountsWithModel(batch, counts, modelOpts);
172
+ counts = enriched.counts;
173
+ modelAdded = enriched.added;
174
+ }
175
+
176
+ // Pairing is optional: without one we still accumulate state locally (the
177
+ // mcp-local owner tier can read it); the deposit just no-ops as 'unpaired'.
178
+ // A capture-specific pairing (the extension's, exported) wins over the MCP's
179
+ // own pairing.json so the draining owner can actually decrypt the deposit.
180
+ let pairing = deps.pairing;
181
+ if (pairing === undefined) {
182
+ try {
183
+ pairing = loadCapturePairing(deps.mcpDir || MCP_DIR, deps.env);
184
+ } catch (e) {
185
+ // A capture-pairing file exists but is broken: do NOT fall back to a different
186
+ // key (that silently deposits to the wrong slot). Accumulate state, skip deposit.
187
+ if (!(e instanceof CapturePairingError)) throw e;
188
+ process.stderr.write(`[phaedo capture] ${e.message}\n`);
189
+ const sourceId0 = deriveSourceDeviceId(null, SURFACE);
190
+ const st = applySession(loadState(statePath), sourceId0, batch.session_local_id || input.session_id, counts, { now: nowIso });
191
+ try { saveState(statePath, st); } catch { /* non-fatal */ }
192
+ return { deposited: false, reason: 'capture-pairing-invalid', counted: counts.message_count };
193
+ }
194
+ if (!pairing) {
195
+ try { pairing = await loadPairingRecord(deps.mcpDir || MCP_DIR); } catch { pairing = null; }
196
+ }
197
+ }
198
+ const sourceId = deriveSourceDeviceId(pairing, SURFACE);
199
+
200
+ const state = applySession(loadState(statePath), sourceId, batch.session_local_id || input.session_id, counts, { now: nowIso });
201
+ try { saveState(statePath, state); } catch { /* state loss just restarts counters — never fatal */ }
202
+
203
+ const signals = liftDeltaSignals(cumulativeCounts(state, sourceId).behavioral_signals, nowIso);
204
+ if (!signals.length) return { deposited: false, reason: 'no-signal', counted: counts.message_count };
205
+
206
+ const delta = buildCaptureDelta(signals, { sourceSurface: SURFACE, sourceDeviceId: sourceId, capturedAt: nowIso });
207
+ // capture-sync's `now` dep is a FUNCTION (envelope created_at); ours is an ISO string.
208
+ const res = await depositCaptureDelta(pairing, delta, { ...deps, now: () => nowIso });
209
+ // Surface a deposit that FAILED for a fixable reason (a 401 = the pairing lost its
210
+ // relay_device_secret; re-pair). 'unpaired'/'no-relay-device-id' are expected
211
+ // local-only states, not worth a warning. Goes to stderr — Claude Code parses only
212
+ // stdout, so this never disturbs the session, but it appears in the hook's error log.
213
+ if (res && res.deposited === false && (res.status === 401 || (res.reason && /^http-/.test(String(res.reason))))) {
214
+ process.stderr.write(`[phaedo capture] deposit rejected (${res.status || res.reason}) — re-pair the extension so capture can sync.\n`);
215
+ }
216
+ return { ...res, counted: counts.message_count, signals: signals.length, ...(modelAdded ? { model_added: modelAdded } : {}) };
217
+ }
218
+
219
+ // ── script entry (Claude Code invokes this file directly) ────────────────────
220
+ // Symlink-safe (lib/is-main.js): the naive URL comparison silently returns false
221
+ // whenever the hook is reached through a link, turning this hook into a no-op that
222
+ // exits 0 and loses the session.
223
+ const isMain = isMainModule(import.meta.url);
224
+ if (isMain) {
225
+ const readStdin = () => new Promise((res) => {
226
+ let data = '';
227
+ process.stdin.on('data', (c) => { data += c; });
228
+ process.stdin.on('end', () => res(data));
229
+ process.stdin.on('error', () => res(''));
230
+ });
231
+ const run = async () => {
232
+ let input = {};
233
+ try { input = JSON.parse(await readStdin()); } catch { /* no/bad input → no-op */ }
234
+ await runCaptureHook(input);
235
+ };
236
+ const timeout = new Promise((res) => setTimeout(res, BUDGET_MS));
237
+ Promise.race([run().catch(() => {}), timeout])
238
+ .then(() => process.exit(0))
239
+ .catch(() => process.exit(0)); // a hook must never break the session
240
+ }
package/install.mjs CHANGED
@@ -22,7 +22,7 @@
22
22
  // node install.mjs --only cursor restrict to one client id
23
23
  // node install.mjs --help
24
24
 
25
- import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'fs';
25
+ import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync, rmSync } from 'fs';
26
26
  import { dirname, join, resolve } from 'path';
27
27
  import { homedir } from 'os';
28
28
  import { fileURLToPath } from 'url';
@@ -31,8 +31,10 @@ import { spawnSync } from 'child_process';
31
31
  const BASE_DIR = dirname(fileURLToPath(import.meta.url));
32
32
  const SERVER_PATH = join(BASE_DIR, 'server.js');
33
33
  const HOOK_PATH = join(BASE_DIR, 'hooks', 'session-start.mjs');
34
+ const CAPTURE_HOOK_PATH = join(BASE_DIR, 'hooks', 'session-end.mjs');
34
35
  const ENTRY_KEY = 'phaedo';
35
- const HOOK_MARKER = 'hooks/session-start.mjs'; // identifies our SessionStart entry
36
+ const HOOK_MARKER = 'hooks/session-start.mjs'; // identifies our SessionStart entry
37
+ const CAPTURE_HOOK_MARKER = 'hooks/session-end.mjs'; // identifies our SessionEnd capture entry
36
38
 
37
39
  // ── Client registry ──────────────────────────────────────────────────────────
38
40
  // Every supported client stores MCP servers as a top-level `mcpServers` object,
@@ -147,32 +149,50 @@ export function hookCommand({ nodePath = process.execPath, hookPath = HOOK_PATH
147
149
  return `"${nodePath}" "${hookPath}"`;
148
150
  }
149
151
 
150
- const isPhaedoHookEntry = (e) =>
152
+ // Match the marker path-separator-agnostically: the marker uses forward slashes
153
+ // ('hooks/session-end.mjs') but the command is built with path.join(), which on Windows
154
+ // yields backslashes ('...\\hooks\\session-end.mjs'). Without normalizing, Windows never
155
+ // recognizes its own hook entries — re-installs duplicate them and uninstall can't remove
156
+ // them. Normalize both sides to forward slashes before the substring test.
157
+ const norm = (s) => String(s).replace(/\\/g, '/');
158
+ const isPhaedoHookEntryFor = (marker) => (e) =>
151
159
  e && Array.isArray(e.hooks) &&
152
- e.hooks.some((h) => typeof h.command === 'string' && h.command.includes(HOOK_MARKER));
160
+ e.hooks.some((h) => typeof h.command === 'string' && norm(h.command).includes(norm(marker)));
153
161
 
154
- export function mergeSessionStartHook(settings, command) {
162
+ // Generic per-event merge/remove — the SessionStart (injection) and SessionEnd
163
+ // (capture) entries use the same shape, distinguished by their script-path marker.
164
+ export function mergeHookEntry(settings, event, command, marker) {
165
+ const isOurs = isPhaedoHookEntryFor(marker);
155
166
  const next = { ...settings, hooks: { ...(settings.hooks || {}) } };
156
- const prior = Array.isArray(next.hooks.SessionStart) ? next.hooks.SessionStart : [];
157
- const others = prior.filter((e) => !isPhaedoHookEntry(e)); // drop a stale phaedo entry (path refresh)
158
- const existing = prior.find(isPhaedoHookEntry);
167
+ const prior = Array.isArray(next.hooks[event]) ? next.hooks[event] : [];
168
+ const others = prior.filter((e) => !isOurs(e)); // drop a stale phaedo entry (path refresh)
169
+ const existing = prior.find(isOurs);
159
170
  const already = !!existing && existing.hooks.some((h) => h.command === command);
160
- next.hooks.SessionStart = [...others, { hooks: [{ type: 'command', command }] }];
171
+ next.hooks[event] = [...others, { hooks: [{ type: 'command', command }] }];
161
172
  return { settings: next, changed: !already };
162
173
  }
163
174
 
164
- export function removeSessionStartHook(settings) {
165
- if (!settings.hooks || !Array.isArray(settings.hooks.SessionStart)) return { settings, changed: false };
166
- const prior = settings.hooks.SessionStart;
167
- const others = prior.filter((e) => !isPhaedoHookEntry(e));
175
+ export function removeHookEntry(settings, event, marker) {
176
+ const isOurs = isPhaedoHookEntryFor(marker);
177
+ if (!settings.hooks || !Array.isArray(settings.hooks[event])) return { settings, changed: false };
178
+ const prior = settings.hooks[event];
179
+ const others = prior.filter((e) => !isOurs(e));
168
180
  if (others.length === prior.length) return { settings, changed: false };
169
181
  const hooks = { ...settings.hooks };
170
- if (others.length) hooks.SessionStart = others; else delete hooks.SessionStart;
182
+ if (others.length) hooks[event] = others; else delete hooks[event];
171
183
  const next = { ...settings, hooks };
172
184
  if (Object.keys(next.hooks).length === 0) delete next.hooks;
173
185
  return { settings: next, changed: true };
174
186
  }
175
187
 
188
+ // Back-compat wrappers (SessionStart injection hook).
189
+ export function mergeSessionStartHook(settings, command) {
190
+ return mergeHookEntry(settings, 'SessionStart', command, HOOK_MARKER);
191
+ }
192
+ export function removeSessionStartHook(settings) {
193
+ return removeHookEntry(settings, 'SessionStart', HOOK_MARKER);
194
+ }
195
+
176
196
  // action: 'install' | 'uninstall'. Returns a result record. settings.json is a
177
197
  // small, safe-to-create file (unlike the CLI-owned ~/.claude.json).
178
198
  export function applyCodeHook({ action, dryRun = false, home = homedir() } = {}) {
@@ -186,15 +206,46 @@ export function applyCodeHook({ action, dryRun = false, home = homedir() } = {})
186
206
  }
187
207
  if (action === 'uninstall') {
188
208
  if (!read.existed) return { name, status: 'absent', detail: 'no settings.json' };
189
- const { settings, changed } = removeSessionStartHook(read.config);
190
- if (!changed) return { name, status: 'absent', detail: 'no phaedo hook to remove' };
191
- if (!dryRun) { backup(path); writeConfig(path, settings); }
209
+ const start = removeSessionStartHook(read.config);
210
+ const both = removeHookEntry(start.settings, 'SessionEnd', CAPTURE_HOOK_MARKER);
211
+ if (!start.changed && !both.changed) return { name, status: 'absent', detail: 'no phaedo hook to remove' };
212
+ if (!dryRun) { backup(path); writeConfig(path, both.settings); }
192
213
  return { name, status: dryRun ? 'would-remove' : 'removed', detail: path };
193
214
  }
194
- const { settings, changed } = mergeSessionStartHook(read.config, hookCommand());
195
- if (!changed) return { name, status: 'unchanged', detail: 'already configured' };
215
+ // Always install SessionStart injection. Only install the SessionEnd CAPTURE hook
216
+ // when its dependencies actually resolve from THIS install location: the hook loads
217
+ // capture/* and extraction/delta-lift.js via lib/capture-home.js — repo root in a
218
+ // clone, mcp/vendor/ in the published tarball (build-package.mjs vendors the
219
+ // closure). captureHookAvailable() gates the wiring so a broken layout (e.g. a
220
+ // hand-copied hooks/ dir with neither home) degrades to injection-only instead of
221
+ // failing every session end with ERR_MODULE_NOT_FOUND.
222
+ const start = mergeSessionStartHook(read.config, hookCommand());
223
+ let settings = start.settings;
224
+ let captureChanged = false;
225
+ let captureNote = '';
226
+ if (captureHookAvailable()) {
227
+ const both = mergeHookEntry(settings, 'SessionEnd', hookCommand({ hookPath: CAPTURE_HOOK_PATH }), CAPTURE_HOOK_MARKER);
228
+ settings = both.settings; captureChanged = both.changed;
229
+ } else {
230
+ // Never leave a broken capture hook wired if we can't run it here.
231
+ const rm = removeHookEntry(settings, 'SessionEnd', CAPTURE_HOOK_MARKER);
232
+ settings = rm.settings; captureChanged = rm.changed;
233
+ captureNote = ' (capture hook skipped — not available in this install; injection only)';
234
+ }
235
+ const changed = start.changed || captureChanged;
236
+ if (!changed) return { name, status: 'unchanged', detail: `already configured${captureNote}` };
196
237
  if (!dryRun) { if (read.existed) backup(path); writeConfig(path, settings); }
197
- return { name, status: dryRun ? 'would-write' : 'configured', detail: path };
238
+ return { name, status: dryRun ? 'would-write' : 'configured', detail: path + captureNote };
239
+ }
240
+
241
+ // The SessionEnd capture hook can only run where its imported modules resolve —
242
+ // the repo root (dev clone) or mcp/vendor/ (published tarball), mirroring
243
+ // lib/capture-home.js's resolution exactly.
244
+ export function captureHookAvailable(hookPath = CAPTURE_HOOK_PATH) {
245
+ const hookDir = dirname(hookPath);
246
+ const homes = [join(hookDir, '..', '..'), join(hookDir, '..', 'vendor')];
247
+ return homes.some((h) => existsSync(join(h, 'capture', 'transcript.js')) &&
248
+ existsSync(join(h, 'extraction', 'delta-lift.js')));
198
249
  }
199
250
 
200
251
  function backup(path) {
@@ -240,18 +291,87 @@ export function applyToClient(client, { action, entry, dryRun = false } = {}) {
240
291
  return { ...client, status: dryRun ? 'would-write' : 'configured', detail: configPath };
241
292
  }
242
293
 
294
+ // Default on-disk locations for everything the server persists. The state dir
295
+ // (cache.key + cache.enc + receipts.enc) mirrors mcp/cache.js; the pairing/identity
296
+ // records sit next to the server.
297
+ const STATE_DIR = process.env.PHAEDO_MCP_STATE_DIR || join(homedir(), '.phaedo-mcp');
298
+
299
+ // Delete all local Phaedo state the server wrote — the "leave nothing behind" half
300
+ // of uninstall. Client-config removal only unwires the server; without this the
301
+ // derived key material (cache.key), the ciphertext at-rest cache (cache.enc), the
302
+ // §10.6 receipts audit store (receipts.enc), and the pairing/identity records all
303
+ // persist on disk after "uninstall". Parameterized (stateDir/baseDir) so it's
304
+ // node-testable against temp dirs, not the real home.
305
+ export function purgeLocalData({ stateDir = STATE_DIR, baseDir = BASE_DIR, dryRun = false } = {}) {
306
+ const targets = [
307
+ stateDir, // cache.key + cache.enc + receipts.enc
308
+ join(baseDir, 'pairing.json'),
309
+ join(baseDir, 'identity.json'),
310
+ ];
311
+ const removed = [];
312
+ for (const t of targets) {
313
+ if (!existsSync(t)) continue;
314
+ if (!dryRun) {
315
+ try { rmSync(t, { recursive: true, force: true }); }
316
+ catch (e) { removed.push(`${t} (error: ${e.message})`); continue; }
317
+ }
318
+ removed.push(t);
319
+ }
320
+ return removed;
321
+ }
322
+
323
+ // ── Capture-model config (--capture-model) ────────────────────────────────────
324
+ // The durable on-switch for the SessionEnd capture hook's option-(b) model
325
+ // detector: <state dir>/capture-model.json, read by capture/model-detect.js
326
+ // captureModelOpts (env vars still win as a per-session override). A config
327
+ // FILE — not env in the MCP client entries — because the hook is spawned by
328
+ // Claude Code / Cowork, not by the MCP server, and Dock-launched apps never
329
+ // read shell-profile exports. `off` removes the file. Exported for tests.
330
+ export async function applyCaptureModelConfig(model, { url, stateDir = STATE_DIR, dryRun = false, fetchImpl } = {}) {
331
+ const path = join(stateDir, 'capture-model.json');
332
+ if (model === 'off') {
333
+ if (!existsSync(path)) return { status: 'unchanged', detail: 'capture model already off', path };
334
+ if (!dryRun) rmSync(path);
335
+ return { status: dryRun ? 'would-remove' : 'removed', detail: `capture model OFF (${path})`, path };
336
+ }
337
+ if (!model || typeof model !== 'string') {
338
+ return { status: 'error', detail: '--capture-model needs a model name (e.g. llama3.1:8b) or "off"', path };
339
+ }
340
+ if (!dryRun) {
341
+ mkdirSync(stateDir, { recursive: true });
342
+ writeFileSync(path, JSON.stringify({ model, ...(url ? { url } : {}) }, null, 2) + '\n', 'utf8');
343
+ }
344
+ // Advisory reachability/presence probe (same endpoint shape as the consult
345
+ // judge's) — config is written regardless; the hook fails safe to regex-only.
346
+ let note = '';
347
+ try {
348
+ const probeUrl = (url || 'http://localhost:11434').replace(/\/$/, '');
349
+ const res = await (fetchImpl || globalThis.fetch)(`${probeUrl}/api/tags`, { signal: AbortSignal.timeout(1500) });
350
+ const names = res.ok ? ((await res.json()).models || []).map((m) => m.name || m.model).filter(Boolean) : [];
351
+ note = names.some((n) => n === model || n.startsWith(`${model}:`))
352
+ ? ' (model found in Ollama)'
353
+ : ` (endpoint reachable but "${model}" not pulled — run: ollama pull ${model})`;
354
+ } catch {
355
+ note = ' (endpoint not reachable — hook stays regex-only until it is)';
356
+ }
357
+ return { status: dryRun ? 'would-write' : 'configured', detail: `capture model → ${model}${url ? ` @ ${url}` : ''}${note}`, path };
358
+ }
359
+
243
360
  function parseArgs(argv) {
244
- const opts = { pair: false, uninstall: false, dryRun: false, only: null, codeHook: false, help: false };
361
+ const opts = { pair: false, uninstall: false, purge: false, dryRun: false, only: null, codeHook: false, help: false };
245
362
  for (let i = 0; i < argv.length; i++) {
246
363
  const a = argv[i];
247
364
  if (a === '--pair') opts.pair = true;
248
365
  else if (a === '--uninstall') opts.uninstall = true;
366
+ else if (a === '--purge') opts.purge = true;
249
367
  else if (a === '--dry-run') opts.dryRun = true;
250
368
  else if (a === '--only') opts.only = argv[++i];
251
369
  else if (a === '--code-hook') opts.codeHook = true;
252
370
  else if (a === '--consult-model') opts.consultModel = argv[++i];
253
371
  else if (a === '--consult-url') opts.consultUrl = argv[++i];
254
372
  else if (a === '--no-model-judge') opts.noModelJudge = true;
373
+ else if (a === '--capture-model') opts.captureModel = argv[++i];
374
+ else if (a === '--capture-url') opts.captureUrl = argv[++i];
255
375
  else if (a === '--help' || a === '-h') opts.help = true;
256
376
  else throw new Error(`unknown option: ${a}`);
257
377
  }
@@ -264,7 +384,15 @@ const HELP = `Phaedo MCP installer
264
384
  node install.mjs --pair configure, then run the one-time pairing
265
385
  node install.mjs --code-hook also add the Claude Code SessionStart hook
266
386
  (true auto-injection; ~/.claude/settings.json)
387
+ node install.mjs --capture-model <name|off> turn the capture hook's local
388
+ model detector on (e.g. llama3.1:8b) or off.
389
+ Writes <state dir>/capture-model.json, which
390
+ reaches EVERY launch path (Dock apps never
391
+ read shell-profile exports). --capture-url
392
+ overrides the endpoint (default Ollama).
267
393
  node install.mjs --uninstall remove the phaedo entry (and hook) everywhere
394
+ node install.mjs --uninstall --purge ALSO delete all local Phaedo data on disk
395
+ (cache, key, receipts, pairing/identity records)
268
396
  node install.mjs --dry-run preview changes, write nothing
269
397
  node install.mjs --only <id> one client only: claude-desktop | cursor | claude-code
270
398
 
@@ -332,6 +460,11 @@ async function main(argv) {
332
460
  if (opts.codeHook || action === 'uninstall') {
333
461
  results.push(applyCodeHook({ action, dryRun: opts.dryRun }));
334
462
  }
463
+ // Capture-model on-switch (state-dir config file; see applyCaptureModelConfig).
464
+ if (action === 'install' && opts.captureModel !== undefined) {
465
+ const r = await applyCaptureModelConfig(opts.captureModel, { url: opts.captureUrl, dryRun: opts.dryRun });
466
+ results.push({ name: 'capture-model', ...r });
467
+ }
335
468
  const touched = [];
336
469
  for (const r of results) {
337
470
  const mark = { configured: '✓', removed: '✓', 'would-write': '·', 'would-remove': '·',
@@ -340,6 +473,16 @@ async function main(argv) {
340
473
  if (r.status === 'configured' || r.status === 'would-write') touched.push(r);
341
474
  }
342
475
 
476
+ if (opts.purge) {
477
+ const removed = purgeLocalData({ dryRun: opts.dryRun });
478
+ console.log(`\n${opts.dryRun ? 'Would delete' : 'Deleted'} local Phaedo data:`);
479
+ if (removed.length) for (const p of removed) console.log(` ✓ ${p}`);
480
+ else console.log(' (nothing on disk to delete)');
481
+ } else if (action === 'uninstall') {
482
+ console.log(`\nLocal key material, cache, and receipts remain on disk (${STATE_DIR}, plus pairing.json/identity.json).`);
483
+ console.log('Run `node install.mjs --uninstall --purge` to delete those too.');
484
+ }
485
+
343
486
  if (action === 'install') {
344
487
  if (!touched.length) {
345
488
  console.log('\nNo clients were updated. Are Claude Desktop / Cursor / Claude Code installed?');
@@ -0,0 +1,46 @@
1
+ // Where the capture stack lives — repo root (dev) or mcp/vendor/ (published).
2
+ //
3
+ // The SessionEnd hook and the mcp-local tier-3 fold import capture/* and a
4
+ // slice of extraction/* . In the dev clone those live at the repo root (the
5
+ // single source of truth, shared with the extension and parity-tested); in the
6
+ // published npm tarball build-package.mjs vendors them under mcp/vendor/.
7
+ //
8
+ // Unlike lib/phaedo-crypto.js (which prefers the vendored copies), this
9
+ // resolver PREFERS THE REPO ROOT when it exists: the hook runs headless on
10
+ // every session end, and a stale vendor/ left behind by an earlier `npm test`
11
+ // build on a dev clone would silently shadow freshly-pulled, parity-tested
12
+ // sources. A published install has no repo root two levels up, so it always
13
+ // lands on vendor/ there; the vendored copies are exercised by
14
+ // test-vendored-capture.mjs (a rootless layout), so they can't rot unnoticed.
15
+
16
+ import { existsSync } from 'node:fs';
17
+ import { dirname, resolve } from 'node:path';
18
+ import { fileURLToPath, pathToFileURL } from 'node:url';
19
+
20
+ const HERE = dirname(fileURLToPath(import.meta.url)); // mcp/lib
21
+ const ROOT = resolve(HERE, '..', '..'); // repo root — dev
22
+ const VENDOR = resolve(HERE, '..', 'vendor'); // mcp/vendor — bundled (npm package)
23
+
24
+ // Absolute path for a capture-stack module, e.g. 'capture/state.js' or
25
+ // 'extraction/delta-lift.js' — or null when it exists in neither home.
26
+ export function captureModulePath(subpath) {
27
+ const dev = resolve(ROOT, subpath);
28
+ if (existsSync(dev)) return dev;
29
+ const vendored = resolve(VENDOR, subpath);
30
+ return existsSync(vendored) ? vendored : null;
31
+ }
32
+
33
+ // True when the whole capture stack is loadable from this install location
34
+ // (the install.mjs gate for wiring the SessionEnd hook).
35
+ export function captureStackAvailable() {
36
+ return !!(captureModulePath('capture/transcript.js') && captureModulePath('extraction/delta-lift.js'));
37
+ }
38
+
39
+ // Dynamic-import a capture-stack module from whichever home resolves. Throws
40
+ // ERR_MODULE_NOT_FOUND-equivalent when neither does (callers that must degrade
41
+ // gracefully — capture-local.js — catch it).
42
+ export function loadCaptureModule(subpath) {
43
+ const path = captureModulePath(subpath);
44
+ if (!path) throw new Error(`capture module not found in repo root or vendor/: ${subpath}`);
45
+ return import(pathToFileURL(path).href);
46
+ }