phaedo-mcp 0.3.7 → 0.3.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -11
- package/agent-hooks.js +160 -0
- package/consult-core.js +44 -7
- package/escalation.js +28 -0
- package/fingerprint-source.js +14 -4
- package/history-import.js +202 -0
- package/hooks/agent-hook.mjs +160 -0
- package/hooks/pre-ship.mjs +43 -18
- package/hooks/session-start.mjs +8 -2
- package/install.mjs +23 -1
- package/learned.js +227 -0
- package/package.json +15 -3
- package/precedent.js +267 -0
- package/pull.js +14 -1
- package/receipts-sync.js +6 -1
- package/rules-files.js +278 -0
- package/server.js +11 -6
- package/server.json +2 -2
- package/ship-actions.js +151 -2
- package/ship-readout.js +12 -2
- package/weekly.js +63 -0
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
|
|
204
|
+
'Phaedo knows how the user likes answers written and how they want decisions handled.',
|
|
202
205
|
DIRECTORY_EDITION
|
|
203
|
-
? '
|
|
204
|
-
: '
|
|
205
|
-
'
|
|
206
|
-
'
|
|
207
|
-
|
|
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.
|
|
5
|
+
"version": "0.3.13",
|
|
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.
|
|
16
|
+
"version": "0.3.13",
|
|
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
|
-
|
|
77
|
-
|
|
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 (
|
|
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
|
|
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
|
+
}
|