phaedo-mcp 0.3.7 → 0.3.14

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/rules-files.js ADDED
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env node
2
+ // Phaedo — write what Phaedo has learned into other agents' own instruction files.
3
+ //
4
+ // Claude Code gets the profile from the SessionStart hook. Codex, Copilot CLI, Gemini CLI and
5
+ // Antigravity have no such hook, but each reads a user-level instructions file at the start of
6
+ // every session. This writes a short version of the profile there, inside a marked block that
7
+ // Phaedo owns and replaces; everything else in the file is left exactly as it was.
8
+ //
9
+ // npm run rules # every agent found on this computer
10
+ // npm run rules -- --for codex,gemini
11
+ // npm run rules -- --project ~/code/app # Cursor: .cursor/rules/phaedo.mdc, kept out of git
12
+ // npm run rules -- --remove # take the block out again
13
+ // npm run rules -- --dry-run # show what would be written
14
+ //
15
+ // User-level files only, by default: a profile in a repository's AGENTS.md would be committed
16
+ // and shared. Cursor has no user-level rules file, so it takes an explicit project, and the
17
+ // rule file is added to that repository's .git/info/exclude (local, never committed).
18
+ //
19
+ // Opt-in only. A file that already carries the block is how the subject said yes, so the
20
+ // SessionStart hook refreshes those files (refreshExisting) and never adds new ones.
21
+
22
+ import { homedir } from 'os';
23
+ import { join, dirname, resolve } from 'path';
24
+ import { promises as fs } from 'fs';
25
+ import { execFileSync } from 'child_process';
26
+ import { fileURLToPath } from 'url';
27
+ import { isMainModule } from './lib/is-main.js';
28
+
29
+ export const BEGIN = '<!-- phaedo:begin — written by Phaedo and refreshed as it learns. Delete this whole block to stop. -->';
30
+ export const END = '<!-- phaedo:end -->';
31
+ export const MAX_CHARS = 6000; // under Antigravity's ~12k per file and Codex's 32 KiB combined
32
+ export const CURSOR_RULE = join('.cursor', 'rules', 'phaedo.mdc');
33
+
34
+ // The sections an agent can act on, in the order they are worth keeping when space runs out.
35
+ const KEEP = ['## Operating rules (user-confirmed)', '## How to respond to this user', '## How this user thinks'];
36
+
37
+ /** The user-level instruction files Phaedo knows, for this home and environment. */
38
+ export function targets({ home = homedir(), env = process.env } = {}) {
39
+ const codexHome = env.CODEX_HOME ? resolve(env.CODEX_HOME) : join(home, '.codex');
40
+ return {
41
+ codex: { label: 'Codex', file: join(codexHome, 'AGENTS.md'), dir: codexHome },
42
+ copilot: { label: 'Copilot CLI', file: join(home, '.copilot', 'copilot-instructions.md'), dir: join(home, '.copilot') },
43
+ gemini: { label: 'Gemini CLI and Antigravity', file: join(home, '.gemini', 'GEMINI.md'), dir: join(home, '.gemini') },
44
+ // Claude Code already gets the full profile from the SessionStart hook; only on request.
45
+ claude: { label: 'Claude Code', file: join(home, '.claude', 'CLAUDE.md'), dir: join(home, '.claude'), onRequest: true },
46
+ };
47
+ }
48
+
49
+ function sections(projection) {
50
+ const body = String(projection || '').replace(/^\s*<phaedo_user_profile>\s*/, '').replace(/\s*<\/phaedo_user_profile>\s*$/, '');
51
+ const parts = body.split(/\n(?=## )/);
52
+ const preamble = parts[0].startsWith('## ') ? '' : parts.shift().trim();
53
+ const byHead = new Map(parts.map((p) => [p.split('\n', 1)[0].trim(), p.trim()]));
54
+ return { preamble, byHead };
55
+ }
56
+
57
+ /**
58
+ * The short profile for an instructions file, from the same projection Claude Code gets.
59
+ * PURE. Null when the projection has nothing an agent can act on.
60
+ */
61
+ export function rulesText(projection, { now = Date.now(), maxChars = MAX_CHARS } = {}) {
62
+ const { preamble, byHead } = sections(projection);
63
+ const kept = KEEP.map((h) => byHead.get(h)).filter(Boolean);
64
+ if (!kept.length) return null;
65
+ const date = new Date(now).toISOString().slice(0, 10);
66
+ const head = [
67
+ '# How I like to work (from Phaedo)',
68
+ '',
69
+ `Learned by Phaedo and refreshed ${date}. ${preamble.replace(/\s+/g, ' ').trim()}`.trim(),
70
+ ].join('\n');
71
+ // Shed whole sections from the end first; cut inside one only if the first alone is too long.
72
+ while (kept.length > 1 && [head, ...kept].join('\n\n').length > maxChars) kept.pop();
73
+ let text = [head, ...kept].join('\n\n');
74
+ if (text.length > maxChars) text = text.slice(0, text.lastIndexOf('\n', maxChars)).trimEnd();
75
+ return text;
76
+ }
77
+
78
+ /** The managed block, markers included. */
79
+ export function block(text) {
80
+ return `${BEGIN}\n${text}\n${END}`;
81
+ }
82
+
83
+ function findBlock(content) {
84
+ const b = content.indexOf('<!-- phaedo:begin');
85
+ const e = content.indexOf(END);
86
+ if (b < 0 && e < 0) return { state: 'absent' };
87
+ if (b < 0 || e < 0 || e < b || content.indexOf('<!-- phaedo:begin', b + 1) >= 0) return { state: 'broken' };
88
+ return { state: 'present', start: b, end: e + END.length };
89
+ }
90
+
91
+ /**
92
+ * The file's new content with the block put in (or replaced), everything else untouched.
93
+ * PURE. { content, changed } — or { error } when the markers are damaged, so a hand-edited
94
+ * file is never guessed at.
95
+ */
96
+ export function upsertBlock(existing, text) {
97
+ const src = existing ?? '';
98
+ const f = findBlock(src);
99
+ if (f.state === 'broken') return { error: 'the Phaedo markers in this file are damaged — fix or delete them by hand' };
100
+ let content;
101
+ if (f.state === 'present') content = src.slice(0, f.start) + block(text) + src.slice(f.end);
102
+ else content = src.trim() ? `${src.replace(/\s*$/, '')}\n\n${block(text)}\n` : `${block(text)}\n`;
103
+ return { content, changed: content !== src };
104
+ }
105
+
106
+ /** The file's content with the block taken out. PURE. */
107
+ export function removeBlock(existing) {
108
+ const src = existing ?? '';
109
+ const f = findBlock(src);
110
+ if (f.state === 'broken') return { error: 'the Phaedo markers in this file are damaged — fix or delete them by hand' };
111
+ if (f.state === 'absent') return { content: src, changed: false };
112
+ const before = src.slice(0, f.start).replace(/\s*$/, '');
113
+ const after = src.slice(f.end).replace(/^\s*/, '');
114
+ const content = before && after ? `${before}\n\n${after}` : before ? `${before}\n` : after;
115
+ return { content, changed: true };
116
+ }
117
+
118
+ export const hasBlock = (content) => findBlock(content ?? '').state === 'present';
119
+
120
+ /** Cursor's project rule: a whole file Phaedo owns, always applied. PURE. */
121
+ export function cursorRule(text) {
122
+ return [
123
+ '---',
124
+ 'description: How I like to work, learned by Phaedo',
125
+ 'alwaysApply: true',
126
+ '---',
127
+ '',
128
+ text,
129
+ '',
130
+ ].join('\n');
131
+ }
132
+
133
+ // ── I/O ───────────────────────────────────────────────────────────────────────
134
+
135
+ const read = (file) => fs.readFile(file, 'utf8').catch((e) => (e.code === 'ENOENT' ? null : Promise.reject(e)));
136
+ const exists = (p) => fs.stat(p).then(() => true, () => false);
137
+
138
+ async function writeAtomic(file, content) {
139
+ await fs.mkdir(dirname(file), { recursive: true });
140
+ const tmp = `${file}.phaedo-${process.pid}.tmp`;
141
+ await fs.writeFile(tmp, content, 'utf8');
142
+ await fs.rename(tmp, file);
143
+ }
144
+
145
+ /** Put (or refresh) the block in one file. → { status: 'written'|'unchanged'|'error', error? } */
146
+ export async function applyToFile(file, text, { dryRun = false } = {}) {
147
+ const r = upsertBlock(await read(file), text);
148
+ if (r.error) return { status: 'error', error: r.error };
149
+ if (!r.changed) return { status: 'unchanged' };
150
+ if (!dryRun) await writeAtomic(file, r.content);
151
+ return { status: 'written' };
152
+ }
153
+
154
+ export async function removeFromFile(file, { dryRun = false } = {}) {
155
+ const src = await read(file);
156
+ if (src === null) return { status: 'unchanged' };
157
+ const r = removeBlock(src);
158
+ if (r.error) return { status: 'error', error: r.error };
159
+ if (!r.changed) return { status: 'unchanged' };
160
+ if (!dryRun) await writeAtomic(file, r.content);
161
+ return { status: 'removed' };
162
+ }
163
+
164
+ /** Which agents to write for: those named, or every agent whose folder exists here. */
165
+ export async function chooseTargets(all, names = null) {
166
+ if (names) return names.filter((n) => all[n]).map((n) => [n, all[n]]);
167
+ const out = [];
168
+ for (const [n, t] of Object.entries(all)) if (!t.onRequest && await exists(t.dir)) out.push([n, t]);
169
+ return out;
170
+ }
171
+
172
+ // Keep a project's Cursor rule out of git without touching its committed .gitignore.
173
+ function excludeFromGit(projectDir) {
174
+ let excl;
175
+ try {
176
+ excl = execFileSync('git', ['-C', projectDir, 'rev-parse', '--git-path', 'info/exclude'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
177
+ } catch { return false; }
178
+ return resolve(projectDir, excl);
179
+ }
180
+
181
+ export async function applyToCursorProject(projectDir, text, { dryRun = false } = {}) {
182
+ const file = join(projectDir, CURSOR_RULE);
183
+ const content = cursorRule(text);
184
+ const changed = (await read(file)) !== content;
185
+ const excl = excludeFromGit(projectDir);
186
+ const line = '/' + CURSOR_RULE.split('\\').join('/');
187
+ if (!dryRun) {
188
+ if (changed) await writeAtomic(file, content);
189
+ if (excl) {
190
+ const cur = (await read(excl)) || '';
191
+ if (!cur.split('\n').includes(line)) await writeAtomic(excl, `${cur.replace(/\s*$/, '')}${cur.trim() ? '\n' : ''}${line}\n`);
192
+ }
193
+ }
194
+ return { status: changed ? 'written' : 'unchanged', file, keptOutOfGit: Boolean(excl) };
195
+ }
196
+
197
+ /**
198
+ * Refresh only the files that already carry the block — the subject opted those in. Used by
199
+ * the SessionStart hook; never adds the block anywhere new, never throws.
200
+ */
201
+ export async function refreshExisting(projection, { home, env, now } = {}) {
202
+ const text = rulesText(projection, { now });
203
+ if (!text) return [];
204
+ const out = [];
205
+ for (const [name, t] of Object.entries(targets({ home, env }))) {
206
+ try {
207
+ const src = await read(t.file);
208
+ if (!hasBlock(src)) continue;
209
+ out.push({ name, ...(await applyToFile(t.file, text)) });
210
+ } catch { /* one unreadable file never stops the rest */ }
211
+ }
212
+ return out;
213
+ }
214
+
215
+ // ── CLI ───────────────────────────────────────────────────────────────────────
216
+
217
+ const tilde = (p) => (p.startsWith(homedir()) ? '~' + p.slice(homedir().length) : p);
218
+
219
+ async function main(argv) {
220
+ const flag = (f) => argv.includes(f);
221
+ const opt = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : null; };
222
+ const dryRun = flag('--dry-run');
223
+ const names = opt('--for') ? opt('--for').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean) : null;
224
+ const project = opt('--project');
225
+ const all = targets();
226
+ if (names) {
227
+ const unknown = names.filter((n) => !all[n] && n !== 'cursor');
228
+ if (unknown.length) { process.stderr.write(`Unknown agent: ${unknown.join(', ')}. Known: ${Object.keys(all).join(', ')}, cursor (with --project).\n`); return 1; }
229
+ }
230
+ const chosen = await chooseTargets(all, names ? names.filter((n) => n !== 'cursor') : null);
231
+ const say = (s) => process.stdout.write(s + '\n');
232
+
233
+ if (flag('--remove')) {
234
+ for (const [, t] of names ? chosen : Object.entries(all)) {
235
+ const r = await removeFromFile(t.file, { dryRun });
236
+ if (r.status === 'removed') say(`✓ ${t.label}: took Phaedo's section out of ${tilde(t.file)}`);
237
+ else if (r.status === 'error') say(`✗ ${t.label}: ${r.error} (${tilde(t.file)})`);
238
+ }
239
+ if (project) {
240
+ const file = join(resolve(project), CURSOR_RULE);
241
+ if (await exists(file)) { if (!dryRun) await fs.rm(file); say(`✓ Cursor: removed ${tilde(file)}`); }
242
+ }
243
+ return 0;
244
+ }
245
+
246
+ const MCP_DIR = dirname(fileURLToPath(import.meta.url));
247
+ const { loadFingerprintPreferCache } = await import('./fingerprint-source.js');
248
+ const { buildInjectionResponse } = await import('./projection.js');
249
+ let text = null;
250
+ try {
251
+ const vd = await loadFingerprintPreferCache(MCP_DIR);
252
+ text = rulesText(buildInjectionResponse(vd, { requested_layers: ['all'], mode: 'standard' }).projection);
253
+ } catch { /* falls through to the message below */ }
254
+ if (!text) { say('Phaedo has no profile with rules or preferences yet, so nothing was written.'); return 0; }
255
+
256
+ if (dryRun) say(`Would write this (${text.length} characters):\n\n${text}\n`);
257
+ if (!chosen.length && !project) {
258
+ say('No other AI agents found on this computer (looked for Codex, Copilot CLI, Gemini CLI and Antigravity).');
259
+ say('Name one with --for codex,copilot,gemini,claude, or a Cursor project with --project <folder>.');
260
+ return 0;
261
+ }
262
+ let failed = false;
263
+ for (const [, t] of chosen) {
264
+ const r = await applyToFile(t.file, text, { dryRun });
265
+ if (r.status === 'error') { failed = true; say(`✗ ${t.label}: ${r.error} (${tilde(t.file)})`); }
266
+ else say(`✓ ${t.label}: ${r.status === 'written' ? (dryRun ? 'would update' : 'updated') : 'already up to date'} — ${tilde(t.file)}`);
267
+ }
268
+ if (project) {
269
+ const r = await applyToCursorProject(resolve(project), text, { dryRun });
270
+ say(`✓ Cursor: ${r.status === 'written' ? (dryRun ? 'would update' : 'updated') : 'already up to date'} — ${tilde(r.file)}${r.keptOutOfGit ? ' (kept out of git)' : ''}`);
271
+ }
272
+ if (!dryRun) say('Phaedo refreshes these whenever a Claude Code session starts with Phaedo on. To take them out: npm run rules -- --remove');
273
+ return failed ? 1 : 0;
274
+ }
275
+
276
+ if (isMainModule(import.meta.url)) {
277
+ main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { process.stderr.write(`✗ ${err.message}\n`); process.exit(1); });
278
+ }
package/server.js CHANGED
@@ -197,14 +197,19 @@ const PKG_VERSION = JSON.parse(readFileSync(join(BASE_DIR, 'package.json'), 'utf
197
197
  // put this in the model's context on every turn.
198
198
  // The directory edition loads the profile only when asked (lib/config-dir.js explains why).
199
199
  const DIRECTORY_EDITION = isDirectoryEdition(BASE_DIR);
200
+ // Codex keeps only the first 512 characters of a server's instructions, so the three rules an
201
+ // agent must follow come first, tersely; the detail that only helps follows. A test pins it.
202
+ export const INSTRUCTIONS_CORE_LIMIT = 512;
200
203
  const SERVER_INSTRUCTIONS = [
201
- 'Phaedo holds the user\'s profile: how they like answers written, and how they want decisions handled when an AI acts for them.',
204
+ 'Phaedo knows how the user likes answers written and how they want decisions handled.',
202
205
  DIRECTORY_EDITION
203
- ? '- When the user asks to load their Phaedo profile, call phaedo_request_injection and treat the returned projection as background on the user\'s style. Don\'t load it unasked.'
204
- : '- At the start of a session, call phaedo_request_injection once and treat the returned projection as background on the user\'s style.',
205
- '- Before a consequential action for the user (merging, deploying, publishing, sending a message, spending money, deleting, changing an account or setting), call phaedo_consult and follow its signal. If you are acting on your own and the decision should be the user\'s, use phaedo_escalate and do not proceed until it resolves.',
206
- '- When the user reacts to an action you consulted on, call phaedo_record_outcome with its receipt_id: record what they chose, never what you inferred.',
207
- '- If a tool says no Phaedo profile is set up, tell the user once, plainly, and carry on normally. Do not retry.',
206
+ ? '1. Only when the user asks to load their Phaedo profile, call phaedo_request_injection. Don\'t load it unasked.'
207
+ : '1. At session start, call phaedo_request_injection once and use it as background on their style.',
208
+ '2. Before a consequential action (merge, deploy, publish, send, spend, delete, change a setting), call phaedo_consult and follow its signal.',
209
+ '3. When the user reacts to that action, call phaedo_record_outcome with its receipt_id: what they chose, never what you inferred.',
210
+ // ── beyond the 512-character core ──
211
+ 'If you are acting on your own and the decision should be the user\'s, use phaedo_escalate and do not proceed until it resolves.',
212
+ 'If a tool says no Phaedo profile is set up, tell the user once, plainly, and carry on normally. Do not retry.',
208
213
  ].join('\n');
209
214
  const server = new McpServer({ name: 'phaedo', version: PKG_VERSION }, { instructions: SERVER_INSTRUCTIONS });
210
215
 
package/server.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.phaedo-labs/phaedo-mcp",
4
4
  "description": "Make AI apps answer your way, check with you before acting, and get approvals on your phone.",
5
- "version": "0.3.7",
5
+ "version": "0.3.14",
6
6
  "repository": {
7
7
  "url": "https://github.com/phaedo-labs/phaedo-mcp",
8
8
  "source": "github",
@@ -13,7 +13,7 @@
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "phaedo-mcp",
16
- "version": "0.3.7",
16
+ "version": "0.3.14",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  }
package/ship-actions.js CHANGED
@@ -71,10 +71,82 @@ const CLASSES = [
71
71
  : { magnitude: 'medium', reversible: true, summary: 'push directly to main' };
72
72
  },
73
73
  },
74
+ // ── The personal reviewer (2026-10-27): the other things an agent does for the subject that
75
+ // people disagree about. Auto mode's classifier judges these by TYPE and lets the routine
76
+ // ones through; Phaedo asks whether THIS subject would want to see this one.
77
+ {
78
+ id: 'force_push',
79
+ // Any force-push (main is push_main's, ranked first). Rewrites shared history.
80
+ match: (w) => w[0] === 'git' && w[1] === 'push' && hasForce(w),
81
+ describe: (w) => {
82
+ const ref = w.slice(2).filter((x) => !x.startsWith('-')).pop();
83
+ return { magnitude: 'high', reversible: false, summary: `force-push${ref ? ` ${ref.replace(/^\+/, '')}` : ''}` };
84
+ },
85
+ },
86
+ {
87
+ id: 'delete_branch',
88
+ // `git push <remote> --delete x` / `git push <remote> :x`.
89
+ match: (w) => w[0] === 'git' && w[1] === 'push' && (w.includes('--delete') || w.includes('-d') || w.slice(3).some((x) => /^:[^:]+$/.test(x))),
90
+ describe: (w) => {
91
+ const ref = w.slice(2).filter((x) => !x.startsWith('-')).slice(1).map((x) => x.replace(/^:/, '')).join(', ');
92
+ return { magnitude: 'medium', reversible: false, summary: `delete remote branch${ref ? ` ${ref}` : ''}` };
93
+ },
94
+ },
95
+ {
96
+ id: 'open_pr',
97
+ // Opening a PR is cheap and reversible, but whether an agent may open one unasked is
98
+ // exactly the kind of thing people differ on.
99
+ match: (w) => w[0] === 'gh' && w[1] === 'pr' && w[2] === 'create',
100
+ describe: () => ({ magnitude: 'low', reversible: true, summary: 'open a pull request' }),
101
+ },
102
+ {
103
+ id: 'comment',
104
+ // Speaking for the subject in public: comments and reviews can be edited, not unsaid.
105
+ match: (w) => w[0] === 'gh' && ((['pr', 'issue'].includes(w[1]) && w[2] === 'comment') || (w[1] === 'pr' && w[2] === 'review')),
106
+ describe: (w) => {
107
+ const approve = w[2] === 'review' && (w.includes('--approve') || w.includes('-a'));
108
+ const reject = w[2] === 'review' && (w.includes('--request-changes') || w.includes('-r'));
109
+ return {
110
+ magnitude: 'medium', reversible: false,
111
+ summary: approve ? 'approve a pull request' : reject ? 'request changes on a pull request' : `post a comment on a GitHub ${w[1] === 'issue' ? 'issue' : 'pull request'}`,
112
+ };
113
+ },
114
+ },
115
+ {
116
+ id: 'publish',
117
+ // A published package version can't be taken back cleanly (npm forbids re-using one).
118
+ match: (w) => !w.includes('--dry-run') && (
119
+ (['npm', 'pnpm', 'bun'].includes(w[0]) && w[1] === 'publish')
120
+ || (w[0] === 'yarn' && (w[1] === 'publish' || (w[1] === 'npm' && w[2] === 'publish')))
121
+ || (w[0] === 'cargo' && w[1] === 'publish') || (w[0] === 'twine' && w[1] === 'upload') || (w[0] === 'gem' && w[1] === 'push')),
122
+ describe: (w) => ({ magnitude: 'high', reversible: false, summary: `publish a package (${w[0]})` }),
123
+ },
124
+ {
125
+ id: 'release',
126
+ match: (w) => w[0] === 'gh' && w[1] === 'release' && w[2] === 'create',
127
+ describe: (w) => ({ magnitude: 'medium', reversible: true, summary: `create a GitHub release${w[3] && !w[3].startsWith('-') ? ` ${w[3]}` : ''}` }),
128
+ },
129
+ {
130
+ id: 'deploy_other',
131
+ // Production deploys outside Fly. `terraform destroy` and `kubectl delete` can't be undone.
132
+ match: (w) => (w[0] === 'vercel' && w.includes('--prod')) || (w[0] === 'netlify' && w[1] === 'deploy' && w.includes('--prod'))
133
+ || (w[0] === 'firebase' && w[1] === 'deploy') || (w[0] === 'terraform' && ['apply', 'destroy'].includes(w[1]))
134
+ || (w[0] === 'kubectl' && ['apply', 'delete'].includes(w[1])),
135
+ describe: (w) => {
136
+ const destroy = (w[0] === 'terraform' && w[1] === 'destroy') || (w[0] === 'kubectl' && w[1] === 'delete');
137
+ return { magnitude: 'high', reversible: !destroy, summary: destroy ? `${w[0]} ${w[1]}` : `deploy with ${w[0]}` };
138
+ },
139
+ },
74
140
  ];
75
141
 
76
- // Order matters for one pair: `eas build --auto-submit` is a submit, not a build.
77
- const ORDER = ['merge_pr', 'deploy', 'app_submit', 'app_build', 'push_main'];
142
+ function hasForce(w) {
143
+ return w.some((x) => x === '-f' || x === '--force' || x.startsWith('--force-with-lease') || (x.startsWith('+') && x.length > 1));
144
+ }
145
+
146
+ // Order matters: `eas build --auto-submit` is a submit, not a build, and a force-push to main is
147
+ // push_main's (it names main), so push_main is tried before the general force_push.
148
+ const ORDER = ['merge_pr', 'deploy', 'app_submit', 'app_build', 'push_main', 'force_push', 'delete_branch',
149
+ 'open_pr', 'comment', 'publish', 'release', 'deploy_other'];
78
150
 
79
151
  function isEas(w0) { return w0 === 'eas' || w0 === 'eas-cli'; }
80
152
  function platform(w) {
@@ -121,6 +193,83 @@ export function words(cmd) {
121
193
  const MAG_RANK = { low: 0, medium: 1, high: 2 };
122
194
  const stakes = (d) => (d.reversible === false ? 10 : 0) + (MAG_RANK[d.magnitude] ?? 1);
123
195
 
196
+ // ── Actions through connected tools (MCP), not the shell ──────────────────────────────────
197
+ // The same decisions arrive as tool calls: a Gmail `send_message`, a Slack `post_message`, a
198
+ // payments `create_payment`, a Drive `trash_file`. Tool names follow `mcp__<server>__<tool>`;
199
+ // the VERB in the tool name says what it does. Read-only tools (get_/list_/search_) never match,
200
+ // and Phaedo's own tools are never classified (consulting Phaedo about consulting Phaedo).
201
+ const MCP_VERBS = [
202
+ { re: /(?:^|_)(send|reply|forward|post|publish|comment|share|invite)(?:_|$)/, kind: 'send' },
203
+ { re: /(?:^|_)(pay|payment|purchase|transfer|checkout|buy)(?:_|$)/, kind: 'spend' },
204
+ { re: /(?:^|_)(delete|purge|destroy)(?:_|$)/, kind: 'delete' },
205
+ { re: /(?:^|_)(trash)(?:_|$)/, kind: 'trash' },
206
+ { re: /(?:^|_)(merge)(?:_|$)/, kind: 'merge' },
207
+ ];
208
+ // A broad first cut for the hook's matcher (hooks.json): cheap to spawn on, then refined here.
209
+ export const MCP_HOOK_MATCHER = 'mcp__.*(send|reply|forward|post|publish|comment|share|invite|pay|purchase|transfer|checkout|buy|delete|purge|destroy|trash|merge).*';
210
+
211
+ export function parseMcpToolName(name) {
212
+ const m = /^mcp__(.+?)__(.+)$/.exec(String(name || ''));
213
+ return m ? { server: m[1], tool: m[2].toLowerCase() } : null;
214
+ }
215
+
216
+ // A short, privacy-conscious preview of who/what the call targets — never a message body.
217
+ const PREVIEW_KEYS = ['to', 'recipient', 'recipients', 'channel', 'chat', 'subject', 'title', 'name', 'amount', 'currency', 'merchant', 'payee', 'repo', 'pull_number', 'file_id', 'id'];
218
+ function previewOf(input) {
219
+ if (!input || typeof input !== 'object') return '';
220
+ const parts = [];
221
+ for (const k of PREVIEW_KEYS) {
222
+ const v = input[k];
223
+ if (v == null || typeof v === 'object' && !Array.isArray(v)) continue;
224
+ parts.push(`${k}: ${Array.isArray(v) ? v.slice(0, 3).join(', ') : String(v)}`);
225
+ }
226
+ return parts.join('; ').slice(0, 120);
227
+ }
228
+
229
+ /**
230
+ * Classify an MCP tool call. Returns null for anything that isn't a consequential action, else
231
+ * the same shape as classifyShipCommand.
232
+ */
233
+ export function classifyMcpCall(toolName, toolInput) {
234
+ const p = parseMcpToolName(toolName);
235
+ if (!p || /(^|_)phaedo($|_)/.test(p.server) || p.server.startsWith('plugin_phaedo')) return null;
236
+ const verb = MCP_VERBS.find((v) => v.re.test(p.tool));
237
+ if (!verb) return null;
238
+ // Connector servers are often named by an id ("597bdf05-a786-…"); say what it is instead.
239
+ const named = p.server.replace(/^plugin_/, '').replace(/_/g, ' ');
240
+ const via = /^[0-9a-f]{6,}(-[0-9a-f]+)*$/i.test(p.server) ? 'a connected app' : named;
241
+ // An amount only means money on a payment; on a message or a delete it's just a field.
242
+ const amount = verb.kind === 'spend' && typeof toolInput?.amount === 'number' && isFinite(toolInput.amount) ? toolInput.amount : undefined;
243
+ const d = {
244
+ send: { domain: 'communication', magnitude: 'medium', reversible: false, summary: `send or post a message via ${via}` },
245
+ spend: { domain: 'payments', magnitude: 'high', reversible: false, summary: `make a payment via ${via}` },
246
+ delete: { domain: 'data', magnitude: 'medium', reversible: false, summary: `delete something via ${via}` },
247
+ trash: { domain: 'data', magnitude: 'low', reversible: true, summary: `move something to the trash via ${via}` },
248
+ merge: { domain: 'engineering', magnitude: 'medium', reversible: true, summary: `merge via ${via}` },
249
+ }[verb.kind];
250
+ const preview = previewOf(toolInput);
251
+ return {
252
+ class: `mcp_${verb.kind}`,
253
+ summary: d.summary,
254
+ multiple: false,
255
+ action_descriptor: {
256
+ domain: d.domain, reversible: d.reversible, magnitude: d.magnitude, summary: d.summary,
257
+ ...(amount !== undefined ? { amount } : {}),
258
+ },
259
+ action_binding: {
260
+ tool_name: String(toolName),
261
+ arguments_hash: createHash('sha256').update(JSON.stringify(toolInput ?? null)).digest('hex'),
262
+ ...(preview ? { arguments_preview: preview } : {}),
263
+ },
264
+ };
265
+ }
266
+
267
+ /** Either kind of call: a Bash command, or an MCP tool. Null when it isn't a decision. */
268
+ export function classifyToolCall(toolName, toolInput) {
269
+ if (toolName === 'Bash') return classifyShipCommand(toolInput?.command);
270
+ return classifyMcpCall(toolName, toolInput);
271
+ }
272
+
124
273
  /**
125
274
  * Classify a Bash command. Returns null when no part of it is a ship action, else the
126
275
  * highest-stakes ship action in it (ties: the first), with `multiple` set when the line
package/ship-readout.js CHANGED
@@ -21,10 +21,20 @@ const ASKING = new Set(['escalate', 'clarify', 'decline']);
21
21
  export function actionKind(summary) {
22
22
  const s = String(summary || '');
23
23
  if (/^(admin-)?merge\b/.test(s)) return 'merge';
24
- if (/^deploy\b/.test(s)) return 'deploy';
24
+ if (/^deploy\b|^terraform |^kubectl /.test(s)) return 'deploy';
25
25
  if (/^submit\b/.test(s)) return 'phone submit';
26
26
  if (/^phone build\b/.test(s)) return 'phone build';
27
- if (/push/.test(s)) return 'push to main';
27
+ if (/^(force-push to main|push directly to main)$/.test(s)) return 'push to main';
28
+ // The personal reviewer's kinds (ship-actions.js, 2026-10-27).
29
+ if (/^force-push\b/.test(s)) return 'force-push';
30
+ if (/^delete remote branch\b/.test(s)) return 'delete branch';
31
+ if (/^open a pull request$/.test(s)) return 'open PR';
32
+ if (/^(post a comment|approve a pull request|request changes)/.test(s)) return 'comment or review';
33
+ if (/^publish a package\b/.test(s)) return 'publish';
34
+ if (/^create a GitHub release\b/.test(s)) return 'release';
35
+ if (/^send or post a message\b/.test(s)) return 'message';
36
+ if (/^make a payment\b/.test(s)) return 'payment';
37
+ if (/^(delete something|move something to the trash)\b/.test(s)) return 'delete';
28
38
  return 'other';
29
39
  }
30
40
 
@@ -297,6 +297,8 @@
297
297
  ask: 'When things are unclear, ask me before deciding.',
298
298
  delay: 'When things are unclear, wait rather than commit too early.',
299
299
  },
300
+ // The request-level words (ambiguity_response) for "guess and carry on" mean be decisive here.
301
+ aliases: { assume_state_proceed: 'default', assume_silent: 'default', assume_state: 'default' },
300
302
  },
301
303
  evidence_threshold: {
302
304
  topic: 'how sure you need to be before committing',
package/weekly.js ADDED
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ // "This week Phaedo handled N decisions for you" — the visible win.
3
+ //
4
+ // Phaedo's case is fewer, better interruptions: an agent asks Phaedo instead of you, and only the
5
+ // decisions you'd actually want to see reach you. That is invisible unless it's counted, so this
6
+ // counts it from the local decision records (receipts.js), over the last 7 days:
7
+ //
8
+ // went_ahead — Phaedo said go ahead (proceed / proceed_with_note): you weren't interrupted
9
+ // stopped — Phaedo said no (decline), usually one of your rules: you weren't interrupted
10
+ // came_to_you — Phaedo said check with you (escalate / clarify)
11
+ // unsure — Phaedo didn't know (insufficient_signal); the agent fell back to its own default
12
+ //
13
+ // "Handled" is went_ahead + stopped. Pure over the receipts list; the CLI reads the local store.
14
+ //
15
+ // npm run week
16
+
17
+ import { readReceipts } from './receipts.js';
18
+ import { defaultStateDir } from './cache.js';
19
+ import { isMainModule } from './lib/is-main.js';
20
+
21
+ export const WEEK_DAYS = 7;
22
+ const DAY_MS = 24 * 60 * 60 * 1000;
23
+ const BUCKET = {
24
+ proceed: 'went_ahead', proceed_with_note: 'went_ahead',
25
+ decline: 'stopped',
26
+ escalate: 'came_to_you', clarify: 'came_to_you',
27
+ insufficient_signal: 'unsure',
28
+ };
29
+
30
+ export function weeklyHandled(receipts, { now = Date.now(), days = WEEK_DAYS } = {}) {
31
+ const since = now - days * DAY_MS;
32
+ const out = { days, went_ahead: 0, stopped: 0, came_to_you: 0, unsure: 0 };
33
+ for (const r of Array.isArray(receipts) ? receipts : []) {
34
+ const t = Date.parse(r?.created_at);
35
+ if (!Number.isFinite(t) || t < since || t > now) continue;
36
+ const b = BUCKET[r?.response?.signal];
37
+ if (b) out[b]++;
38
+ }
39
+ out.handled = out.went_ahead + out.stopped;
40
+ out.total = out.handled + out.came_to_you + out.unsure;
41
+ return out;
42
+ }
43
+
44
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
45
+
46
+ /** One plain sentence (two at most) for a person. */
47
+ export function formatWeekly(w) {
48
+ if (!w || !w.total) return 'No decisions came to Phaedo this week yet.';
49
+ const parts = [];
50
+ if (w.went_ahead) parts.push(`${w.went_ahead} went ahead`);
51
+ if (w.stopped) parts.push(`${w.stopped} stopped`);
52
+ const handled = w.handled
53
+ ? `This week Phaedo handled ${plural(w.handled, 'decision')} without interrupting you (${parts.join(', ')})`
54
+ : 'This week Phaedo handled no decisions on its own';
55
+ const asked = w.came_to_you ? ` and brought ${w.came_to_you} to you` : '';
56
+ const unsure = w.unsure ? ` ${w.unsure === 1 ? 'One more was' : `${w.unsure} more were`} too unclear to call.` : '';
57
+ return `${handled}${asked}.${unsure}`;
58
+ }
59
+
60
+ if (isMainModule(import.meta.url)) {
61
+ const receipts = await readReceipts(defaultStateDir()).catch(() => []);
62
+ console.log(formatWeekly(weeklyHandled(receipts)));
63
+ }