phaedo-mcp 0.2.2 → 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.
Files changed (68) hide show
  1. package/NOTICE +17 -0
  2. package/README.md +182 -19
  3. package/action-binding.js +82 -0
  4. package/agreement.js +86 -2
  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 +126 -8
  12. package/consult-policy.js +427 -33
  13. package/consult-runtime.js +60 -0
  14. package/consult.js +29 -1
  15. package/delegation-sync.js +95 -0
  16. package/escalation-policy.sample.json +13 -0
  17. package/escalation.js +383 -0
  18. package/fingerprint-source.js +112 -13
  19. package/health.js +54 -0
  20. package/hooks/pre-ship.mjs +409 -0
  21. package/hooks/session-end.mjs +244 -0
  22. package/hooks/stop-capture.mjs +140 -0
  23. package/install.mjs +226 -36
  24. package/lib/capture-home.js +46 -0
  25. package/lib/domain.js +101 -0
  26. package/lib/is-main.js +40 -0
  27. package/lib/phaedo-crypto.js +17 -1
  28. package/package.json +29 -5
  29. package/phone-source.js +85 -12
  30. package/projection.js +4 -1
  31. package/pull.js +19 -3
  32. package/receipts-sync.js +197 -0
  33. package/receipts.js +34 -3
  34. package/server.js +323 -54
  35. package/server.json +3 -3
  36. package/ship-actions.js +164 -0
  37. package/ship-readout.js +106 -0
  38. package/signing-audit.js +111 -0
  39. package/signing-request.js +217 -0
  40. package/signing.js +176 -0
  41. package/vendor/authored-source.js +250 -0
  42. package/vendor/capabilities.js +203 -0
  43. package/vendor/capture/capture-core.js +67 -0
  44. package/vendor/capture/detectors.js +287 -0
  45. package/vendor/capture/exchanges.js +77 -0
  46. package/vendor/capture/model-detect.js +195 -0
  47. package/vendor/capture/owner.js +35 -0
  48. package/vendor/capture/package.json +4 -0
  49. package/vendor/capture/source-id.js +56 -0
  50. package/vendor/capture/state.js +148 -0
  51. package/vendor/capture/transcript.js +184 -0
  52. package/vendor/commitments.js +169 -0
  53. package/vendor/context-block.js +251 -18
  54. package/vendor/envelope.js +50 -4
  55. package/vendor/extraction/capture-combine.js +160 -0
  56. package/vendor/extraction/catalog.js +232 -0
  57. package/vendor/extraction/delta-lift.js +109 -0
  58. package/vendor/extraction/fingerprint-update.js +227 -0
  59. package/vendor/extraction/package.json +4 -0
  60. package/vendor/extraction/persist.js +45 -0
  61. package/vendor/extraction/reconcile.js +310 -0
  62. package/vendor/extraction/resolve.js +178 -0
  63. package/vendor/extraction/rule-promotion.js +103 -0
  64. package/vendor/kdf.js +20 -1
  65. package/vendor/protocol.js +7 -0
  66. package/vendor/signal-ledger.js +402 -0
  67. package/vendor/trait-phrases.js +656 -0
  68. package/vendored-files.mjs +86 -0
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,22 @@ 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
38
+ // The same capture, run on Stop (end of each turn) for sessions that stay open for days
39
+ // (the desktop app's) — throttled per session inside the hook, so most Stops cost one read.
40
+ const STOP_CAPTURE_HOOK_PATH = join(BASE_DIR, 'hooks', 'stop-capture.mjs');
41
+ const STOP_CAPTURE_HOOK_MARKER = 'hooks/stop-capture.mjs';
42
+ const SHIP_HOOK_PATH = join(BASE_DIR, 'hooks', 'pre-ship.mjs');
43
+ const SHIP_HOOK_MARKER = 'hooks/pre-ship.mjs'; // identifies our ship-consult entries
44
+ // The ship hook (consult before a merge / deploy / phone build) runs on four Bash events:
45
+ // before the command, when a prompt is shown (the evidence a person was asked), and after
46
+ // it ran or failed (to record that person's yes). Its own budget is 12s; the timeout is
47
+ // the backstop.
48
+ export const SHIP_HOOK_EVENTS = ['PreToolUse', 'PermissionRequest', 'PostToolUse', 'PostToolUseFailure'];
49
+ const SHIP_HOOK_ENTRY = { matcher: 'Bash', timeout: 15 };
36
50
 
37
51
  // ── Client registry ──────────────────────────────────────────────────────────
38
52
  // Every supported client stores MCP servers as a top-level `mcpServers` object,
@@ -72,7 +86,13 @@ export function serverEntry({ nodePath = process.execPath, serverPath = SERVER_P
72
86
  return entry;
73
87
  }
74
88
 
75
- // ── Consult model judge config (on by default) ────────────────────────────────
89
+ // ── Consult model judge config (OFF by default — opt in) ──────────────────────
90
+ // Was on by default. Measured 2026-10-18 on the dev Mac against the same cached profile:
91
+ // llama3.1:8b answered a $12k IRREVERSIBLE payment `clarify 0.9` on one run and
92
+ // `proceed_with_note 0.9` on the next — flipping between runs, toward proceeding, with
93
+ // high confidence both times — where the rule engine says `clarify` every time. A judge
94
+ // that can talk itself into a payment does not get to be the default. Opt in with
95
+ // --model-judge (auto-detect) or --consult-model <id> (pin).
76
96
  // The MCP server runs the consult model judge when PHAEDO_CONSULT_MODEL is set;
77
97
  // it falls back to the local rule engine if the model/Ollama isn't there, so
78
98
  // defaulting it ON is safe. We write a LOCAL endpoint only (privacy-clean); a
@@ -86,15 +106,21 @@ export function buildConsultEnv({ model = DEFAULT_CONSULT_MODEL, url = DEFAULT_C
86
106
  }
87
107
 
88
108
  // Probe local Ollama so "on by default" works with whatever model the user has.
89
- // Picks the first installed general-purpose model (skips the phaedo extraction
90
- // fine-tune). Falls back to the default string + reachable:false on any failure.
109
+ // Prefers the default when it is installed, else the first installed CHAT model —
110
+ // skipping the phaedo extraction fine-tunes and embedding models. An embedding model
111
+ // cannot answer a chat prompt: `nomic-embed-text` was picked on the dev Mac (it sorts
112
+ // before llama3.1:8b), every judge call failed in ~15 ms and fell back to the rules,
113
+ // and the installer kept printing "model judge ON" for a judge that had never run.
114
+ // Falls back to the default string + reachable:false on any failure.
115
+ const NOT_A_JUDGE = /phaedo|extract|embed|nomic|bge|minilm|mxbai|arctic|e5-|gte-|rerank/i;
91
116
  export async function detectConsultModel({ url = DEFAULT_CONSULT_URL, fallback = DEFAULT_CONSULT_MODEL, fetchImpl = globalThis.fetch } = {}) {
92
117
  try {
93
118
  const res = await fetchImpl(`${url.replace(/\/$/, '')}/api/tags`, { signal: AbortSignal.timeout(1500) });
94
119
  if (!res.ok) return { model: fallback, reachable: false, hadModels: false };
95
120
  const data = await res.json();
96
121
  const names = (data.models || []).map((m) => m.name || m.model).filter(Boolean);
97
- const general = names.find((n) => !/phaedo|extract/i.test(n));
122
+ const bare = (n) => String(n).replace(/:latest$/, '');
123
+ const general = names.find((n) => bare(n) === bare(fallback)) || names.find((n) => !NOT_A_JUDGE.test(n));
98
124
  return { model: general || fallback, reachable: true, hadModels: names.length > 0, picked: !!general };
99
125
  } catch {
100
126
  return { model: fallback, reachable: false, hadModels: false };
@@ -147,32 +173,54 @@ export function hookCommand({ nodePath = process.execPath, hookPath = HOOK_PATH
147
173
  return `"${nodePath}" "${hookPath}"`;
148
174
  }
149
175
 
150
- const isPhaedoHookEntry = (e) =>
176
+ // Match the marker path-separator-agnostically: the marker uses forward slashes
177
+ // ('hooks/session-end.mjs') but the command is built with path.join(), which on Windows
178
+ // yields backslashes ('...\\hooks\\session-end.mjs'). Without normalizing, Windows never
179
+ // recognizes its own hook entries — re-installs duplicate them and uninstall can't remove
180
+ // them. Normalize both sides to forward slashes before the substring test.
181
+ const norm = (s) => String(s).replace(/\\/g, '/');
182
+ const isPhaedoHookEntryFor = (marker) => (e) =>
151
183
  e && Array.isArray(e.hooks) &&
152
- e.hooks.some((h) => typeof h.command === 'string' && h.command.includes(HOOK_MARKER));
184
+ e.hooks.some((h) => typeof h.command === 'string' && norm(h.command).includes(norm(marker)));
153
185
 
154
- export function mergeSessionStartHook(settings, command) {
186
+ // Generic per-event merge/remove — the SessionStart (injection) and SessionEnd
187
+ // (capture) entries use the same shape, distinguished by their script-path marker.
188
+ // `matcher` / `timeout` (optional) are for tool events: the ship hook filters to Bash.
189
+ export function mergeHookEntry(settings, event, command, marker, { matcher, timeout } = {}) {
190
+ const isOurs = isPhaedoHookEntryFor(marker);
155
191
  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);
159
- const already = !!existing && existing.hooks.some((h) => h.command === command);
160
- next.hooks.SessionStart = [...others, { hooks: [{ type: 'command', command }] }];
192
+ const prior = Array.isArray(next.hooks[event]) ? next.hooks[event] : [];
193
+ const others = prior.filter((e) => !isOurs(e)); // drop a stale phaedo entry (path refresh)
194
+ const existing = prior.find(isOurs);
195
+ const handler = { type: 'command', command, ...(timeout ? { timeout } : {}) };
196
+ const entry = { ...(matcher ? { matcher } : {}), hooks: [handler] };
197
+ const already = !!existing && existing.matcher === entry.matcher &&
198
+ existing.hooks.some((h) => h.command === command && h.timeout === handler.timeout);
199
+ next.hooks[event] = [...others, entry];
161
200
  return { settings: next, changed: !already };
162
201
  }
163
202
 
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));
203
+ export function removeHookEntry(settings, event, marker) {
204
+ const isOurs = isPhaedoHookEntryFor(marker);
205
+ if (!settings.hooks || !Array.isArray(settings.hooks[event])) return { settings, changed: false };
206
+ const prior = settings.hooks[event];
207
+ const others = prior.filter((e) => !isOurs(e));
168
208
  if (others.length === prior.length) return { settings, changed: false };
169
209
  const hooks = { ...settings.hooks };
170
- if (others.length) hooks.SessionStart = others; else delete hooks.SessionStart;
210
+ if (others.length) hooks[event] = others; else delete hooks[event];
171
211
  const next = { ...settings, hooks };
172
212
  if (Object.keys(next.hooks).length === 0) delete next.hooks;
173
213
  return { settings: next, changed: true };
174
214
  }
175
215
 
216
+ // Back-compat wrappers (SessionStart injection hook).
217
+ export function mergeSessionStartHook(settings, command) {
218
+ return mergeHookEntry(settings, 'SessionStart', command, HOOK_MARKER);
219
+ }
220
+ export function removeSessionStartHook(settings) {
221
+ return removeHookEntry(settings, 'SessionStart', HOOK_MARKER);
222
+ }
223
+
176
224
  // action: 'install' | 'uninstall'. Returns a result record. settings.json is a
177
225
  // small, safe-to-create file (unlike the CLI-owned ~/.claude.json).
178
226
  export function applyCodeHook({ action, dryRun = false, home = homedir() } = {}) {
@@ -186,15 +234,63 @@ export function applyCodeHook({ action, dryRun = false, home = homedir() } = {})
186
234
  }
187
235
  if (action === 'uninstall') {
188
236
  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); }
237
+ const start = removeSessionStartHook(read.config);
238
+ const both = removeHookEntry(start.settings, 'SessionEnd', CAPTURE_HOOK_MARKER);
239
+ const stop = removeHookEntry(both.settings, 'Stop', STOP_CAPTURE_HOOK_MARKER);
240
+ let s = stop.settings;
241
+ let shipChanged = false;
242
+ for (const ev of SHIP_HOOK_EVENTS) {
243
+ const r = removeHookEntry(s, ev, SHIP_HOOK_MARKER);
244
+ s = r.settings; shipChanged = shipChanged || r.changed;
245
+ }
246
+ if (!start.changed && !both.changed && !stop.changed && !shipChanged) return { name, status: 'absent', detail: 'no phaedo hook to remove' };
247
+ if (!dryRun) { backup(path); writeConfig(path, s); }
192
248
  return { name, status: dryRun ? 'would-remove' : 'removed', detail: path };
193
249
  }
194
- const { settings, changed } = mergeSessionStartHook(read.config, hookCommand());
195
- if (!changed) return { name, status: 'unchanged', detail: 'already configured' };
250
+ // Always install SessionStart injection. Only install the SessionEnd + Stop CAPTURE hooks
251
+ // when its dependencies actually resolve from THIS install location: the hook loads
252
+ // capture/* and extraction/delta-lift.js via lib/capture-home.js — repo root in a
253
+ // clone, mcp/vendor/ in the published tarball (build-package.mjs vendors the
254
+ // closure). captureHookAvailable() gates the wiring so a broken layout (e.g. a
255
+ // hand-copied hooks/ dir with neither home) degrades to injection-only instead of
256
+ // failing every session end with ERR_MODULE_NOT_FOUND.
257
+ const start = mergeSessionStartHook(read.config, hookCommand());
258
+ let settings = start.settings;
259
+ let captureChanged = false;
260
+ let captureNote = '';
261
+ if (captureHookAvailable()) {
262
+ const both = mergeHookEntry(settings, 'SessionEnd', hookCommand({ hookPath: CAPTURE_HOOK_PATH }), CAPTURE_HOOK_MARKER);
263
+ const stop = mergeHookEntry(both.settings, 'Stop', hookCommand({ hookPath: STOP_CAPTURE_HOOK_PATH }), STOP_CAPTURE_HOOK_MARKER);
264
+ settings = stop.settings; captureChanged = both.changed || stop.changed;
265
+ } else {
266
+ // Never leave a broken capture hook wired if we can't run it here.
267
+ const rm = removeHookEntry(settings, 'SessionEnd', CAPTURE_HOOK_MARKER);
268
+ const rmStop = removeHookEntry(rm.settings, 'Stop', STOP_CAPTURE_HOOK_MARKER);
269
+ settings = rmStop.settings; captureChanged = rm.changed || rmStop.changed;
270
+ captureNote = ' (capture hook skipped — not available in this install; injection only)';
271
+ }
272
+ // Ship hook: consult before a merge / deploy / phone build. Its imports are all inside
273
+ // mcp/, so it runs wherever the server does — no availability gate needed.
274
+ let shipChanged = false;
275
+ for (const ev of SHIP_HOOK_EVENTS) {
276
+ const r = mergeHookEntry(settings, ev, hookCommand({ hookPath: SHIP_HOOK_PATH }), SHIP_HOOK_MARKER, SHIP_HOOK_ENTRY);
277
+ settings = r.settings; shipChanged = shipChanged || r.changed;
278
+ }
279
+ const changed = start.changed || captureChanged || shipChanged;
280
+ if (!changed) return { name, status: 'unchanged', detail: `already configured${captureNote}` };
196
281
  if (!dryRun) { if (read.existed) backup(path); writeConfig(path, settings); }
197
- return { name, status: dryRun ? 'would-write' : 'configured', detail: path };
282
+ return { name, status: dryRun ? 'would-write' : 'configured', detail: path + captureNote,
283
+ restartHint: 'Start a new Claude Code session — hooks load when a session starts.' };
284
+ }
285
+
286
+ // The SessionEnd capture hook can only run where its imported modules resolve —
287
+ // the repo root (dev clone) or mcp/vendor/ (published tarball), mirroring
288
+ // lib/capture-home.js's resolution exactly.
289
+ export function captureHookAvailable(hookPath = CAPTURE_HOOK_PATH) {
290
+ const hookDir = dirname(hookPath);
291
+ const homes = [join(hookDir, '..', '..'), join(hookDir, '..', 'vendor')];
292
+ return homes.some((h) => existsSync(join(h, 'capture', 'transcript.js')) &&
293
+ existsSync(join(h, 'extraction', 'delta-lift.js')));
198
294
  }
199
295
 
200
296
  function backup(path) {
@@ -240,18 +336,88 @@ export function applyToClient(client, { action, entry, dryRun = false } = {}) {
240
336
  return { ...client, status: dryRun ? 'would-write' : 'configured', detail: configPath };
241
337
  }
242
338
 
339
+ // Default on-disk locations for everything the server persists. The state dir
340
+ // (cache.key + cache.enc + receipts.enc) mirrors mcp/cache.js; the pairing/identity
341
+ // records sit next to the server.
342
+ const STATE_DIR = process.env.PHAEDO_MCP_STATE_DIR || join(homedir(), '.phaedo-mcp');
343
+
344
+ // Delete all local Phaedo state the server wrote — the "leave nothing behind" half
345
+ // of uninstall. Client-config removal only unwires the server; without this the
346
+ // derived key material (cache.key), the ciphertext at-rest cache (cache.enc), the
347
+ // §10.6 receipts audit store (receipts.enc), and the pairing/identity records all
348
+ // persist on disk after "uninstall". Parameterized (stateDir/baseDir) so it's
349
+ // node-testable against temp dirs, not the real home.
350
+ export function purgeLocalData({ stateDir = STATE_DIR, baseDir = BASE_DIR, dryRun = false } = {}) {
351
+ const targets = [
352
+ stateDir, // cache.key + cache.enc + receipts.enc
353
+ join(baseDir, 'pairing.json'),
354
+ join(baseDir, 'identity.json'),
355
+ ];
356
+ const removed = [];
357
+ for (const t of targets) {
358
+ if (!existsSync(t)) continue;
359
+ if (!dryRun) {
360
+ try { rmSync(t, { recursive: true, force: true }); }
361
+ catch (e) { removed.push(`${t} (error: ${e.message})`); continue; }
362
+ }
363
+ removed.push(t);
364
+ }
365
+ return removed;
366
+ }
367
+
368
+ // ── Capture-model config (--capture-model) ────────────────────────────────────
369
+ // The durable on-switch for the SessionEnd capture hook's option-(b) model
370
+ // detector: <state dir>/capture-model.json, read by capture/model-detect.js
371
+ // captureModelOpts (env vars still win as a per-session override). A config
372
+ // FILE — not env in the MCP client entries — because the hook is spawned by
373
+ // Claude Code / Cowork, not by the MCP server, and Dock-launched apps never
374
+ // read shell-profile exports. `off` removes the file. Exported for tests.
375
+ export async function applyCaptureModelConfig(model, { url, stateDir = STATE_DIR, dryRun = false, fetchImpl } = {}) {
376
+ const path = join(stateDir, 'capture-model.json');
377
+ if (model === 'off') {
378
+ if (!existsSync(path)) return { status: 'unchanged', detail: 'capture model already off', path };
379
+ if (!dryRun) rmSync(path);
380
+ return { status: dryRun ? 'would-remove' : 'removed', detail: `capture model OFF (${path})`, path };
381
+ }
382
+ if (!model || typeof model !== 'string') {
383
+ return { status: 'error', detail: '--capture-model needs a model name (e.g. llama3.1:8b) or "off"', path };
384
+ }
385
+ if (!dryRun) {
386
+ mkdirSync(stateDir, { recursive: true });
387
+ writeFileSync(path, JSON.stringify({ model, ...(url ? { url } : {}) }, null, 2) + '\n', 'utf8');
388
+ }
389
+ // Advisory reachability/presence probe (same endpoint shape as the consult
390
+ // judge's) — config is written regardless; the hook fails safe to regex-only.
391
+ let note = '';
392
+ try {
393
+ const probeUrl = (url || 'http://localhost:11434').replace(/\/$/, '');
394
+ const res = await (fetchImpl || globalThis.fetch)(`${probeUrl}/api/tags`, { signal: AbortSignal.timeout(1500) });
395
+ const names = res.ok ? ((await res.json()).models || []).map((m) => m.name || m.model).filter(Boolean) : [];
396
+ note = names.some((n) => n === model || n.startsWith(`${model}:`))
397
+ ? ' (model found in Ollama)'
398
+ : ` (endpoint reachable but "${model}" not pulled — run: ollama pull ${model})`;
399
+ } catch {
400
+ note = ' (endpoint not reachable — hook stays regex-only until it is)';
401
+ }
402
+ return { status: dryRun ? 'would-write' : 'configured', detail: `capture model → ${model}${url ? ` @ ${url}` : ''}${note}`, path };
403
+ }
404
+
243
405
  function parseArgs(argv) {
244
- const opts = { pair: false, uninstall: false, dryRun: false, only: null, codeHook: false, help: false };
406
+ const opts = { pair: false, uninstall: false, purge: false, dryRun: false, only: null, codeHook: false, help: false };
245
407
  for (let i = 0; i < argv.length; i++) {
246
408
  const a = argv[i];
247
409
  if (a === '--pair') opts.pair = true;
248
410
  else if (a === '--uninstall') opts.uninstall = true;
411
+ else if (a === '--purge') opts.purge = true;
249
412
  else if (a === '--dry-run') opts.dryRun = true;
250
413
  else if (a === '--only') opts.only = argv[++i];
251
414
  else if (a === '--code-hook') opts.codeHook = true;
252
415
  else if (a === '--consult-model') opts.consultModel = argv[++i];
253
416
  else if (a === '--consult-url') opts.consultUrl = argv[++i];
254
417
  else if (a === '--no-model-judge') opts.noModelJudge = true;
418
+ else if (a === '--model-judge') opts.modelJudge = true;
419
+ else if (a === '--capture-model') opts.captureModel = argv[++i];
420
+ else if (a === '--capture-url') opts.captureUrl = argv[++i];
255
421
  else if (a === '--help' || a === '-h') opts.help = true;
256
422
  else throw new Error(`unknown option: ${a}`);
257
423
  }
@@ -264,14 +430,23 @@ const HELP = `Phaedo MCP installer
264
430
  node install.mjs --pair configure, then run the one-time pairing
265
431
  node install.mjs --code-hook also add the Claude Code SessionStart hook
266
432
  (true auto-injection; ~/.claude/settings.json)
433
+ node install.mjs --capture-model <name|off> turn the capture hook's local
434
+ model detector on (e.g. llama3.1:8b) or off.
435
+ Writes <state dir>/capture-model.json, which
436
+ reaches EVERY launch path (Dock apps never
437
+ read shell-profile exports). --capture-url
438
+ overrides the endpoint (default Ollama).
267
439
  node install.mjs --uninstall remove the phaedo entry (and hook) everywhere
440
+ node install.mjs --uninstall --purge ALSO delete all local Phaedo data on disk
441
+ (cache, key, receipts, pairing/identity records)
268
442
  node install.mjs --dry-run preview changes, write nothing
269
443
  node install.mjs --only <id> one client only: claude-desktop | cursor | claude-code
270
444
 
271
- Consult model judge (ON by default, local Ollama, falls back to the rule engine):
272
- node install.mjs --consult-model <id> pin the model (e.g. llama3.1:8b)
445
+ Consult model judge (OFF by default — the rule engine answers; opt in below):
446
+ node install.mjs --model-judge turn it on, auto-detecting a local Ollama chat model
447
+ node install.mjs --consult-model <id> turn it on with a pinned model (e.g. llama3.1:8b)
273
448
  node install.mjs --consult-url <url> endpoint (default http://localhost:11434)
274
- node install.mjs --no-model-judge rule engine only (no model env)
449
+ node install.mjs --no-model-judge rule engine only (the default; kept for scripts)
275
450
  `;
276
451
 
277
452
  function runPairing() {
@@ -299,12 +474,12 @@ async function main(argv) {
299
474
 
300
475
  const action = opts.uninstall ? 'uninstall' : 'install';
301
476
 
302
- // Consult model judge: ON by default. Pin it if --consult-model is given,
303
- // otherwise probe local Ollama and pick an installed general model. Off with
304
- // --no-model-judge. Always falls back to the rule engine at runtime, so a
305
- // missing model never breaks consultation.
477
+ // Consult model judge: OFF unless asked for (see the measurement above buildConsultEnv).
478
+ // --consult-model pins one; --model-judge probes local Ollama for an installed chat model.
479
+ // Always falls back to the rule engine at runtime, so a missing model never breaks
480
+ // consultation. A plain install REMOVES a judge env an earlier default-on install wrote.
306
481
  let env = {}, consultNote = '';
307
- if (action === 'install' && !opts.noModelJudge) {
482
+ if (action === 'install' && !opts.noModelJudge && (opts.consultModel || opts.modelJudge)) {
308
483
  const url = opts.consultUrl || DEFAULT_CONSULT_URL;
309
484
  if (opts.consultModel) {
310
485
  env = buildConsultEnv({ model: opts.consultModel, url });
@@ -317,7 +492,7 @@ async function main(argv) {
317
492
  : `model judge ON → ${d.model} @ ${url} (Ollama not detected — falls back to the rule engine until it's running)`;
318
493
  }
319
494
  } else if (action === 'install') {
320
- consultNote = 'model judge OFF (rule engine only)';
495
+ consultNote = 'model judge OFF — the rule engine answers (opt in: --model-judge)';
321
496
  }
322
497
  const entry = serverEntry({ env });
323
498
  console.log(`Phaedo MCP — ${action}${opts.dryRun ? ' (dry run)' : ''}`);
@@ -332,6 +507,11 @@ async function main(argv) {
332
507
  if (opts.codeHook || action === 'uninstall') {
333
508
  results.push(applyCodeHook({ action, dryRun: opts.dryRun }));
334
509
  }
510
+ // Capture-model on-switch (state-dir config file; see applyCaptureModelConfig).
511
+ if (action === 'install' && opts.captureModel !== undefined) {
512
+ const r = await applyCaptureModelConfig(opts.captureModel, { url: opts.captureUrl, dryRun: opts.dryRun });
513
+ results.push({ name: 'capture-model', ...r });
514
+ }
335
515
  const touched = [];
336
516
  for (const r of results) {
337
517
  const mark = { configured: '✓', removed: '✓', 'would-write': '·', 'would-remove': '·',
@@ -340,6 +520,16 @@ async function main(argv) {
340
520
  if (r.status === 'configured' || r.status === 'would-write') touched.push(r);
341
521
  }
342
522
 
523
+ if (opts.purge) {
524
+ const removed = purgeLocalData({ dryRun: opts.dryRun });
525
+ console.log(`\n${opts.dryRun ? 'Would delete' : 'Deleted'} local Phaedo data:`);
526
+ if (removed.length) for (const p of removed) console.log(` ✓ ${p}`);
527
+ else console.log(' (nothing on disk to delete)');
528
+ } else if (action === 'uninstall') {
529
+ console.log(`\nLocal key material, cache, and receipts remain on disk (${STATE_DIR}, plus pairing.json/identity.json).`);
530
+ console.log('Run `node install.mjs --uninstall --purge` to delete those too.');
531
+ }
532
+
343
533
  if (action === 'install') {
344
534
  if (!touched.length) {
345
535
  console.log('\nNo clients were updated. Are Claude Desktop / Cursor / Claude Code installed?');
@@ -347,7 +537,7 @@ async function main(argv) {
347
537
  console.log('\nNext:');
348
538
  console.log(' 1. Pair with your phone: npm run pair (or re-run with --pair)');
349
539
  console.log(' 2. Restart any client that was already open:');
350
- for (const r of touched) console.log(` • ${r.name}: ${r.restartHint}`);
540
+ for (const r of touched) if (r.restartHint) console.log(` • ${r.name}: ${r.restartHint}`);
351
541
  console.log(' 3. Inject your fingerprint at session start:');
352
542
  console.log(' • any client: run the `/phaedo` prompt (one click), or call');
353
543
  console.log(' the `phaedo_request_injection` tool.');
@@ -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
+ }
package/lib/domain.js ADDED
@@ -0,0 +1,101 @@
1
+ // §10.2 `action_descriptor.domain` — normalization and the published vocabulary.
2
+ //
3
+ // WHY ANY OF THIS EXISTS. `domain` is a bare `{"type":"string"}` the AGENT picks, and
4
+ // everything downstream keys on it verbatim: §10.5 rule matching, the §10.6 receipt,
5
+ // the delegation tally, §10.7's per-class approval counts. Observed live on the dev
6
+ // machine: `financial` ×16, `finance` ×26, `finance.crypto_trading` ×1 — one concept,
7
+ // three keys, and 7 approvals split 3/4 so neither class reached §10.7's
8
+ // `MIN_APPROVALS = 5`. Analysis: `docs/roadmap/domain-identity-2026-07-22.md`.
9
+ //
10
+ // TWO FUNCTIONS, DELIBERATELY DISTINCT. Conflating them is how an alias table starts
11
+ // granting authority nobody authored.
12
+ //
13
+ // normalizeDomain() lowercase + trim + collapse whitespace. Pure presentation;
14
+ // `"Finance"` and `" finance"` cannot mean different things, so
15
+ // merging them can lose no distinction anyone intended.
16
+ //
17
+ // canonicalDomain() normalizeDomain, then map a published ALIAS onto its canonical
18
+ // term (`financial` → `finance`). This is a SEMANTIC claim, and
19
+ // it is only safe where a wrong merge cannot expand authority.
20
+ //
21
+ // THE SAFETY PROPERTY, stated once and enforced at every call site: a vocabulary alias
22
+ // may widen a GUARDRAIL's reach, never a GRANT's. `canonicalDomain` is therefore used
23
+ // for evidence classification and for matching RESTRICTIVE rules; relaxing rules
24
+ // (`autoproceed`, `bias_bold`) keep matching on `normalizeDomain` alone, exactly as
25
+ // before. So adopting this vocabulary cannot hand any client more autonomy than it
26
+ // already had — it can only make an existing guardrail cover more. If the table below
27
+ // is wrong, the failure is "an agent asks when it needn't", never the reverse.
28
+ //
29
+ // ALIASES ARE SYNONYMS, NEVER GENERALIZATIONS. `financial` → `finance` is a synonym.
30
+ // `crypto_trading` → `finance` would be a child→parent generalization, and it is
31
+ // forbidden: a grant authored for the general class would silently cover a narrower,
32
+ // riskier one the subject never demonstrated. Same defect that made dotted-suffix
33
+ // stripping unsafe (v0.1.55), which is why `investing` is its own term here rather
34
+ // than a `finance` alias, and why `crypto_trading` is absent entirely — an
35
+ // unrecognized domain still works, it just does not merge.
36
+ //
37
+ // PUBLISHED, not private. The vocabulary below is a copy of `spec/domains.json` so the
38
+ // npm package (whose `files` whitelist excludes `spec/`) is self-contained;
39
+ // `mcp/test-domain.mjs` asserts the two cannot drift. A private synonym table would
40
+ // put decision semantics somewhere the subject cannot read and a third-party
41
+ // implementer cannot reproduce.
42
+
43
+ /** Canonical terms → their aliases. Mirror of spec/domains.json `domains`. */
44
+ const VOCABULARY = {
45
+ finance: ['financial', 'finances', 'money', 'billing', 'invoicing', 'expenses', 'accounting'],
46
+ investing: ['investment', 'investments', 'trading', 'brokerage', 'portfolio'],
47
+ legal: ['contracts', 'compliance', 'regulatory'],
48
+ health: ['medical', 'healthcare', 'wellness', 'clinical'],
49
+ scheduling: ['calendar', 'meetings', 'appointments', 'availability', 'booking'],
50
+ communication: ['email', 'outbound email', 'messaging', 'messages', 'comms', 'correspondence'],
51
+ writing: ['drafting', 'editing', 'content', 'copywriting', 'documentation'],
52
+ research: ['analysis', 'reading', 'summarization', 'search'],
53
+ shopping: ['purchasing', 'purchase', 'procurement', 'orders', 'commerce', 'ecommerce'],
54
+ travel: ['trips', 'flights', 'lodging', 'itinerary', 'transportation'],
55
+ engineering: ['code', 'coding', 'software', 'development', 'dev', 'devops', 'infrastructure', 'deployment'],
56
+ data: ['files', 'records', 'storage', 'database', 'export'],
57
+ security: ['access', 'permissions', 'credentials', 'authentication', 'authorization'],
58
+ people: ['hr', 'human resources', 'hiring', 'recruiting', 'personnel', 'staffing'],
59
+ social: ['social media', 'posting', 'publishing', 'community'],
60
+ support: ['customer support', 'customer service', 'helpdesk', 'tickets'],
61
+ admin: ['administration', 'settings', 'accounts', 'subscriptions', 'operations', 'ops'],
62
+ };
63
+
64
+ /** alias → canonical, plus every canonical term mapping to itself. */
65
+ const ALIAS_TO_CANONICAL = (() => {
66
+ const m = new Map();
67
+ for (const [term, aliases] of Object.entries(VOCABULARY)) {
68
+ m.set(term, term);
69
+ for (const a of aliases) m.set(a, term);
70
+ }
71
+ return m;
72
+ })();
73
+
74
+ export const VOCABULARY_TERMS = Object.freeze(Object.keys(VOCABULARY).sort());
75
+ export const VOCABULARY_MAP = VOCABULARY;
76
+
77
+ /**
78
+ * Presentation-only normalization. Returns '' for anything unusable, which every
79
+ * caller already treats as "no domain".
80
+ */
81
+ export function normalizeDomain(raw) {
82
+ if (typeof raw !== 'string') return '';
83
+ return raw.trim().toLowerCase().replace(/\s+/g, ' ');
84
+ }
85
+
86
+ /**
87
+ * Normalize, then resolve a published alias to its canonical term. An unrecognized
88
+ * domain is returned normalized but otherwise untouched — free text stays legal and
89
+ * behaves exactly as it does today. Never throws.
90
+ */
91
+ export function canonicalDomain(raw) {
92
+ const n = normalizeDomain(raw);
93
+ if (!n) return '';
94
+ return ALIAS_TO_CANONICAL.get(n) || n;
95
+ }
96
+
97
+ /** True iff this domain resolves to a published vocabulary term. */
98
+ export function isVocabularyTerm(raw) {
99
+ const n = normalizeDomain(raw);
100
+ return !!n && ALIAS_TO_CANONICAL.has(n);
101
+ }
package/lib/is-main.js ADDED
@@ -0,0 +1,40 @@
1
+ // "Was this module executed directly?" — symlink-safe.
2
+ //
3
+ // The obvious ESM idiom
4
+ //
5
+ // import.meta.url === pathToFileURL(resolve(process.argv[1])).href
6
+ //
7
+ // is WRONG whenever any component of the path is a symlink. Node resolves
8
+ // `import.meta.url` through symlinks (it is the module's real URL), while
9
+ // `resolve(process.argv[1])` normalizes `.`/`..` but does NOT follow links — so
10
+ // the two disagree and the check silently returns false.
11
+ //
12
+ // That is not a hypothetical. `os.tmpdir()` on macOS is `/var/folders/…`, a
13
+ // symlink to `/private/var/folders/…`, which made the SessionEnd capture hook a
14
+ // silent no-op for every macOS spawn from a temp path (caught by
15
+ // test-vendored-capture.mjs, which spawns the hook exactly as Claude Code does).
16
+ // The same trap is live in production for any install reached through a link — a
17
+ // symlinked home directory, a linked global npm root, `/tmp` on macOS.
18
+ //
19
+ // A capture hook that quietly does nothing is the worst failure this codebase
20
+ // has: it exits 0, prints nothing, and loses the session. So compare REAL paths,
21
+ // and fall back to the plain URL comparison only if realpath throws (a deleted or
22
+ // unreadable entry point — in which case "not main" is the safe answer anyway).
23
+
24
+ import { realpathSync } from 'node:fs';
25
+ import { resolve } from 'node:path';
26
+ import { fileURLToPath, pathToFileURL } from 'node:url';
27
+
28
+ // (import.meta.url of the caller, [argv]) -> boolean
29
+ export function isMainModule(moduleUrl, argv = process.argv) {
30
+ const entry = argv && argv[1];
31
+ if (!entry || !moduleUrl) return false;
32
+ const self = fileURLToPath(moduleUrl);
33
+ const target = resolve(entry);
34
+ if (self === target) return true; // fast path, no IO
35
+ try {
36
+ return realpathSync(self) === realpathSync(target);
37
+ } catch {
38
+ return pathToFileURL(target).href === moduleUrl; // best effort
39
+ }
40
+ }
@@ -21,7 +21,7 @@ const ROOT = resolve(HERE, '..', '..'); // repo root — dev
21
21
 
22
22
  // Prefer the bundled copies (self-contained package) over the repo root (dev).
23
23
  // Load order matters: protocol → envelope → kdf → identity.
24
- for (const f of ['protocol.js', 'envelope.js', 'kdf.js', 'identity.js']) {
24
+ for (const f of ['protocol.js', 'envelope.js', 'kdf.js', 'identity.js', 'capabilities.js', 'authored-source.js']) {
25
25
  const vf = resolve(VENDOR, f);
26
26
  require(existsSync(vf) ? vf : resolve(ROOT, f));
27
27
  }
@@ -31,6 +31,22 @@ if (!Phaedo?.Envelope || !Phaedo?.Kdf || !Phaedo?.Identity || !Phaedo?.Protocol)
31
31
  throw new Error('Phaedo crypto modules did not load (Protocol/Envelope/Kdf/Identity).');
32
32
  }
33
33
 
34
+ // The §5 step-3 upgrade gate's vocabulary. The agent READS authored content, so it has to be
35
+ // able to say what it can do — otherwise the gate stays shut on its silence forever, which is
36
+ // correct but useless. Loaded here rather than imported directly so the published package picks
37
+ // up the vendored copy exactly like the crypto modules do.
38
+ export const Capabilities = globalThis.PhaedoCapabilities;
39
+ if (!Capabilities?.advertise) {
40
+ throw new Error('Phaedo capabilities module did not load.');
41
+ }
42
+
43
+ // The step-3 read path's §5.4 rule. Used at the vd boundary so every authored-content reader
44
+ // downstream is §5-canonical without threading the configuration through its signature.
45
+ export const AuthoredSource = globalThis.PhaedoAuthoredSource;
46
+ if (!AuthoredSource?.configGoverns) {
47
+ throw new Error('Phaedo authored-source module did not load.');
48
+ }
49
+
34
50
  // base64url <-> Uint8Array (Node-native), matching the extension's wire encoding.
35
51
  export const b64uEnc = (u8) => Buffer.from(u8).toString('base64url');
36
52
  export const b64uDec = (s) => new Uint8Array(Buffer.from(s, 'base64url'));