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/ap2.js ADDED
@@ -0,0 +1,214 @@
1
+ // Anamnesis ↔ AP2 profile — standing authorizations as Intent Mandates.
2
+ //
3
+ // WHY THIS EXISTS. The standing risk to Anamnesis is not that a rival builds a
4
+ // better decision oracle; it is the standards cold-start. One implementer is a
5
+ // format, not a standard, and evangelism has not moved that number. Binding to a
6
+ // standard that ALREADY has implementers does: every AP2 participant becomes a
7
+ // partial Anamnesis consumer without adopting Anamnesis.
8
+ //
9
+ // AP2 (Google → FIDO Alliance, May 2026; Mastercard's Verifiable Intent
10
+ // co-contributed; the FIDO Payments TWG is chaired by Mastercard and Visa) defines
11
+ // an INTENT MANDATE: a verifiable credential, signed by the user with a
12
+ // hardware-backed device key, TTL'd, carrying structured constraints and the
13
+ // agent's stated understanding of the request ("prompt playback"), plus an
14
+ // intentionally open-ended risk payload.
15
+ //
16
+ // Read that against §10.5. An Intent Mandate is a standing authorization
17
+ // (`match` + `amount_gt` + `expires_at`) rendered as a credential the card networks
18
+ // already accept — and Phaedo's escalate-to-phone flow is architecturally the signing
19
+ // ceremony, once §8.3 gives the phone a signing key. The two designs arrived
20
+ // at the same primitive from opposite ends. "Prompt playback" is the payments
21
+ // industry independently discovering §10.2 `action_binding`.
22
+ //
23
+ // WHAT THIS MODULE DOES, AND DOES NOT. It is a PURE SERIALIZER: it renders the
24
+ // subject's authorizations into the Intent Mandate payload shape, and renders a
25
+ // consultation signal into a risk-payload entry. It does NOT sign — and today
26
+ // NOTHING does: §8.3 asymmetric signing is unbuilt, so no subject signing key
27
+ // exists anywhere in the product yet. That is the honest current state; the
28
+ // intended design is that the key is generated non-extractably on the phone and
29
+ // the ceremony that turns this payload into a credential is the phone's, not the
30
+ // server's. Deliberately unreachable from here either way. Scope + the P-256 vs
31
+ // Ed25519 decision: docs/roadmap/asymmetric-signing-scope-2026-07-23.md.
32
+ //
33
+ // The mapping is lossy in ONE direction on purpose. Anamnesis carries dispositions
34
+ // and inferred signals that have no place in a payment credential, so only the
35
+ // deterministic, subject-authored half crosses: authorizations, their bounds, and
36
+ // their expiry. Nothing inferred, no layer content, no persona. That is the §10.4
37
+ // boundary applied to a new transport, and it is also why this is safe to hand to a
38
+ // merchant's stack.
39
+ //
40
+ // PURE: no I/O, no crypto, no network.
41
+
42
+ import { signingInput } from './signing.js';
43
+
44
+ const MAG_RANK = { low: 0, medium: 1, high: 2 };
45
+
46
+ const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
47
+
48
+ // AP2 expresses shopping constraints as structured fields, not prose. §10.5 `match`
49
+ // is already structured, so the translation is mechanical — the only real decision is
50
+ // which constraints have a faithful counterpart and which must be dropped rather than
51
+ // approximated. `magnitude` has none (it is a behavioral notion, not a commerce one),
52
+ // so it is carried as an advisory annotation, never as a spend limit.
53
+ function matchToConstraints(match) {
54
+ const m = isObj(match) ? match : {};
55
+ const out = {};
56
+ const domains = Array.isArray(m.domains) ? m.domains.map((d) => String(d)).filter(Boolean) : [];
57
+ if (domains.length) out.categories = domains;
58
+ // AP2's `max_amount` is a spend cap, so only a genuine CEILING may fill it.
59
+ // `amount_lt` is that ceiling. `amount_gt` is a FLOOR — "fires above N" — and on an
60
+ // autoproceed rule it means "auto-proceed only the LARGE ones", the opposite of a
61
+ // cap. Mapping it to `max_amount` (as this did before `amount_lt` existed) would
62
+ // have told a merchant the subject capped spending at exactly the figure above
63
+ // which they wanted extra scrutiny. It is carried as an annotation instead.
64
+ // Both ceiling forms map to `max_amount`, and when both are present the LOWER wins — AP2 has
65
+ // one field and no way to say "exclusive", so the credential must not assert more headroom than
66
+ // the rule grants. `amount_lte: 20` becomes `max_amount: 20`; `amount_lt: 20` also becomes 20,
67
+ // which is the pre-existing (slightly generous by one cent) mapping, unchanged.
68
+ const ceilings = [m.amount_lt, m.amount_lte].filter((v) => typeof v === 'number' && Number.isFinite(v));
69
+ if (ceilings.length) {
70
+ out.max_amount = Math.min(...ceilings);
71
+ }
72
+ if (typeof m.amount_gt === 'number' && Number.isFinite(m.amount_gt)) {
73
+ out.annotations = { ...(out.annotations || {}), anamnesis_amount_floor: m.amount_gt };
74
+ }
75
+ if (typeof m.reversible === 'boolean') out.refundable_only = m.reversible;
76
+ if (m.magnitude_max && MAG_RANK[m.magnitude_max] !== undefined) {
77
+ out.annotations = { ...(out.annotations || {}), anamnesis_magnitude_max: m.magnitude_max };
78
+ }
79
+ return out;
80
+ }
81
+
82
+ // One §10.5 authorization → the Intent Mandate constraint block it corresponds to,
83
+ // or null when it has no faithful translation.
84
+ //
85
+ // Only AUTOPROCEED authorizations become mandates. That is the whole semantic match:
86
+ // an Intent Mandate is a grant of authority to act without the human present, and
87
+ // `autoproceed` is the only Anamnesis effect that grants anything. An `escalate` or
88
+ // `decline` rule is the ABSENCE of a mandate — the correct AP2 rendering of "ask me
89
+ // first" is not a restrictive credential, it is no credential at all, and emitting
90
+ // one would invert its meaning to a merchant.
91
+ export function authorizationToIntentConstraints(rule, { now = Date.now() } = {}) {
92
+ if (!isObj(rule)) return null;
93
+ if (rule.kind !== undefined && rule.kind !== 'authorization') return null;
94
+ if (rule.effect !== 'autoproceed') return null;
95
+ // A lapsed authorization must not be exported as live authority.
96
+ const exp = Date.parse(rule.expires_at);
97
+ if (Number.isFinite(exp) && exp <= now) return null;
98
+ // Nor must a CLIENT-SCOPED one (§10.5 `agents`). AP2 has no vocabulary for "this
99
+ // specific paired client", so a scoped grant cannot be expressed in a mandate — and
100
+ // emitting it unscoped would tell a merchant the subject granted unconditionally
101
+ // what they in fact granted to exactly one client. That is the same inversion this
102
+ // function already refuses for restrictive rules, and it crosses to a third party,
103
+ // where it is least correctable. A scope that cannot be carried must instead
104
+ // withhold the credential: the correct AP2 rendering of a grant this format cannot
105
+ // express is no grant at all. (An ABSENT `agents` is unscoped and exports normally;
106
+ // an empty array is a scope naming nobody, so it withholds too.)
107
+ if (Array.isArray(rule.agents)) return null;
108
+ return {
109
+ source_rule_id: rule.instruction_id || rule.id || null,
110
+ constraints: matchToConstraints(rule.match),
111
+ // §10.5 require_binding maps onto AP2's own posture that a mandate authorizes a
112
+ // BOUNDED transaction, not open-ended spending. Carried through so a verifier can
113
+ // see the subject demanded the agent commit to a concrete action.
114
+ requires_action_binding: rule.require_binding === true,
115
+ expires_at: Number.isFinite(exp) ? new Date(exp).toISOString() : null,
116
+ };
117
+ }
118
+
119
+ // The unsigned Intent Mandate payload for a subject's live authorizations.
120
+ //
121
+ // Returns null when the subject has granted nothing — which is the common case and
122
+ // the right answer: no authority, no mandate. Callers MUST NOT synthesize an empty
123
+ // mandate, because an empty credential reads to a merchant as "authorized with no
124
+ // constraints", the exact inversion this module exists to avoid.
125
+ export function buildIntentMandatePayload(fp, opts = {}) {
126
+ const {
127
+ now = Date.now(),
128
+ agentId = null,
129
+ promptPlayback = null, // the agent's stated understanding (AP2 "prompt playback")
130
+ consultSignal = null, // §10.3 signal, if a consultation preceded this
131
+ } = opts;
132
+
133
+ // A standing authorization lives in EITHER home (spec §10.5: "a `consult_policies` rule
134
+ // or `standing_rules` authorization"), so an Intent Mandate — the subject's live
135
+ // authorizations — must read both. Reading only `standing_rules` silently dropped every
136
+ // autoproceed guardrail authored through the extension's Decision-guardrails form (which
137
+ // writes `consult_policies`), so a subject with real grants got an empty mandate. The
138
+ // per-rule guards are unchanged and live in authorizationToIntentConstraints: autoproceed
139
+ // only, not lapsed, and never a client-scoped grant (AP2 can't express `agents`).
140
+ const standingRules = Array.isArray(fp?.standing_rules) ? fp.standing_rules : [];
141
+ const policyRules = Array.isArray(fp?.consult_policies?.rules) ? fp.consult_policies.rules : [];
142
+ // §10.5 tombstones — a REVOKED authorization must never become a mandate grant. This is
143
+ // the highest-stakes read of the two homes: a grant here is what a payment credential is
144
+ // minted against, so a rule the subject revoked that survived in a stale or restored copy
145
+ // would sign away authority they took back. Same consumer MUST as normalizePolicies.
146
+ const revoked = new Set(
147
+ (Array.isArray(fp?.consult_policies?.revoked) ? fp.consult_policies.revoked : [])
148
+ .map((t) => (t && typeof t === 'object' && t.id != null ? String(t.id) : null))
149
+ .filter(Boolean)
150
+ );
151
+ const grants = [];
152
+ const seen = new Set(); // dedupe by source rule id — the same authorization must not
153
+ for (const r of [...standingRules, ...policyRules]) { // become two mandate grants
154
+ if (r && typeof r === 'object') {
155
+ const rid = r.id != null ? String(r.id) : (r.instruction_id != null ? String(r.instruction_id) : null);
156
+ if (rid && revoked.has(rid)) continue;
157
+ }
158
+ const g = authorizationToIntentConstraints(r, { now });
159
+ if (!g) continue;
160
+ if (g.source_rule_id && seen.has(g.source_rule_id)) continue;
161
+ if (g.source_rule_id) seen.add(g.source_rule_id);
162
+ grants.push(g);
163
+ }
164
+ if (!grants.length) return null;
165
+
166
+ // The mandate expires with the FIRST grant to lapse — an aggregate credential must
167
+ // not outlive the narrowest authority inside it.
168
+ const expiries = grants.map((g) => Date.parse(g.expires_at)).filter(Number.isFinite);
169
+ const expiresAt = expiries.length ? new Date(Math.min(...expiries)).toISOString() : null;
170
+
171
+ return {
172
+ mandate_type: 'intent',
173
+ // Version the profile, not the payment protocol: AP2 moves on its own cadence and
174
+ // a verifier needs to know which mapping produced this.
175
+ anamnesis_profile: '0.1',
176
+ issued_at: new Date(now).toISOString(),
177
+ expires_at: expiresAt,
178
+ ...(agentId ? { agent_id: String(agentId) } : {}),
179
+ ...(promptPlayback ? { prompt_playback: String(promptPlayback).slice(0, 500) } : {}),
180
+ grants,
181
+ // AP2 leaves the risk payload open-ended for exactly this kind of signal. It is
182
+ // the ONE place a behavioral system has something to say to a payments network,
183
+ // and it says it in the §10.4 currency: a constrained signal, never persona.
184
+ risk_payload: buildRiskPayload({ consultSignal }),
185
+ // Unsigned by construction. The server CANNOT sign: per §8.3 the subject's key is
186
+ // non-extractable in the phone's Secure Enclave, so signing is a ceremony that belongs
187
+ // to the device holding the key. `intentMandateSigningInput()` below computes the exact
188
+ // bytes the phone would be asked to sign. Until a signature comes back, this object is
189
+ // a proposal, never a credential.
190
+ signature: null,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * The exact bytes a §8.3 signature over an Intent Mandate covers: the §6 RFC 8785 canonical
196
+ * form of the payload with `signature` omitted. This is the seam between the server (which
197
+ * builds the mandate) and the phone (which holds the only key that can sign it) — both sides
198
+ * must agree on these bytes or the resulting signature verifies nowhere.
199
+ *
200
+ * Omitting `signature` is what keeps the input stable once the signature is attached, so a
201
+ * verifier recomputes the same bytes from the finished credential.
202
+ */
203
+ export function intentMandateSigningInput(payload) {
204
+ return signingInput(payload, { field: 'signature' });
205
+ }
206
+
207
+ // §10.3 signal → AP2 risk-payload entry. Deliberately minimal: a merchant learns
208
+ // that the subject's own oracle was consulted and what it said — which is exactly the
209
+ // claim that makes a mandate more trustworthy — and learns nothing about the subject.
210
+ export function buildRiskPayload({ consultSignal = null } = {}) {
211
+ const payload = { anamnesis_consulted: !!consultSignal };
212
+ if (consultSignal) payload.anamnesis_signal = String(consultSignal);
213
+ return payload;
214
+ }
@@ -0,0 +1,280 @@
1
+ // §10.7 — fatigue-aware autonomy promotion.
2
+ //
3
+ // THE PROBLEM THIS SOLVES. Phaedo can add a gate and never remove one. The
4
+ // delegation-promotion channel learns only from DISAGREEMENTS
5
+ // (consult-core.js `buildDelegationPromotions`), and §10.5 permanently caps what
6
+ // an authored disposition may do at `bias_cautious`: "an authored disposition may
7
+ // make the agent MORE deferential, never less." Expanding autonomy requires the
8
+ // subject to hand-author a `kind:"authorization"` rule with a `match`. Almost
9
+ // nobody will. So in real use the oracle accumulates friction and never sheds it,
10
+ // and the 2026 approval-fatigue literature is unanimous about where that ends:
11
+ // approval stops being a decision and becomes a reflex, which is both a usability
12
+ // failure and an attack surface.
13
+ //
14
+ // THE OBJECTION, AND WHY IT SHAPES THE DESIGN. "Learn from approvals" is exactly
15
+ // what the honesty rail forbids — rubber stamps must not teach. That objection is
16
+ // correct, and it is the reason this module is not a counter. A subject clicking
17
+ // approve fourteen times in ninety seconds is not expressing a policy; they are
18
+ // tired. So the gate here is not "how many approvals" but "is this evidence of
19
+ // DELIBERATION":
20
+ //
21
+ // - spread over distinct days, not a single sitting (volume ≠ deliberation)
22
+ // - no contrary evidence at all: one rejection or modification in the class kills it
23
+ // - the oracle must already be tracking the subject well in that domain
24
+ // (agreement rate — the interlock the agreement read-out exists to provide)
25
+ // - not batch-marked: approvals recorded in one burst are bookkeeping, not consent
26
+ //
27
+ // AND WHAT IS PROPOSED IS DELIBERATELY SMALL. Reversible only. Never `high`
28
+ // magnitude. A priced class is bounded by an `amount_lt` CEILING set to the
29
+ // SECOND-highest amount approved, never the highest — one outlier must not set the
30
+ // bar (see spendCeiling). Always `require_binding: true`, so the relaxation cannot
31
+ // be unlocked by a fabricated descriptor. Always `expires_at`, so an autonomy grant
32
+ // the subject stops wanting lapses by itself.
33
+ //
34
+ // AND IT IS STILL ONLY A PROPOSAL. This writes nothing authored. It stages a
35
+ // `suggested_rules` entry the subject reviews and endorses, exactly like every
36
+ // other promotion channel — the authored-only invariant holds. The producer
37
+ // stages; the user authors.
38
+ //
39
+ // PURE: (receipts, fp, agreement, opts) -> suggestions[]. No I/O, no globals.
40
+
41
+ import { computeAgreement, DEFAULT_WINDOW_DAYS } from './agreement.js';
42
+
43
+ // ── The bar. Every one of these is a way of asking "was this deliberate?" ─────
44
+ export const MIN_APPROVALS = 5; // enough instances to be a pattern
45
+ export const MIN_DISTINCT_DAYS = 3; // ...spread across days, not one sitting
46
+ export const MIN_AGREEMENT = 0.8; // ...in a domain the oracle already tracks well
47
+ export const MIN_AGREEMENT_SCORED = 5; // ...on enough scored decisions to mean anything
48
+ export const BATCH_WINDOW_MS = 60_000; // marks this close together are bookkeeping
49
+ export const MAX_BATCH_FRACTION = 0.5; // ...and may not carry the majority of the evidence
50
+ export const DEFAULT_TTL_DAYS = 30; // a grant expires unless re-earned
51
+
52
+ const DAY_MS = 24 * 60 * 60 * 1000;
53
+ const MAG_RANK = { low: 0, medium: 1, high: 2 };
54
+ const RANK_MAG = ['low', 'medium', 'high'];
55
+
56
+ const uuid = () =>
57
+ (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID()
58
+ : `aut-${Math.random().toString(36).slice(2, 10)}`;
59
+
60
+ const utcDay = (iso) => String(iso || '').slice(0, 10); // RFC 3339 → YYYY-MM-DD
61
+
62
+ // A promotable class is (domain × reversible-true). Magnitude is not part of the
63
+ // key — it is the CEILING we derive from what was actually observed, so a class
64
+ // seen only at `low` never proposes a rule that would fire at `medium`.
65
+ function classKey(domain) { return String(domain || '').toLowerCase(); }
66
+
67
+ // Receipts that count as evidence FOR autonomy in a class. Deliberately narrow:
68
+ // the oracle must have been decisive, the subject must have approved, and the
69
+ // action must be one we would ever propose relaxing.
70
+ function isPromotableApproval(r) {
71
+ const ad = r?.request?.action_descriptor || {};
72
+ if (r?.user_action !== 'approved') return false;
73
+ if (r?.response?.signal !== 'proceed' && r?.response?.signal !== 'proceed_with_note') return false;
74
+ if (ad.reversible !== true) return false; // irreversible is never auto-proceeded
75
+ if (ad.magnitude === 'high') return false; // nor is a major action
76
+ if (ad.amount !== undefined && !(typeof ad.amount === 'number' && Number.isFinite(ad.amount) && ad.amount >= 0))
77
+ return false; // a malformed amount is not evidence of anything
78
+ if (!ad.domain) return false; // an unscoped autoproceed rule is a blank cheque
79
+ return true;
80
+ }
81
+
82
+ // The spend ceiling for a money class: the SECOND-highest amount approved, not the
83
+ // highest.
84
+ //
85
+ // Using the maximum would let ONE outlier set the bar — approve five $20 orders and a
86
+ // single $500 one, and the rule would thereafter auto-proceed anything up to $500.
87
+ // Requiring two approvals at a level before the rule reaches it is the money-shaped
88
+ // version of the "spread across days, not one sitting" rule: a bound has to be
89
+ // demonstrated more than once.
90
+ //
91
+ // Returns null when the class carries no amounts at all (then no ceiling is needed),
92
+ // or when fewer than two priced approvals exist (then no ceiling can be justified, so
93
+ // the class is not promotable).
94
+ export function spendCeiling(receipts) {
95
+ const amounts = receipts
96
+ .map((r) => r?.request?.action_descriptor?.amount)
97
+ .filter((a) => typeof a === 'number' && Number.isFinite(a));
98
+ if (!amounts.length) return null; // unpriced class — no ceiling needed
99
+ if (amounts.length < 2) return undefined; // one priced approval cannot justify a bound
100
+ const sorted = amounts.slice().sort((a, b) => b - a);
101
+ return sorted[1];
102
+ }
103
+
104
+ // Anything that argues AGAINST relaxing this class. One is enough to disqualify:
105
+ // a class the subject has ever rejected or edited is not settled.
106
+ function isContraryEvidence(r, domain) {
107
+ const ad = r?.request?.action_descriptor || {};
108
+ if (classKey(ad.domain) !== domain) return false;
109
+ return r?.user_action === 'rejected' || r?.user_action === 'modified';
110
+ }
111
+
112
+ // Were these approvals RECORDED in a burst? Marking a screenful of receipts in one
113
+ // pass is bookkeeping, not consent — so if most of the supporting evidence was
114
+ // stamped inside one short window, it does not support a policy. Receipts without
115
+ // a mark timestamp (older stores) are treated as non-batch: absence of evidence
116
+ // is not evidence of fatigue, and the other gates still apply.
117
+ export function batchFraction(receipts, { windowMs = BATCH_WINDOW_MS } = {}) {
118
+ const stamps = receipts
119
+ .map((r) => Date.parse(r?.user_action_at))
120
+ .filter((t) => Number.isFinite(t))
121
+ .sort((a, b) => a - b);
122
+ if (stamps.length < 2) return 0;
123
+ let best = 1;
124
+ for (let i = 0; i < stamps.length; i++) {
125
+ let j = i;
126
+ while (j < stamps.length && stamps[j] - stamps[i] <= windowMs) j++;
127
+ best = Math.max(best, j - i);
128
+ }
129
+ return best / receipts.length;
130
+ }
131
+
132
+ function proposedText(domain, magMax, ceiling = null) {
133
+ const scope = magMax === 'low' ? 'small, reversible' : 'routine, reversible';
134
+ // The ceiling is the single most consequential term on a money rule, so it belongs
135
+ // in the sentence the subject reads — not only in the terms line beneath it.
136
+ // "up to", not "under" — the ceiling is INCLUSIVE now (M7), and this sentence is what the
137
+ // subject reads before endorsing. Prose that says "under 20" for a rule that fires AT 20
138
+ // misdescribes the authority being granted, on the one screen where accuracy is consent.
139
+ const under = ceiling !== null ? ` up to ${ceiling}` : '';
140
+ return `Let agents go ahead with ${scope} ${domain} actions${under} without asking me first.`;
141
+ }
142
+
143
+ // (receipts, fp, opts) -> suggestion[] — the §10.5 authorizations worth PROPOSING.
144
+ //
145
+ // `fp` supplies what is already authored or already staged, so an endorsed or
146
+ // pending suggestion is never re-proposed (the no-nag rule every promotion
147
+ // channel follows).
148
+ export function buildAutonomyPromotions(receipts, fp, opts = {}) {
149
+ const {
150
+ now = Date.now(),
151
+ windowDays = DEFAULT_WINDOW_DAYS,
152
+ minApprovals = MIN_APPROVALS,
153
+ minDistinctDays = MIN_DISTINCT_DAYS,
154
+ minAgreement = MIN_AGREEMENT,
155
+ minAgreementScored = MIN_AGREEMENT_SCORED,
156
+ maxBatchFraction = MAX_BATCH_FRACTION,
157
+ ttlDays = DEFAULT_TTL_DAYS,
158
+ } = opts;
159
+
160
+ const list = Array.isArray(receipts) ? receipts : [];
161
+ const since = now - windowDays * DAY_MS;
162
+ const inWindow = list.filter((r) => {
163
+ const t = Date.parse(r?.created_at);
164
+ return Number.isFinite(t) && t >= since && t <= now;
165
+ });
166
+
167
+ // Already covered? An authored autoproceed for the domain, or a live suggestion.
168
+ const authored = new Set(
169
+ (Array.isArray(fp?.standing_rules) ? fp.standing_rules : [])
170
+ .filter((r) => r?.kind === 'authorization' && r?.effect === 'autoproceed')
171
+ .flatMap((r) => (Array.isArray(r?.match?.domains) ? r.match.domains : []).map(classKey)),
172
+ );
173
+ const staged = new Set(
174
+ (Array.isArray(fp?.suggested_rules) ? fp.suggested_rules : [])
175
+ .filter((s) => s?.source === 'autonomy_promotion')
176
+ .flatMap((s) => (Array.isArray(s?.match?.domains) ? s.match.domains : []).map(classKey)),
177
+ );
178
+
179
+ const agreement = computeAgreement(list, { windowDays, now });
180
+
181
+ const byClass = new Map();
182
+ for (const r of inWindow) {
183
+ if (!isPromotableApproval(r)) continue;
184
+ const key = classKey(r.request.action_descriptor.domain);
185
+ const bucket = byClass.get(key) || { receipts: [], days: new Set(), magRank: 0, clients: new Set() };
186
+ bucket.receipts.push(r);
187
+ bucket.days.add(utcDay(r.created_at));
188
+ bucket.magRank = Math.max(bucket.magRank, MAG_RANK[r.request.action_descriptor.magnitude] ?? 0);
189
+ // §10.5 scoping (step 5): WHICH paired client's actions earned this. Recorded from
190
+ // the receipt's `client_id` — the serving client, established out-of-band with the
191
+ // phone — never from `agent_id`, which is the caller's claim about itself.
192
+ if (typeof r.client_id === 'string' && r.client_id) bucket.clients.add(r.client_id);
193
+ byClass.set(key, bucket);
194
+ }
195
+
196
+ const out = [];
197
+ for (const [domain, bucket] of byClass) {
198
+ if (authored.has(domain) || staged.has(domain)) continue; // no-nag
199
+ if (bucket.receipts.length < minApprovals) continue; // not yet a pattern
200
+ if (bucket.days.size < minDistinctDays) continue; // one sitting is not a policy
201
+ if (inWindow.some((r) => isContraryEvidence(r, domain))) continue; // any pushback disqualifies the class
202
+ if (batchFraction(bucket.receipts, opts) > maxBatchFraction) continue; // batch-marked = bookkeeping
203
+
204
+ // The interlock: don't hand autonomy to an oracle that isn't tracking this
205
+ // subject here. `(none)` is how computeAgreement keys undomained receipts.
206
+ const dom = agreement.domains[domain];
207
+ if (!dom || dom.scored < minAgreementScored || dom.rate === null || dom.rate < minAgreement) continue;
208
+
209
+ // Money needs a ceiling, and a ceiling needs to have been demonstrated twice.
210
+ // `undefined` means the class is priced but cannot justify a bound — skip it
211
+ // rather than propose an unbounded spend authorization.
212
+ const ceiling = spendCeiling(bucket.receipts);
213
+ if (ceiling === undefined) continue;
214
+ if (ceiling !== null && !(ceiling > 0)) continue; // a 0 ceiling can never match
215
+
216
+ const magMax = RANK_MAG[bucket.magRank] || 'low';
217
+
218
+ // §10.5 `agents` — scope the proposed grant to the clients that EARNED it.
219
+ //
220
+ // Only meaningful now that scoping is enforced (v0.1.53) and authorable on the
221
+ // phone (#197). Before that, writing this field would have shipped something that
222
+ // looked like a boundary and was not — the reason it was deliberately left off.
223
+ //
224
+ // Strictly narrowing versus the previous behaviour, which granted every paired
225
+ // client whatever any one of them earned. A client that contributed approvals but
226
+ // cannot be attributed (receipts written before v0.1.53 carry no `client_id`) is
227
+ // simply left out — it keeps asking, which is the fail-safe direction.
228
+ //
229
+ // The empty case is the one that matters: when NO receipt in the class can be
230
+ // attributed, the field is OMITTED rather than written as `[]`. Absent means
231
+ // unscoped (today's behaviour, preserved for pre-v0.1.53 history); `[]` means a
232
+ // scope naming nobody, which would stage a grant that can never fire. Getting
233
+ // those two confused is precisely the defect the v0.1.54 hardening pass caught.
234
+ const agents = [...bucket.clients].sort();
235
+ out.push({
236
+ suggestion_id: uuid(),
237
+ status: 'suggested',
238
+ kind: 'authorization',
239
+ source: 'autonomy_promotion',
240
+ proposed_text: proposedText(domain, magMax, ceiling),
241
+ match: {
242
+ domains: [domain],
243
+ reversible: true,
244
+ magnitude_max: magMax,
245
+ // INCLUSIVE (M7). This staged `amount_lt: ceiling`, and `ceiling` is the SECOND-HIGHEST
246
+ // amount approved — so five $20 approvals minted a rule matching `amount < 20`, which
247
+ // excludes every purchase that earned it. The grant was unfireable by construction, and
248
+ // silently: it appeared in the subject's rules, read correctly in prose, and never
249
+ // matched. §10.5 gained `amount_lte` for this.
250
+ ...(ceiling !== null ? { amount_lte: ceiling } : {}),
251
+ },
252
+ effect: 'autoproceed',
253
+ // Non-negotiable on a PROPOSED relaxation: it may only apply to a request
254
+ // bound to a concrete call, and it lapses unless the subject re-earns it.
255
+ require_binding: true,
256
+ expires_at: new Date(now + ttlDays * DAY_MS).toISOString(),
257
+ ...(agents.length ? { agents } : {}),
258
+ note: agents.length
259
+ // Say the grant is limited, without printing an opaque `c_…` the subject
260
+ // cannot recognise — only the phone holds the registry that names it.
261
+ ? `you have approved every ${domain} action like this that Phaedo has seen — limited to the agent that earned it`
262
+ : `you have approved every ${domain} action like this that Phaedo has seen`,
263
+ evidence: {
264
+ observation_count: bucket.receipts.length,
265
+ distinct_days: bucket.days.size,
266
+ agreement_rate: dom.rate,
267
+ evidence_basis: 'observed',
268
+ last_observed: bucket.receipts[bucket.receipts.length - 1]?.created_at || null,
269
+ // Provenance for the audit: how many paired clients this evidence came from,
270
+ // and whether any of it predates client attribution. Distinct from `agents`,
271
+ // which is the enforceable scope.
272
+ client_count: bucket.clients.size,
273
+ unattributed: bucket.receipts.some((r) => !(typeof r.client_id === 'string' && r.client_id)),
274
+ },
275
+ created: new Date(now).toISOString(),
276
+ });
277
+ }
278
+ // Deterministic order — this rides a last-write-wins mailbox deposit.
279
+ return out.sort((a, b) => a.match.domains[0].localeCompare(b.match.domains[0]));
280
+ }
package/build-package.mjs CHANGED
@@ -1,22 +1,37 @@
1
1
  #!/usr/bin/env node
2
- // Make the npm package self-contained. The MCP server reaches two sets of files at
3
- // the repo root (one source of truth, shared with the extension): the injection
4
- // renderer (context-block.js, via projection.js) and the classic crypto modules
5
- // (protocol/envelope/kdf/identity.js, via lib/phaedo-crypto.js). The published
6
- // package ships only mcp/, so this copies those root modules into mcp/vendor/;
7
- // projection.js and lib/phaedo-crypto.js prefer the vendored copies when present
8
- // and fall back to the repo root in dev. Runs as `prepack` (npm pack / publish)
9
- // and `npm run build`. Idempotent; mcp/vendor/ is gitignored (a build artifact).
2
+ // Make the npm package self-contained. The MCP server reaches three sets of files
3
+ // at the repo root (one source of truth, shared with the extension): the injection
4
+ // renderer (context-block.js, via projection.js), the classic crypto modules
5
+ // (protocol/envelope/kdf/identity.js, via lib/phaedo-crypto.js), and the capture
6
+ // stack — capture/* for the SessionEnd hook plus the extraction modules the hook
7
+ // and the mcp-local tier-3 fold need (loaded via lib/capture-home.js). The
8
+ // published package ships only mcp/, so this copies those root modules into
9
+ // mcp/vendor/ (capture/extraction keep their subdirectories — their imports are
10
+ // intra-directory relative, so the copies resolve each other unchanged); the
11
+ // consumers prefer the location per their own doc-comments. Runs as `prepack`
12
+ // (npm pack / publish), `pretest`, and `npm run build`. Idempotent; mcp/vendor/
13
+ // is gitignored (a build artifact).
10
14
 
11
15
  import { copyFileSync, mkdirSync } from 'fs';
12
16
  import { dirname, join, resolve } from 'path';
13
17
  import { fileURLToPath } from 'url';
18
+ import { ROOT_FILES, CAPTURE_FILES, EXTRACTION_FILES } from './vendored-files.mjs';
14
19
 
15
20
  const HERE = dirname(fileURLToPath(import.meta.url));
16
21
  const ROOT = resolve(HERE, '..');
17
22
  const VENDOR = join(HERE, 'vendor');
18
- const FILES = ['context-block.js', 'protocol.js', 'envelope.js', 'kdf.js', 'identity.js'];
23
+ // Single source of truth, shared with scripts/export-public-repo.mjs (they drifted once).
24
+ const FILES = ROOT_FILES;
25
+ // The capture hook + tier-3 fold closure. Keep in lockstep with what
26
+ // lib/capture-home.js consumers actually import — test-vendored-capture.mjs
27
+ // exercises the whole set from a rootless copy, so a missing module fails CI.
28
+
29
+
19
30
 
20
31
  mkdirSync(VENDOR, { recursive: true });
21
32
  for (const f of FILES) copyFileSync(resolve(ROOT, f), join(VENDOR, f));
22
- process.stdout.write(`[phaedo-mcp] bundled ${FILES.length} root modules → mcp/vendor/\n`);
33
+ mkdirSync(join(VENDOR, 'capture'), { recursive: true });
34
+ for (const f of CAPTURE_FILES) copyFileSync(resolve(ROOT, 'capture', f), join(VENDOR, 'capture', f));
35
+ mkdirSync(join(VENDOR, 'extraction'), { recursive: true });
36
+ for (const f of EXTRACTION_FILES) copyFileSync(resolve(ROOT, 'extraction', f), join(VENDOR, 'extraction', f));
37
+ process.stdout.write(`[phaedo-mcp] bundled ${FILES.length} root + ${CAPTURE_FILES.length} capture + ${EXTRACTION_FILES.length} extraction modules → mcp/vendor/\n`);