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/NOTICE ADDED
@@ -0,0 +1,17 @@
1
+ Phaedo
2
+ Copyright 2026 Phaedo Labs, Inc.
3
+
4
+ This product includes software developed by Phaedo Labs, Inc. (https://phaedo.so),
5
+ licensed under the Apache License, Version 2.0.
6
+
7
+ "Phaedo" and the Phaedo logo are trademarks of Phaedo Labs, Inc. The Apache License,
8
+ Version 2.0 grants no trademark rights (Apache-2.0 §6); see TRADEMARKS for the brand
9
+ policy and permitted nominative use.
10
+
11
+ ----------------------------------------------------------------------
12
+ Third-party components (not redistributed in this package)
13
+ ----------------------------------------------------------------------
14
+ The reference browser extension vendors @mlc-ai/web-llm (Apache-2.0). That bundle
15
+ is NOT included in this npm package or the public repository (it is a gitignored
16
+ build artifact). If/when the extension is distributed, the @mlc-ai/web-llm
17
+ LICENSE and NOTICE travel with it.
package/README.md CHANGED
@@ -1,11 +1,98 @@
1
- # Phaedo MCP server — Phase 1 (push injection)
1
+ # Phaedo MCP server (`phaedo-mcp`)
2
2
 
3
- A local [MCP](https://modelcontextprotocol.io) server that exposes your Phaedo
4
- cognitive-fingerprint **projection** to any MCP client (Claude Desktop, Claude
5
- Code, Cursor, agent frameworks) — so the assistant responds in your style
6
- without the Chrome extension. One integration, every MCP client.
3
+ Phaedo learns how you write, work and decide, and keeps that profile on your phone.
4
+ This server lets your AI apps use it: Claude Desktop, Claude Code, Cursor, and any
5
+ other app that supports [MCP](https://modelcontextprotocol.io). With it, they can:
7
6
 
8
- Implements `docs/protocol/mcp-binding.md` §3.1:
7
+ - **Answer the way you like** — your preferred length, tone and format, without
8
+ you repeating it in every chat.
9
+ - **Check with you before acting** — an AI agent asks Phaedo whether you'd want it
10
+ to go ahead. When it's unsure, your phone gets an approve / deny card.
11
+ - **Learn from your answers** — each approve or deny teaches it how you want work
12
+ handled when you're not watching.
13
+ - **Get your signature for purchases** — you sign a spending permission on your
14
+ phone with Face ID; the agent never holds the key.
15
+
16
+ Your profile comes from your phone encrypted. The copy kept on this computer, for
17
+ when your phone is offline, is encrypted too.
18
+
19
+ ## What you need
20
+
21
+ 1. **The Phaedo phone app.** Your profile lives there. The app isn't open to the
22
+ public yet — [phaedo.so](https://phaedo.so) has a link to hear when it is.
23
+ 2. **Node.js**, free from [nodejs.org](https://nodejs.org) (the LTS button).
24
+
25
+ Without the phone app there is no profile, and the server says so plainly rather
26
+ than guessing. You can still try Phaedo with a made-up example profile.
27
+
28
+ ## Try it with the example profile (no phone needed)
29
+
30
+ This loads a made-up person's profile, so you can see what Phaedo adds before you
31
+ have your own.
32
+
33
+ **Claude Code** — run this in Terminal (it applies to all your projects):
34
+
35
+ ```bash
36
+ claude mcp add -s user phaedo -e PHAEDO_FINGERPRINT=sample -- npx -y phaedo-mcp
37
+ ```
38
+
39
+ **Claude Desktop** — open Settings → Developer → Edit Config, add the `phaedo`
40
+ entry below inside `mcpServers`, save, then quit and reopen Claude Desktop:
41
+
42
+ ```json
43
+ {
44
+ "mcpServers": {
45
+ "phaedo": {
46
+ "command": "npx",
47
+ "args": ["-y", "phaedo-mcp"],
48
+ "env": { "PHAEDO_FINGERPRINT": "sample" }
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ If Claude Desktop reports that the server failed to start, it couldn't find
55
+ `npx`. Replace `"npx"` with the full path that this prints:
56
+
57
+ ```bash
58
+ which npx
59
+ ```
60
+
61
+ Then start a new chat and ask: *"Load my Phaedo profile."* To stop using the
62
+ example, delete the `env` line in Claude Desktop, or in Claude Code run
63
+ `claude mcp remove -s user phaedo` and add it again without `-e …`. The server
64
+ will then tell you there's no profile until you pair your phone.
65
+
66
+ ## Use your own profile (with the phone app)
67
+
68
+ 1. **Get this folder.** On [the GitHub page](https://github.com/phaedo-labs/phaedo-mcp),
69
+ click **Code → Download ZIP**, unzip it, and open the `mcp` folder inside.
70
+ 2. **Run the installer.**
71
+ - macOS: double-click **`installers/macos/Install Phaedo MCP.command`**. If macOS
72
+ says it can't be opened, right-click it and choose **Open**.
73
+ - Windows: double-click **`installers/windows/Install Phaedo MCP.bat`**.
74
+ - Linux: run `bash installers/linux/install-phaedo-mcp.sh`.
75
+
76
+ It adds Phaedo to Claude Desktop, Cursor and Claude Code — keeping your other
77
+ settings, and backing each file up first — then starts pairing.
78
+ 3. **Pair with your phone.** Open Phaedo on your phone. On the same Wi-Fi, approve
79
+ **"Phaedo MCP Server"** and check that the code matches the one in Terminal.
80
+ On a different network, tap **Pair with code** and type the code Terminal shows.
81
+ 4. **Quit and reopen your AI apps**, start a new chat, and ask: *"Load my Phaedo
82
+ profile."*
83
+
84
+ You can disconnect this computer at any time from the **Agents** screen on your
85
+ phone.
86
+
87
+ ---
88
+
89
+ ## Reference
90
+
91
+ Everything below is for developers: the full tool list, how the profile is
92
+ loaded, and the test suite.
93
+
94
+ The server implements the MCP binding of the Anamnesis protocol
95
+ (`docs/protocol/mcp-binding.md` §3.1):
9
96
 
10
97
  | Surface | Name | Returns |
11
98
  |---|---|---|
@@ -17,12 +104,24 @@ Implements `docs/protocol/mcp-binding.md` §3.1:
17
104
  | tool | `phaedo_escalate` | run the consult and, on a blocking signal (`escalate`, or a consequential `clarify`), wake the subject's paired device for a live **approve / deny / modify**; returns a `request_id` (non-blocking). Safe default on no answer: **hold**. |
18
105
  | tool | `phaedo_escalation_status` | poll an escalation by `request_id`; on resolution records the subject's live decision as the §10.6 outcome automatically (a real-time approve/deny is the strongest act-as-me signal). |
19
106
  | tool | `phaedo_record_outcome` | §10.6 — record what the SUBJECT decided about a consulted action (`approved`/`rejected`/`modified`/`unknown`). A `rejected`/`modified` outcome teaches the **act-as-me** channel (how the user wants delegated work handled). |
107
+ | tool | `phaedo_sign_mandate` | §8.3 — ask the subject to **sign** an AP2 Intent Mandate built from their standing authorizations (a verifiable payment credential). Deposits the request and returns a `request_id` + the unsigned mandate (non-blocking). The subject signs on their phone in the Secure Enclave, or declines. |
108
+ | tool | `phaedo_sign_status` | poll a `phaedo_sign_mandate` request by `request_id` (+ the returned mandate); returns the mandate carrying a **verified** signature once signed, `refused` if declined, or pending/expired. The signature is checked against the subject's published signing key before it is returned. |
20
109
 
21
110
  The projection is rendered by the **same** `context-block.js` the extension
22
111
  injects, so MCP injection is byte-identical to web injection.
23
112
 
24
113
  ## Status
25
114
 
115
+ The phases below track the **fingerprint source** (how the server obtains the
116
+ fingerprint). The consultation, real-time escalation, act-as-me / delegation, and
117
+ receipts-audit surfaces (the tools table above) shipped on top of these across the
118
+ **0.2.x** line, and the §8.3 signing pair in **0.3.0** — see `CHANGELOG.md`.
119
+
120
+ > **Install 0.3.0 or later.** 0.2.0, 0.2.1 and 0.2.2 were published with three
121
+ > modules missing from the package, so they exit with `ERR_MODULE_NOT_FOUND`
122
+ > instead of starting. 0.3.0 is the first working release since 0.1.1, and
123
+ > `test-package-closure.mjs` now boots the packed tarball in CI.
124
+
26
125
  - **Phase 1a (this cut):** reads the fingerprint from a local JSON file (see
27
126
  *Fingerprint source*). Lets injection work in a real client today, no crypto.
28
127
  - **Phase 1b-i (done):** the spec-faithful key boundary — authorize → pull
@@ -50,8 +149,10 @@ injects, so MCP injection is byte-identical to web injection.
50
149
  `../docs/roadmap/mcp-revocable-approval-plan.md`. Phone-gated unlock stays a
51
150
  deferred enterprise mode behind a config flag.
52
151
 
53
- **Injection-only.** MCP never returns the conversation, so there is **no
54
- capture** here (binding §5) — your fingerprint does not learn from MCP sessions.
152
+ **No conversation capture.** MCP never returns the conversation, so your
153
+ fingerprint does not learn from MCP sessions (binding §5). (Consultation,
154
+ escalation, and `phaedo_record_outcome` shape and record *decisions*, but none
155
+ capture conversation text into the fingerprint.)
55
156
  Local **stdio** only; **standard** mode only (enhanced-privacy/Stained-Glass and
56
157
  remote HTTP/SSE are out of this cut).
57
158
 
@@ -112,9 +213,13 @@ foregrounded/reachable at the configured `endpoint`. *(You can still hand-copy
112
213
  `pairing.sample.json` → `pairing.json` from the extension's `phaedo_local_pairing`
113
214
  if you prefer.)*
114
215
 
115
- **Phase 1a — local JSON (fallback / demo).** With no pairing record, reads a
116
- **vault payload** from `PHAEDO_FINGERPRINT`, then `mcp/fingerprint.json`, then
117
- the shipped `mcp/sample-fingerprint.json` (so it runs out of the box):
216
+ **Phase 1a — local JSON.** With no pairing record, reads a **vault payload**
217
+ from `PHAEDO_FINGERPRINT` (a path, or the word `sample` for the shipped
218
+ `mcp/sample-fingerprint.json`), then `mcp/fingerprint.json`. With neither, there
219
+ is **no profile**: every tool returns a plain-language `decryption_failed` error
220
+ saying so. The sample is never served unless asked for by name — until 0.3.1 it
221
+ was the silent fallback, so a fresh `npx phaedo-mcp` injected a made-up person's
222
+ style without saying so. The file looks like this:
118
223
  ```json
119
224
  {
120
225
  "phaedo_fingerprint": { "layers": { ... }, "fingerprint_id": "...", "persona_strength": 0.6 },
@@ -181,6 +286,60 @@ fully automatic depending on the client:
181
286
 
182
287
  Every chat in that project then injects automatically.
183
288
 
289
+ ### Capture from Claude Code / Cowork (optional)
290
+
291
+ `npm run setup:code-hook` also installs a **SessionEnd** hook that reads the just-ended
292
+ transcript locally, derives behavioral signals (deterministic pattern detectors), and
293
+ deposits an encrypted **signal delta** to the owner — so your Claude Code / Cowork usage
294
+ feeds the fingerprint, not just the browser. The transcript is counted and **discarded**
295
+ (nothing but signal names + integers leaves the machine); a paired phone/extension drains
296
+ and merges it.
297
+
298
+ Optionally enrich the deterministic detectors with a **local model** (opt-in) — it
299
+ classifies the messages the regexes miss, against the same taxonomy, and feeds the same
300
+ counters (so re-fires stay idempotent). Any **Ollama / OpenAI-compatible** endpoint works:
301
+
302
+ ```
303
+ ollama pull llama3.1:8b # or any local model
304
+ npm run setup:capture-model -- llama3.1:8b # writes ~/.phaedo-mcp/capture-model.json
305
+ npm run setup:capture-model -- off # turn it back off
306
+ ```
307
+
308
+ Point it elsewhere with `--capture-url <url>` (default `http://localhost:11434`). It's
309
+ fail-safe: endpoint down/slow/garbage → the hook silently falls back to the deterministic
310
+ detectors, and a model call can never break or stall a session (30s budget, hook always
311
+ exits 0). Prefer the `setup:capture-model` config file over an env var — the file reaches
312
+ Dock-launched Claude Desktop, a shell `export` does not.
313
+
314
+ ### Consult before shipping, from Claude Code (`hooks/pre-ship.mjs`)
315
+
316
+ `npm run setup:code-hook` also installs a **ship hook** on Bash (PreToolUse,
317
+ PermissionRequest, PostToolUse, PostToolUseFailure). Before an agent merges a PR
318
+ (`gh pr merge`), deploys (`fly deploy`), builds or submits a phone app (`eas build` /
319
+ `eas submit`) or pushes straight to `main`, the hook runs a full `phaedo_consult` on it
320
+ (`ship-actions.js` names these; every other command is ignored in ~50 ms) and writes a
321
+ §10.6 receipt, `agent_id: claude-code/ship-hook`.
322
+
323
+ - **It only ever adds friction.** It never returns `allow`. A permissive answer leaves
324
+ Claude Code's own permission flow untouched; `escalate` / `clarify` / `decline` returns
325
+ `ask`, so the action comes to you with one plain line saying why.
326
+ - **Outcomes are yours, never inferred.** The hook records `approved` only when it has
327
+ evidence you were asked: Claude Code fired PermissionRequest for that command, and the
328
+ command then ran. That holds in every mode except auto / dontAsk, where no person
329
+ answers. Decline and the agent records `rejected` (Claude Code fires no hook for a
330
+ decline). No prompt shown, nothing recorded; the agent is told so, and records your
331
+ reaction only if you give one.
332
+ - **What a merge changes.** Before `gh pr merge` the hook reads the PR from GitHub (read-only):
333
+ failing or unfinished checks, a large diff (> 1,000 code lines or > 20 files), or a sensitive
334
+ path (CI/deploy config, auth/crypto/secrets by name, or your repo's `.phaedo-sensitive` list,
335
+ read from the base branch) make it high-stakes. It can raise the stakes, never lower them.
336
+ - **Stale profile.** It reads the at-rest profile cache (so it never pings your phone);
337
+ when that copy is over 14 days old the agent tells you once a day, with the refresh:
338
+ `npm run pull` and approve on the phone.
339
+ - **See what it has done:** `npm run ship-readout` (`-- --days 90` to widen).
340
+ - **Fail-safe:** any error, timeout (12 s) or unusable profile → no output, the command
341
+ runs as if the hook weren't there.
342
+
184
343
  ## Consult at decision points (`phaedo_consult`, §10)
185
344
 
186
345
  Beyond injecting style, an agent can **ask the fingerprint how the user's pattern
@@ -254,17 +413,21 @@ returns a §10.3 signal. Any failure/timeout/parse error **falls back to the rul
254
413
  engine**, so the model can only improve on, never break, the floor (and a missing
255
414
  model never breaks consultation). The §10.4 boundary is enforced on parse.
256
415
 
257
- The **installer turns this on automatically**: it probes local Ollama and writes
258
- `PHAEDO_CONSULT_MODEL` into each client's server entry (picking an installed general
259
- model, else defaulting to `llama3.1:8b`). Toggle it at install time:
416
+ The judge is **off by default** — the rule engine answers. Measured 2026-10-18 against
417
+ one cached profile, `llama3.1:8b` answered a $12k irreversible payment `clarify 0.9` on
418
+ one run and `proceed_with_note 0.9` on the next, where the rules say `clarify` every
419
+ time. Opt in at install time once a model has earned it:
260
420
 
261
421
  ```bash
262
- npm run setup # model judge ON (auto-detect Ollama)
263
- node install.mjs --consult-model qwen2.5:7b # pin a specific model
422
+ npm run setup # model judge OFF (rule engine) — the default
423
+ node install.mjs --model-judge # ON, auto-detect a local Ollama chat model
424
+ node install.mjs --consult-model qwen2.5:7b # ON, pinned
264
425
  node install.mjs --consult-url http://localhost:11434 # custom endpoint
265
- node install.mjs --no-model-judge # rule engine only
266
426
  ```
267
427
 
428
+ Auto-detect prefers `llama3.1:8b` when installed and never picks an extraction
429
+ fine-tune or an embedding model (`nomic-embed-text` cannot answer a chat prompt).
430
+
268
431
  Use a **general** chat model (NOT the extraction fine-tune). To run it: `ollama
269
432
  pull llama3.1:8b`. Raw env (if configuring a client by hand):
270
433
  `PHAEDO_CONSULT_MODEL`, `PHAEDO_CONSULT_MODEL_URL`, `PHAEDO_CONSULT_API_KEY`
@@ -303,7 +466,7 @@ The installer writes exactly this. The minimal **paired** entry needs no `env`
303
466
  "mcpServers": {
304
467
  "phaedo": {
305
468
  "command": "/absolute/path/to/node",
306
- "args": ["/Users/galavoxx/phaedo/mcp/server.js"]
469
+ "args": ["/absolute/path/to/phaedo/mcp/server.js"]
307
470
  }
308
471
  }
309
472
  }
@@ -315,5 +478,5 @@ chat and prepend the returned `projection`.
315
478
 
316
479
  **Claude Code** — `.mcp.json` in a project, or:
317
480
  ```bash
318
- claude mcp add phaedo -- /absolute/path/to/node /Users/galavoxx/phaedo/mcp/server.js
481
+ claude mcp add phaedo -- /absolute/path/to/node /absolute/path/to/phaedo/mcp/server.js
319
482
  ```
@@ -0,0 +1,82 @@
1
+ // §10.2 `action_binding` — tying a consultation to the call it claims to describe.
2
+ //
3
+ // THE PROBLEM. Every field the resolver decides on lives in `action_descriptor`,
4
+ // and every one of them is authored by the agent being gated: domain, magnitude,
5
+ // reversible, amount, summary. A standing authorization like
6
+ //
7
+ // { match: { domains: ["writing"], magnitude_max: "medium", reversible: true },
8
+ // effect: "autoproceed" }
9
+ //
10
+ // therefore fires on whatever the agent SAYS it is doing, not on what it is doing.
11
+ // A buggy, prompt-injected, or hostile agent auto-proceeds on anything by
12
+ // describing it as low-magnitude reversible writing. The escalation rail inherits
13
+ // the same assumption: the subject approves the agent's prose summary.
14
+ //
15
+ // This is a known 2026 vulnerability class, not a hypothetical — agents requesting
16
+ // approval on a shown path while operating on the resolved target (CWE-61 symlink
17
+ // following + CWE-451 UI misrepresentation) produced CVE-2026-12958 (Amazon) and
18
+ // CVE-2026-50549 (Cursor), among others.
19
+ //
20
+ // WHAT THIS DOES, AND WHAT IT DOES NOT. A binding does NOT make the descriptor
21
+ // truthful — nothing at this layer can, short of the resolver executing the call
22
+ // itself. What it does:
23
+ //
24
+ // 1. makes a mismatch PROVABLE after the fact (the receipt records the exact
25
+ // tool and a hash of its arguments, so the agent's own logs can be checked
26
+ // against what the oracle was told),
27
+ // 2. gives the subject the REAL call on the escalation card, next to the
28
+ // agent's description of it, and
29
+ // 3. lets an authored authorization demand a binding before it will RELAX
30
+ // anything (§10.5 `require_binding`), so the blast radius of a fabricated
31
+ // descriptor is bounded by what the subject explicitly opted into.
32
+ //
33
+ // The hash is over the RFC 8785 canonical form of the arguments, so an agent and
34
+ // an auditor computing it independently agree byte-for-byte. Arguments themselves
35
+ // are NEVER carried — only a digest, plus an optional short human preview that the
36
+ // subject sees on the escalation sheet.
37
+ //
38
+ // PURE: no I/O, no globals, no crypto beyond the hash the caller injects. Shared by
39
+ // the resolver (mcp/consult-core.js), the receipt writer (mcp/receipts.js) and the
40
+ // escalation payload builder (mcp/escalation.js) so the three cannot drift.
41
+
42
+ import { PhaedoError } from './errors.js';
43
+
44
+ // A preview is for a human glance on a phone sheet, not a serialization format.
45
+ export const PREVIEW_MAX = 200;
46
+ export const TOOL_NAME_MAX = 120;
47
+ // A hex or base64url digest. Bounded so a malformed field can't bloat the receipt
48
+ // store or the (encrypted) escalation payload.
49
+ const HASH_RE = /^[A-Za-z0-9_-]{16,128}$/;
50
+
51
+ const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
52
+ const clip = (s, n) => String(s).replace(/\s+/g, ' ').trim().slice(0, n);
53
+
54
+ // Parse + validate §10.2 `action_binding`. Returns the normalized binding, or null
55
+ // when absent. THROWS `request_malformed` when present but structurally wrong — a
56
+ // half-formed binding is worse than none, because a caller could believe it is
57
+ // bound when the resolver treats it as unbound.
58
+ export function normalizeActionBinding(raw) {
59
+ if (raw == null) return null;
60
+ if (!isObj(raw)) throw new PhaedoError('request_malformed', 'action_binding must be an object.');
61
+
62
+ const toolName = typeof raw.tool_name === 'string' ? raw.tool_name.trim() : '';
63
+ if (!toolName) throw new PhaedoError('request_malformed', 'action_binding.tool_name is required and must be a non-empty string.');
64
+ if (toolName.length > TOOL_NAME_MAX) throw new PhaedoError('request_malformed', `action_binding.tool_name exceeds ${TOOL_NAME_MAX} characters.`);
65
+
66
+ const hash = typeof raw.arguments_hash === 'string' ? raw.arguments_hash.trim() : '';
67
+ if (!hash) throw new PhaedoError('request_malformed', 'action_binding.arguments_hash is required and must be a non-empty string.');
68
+ if (!HASH_RE.test(hash)) throw new PhaedoError('request_malformed', 'action_binding.arguments_hash must be a hex or base64url digest (16–128 chars).');
69
+
70
+ const out = { tool_name: clip(toolName, TOOL_NAME_MAX), arguments_hash: hash };
71
+ if (typeof raw.arguments_preview === 'string' && raw.arguments_preview.trim()) {
72
+ out.arguments_preview = clip(raw.arguments_preview, PREVIEW_MAX);
73
+ }
74
+ return out;
75
+ }
76
+
77
+ // Is this request bound to a concrete call? The single predicate every consumer
78
+ // asks, so "bound" means exactly one thing across the resolver, the policy engine,
79
+ // the receipt and the escalation card.
80
+ export function isBound(binding) {
81
+ return !!(binding && binding.tool_name && binding.arguments_hash);
82
+ }
package/agreement.js CHANGED
@@ -77,13 +77,97 @@ export function computeAgreement(receipts, { windowDays = DEFAULT_WINDOW_DAYS, n
77
77
  };
78
78
  }
79
79
 
80
+ // ── Split by resolver revision (precedent track Phase 1b) ─────────────────────
81
+ // Which engine answered a receipt: the `resolverRevision` driver the consult stamped (the
82
+ // rules' RESOLVER_REVISION, or `judge:<model>`). A matched pre-flight is a standing rule
83
+ // answering, not the inferred path. Anything else predates the stamp (2026-10-20) or carried
84
+ // no inferred prediction (an abstain) — "not recorded", never guessed.
85
+ export const NOT_RECORDED = 'not recorded';
86
+ export function revisionOf(r) {
87
+ const d = Array.isArray(r?.drivers) ? r.drivers.find((x) => x?.cue === 'resolverRevision') : null;
88
+ if (d && typeof d.value === 'string' && d.value) return d.value;
89
+ if (r?.via === 'phaedo_check_authorization') return 'standing rule (pre-flight)';
90
+ return NOT_RECORDED;
91
+ }
92
+
93
+ /**
94
+ * (receipts, { windowDays, now }) -> { [revision]: overall-bucket }, so a behaviour change
95
+ * shows up as two rates instead of one blurred one. Same scoring as computeAgreement.
96
+ */
97
+ export function computeAgreementByRevision(receipts, { windowDays = DEFAULT_WINDOW_DAYS, now = Date.now() } = {}) {
98
+ const groups = new Map();
99
+ for (const r of Array.isArray(receipts) ? receipts : []) {
100
+ const k = revisionOf(r);
101
+ if (!groups.has(k)) groups.set(k, []);
102
+ groups.get(k).push(r);
103
+ }
104
+ const out = {};
105
+ for (const [k, rs] of groups) {
106
+ const rep = computeAgreement(rs, { windowDays, now });
107
+ if (rep.window.in_window > 0) out[k] = rep.overall;
108
+ }
109
+ return out;
110
+ }
111
+
112
+ // ── The transportable summary (receipts digest → phone) ───────────────────────
113
+ // `computeAgreement` is the full local report; this is the bounded slice that rides
114
+ // the `agent_to_phone` receipts digest so the subject can see how well their oracle
115
+ // is tracking them, per domain, without opening a terminal.
116
+ //
117
+ // It is derived ENTIRELY from the subject's own receipts and only ever travels
118
+ // between the subject's own devices (ciphertext over the relay), so it raises no
119
+ // §10.4 question — but it still carries no layer content, no rationale, and no
120
+ // `drivers`: counts and a rate, nothing else.
121
+ //
122
+ // Bounded on purpose (the digest is a last-write-wins payload, so it must not grow
123
+ // without limit): unscored domains are dropped (a domain with nothing scored says
124
+ // nothing), the rest are ranked by `scored` and capped. Ties break by name so the
125
+ // payload is DETERMINISTIC — an unstable key order would make every deposit look
126
+ // like a change.
127
+ export const DIGEST_DOMAIN_CAP = 8;
128
+
129
+ const slimBucket = (b) => ({
130
+ rate: b.rate,
131
+ scored: b.scored,
132
+ agreements: b.agreements,
133
+ disagreements: b.disagreements,
134
+ non_directional: b.non_directional,
135
+ awaiting_user_action: b.awaiting_user_action,
136
+ });
137
+
138
+ // (receipts, { windowDays, now, domainCap }) -> { window_days, overall, domains }
139
+ export function agreementDigest(receipts, { windowDays = DEFAULT_WINDOW_DAYS, now = Date.now(), domainCap = DIGEST_DOMAIN_CAP } = {}) {
140
+ const report = computeAgreement(receipts, { windowDays, now });
141
+ const domains = {};
142
+ Object.entries(report.domains)
143
+ .filter(([, b]) => b.scored > 0)
144
+ .sort((a, b) => (b[1].scored - a[1].scored) || a[0].localeCompare(b[0]))
145
+ .slice(0, Math.max(0, domainCap))
146
+ .forEach(([name, b]) => { domains[name] = slimBucket(b); });
147
+ return { window_days: windowDays, overall: slimBucket(report.overall), domains };
148
+ }
149
+
80
150
  // ── Dev CLI: `node agreement.js` / `npm run agreement` — local readout only ───
81
151
  // Decrypts the local receipt store with the local key; nothing leaves the device.
82
152
  // NOT product UX and NOT fed back into deference (see header).
83
153
  if (process.argv[1] && process.argv[1].endsWith('agreement.js')) {
84
- const days = Number(process.argv[2]) || DEFAULT_WINDOW_DAYS;
154
+ const args = process.argv.slice(2);
155
+ const byRevision = args.includes('--by-revision');
156
+ const days = Number(args.find((a) => /^\d+$/.test(a))) || DEFAULT_WINDOW_DAYS;
85
157
  Promise.all([import('./receipts.js'), import('./cache.js')]).then(async ([{ readReceipts }, { defaultStateDir }]) => {
86
- const report = computeAgreement(await readReceipts(defaultStateDir()), { windowDays: days });
158
+ const receipts = await readReceipts(defaultStateDir());
159
+ if (byRevision) {
160
+ const split = computeAgreementByRevision(receipts, { windowDays: days });
161
+ console.log(`Agreement by rule-engine version — last ${days}d\n`);
162
+ const names = Object.keys(split).sort((a, b) => (a === NOT_RECORDED) - (b === NOT_RECORDED) || a.localeCompare(b));
163
+ if (!names.length) console.log('No receipts in the window.');
164
+ for (const k of names) {
165
+ const b = split[k];
166
+ console.log(`${k.padEnd(34)} rate ${b.rate === null ? ' — ' : (b.rate * 100).toFixed(1) + '%'} (${b.agreements}✓ ${b.disagreements}✗ of ${b.scored} scored · ${b.non_directional} non-directional · ${b.awaiting_user_action} awaiting)`);
167
+ }
168
+ return;
169
+ }
170
+ const report = computeAgreement(receipts, { windowDays: days });
87
171
  const { overall: o, window: w } = report;
88
172
  console.log(`Agreement over the last ${w.days}d — ${w.in_window}/${w.total_receipts} receipts in window\n`);
89
173
  const line = (label, b) =>