phaedo-mcp 0.1.1 → 0.2.0
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 +4 -1
- package/calibration.js +107 -0
- package/consult-core.js +363 -36
- package/consult-policy.js +61 -12
- package/consult.js +4 -2
- package/fingerprint-source.js +61 -3
- package/hooks/session-start.mjs +9 -2
- package/install.mjs +6 -0
- package/package.json +11 -4
- package/pairing.js +13 -1
- package/receipts.js +12 -1
- package/sample-fingerprint.json +3 -0
- package/server.js +366 -8
- package/server.json +3 -3
- package/vendor/context-block.js +179 -4
- package/vendor/envelope.js +4 -1
- package/vendor/kdf.js +16 -1
- package/vendor/protocol.js +4 -0
package/consult-policy.js
CHANGED
|
@@ -49,6 +49,14 @@ export function normalizePolicies(raw) {
|
|
|
49
49
|
magMax: m.magnitude_max != null ? magRank(m.magnitude_max) : null,
|
|
50
50
|
reversible: typeof m.reversible === 'boolean' ? m.reversible : null,
|
|
51
51
|
amountGt: typeof m.amount_gt === 'number' && isFinite(m.amount_gt) ? m.amount_gt : null,
|
|
52
|
+
// Conditional + revocable authority (§10.5). `expiresAt` is ENFORCED: a lapsed
|
|
53
|
+
// authorization stops firing (time-bound = revocable, no identity needed; the
|
|
54
|
+
// direction is always authority-NARROWING, so it's fail-safe). `agents` is
|
|
55
|
+
// RESERVED (parsed, not yet enforced): scoping authority to specific agents needs
|
|
56
|
+
// an AUTHENTICATED agent identity — a self-asserted agent_id would be false
|
|
57
|
+
// security — so enforcement waits for that (build-later).
|
|
58
|
+
expiresAt: typeof r.expires_at === 'string' && Number.isFinite(Date.parse(r.expires_at)) ? Date.parse(r.expires_at) : null,
|
|
59
|
+
agents: Array.isArray(r.agents) ? r.agents.map((a) => String(a).toLowerCase()).filter(Boolean) : [],
|
|
52
60
|
effect,
|
|
53
61
|
note: typeof r.note === 'string' ? r.note : null,
|
|
54
62
|
});
|
|
@@ -56,16 +64,50 @@ export function normalizePolicies(raw) {
|
|
|
56
64
|
return out;
|
|
57
65
|
}
|
|
58
66
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
// Project the AUTHORIZATION-kind entries of `standing_rules` (Proposal 0007 — the
|
|
68
|
+
// single authored home) into the raw consult_policies rule shape normalizePolicies
|
|
69
|
+
// consumes. `kind:"instruction"` entries are verbatim injection rules, not gates —
|
|
70
|
+
// the resolver ignores them. `note` falls back to the injectable `text` so the
|
|
71
|
+
// rationale_hint still names the rule.
|
|
72
|
+
function authorizationsFromStandingRules(fp) {
|
|
73
|
+
const rules = Array.isArray(fp?.standing_rules) ? fp.standing_rules : [];
|
|
74
|
+
return rules
|
|
75
|
+
.filter((r) => r && r.kind === 'authorization' && r.effect)
|
|
76
|
+
.map((r) => ({ id: r.instruction_id || null, match: r.match || {}, effect: r.effect, note: r.note || r.text || null, expires_at: r.expires_at, agents: r.agents }));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Project a bound `kind:"instruction"` (Proposal 0007 §D) into a consultation BIAS.
|
|
80
|
+
// An instruction carries prose + a `decision` dimension + polarity, but not a
|
|
81
|
+
// structured enum value, so it cannot resolve a dimension to a value the way an
|
|
82
|
+
// inferred signal does. v0.1 semantics, deliberately conservative: a NEGATIVE
|
|
83
|
+
// (anti-preference / "I won't…") bound rule nudges the matching-domain consultation
|
|
84
|
+
// one step cautious (bias_cautious) — authored rules may make the agent MORE
|
|
85
|
+
// deferential, never less. POSITIVE-polarity rules expand autonomy, the riskier
|
|
86
|
+
// direction, and are NOT projected in v0.1 (they still inject verbatim); that is
|
|
87
|
+
// gated behind structured per-dimension values. An unscoped (no decision.domain)
|
|
88
|
+
// rule biases all actions cautious — global by construction.
|
|
89
|
+
function biasesFromStandingRules(fp) {
|
|
90
|
+
const rules = Array.isArray(fp?.standing_rules) ? fp.standing_rules : [];
|
|
91
|
+
return rules
|
|
92
|
+
.filter((r) => r && r.kind === 'instruction' && r.decision && r.decision.polarity === 'negative')
|
|
93
|
+
.map((r) => ({ id: r.instruction_id || null, match: { domains: r.decision.domain ? [r.decision.domain] : [] }, effect: 'bias_cautious', note: r.text || null }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Merge policy sources, the authored home first:
|
|
97
|
+
// 1. `phaedo_fingerprint.standing_rules` — the Proposal 0007 authored home:
|
|
98
|
+
// kind:authorization → a deterministic gate; kind:instruction bound negative →
|
|
99
|
+
// a cautious bias (§D). Rides `inner.data` on the phone pull, travels everywhere
|
|
100
|
+
// the fingerprint does (extension ↔ phone ↔ MCP) with NO profile-schema change.
|
|
101
|
+
// 2. `phaedo_fingerprint.consult_policies` — DEPRECATED (0007) back-compat read path.
|
|
102
|
+
// 3. a top-level vault sibling (`phaedo_consult_policies`) — DEPRECATED, local/extension use.
|
|
103
|
+
// 4. opts.policies — a loaded local file (server: mcp/consult-policies.json).
|
|
104
|
+
// Force rules (authorizations) always take precedence over bias rules in applyPolicies,
|
|
105
|
+
// so ordering within the list only affects which same-class rule wins (first match).
|
|
66
106
|
// Defensive — never throws.
|
|
67
107
|
export function loadPolicies(vd, opts = {}) {
|
|
68
108
|
let list = [];
|
|
109
|
+
try { list = list.concat(normalizePolicies(authorizationsFromStandingRules(vd?.phaedo_fingerprint))); } catch { /* ignore */ }
|
|
110
|
+
try { list = list.concat(normalizePolicies(biasesFromStandingRules(vd?.phaedo_fingerprint))); } catch { /* ignore */ }
|
|
69
111
|
try { list = list.concat(normalizePolicies(vd?.phaedo_fingerprint?.consult_policies)); } catch { /* ignore */ }
|
|
70
112
|
try { list = list.concat(normalizePolicies(vd?.phaedo_consult_policies)); } catch { /* ignore */ }
|
|
71
113
|
try { if (opts.policies) list = list.concat(normalizePolicies(opts.policies)); } catch { /* ignore */ }
|
|
@@ -94,9 +136,16 @@ function ruleMatches(rule, n) {
|
|
|
94
136
|
// This is the engine behind the lightweight `phaedo_check_authorization` pre-flight
|
|
95
137
|
// (brief M3) — an agent can ask "is this already decided by a standing rule?" without
|
|
96
138
|
// paying for a full consultation. voice_draft is exempt (a redirect, not a decision).
|
|
97
|
-
|
|
139
|
+
// A rule is ACTIVE only if it has not lapsed (§10.5 revocable authority). `expiresAt`
|
|
140
|
+
// is enforced; `agents` scoping is reserved (see normalizePolicies). Authority-narrowing
|
|
141
|
+
// only — an expired rule simply stops applying.
|
|
142
|
+
function ruleActive(rule, now) {
|
|
143
|
+
return !(rule.expiresAt != null && now >= rule.expiresAt);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function matchAuthorization(n, policies, { now = Date.now() } = {}) {
|
|
98
147
|
if (!policies || !policies.length || n.type === 'voice_draft') return null;
|
|
99
|
-
const matched = policies.filter((r) => ruleMatches(r, n));
|
|
148
|
+
const matched = policies.filter((r) => ruleActive(r, now) && ruleMatches(r, n));
|
|
100
149
|
const force = matched.find((r) => r.effect in FORCE);
|
|
101
150
|
if (!force) return null;
|
|
102
151
|
return { rule: force, signal: FORCE[force.effect], note: force.note || FORCE_NOTES[force.effect] };
|
|
@@ -107,10 +156,10 @@ export function matchAuthorization(n, policies) {
|
|
|
107
156
|
// Else apply the first matching BIAS rule (nudge one step on the ladder). Returns a
|
|
108
157
|
// NEW response (§10.3 shape preserved; provenance in rationale_hint). voice_draft is
|
|
109
158
|
// never policy-overridden (it's a redirect, not a decision).
|
|
110
|
-
export function applyPolicies(response, n, policies) {
|
|
159
|
+
export function applyPolicies(response, n, policies, { now = Date.now() } = {}) {
|
|
111
160
|
if (!policies || !policies.length || n.type === 'voice_draft') return response;
|
|
112
161
|
|
|
113
|
-
const auth = matchAuthorization(n, policies);
|
|
162
|
+
const auth = matchAuthorization(n, policies, { now });
|
|
114
163
|
if (auth) {
|
|
115
164
|
return {
|
|
116
165
|
...response,
|
|
@@ -121,7 +170,7 @@ export function applyPolicies(response, n, policies) {
|
|
|
121
170
|
};
|
|
122
171
|
}
|
|
123
172
|
|
|
124
|
-
const matched = policies.filter((r) => ruleMatches(r, n));
|
|
173
|
+
const matched = policies.filter((r) => ruleActive(r, now) && ruleMatches(r, n));
|
|
125
174
|
const bias = matched[0];
|
|
126
175
|
if (!bias) return response;
|
|
127
176
|
const i = LADDER.indexOf(response.signal);
|
package/consult.js
CHANGED
|
@@ -14,12 +14,14 @@ import { loadPolicies, applyPolicies } from './consult-policy.js';
|
|
|
14
14
|
import { PhaedoError } from './errors.js';
|
|
15
15
|
import {
|
|
16
16
|
resolveConsultationCore, buildConsultationResponse, checkAuthorization, normalizeRequest,
|
|
17
|
-
shape, SIGNALS, CONSULTATION_TYPES, validateVaultShape,
|
|
17
|
+
shape, SIGNALS, CONSULTATION_TYPES, validateVaultShape, consultDrivers,
|
|
18
|
+
deriveDelegationSignals, withDelegationSignals, buildDelegationPromotions, DELEGATION_PROMOTION_FLOOR,
|
|
19
|
+
authoredDelegationSignals,
|
|
18
20
|
} from './consult-core.js';
|
|
19
21
|
|
|
20
22
|
// Re-export the core surface so existing importers (server.js, tests,
|
|
21
23
|
// spec/test-schemas.mjs) keep their `from './consult.js'` paths.
|
|
22
|
-
export { CONSULTATION_TYPES, buildConsultationResponse, resolveConsultationCore, checkAuthorization, validateVaultShape };
|
|
24
|
+
export { CONSULTATION_TYPES, buildConsultationResponse, resolveConsultationCore, checkAuthorization, validateVaultShape, consultDrivers, deriveDelegationSignals, withDelegationSignals, buildDelegationPromotions, DELEGATION_PROMOTION_FLOOR, authoredDelegationSignals };
|
|
23
25
|
|
|
24
26
|
// ── Model resolver (richest fingerprint, LOCAL compute — opt-in) ──────────────
|
|
25
27
|
// When a local model is configured (PHAEDO_CONSULT_MODEL + an Ollama/OpenAI-
|
package/fingerprint-source.js
CHANGED
|
@@ -13,6 +13,31 @@ import { resolve } from 'path';
|
|
|
13
13
|
import { hasPairingRecord, loadPairingRecord, loadFingerprintFromPhone, PhoneError } from './phone-source.js';
|
|
14
14
|
import { defaultStateDir, writeCache, readCache, clearCache } from './cache.js';
|
|
15
15
|
|
|
16
|
+
// §4.1 protocol-identity normalization (consumer defense-in-depth, 2026-06-16).
|
|
17
|
+
// A stored fingerprint from a producer that predates identity stamping (or whose
|
|
18
|
+
// phone copy was never re-persisted) can arrive missing the REQUIRED §4.1 fields
|
|
19
|
+
// — `npm run health` surfaced exactly this on a live fingerprint. The
|
|
20
|
+
// deterministic ones (phaedo_protocol_version, schema_revision) are safe for any
|
|
21
|
+
// consumer to ensure; subject_id falls back to the existing, stable, non-PII
|
|
22
|
+
// `fingerprint_id` when absent, rather than minting a value that would churn on
|
|
23
|
+
// every load. This makes the reference server serve §4.1-conformant fingerprints
|
|
24
|
+
// regardless of producer staleness. Source cleanup (re-persisting these on the
|
|
25
|
+
// phone) remains the producer's job. SEMANTIC hygiene (episodic/opaque content)
|
|
26
|
+
// is deliberately NOT masked here: health keeps surfacing it as an honest
|
|
27
|
+
// producer signal, and context-block's render guard protects the AI projection.
|
|
28
|
+
const IDENTITY_PROTOCOL_VERSION = '0.1';
|
|
29
|
+
const IDENTITY_SCHEMA_REVISION = 1;
|
|
30
|
+
function normalizeIdentity(vd) {
|
|
31
|
+
const fp = vd && vd.phaedo_fingerprint;
|
|
32
|
+
if (!fp || typeof fp !== 'object') return vd;
|
|
33
|
+
const patch = {};
|
|
34
|
+
if (fp.phaedo_protocol_version === undefined) patch.phaedo_protocol_version = IDENTITY_PROTOCOL_VERSION;
|
|
35
|
+
if (fp.schema_revision === undefined) patch.schema_revision = IDENTITY_SCHEMA_REVISION;
|
|
36
|
+
if (fp.subject_id === undefined && typeof fp.fingerprint_id === 'string' && fp.fingerprint_id) patch.subject_id = fp.fingerprint_id;
|
|
37
|
+
if (!Object.keys(patch).length) return vd;
|
|
38
|
+
return { ...vd, phaedo_fingerprint: { ...fp, ...patch } };
|
|
39
|
+
}
|
|
40
|
+
|
|
16
41
|
// Resolution order for the local fixture (1a):
|
|
17
42
|
// 1. PHAEDO_FINGERPRINT env var (absolute or cwd-relative path)
|
|
18
43
|
// 2. ./fingerprint.json next to the server
|
|
@@ -44,7 +69,7 @@ export async function loadFingerprint(baseDir) {
|
|
|
44
69
|
try {
|
|
45
70
|
const vd = await loadFingerprintFromPhone(rec);
|
|
46
71
|
await writeCache(stateDir, vd);
|
|
47
|
-
return vd;
|
|
72
|
+
return normalizeIdentity(vd);
|
|
48
73
|
} catch (err) {
|
|
49
74
|
// A 4xx means the phone REACHED us and rejected this client (revoked /
|
|
50
75
|
// invalid credentials / failed handshake). Revocation must be effective:
|
|
@@ -56,7 +81,7 @@ export async function loadFingerprint(baseDir) {
|
|
|
56
81
|
throw err;
|
|
57
82
|
}
|
|
58
83
|
const cached = await readCache(stateDir);
|
|
59
|
-
if (cached) return cached.vault; // phone unreachable → encrypted cache (may be stale)
|
|
84
|
+
if (cached) return normalizeIdentity(cached.vault); // phone unreachable → encrypted cache (may be stale)
|
|
60
85
|
throw err; // no phone + no cache → cannot serve
|
|
61
86
|
}
|
|
62
87
|
}
|
|
@@ -73,5 +98,38 @@ export async function loadFingerprint(baseDir) {
|
|
|
73
98
|
if (!vd || typeof vd !== 'object' || !vd.phaedo_fingerprint) {
|
|
74
99
|
throw new Error(`Fingerprint source at ${path} is missing phaedo_fingerprint.`);
|
|
75
100
|
}
|
|
76
|
-
return vd;
|
|
101
|
+
return normalizeIdentity(vd);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Like loadFingerprint, but PREFERS the at-rest encrypted cache over a live phone pull.
|
|
105
|
+
// Used by the escalation path so reading the fingerprint (to compute the consult signal)
|
|
106
|
+
// doesn't trigger a SECOND prompt — the fingerprint-read AUTHORIZE — on top of the
|
|
107
|
+
// escalation decision card the subject already answers. One prompt per escalation, not two.
|
|
108
|
+
//
|
|
109
|
+
// The cache is seeded + refreshed by the normal live-pull consults, so it stays current; a
|
|
110
|
+
// consult SIGNAL tolerates a slightly-stale fingerprint (it doesn't change minute to minute).
|
|
111
|
+
// Falls back to a live pull only when no cache exists yet (first run on this machine), which
|
|
112
|
+
// seeds it. Revocation safety is unaffected: a revoked MCP's escalation can't be decrypted by
|
|
113
|
+
// the phone anyway (its client entry is gone), so it never reaches the subject regardless of
|
|
114
|
+
// what the MCP read locally.
|
|
115
|
+
// Like loadFingerprint, but PREFERS the at-rest encrypted cache over a live phone pull.
|
|
116
|
+
// Used by the escalation path so reading the fingerprint (to compute the consult signal)
|
|
117
|
+
// NEVER triggers an interactive fingerprint-read AUTHORIZE on top of the escalation
|
|
118
|
+
// decision card. One prompt per escalation — the decision — not two, and critically no
|
|
119
|
+
// authorize modal racing the decision modal on the phone (that double-present crashed iOS).
|
|
120
|
+
//
|
|
121
|
+
// Uses ANY decryptable cache regardless of age. Revocation is enforced PHONE-SIDE for the
|
|
122
|
+
// escalation path: a revoked agent's escalation can't be decrypted by the phone (its client
|
|
123
|
+
// registry entry is gone), so reading a stale cache here yields nothing the agent can
|
|
124
|
+
// actually deliver to the subject. (The normal consult path — loadFingerprint — still does
|
|
125
|
+
// the live pull with its 4xx-purge revocation, so a revoked agent stops getting fresh reads
|
|
126
|
+
// there.) Falls through to a live pull ONLY when no cache exists at all (first run on this
|
|
127
|
+
// machine), which seeds it; run one phaedo_consult after pairing to seed without an
|
|
128
|
+
// escalation in flight.
|
|
129
|
+
export async function loadFingerprintPreferCache(baseDir) {
|
|
130
|
+
if (!process.env.PHAEDO_FINGERPRINT && hasPairingRecord(baseDir)) {
|
|
131
|
+
const cached = await readCache(defaultStateDir());
|
|
132
|
+
if (cached) return normalizeIdentity(cached.vault);
|
|
133
|
+
}
|
|
134
|
+
return loadFingerprint(baseDir);
|
|
77
135
|
}
|
package/hooks/session-start.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import { fileURLToPath } from 'url';
|
|
22
22
|
import { dirname, resolve } from 'path';
|
|
23
|
-
import {
|
|
23
|
+
import { loadFingerprintPreferCache } from '../fingerprint-source.js';
|
|
24
24
|
import { buildInjectionResponse } from '../projection.js';
|
|
25
25
|
|
|
26
26
|
// hooks/ lives one level under mcp/ — the sources resolve against mcp/.
|
|
@@ -37,7 +37,14 @@ function emitContext(text) {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
async function run() {
|
|
40
|
-
|
|
40
|
+
// PREFER the at-rest encrypted cache over a live phone pull. Claude Code (and Claude
|
|
41
|
+
// Desktop's per-tab MCP spawn) runs SessionStart often — including on every tab bounce
|
|
42
|
+
// to a session that uses this hook — so a LIVE pull here surfaces a stray "authorize"
|
|
43
|
+
// sheet on the phone with no live caller behind it, since the user didn't initiate
|
|
44
|
+
// anything from the subject side. The cache is exactly what session-start should rely
|
|
45
|
+
// on: fast, non-interrupting, and refreshed by any real consult/escalate that follows.
|
|
46
|
+
// Same pattern that #76 applied to escalation (cache-preferred fingerprint read).
|
|
47
|
+
const vd = await loadFingerprintPreferCache(MCP_DIR);
|
|
41
48
|
const response = buildInjectionResponse(vd, { requested_layers: ['all'], mode: 'standard' });
|
|
42
49
|
if (response && response.projection) emitContext(response.projection);
|
|
43
50
|
}
|
package/install.mjs
CHANGED
|
@@ -356,6 +356,12 @@ async function main(argv) {
|
|
|
356
356
|
} else {
|
|
357
357
|
console.log(' • Claude Code: `--code-hook` makes it automatic at every session start.');
|
|
358
358
|
}
|
|
359
|
+
console.log(' 4. Let Phaedo learn how you delegate — set `phaedo_record_outcome`');
|
|
360
|
+
console.log(' to "Always allow" in your client\'s tool-permission settings.');
|
|
361
|
+
console.log(' It records when you override an agent (the act-as-me signal); on');
|
|
362
|
+
console.log(' "Ask" it prompts every time, so the learning stays cold if skipped.');
|
|
363
|
+
console.log(' It only writes a local, encrypted audit receipt — nothing leaves the');
|
|
364
|
+
console.log(' device, and an authored rule still needs your explicit endorsement.');
|
|
359
365
|
}
|
|
360
366
|
if (opts.pair && !opts.dryRun && touched.length) return runPairing();
|
|
361
367
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "phaedo-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"description": "Phaedo MCP server — inject your cognitive fingerprint into any MCP client, and consult your decision pattern at agent decision points (docs/protocol/mcp-binding.md).",
|
|
8
8
|
"mcpName": "io.github.galavoxx/phaedo-mcp",
|
|
9
|
-
"homepage": "https://
|
|
9
|
+
"homepage": "https://phaedo.so",
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
12
12
|
"url": "git+https://github.com/galavoxx/phaedo-mcp.git"
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"consult-policy.js",
|
|
34
34
|
"receipts.js",
|
|
35
35
|
"agreement.js",
|
|
36
|
+
"calibration.js",
|
|
36
37
|
"phone-source.js",
|
|
37
38
|
"cache.js",
|
|
38
39
|
"pairing.js",
|
|
@@ -54,15 +55,19 @@
|
|
|
54
55
|
"start": "node server.js",
|
|
55
56
|
"build": "node build-package.mjs",
|
|
56
57
|
"prepack": "node build-package.mjs",
|
|
58
|
+
"pretest": "node build-package.mjs",
|
|
57
59
|
"setup": "node install.mjs",
|
|
58
60
|
"setup:pair": "node install.mjs --pair",
|
|
59
61
|
"setup:code-hook": "node install.mjs --code-hook",
|
|
60
62
|
"unsetup": "node install.mjs --uninstall",
|
|
61
63
|
"pair": "node pair.js",
|
|
62
64
|
"pull": "node pull.js",
|
|
63
|
-
"test": "node test-smoke.mjs && node test-phone-boundary.mjs && node test-cache.mjs && node test-pairing.mjs && node test-revoke.mjs && node test-install.mjs && node test-consult.mjs && node test-consult-policy.mjs && node test-receipts.mjs && node test-agreement.mjs",
|
|
65
|
+
"test": "node test-smoke.mjs && node test-phone-boundary.mjs && node test-cache.mjs && node test-pairing.mjs && node test-revoke.mjs && node test-install.mjs && node test-consult.mjs && node test-consult-policy.mjs && node test-delegation-sync.mjs && node test-delegation-relay.mjs && node test-escalation.mjs && node test-receipts.mjs && node test-receipts-sync.mjs && node test-agreement.mjs && node test-calibration.mjs && node test-identity.mjs",
|
|
64
66
|
"receipts": "node receipts.js list",
|
|
65
67
|
"agreement": "node agreement.js",
|
|
68
|
+
"calibration": "node calibration.js",
|
|
69
|
+
"health": "node health.js",
|
|
70
|
+
"validate": "node ../spec/validate-fingerprint.mjs sample-fingerprint.json",
|
|
66
71
|
"smoke": "node test-smoke.mjs",
|
|
67
72
|
"smoke:phone": "node test-phone-boundary.mjs",
|
|
68
73
|
"smoke:cache": "node test-cache.mjs",
|
|
@@ -71,8 +76,10 @@
|
|
|
71
76
|
"smoke:install": "node test-install.mjs",
|
|
72
77
|
"smoke:consult": "node test-consult.mjs",
|
|
73
78
|
"smoke:policy": "node test-consult-policy.mjs",
|
|
79
|
+
"smoke:escalation": "node test-escalation.mjs",
|
|
74
80
|
"smoke:receipts": "node test-receipts.mjs",
|
|
75
|
-
"smoke:agreement": "node test-agreement.mjs"
|
|
81
|
+
"smoke:agreement": "node test-agreement.mjs",
|
|
82
|
+
"smoke:calibration": "node test-calibration.mjs"
|
|
76
83
|
},
|
|
77
84
|
"dependencies": {
|
|
78
85
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
package/pairing.js
CHANGED
|
@@ -137,6 +137,17 @@ export async function pairAtEndpoint(endpoint, { code, relayDeviceId = null, log
|
|
|
137
137
|
if (outcome === 'denied') throw new Error('pairing was denied on the phone');
|
|
138
138
|
if (outcome === 'timeout') throw new Error('timed out waiting for phone confirmation');
|
|
139
139
|
|
|
140
|
+
// The relay lane id: prefer the one resolved during a relay rendezvous, but a LAN
|
|
141
|
+
// pairing ALSO needs it (the escalation channel + fp-mailbox deposit to the relay by
|
|
142
|
+
// this id). The phone returns its current relay_device_id in the /v1/pair response
|
|
143
|
+
// (localServer.ts), so capture it whichever rendezvous we used — without it, a LAN
|
|
144
|
+
// pairing has no relay lane and phaedo_escalate fails with "no-relay-device-id".
|
|
145
|
+
const relayId = relayDeviceId || body.relay_device_id || null;
|
|
146
|
+
// Per-device bearer (relay/auth.js): the phone shares the secret it minted with paired
|
|
147
|
+
// clients so they can authenticate to the relay AS this device. Without it,
|
|
148
|
+
// escalation/fp-mailbox deposits get 401.
|
|
149
|
+
const relaySecret = body.relay_device_secret || null;
|
|
150
|
+
|
|
140
151
|
return {
|
|
141
152
|
endpoint,
|
|
142
153
|
client_id: body.client_id,
|
|
@@ -144,7 +155,8 @@ export async function pairAtEndpoint(endpoint, { code, relayDeviceId = null, log
|
|
|
144
155
|
pair_key: b64uEnc(pairKey),
|
|
145
156
|
vault_pub: body.vault_pub,
|
|
146
157
|
sas,
|
|
147
|
-
...(
|
|
158
|
+
...(relayId ? { relay_device_id: relayId } : {}),
|
|
159
|
+
...(relaySecret ? { relay_device_secret: relaySecret } : {}),
|
|
148
160
|
};
|
|
149
161
|
}
|
|
150
162
|
|
package/receipts.js
CHANGED
|
@@ -87,8 +87,18 @@ async function writeStore(stateDir, store, now) {
|
|
|
87
87
|
// never the projection (§10.4 holds inside the vault too). `via` records which
|
|
88
88
|
// tool resolved it (phaedo_consult | phaedo_check_authorization) so M5 can weigh
|
|
89
89
|
// full consultations and pre-flight matches separately.
|
|
90
|
-
export function buildReceipt({ via, agentId, request, response, now = Date.now() }) {
|
|
90
|
+
export function buildReceipt({ via, agentId, request, response, drivers, now = Date.now() }) {
|
|
91
91
|
const ad = (request && typeof request.action_descriptor === 'object' && request.action_descriptor) || {};
|
|
92
|
+
// Provenance (optional, §10.4-safe — LOCAL audit only): the Decision&Risk cues that
|
|
93
|
+
// drove an inferred consult, each {cue, lean?/value?, confidence, basis}. No layer
|
|
94
|
+
// text; coerced to the known shape; absent for deterministic pre-flights.
|
|
95
|
+
const drv = Array.isArray(drivers) ? drivers.slice(0, 16).map((d) => ({
|
|
96
|
+
cue: typeof d?.cue === 'string' ? d.cue : null,
|
|
97
|
+
...(typeof d?.lean === 'number' ? { lean: d.lean } : {}),
|
|
98
|
+
...(d?.value != null ? { value: String(d.value) } : {}),
|
|
99
|
+
confidence: typeof d?.confidence === 'number' ? d.confidence : null,
|
|
100
|
+
basis: typeof d?.basis === 'string' ? d.basis : null,
|
|
101
|
+
})) : [];
|
|
92
102
|
return {
|
|
93
103
|
receipt_id: crypto.randomUUID(),
|
|
94
104
|
created_at: new Date(now).toISOString(),
|
|
@@ -109,6 +119,7 @@ export function buildReceipt({ via, agentId, request, response, now = Date.now()
|
|
|
109
119
|
confidence: typeof response?.confidence === 'number' ? response.confidence : null,
|
|
110
120
|
deference_level: response?.deference_level ?? null,
|
|
111
121
|
},
|
|
122
|
+
...(drv.length ? { drivers: drv } : {}),
|
|
112
123
|
user_action: null,
|
|
113
124
|
};
|
|
114
125
|
}
|
package/sample-fingerprint.json
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"phaedo_fingerprint": {
|
|
3
|
+
"phaedo_protocol_version": "0.1",
|
|
3
4
|
"fingerprint_id": "sample-0000-0000-0000-000000000000",
|
|
5
|
+
"subject_id": "sample-subj-0000-0000-000000000000",
|
|
6
|
+
"schema_revision": 1,
|
|
4
7
|
"updated_at": "2026-06-01T12:00:00.000Z",
|
|
5
8
|
"persona_strength": 0.58,
|
|
6
9
|
"layers": {
|