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-core.js
CHANGED
|
@@ -39,6 +39,14 @@ const ABSTAIN_HINT =
|
|
|
39
39
|
'The fingerprint has no decision/risk coverage to ground a signal, so the oracle ' +
|
|
40
40
|
'abstains — fall back to your own default or escalate to the subject.';
|
|
41
41
|
|
|
42
|
+
// §4.7: distinct from "no coverage" — coverage EXISTS but the dimension(s) this action
|
|
43
|
+
// turns on are under an open conflict the subject hasn't settled, so the oracle withholds
|
|
44
|
+
// a signal (the consult-side mirror of injection suppression). Escalate, don't assume.
|
|
45
|
+
const CONTESTED_HINT =
|
|
46
|
+
'The subject is actively reconciling a contradiction on the decision dimension this ' +
|
|
47
|
+
'action turns on, so the oracle withholds a signal until they settle it — escalate to ' +
|
|
48
|
+
'the subject rather than assume a default.';
|
|
49
|
+
|
|
42
50
|
// ── vd contract guard (brief M0.4) ────────────────────────────────────────────
|
|
43
51
|
// The consultation boundary's mirror of context-block.js validateVaultData (which
|
|
44
52
|
// cannot be imported here — it is a classic browser script; the two are kept in
|
|
@@ -94,6 +102,11 @@ function evidenceBasis(source) {
|
|
|
94
102
|
return 'observed'; // extraction/delta-sourced structured signals
|
|
95
103
|
}
|
|
96
104
|
const BASIS_WEIGHT = { observed: 1.0, corroborated: 1.0, self_reported: 0.9, text_scan: 0.75 };
|
|
105
|
+
// The act-as-me channel: a `delegation`-origin cue (how the subject wants work done FOR
|
|
106
|
+
// them) reads as MORE authoritative for a consult — an agent is about to act on their
|
|
107
|
+
// behalf — so it is boosted above a `self`-origin one. `self` is the neutral 1.0, so
|
|
108
|
+
// today's all-self fingerprints are unchanged; the per-cue product is clamped to 1.0.
|
|
109
|
+
const ORIGIN_WEIGHT = { delegation: 1.15, self: 1.0 };
|
|
97
110
|
|
|
98
111
|
// Does a signal's §4.2.7 domain tag pertain to the action's domain? Same
|
|
99
112
|
// case-insensitive substring convention as authored policies (either direction:
|
|
@@ -103,7 +116,39 @@ function domainMatches(sigDomain, actionDomain) {
|
|
|
103
116
|
return a.includes(b) || b.includes(a);
|
|
104
117
|
}
|
|
105
118
|
|
|
106
|
-
//
|
|
119
|
+
// ── Open-conflict suppression (§4.7, the consult-side mirror of injection) ──────
|
|
120
|
+
// When a dimension has an OPEN conflict_record (the subject's self-report and a fresh
|
|
121
|
+
// observation disagree, not yet settled), the injection projection withholds it from
|
|
122
|
+
// the layer summary (extraction/reconcile.js openConflictFieldKeys → renderLayerSummary
|
|
123
|
+
// suppress). Consult reads the raw signals, not the summary, so it must withhold the
|
|
124
|
+
// SAME contested dimensions itself — otherwise an agent gets a confident answer on a
|
|
125
|
+
// dimension the subject is still deciding. A withheld cue counts as "not found", so if
|
|
126
|
+
// every relevant cue is contested, coverage→0 and the resolver abstains (below). Field
|
|
127
|
+
// keys mirror reconcile.js: `field` (untagged → fingerprint-wide) or `field::domain`.
|
|
128
|
+
function openConflictKeys(vd) {
|
|
129
|
+
const recs = vd?.phaedo_fingerprint?.conflict_records;
|
|
130
|
+
const set = new Set();
|
|
131
|
+
for (const r of (Array.isArray(recs) ? recs : [])) {
|
|
132
|
+
if (r && r.status === 'open' && r.field) set.add(r.field + (typeof r.domain === 'string' ? `::${r.domain}` : ''));
|
|
133
|
+
}
|
|
134
|
+
return set;
|
|
135
|
+
}
|
|
136
|
+
// Is `field` contested for this action? An UNTAGGED open conflict suppresses it
|
|
137
|
+
// fingerprint-wide (matching untagged signals' fingerprint-wide reach); a domain-TAGGED
|
|
138
|
+
// conflict suppresses only when the action's domain matches.
|
|
139
|
+
function isFieldContested(suppressed, field, actionDomain) {
|
|
140
|
+
if (!suppressed || !suppressed.size) return false;
|
|
141
|
+
if (suppressed.has(field)) return true;
|
|
142
|
+
if (actionDomain) {
|
|
143
|
+
for (const key of suppressed) {
|
|
144
|
+
const i = key.indexOf('::');
|
|
145
|
+
if (i > 0 && key.slice(0, i) === field && domainMatches(key.slice(i + 2), actionDomain)) return true;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Resolve a relevant cue to { text, confidence, basis, origin }, preferring a structured
|
|
107
152
|
// §4.2 signal (which carries a real per-signal confidence + provenance) over a
|
|
108
153
|
// dimensioned questionnaire response. Returns undefined when neither is present
|
|
109
154
|
// (caller falls back to a free-text keyword scan). Positive/neutral polarity only.
|
|
@@ -113,6 +158,13 @@ function domainMatches(sigDomain, actionDomain) {
|
|
|
113
158
|
// fallback (today's fingerprint-wide behavior); a signal tagged with a DIFFERENT
|
|
114
159
|
// domain is skipped — financial caution must not drive a writing decision. When
|
|
115
160
|
// the action has no domain, tags are ignored (any field-matching signal serves).
|
|
161
|
+
//
|
|
162
|
+
// Origin preference (the act-as-me channel, §4.2 `origin`): a `delegation`-origin
|
|
163
|
+
// signal — learned from the subject CORRECTING an agent acting on their behalf —
|
|
164
|
+
// OUTRANKS a `self`-origin one for the same dimension. A consultation IS an agent
|
|
165
|
+
// about to act on the subject's behalf (§10), so how they want work done FOR them
|
|
166
|
+
// dominates how they do it themselves. Absent `origin` ⇒ `self`, so today's
|
|
167
|
+
// (all-self) fingerprints are unaffected — this only bites once delegation signals exist.
|
|
116
168
|
function signalCue(vd, layerId, fields, actionDomain) {
|
|
117
169
|
const L = layer(vd, layerId);
|
|
118
170
|
if (!L) return undefined;
|
|
@@ -121,23 +173,33 @@ function signalCue(vd, layerId, fields, actionDomain) {
|
|
|
121
173
|
const confidence = typeof s.confidence === 'number' ? Math.max(0, Math.min(1, s.confidence)) : 0.6;
|
|
122
174
|
// Prefer the producer-emitted evidence_basis (proposal 0002); else derive from source.
|
|
123
175
|
const basis = typeof s.evidence_basis === 'string' ? s.evidence_basis : evidenceBasis(s.source);
|
|
124
|
-
|
|
176
|
+
const origin = s.origin === 'delegation' ? 'delegation' : 'self';
|
|
177
|
+
return { text: String(Array.isArray(s.value) ? s.value.join(' ') : s.value).toLowerCase(), confidence, basis, origin };
|
|
125
178
|
};
|
|
126
|
-
|
|
179
|
+
// Score each matching signal and keep the best. tier = domain relevance (a domain
|
|
180
|
+
// match outranks an untagged fallback, as before); within a tier, `delegation`
|
|
181
|
+
// outranks `self`. Ties keep document order (first wins) — so for today's all-self,
|
|
182
|
+
// mostly-untagged fingerprints the selection is identical to the prior first-match.
|
|
183
|
+
let best;
|
|
127
184
|
for (const s of (L.signals || [])) {
|
|
128
185
|
if (!(s && want.has(s.field) && (s.polarity ?? 'positive') !== 'negative' && s.value != null)) continue;
|
|
186
|
+
let tier;
|
|
129
187
|
if (typeof s.domain === 'string' && actionDomain) {
|
|
130
|
-
if (domainMatches(s.domain, actionDomain))
|
|
131
|
-
|
|
188
|
+
if (!domainMatches(s.domain, actionDomain)) continue; // wrong domain — skip
|
|
189
|
+
tier = 2; // domain match
|
|
190
|
+
} else if (typeof s.domain === 'string') {
|
|
191
|
+
tier = 1; // tagged, action domain-free
|
|
192
|
+
} else {
|
|
193
|
+
tier = 0; // untagged fallback (today's norm)
|
|
132
194
|
}
|
|
133
|
-
|
|
134
|
-
if (!
|
|
195
|
+
const score = tier * 2 + (s.origin === 'delegation' ? 1 : 0);
|
|
196
|
+
if (!best || score > best.score) best = { score, cue: asCue(s) };
|
|
135
197
|
}
|
|
136
|
-
if (
|
|
198
|
+
if (best) return best.cue;
|
|
137
199
|
for (const r of (L.responses || L.answers || [])) {
|
|
138
200
|
const dim = r?.dimension || r?.field;
|
|
139
201
|
const v = r?.value ?? r?.answer;
|
|
140
|
-
if (dim && want.has(dim) && v != null) return { text: String(v).toLowerCase(), confidence: 0.7, basis: 'self_reported' };
|
|
202
|
+
if (dim && want.has(dim) && v != null) return { text: String(v).toLowerCase(), confidence: 0.7, basis: 'self_reported', origin: 'self' };
|
|
141
203
|
}
|
|
142
204
|
return undefined;
|
|
143
205
|
}
|
|
@@ -196,7 +258,13 @@ const AMBIGUITY = {
|
|
|
196
258
|
proceed: ['assume_silent', 'assume_state', 'reasonable assumption', 'offer_two', 'offer two interpretations'],
|
|
197
259
|
};
|
|
198
260
|
|
|
199
|
-
function readCue(vd, spec, actionDomain) {
|
|
261
|
+
function readCue(vd, spec, actionDomain, suppressed) {
|
|
262
|
+
// §4.7: if any of this cue's fields is under an open conflict, withhold the whole cue
|
|
263
|
+
// (its field-aliases name the same contested dimension). `withheld` lets the resolver
|
|
264
|
+
// tell "abstaining because contested" apart from "abstaining for no coverage".
|
|
265
|
+
if (suppressed && spec.fields.some((f) => isFieldContested(suppressed, f, actionDomain))) {
|
|
266
|
+
return { found: false, lean: 0, confidence: 0, basis: null, withheld: true };
|
|
267
|
+
}
|
|
200
268
|
let cue;
|
|
201
269
|
for (const lid of spec.layers) { cue = signalCue(vd, lid, spec.fields, actionDomain); if (cue) break; } // a structured §4.2 signal wins
|
|
202
270
|
const text = cue ? cue.text : spec.layers.map(lid => layerText(vd, lid)).join(' '); // else scan answers/summary
|
|
@@ -204,19 +272,28 @@ function readCue(vd, spec, actionDomain) {
|
|
|
204
272
|
const c = hit(text, spec.cautious), b = hit(text, spec.bold);
|
|
205
273
|
const found = c || b;
|
|
206
274
|
const lean = (c && !b) ? 1 : (b && !c) ? -1 : 0; // both/neither → present but neutral
|
|
207
|
-
// per-cue confidence + basis: a structured signal carries its own; a keyword
|
|
208
|
-
// in free text is the weakest evidence (text_scan).
|
|
209
|
-
return {
|
|
275
|
+
// per-cue confidence + basis + origin: a structured signal carries its own; a keyword
|
|
276
|
+
// hit in free text is the weakest evidence (text_scan), always self-origin.
|
|
277
|
+
return {
|
|
278
|
+
found, lean,
|
|
279
|
+
confidence: found ? (cue ? cue.confidence : 0.6) : 0,
|
|
280
|
+
basis: found ? (cue ? cue.basis : 'text_scan') : null,
|
|
281
|
+
origin: found ? (cue ? cue.origin : 'self') : null,
|
|
282
|
+
};
|
|
210
283
|
}
|
|
211
284
|
|
|
212
|
-
function readAmbiguity(vd, actionDomain) {
|
|
285
|
+
function readAmbiguity(vd, actionDomain, suppressed) {
|
|
286
|
+
if (suppressed && AMBIGUITY.fields.some((f) => isFieldContested(suppressed, f, actionDomain))) {
|
|
287
|
+
return { found: false, value: null, confidence: 0, basis: null, withheld: true };
|
|
288
|
+
}
|
|
213
289
|
let cue;
|
|
214
290
|
for (const lid of AMBIGUITY.layers) { cue = signalCue(vd, lid, AMBIGUITY.fields, actionDomain); if (cue) break; }
|
|
215
291
|
const text = cue ? cue.text : AMBIGUITY.layers.map(lid => layerText(vd, lid)).join(' ');
|
|
216
292
|
const confidence = cue ? cue.confidence : 0.6;
|
|
217
293
|
const basis = cue ? cue.basis : 'text_scan';
|
|
218
|
-
|
|
219
|
-
if (hit(text, AMBIGUITY.
|
|
294
|
+
const origin = cue ? cue.origin : 'self';
|
|
295
|
+
if (hit(text, AMBIGUITY.askFirst)) return { found: true, value: 'ask_first', confidence, basis, origin };
|
|
296
|
+
if (hit(text, AMBIGUITY.proceed)) return { found: true, value: 'proceed', confidence, basis, origin };
|
|
220
297
|
return { found: false, value: null, confidence: 0, basis: null };
|
|
221
298
|
}
|
|
222
299
|
|
|
@@ -229,22 +306,26 @@ function personaStrength(vd) {
|
|
|
229
306
|
// type (§10 relevance scoping). Returns the caution score (0..1), the coverage
|
|
230
307
|
// (found / relevant), the raw `found` count (0 = no relevant cue → abstain), and
|
|
231
308
|
// the ambiguity verdict.
|
|
232
|
-
function profile(vd, relevantCues, includeAmbiguity, actionDomain) {
|
|
233
|
-
let score = 0.5, found = 0, total = relevantCues.length + (includeAmbiguity ? 1 : 0);
|
|
234
|
-
const effConfs = []; // per-cue confidence × evidence-basis weight, for the cues
|
|
309
|
+
function profile(vd, relevantCues, includeAmbiguity, actionDomain, suppressed) {
|
|
310
|
+
let score = 0.5, found = 0, withheld = 0, total = relevantCues.length + (includeAmbiguity ? 1 : 0);
|
|
311
|
+
const effConfs = []; // per-cue confidence × evidence-basis × origin weight, for the cues used
|
|
312
|
+
const eff = (r) => Math.min(1, r.confidence * (BASIS_WEIGHT[r.basis] ?? 0.75) * (ORIGIN_WEIGHT[r.origin] ?? 1.0));
|
|
235
313
|
for (const key of relevantCues) {
|
|
236
|
-
const r = readCue(vd, CUES[key], actionDomain);
|
|
237
|
-
if (r.found) { found++; score += r.lean * 0.13; effConfs.push(
|
|
314
|
+
const r = readCue(vd, CUES[key], actionDomain, suppressed);
|
|
315
|
+
if (r.found) { found++; score += r.lean * 0.13; effConfs.push(eff(r)); }
|
|
316
|
+
else if (r.withheld) withheld++;
|
|
238
317
|
}
|
|
239
318
|
let ambiguity = { found: false, value: null };
|
|
240
319
|
if (includeAmbiguity) {
|
|
241
|
-
ambiguity = readAmbiguity(vd, actionDomain);
|
|
242
|
-
if (ambiguity.found) { found++; if (ambiguity.value === 'ask_first') score += 0.13; effConfs.push(
|
|
320
|
+
ambiguity = readAmbiguity(vd, actionDomain, suppressed);
|
|
321
|
+
if (ambiguity.found) { found++; if (ambiguity.value === 'ask_first') score += 0.13; effConfs.push(eff(ambiguity)); }
|
|
322
|
+
else if (ambiguity.withheld) withheld++;
|
|
243
323
|
}
|
|
244
324
|
return {
|
|
245
325
|
score: Math.max(0, Math.min(1, score)),
|
|
246
326
|
coverage: total ? found / total : 0,
|
|
247
327
|
found,
|
|
328
|
+
withheld,
|
|
248
329
|
signalConfidence: effConfs.length ? effConfs.reduce((a, b) => a + b, 0) / effConfs.length : 0,
|
|
249
330
|
ambiguity,
|
|
250
331
|
};
|
|
@@ -297,9 +378,19 @@ export function normalizeRequest(request) {
|
|
|
297
378
|
};
|
|
298
379
|
}
|
|
299
380
|
|
|
381
|
+
// The Decision&Risk cues each consultation type weighs (single source of truth —
|
|
382
|
+
// the resolvers AND the provenance reader consultDrivers() read from here, so the
|
|
383
|
+
// recorded "what drove this" can never drift from what actually drove it).
|
|
384
|
+
const RELEVANT_CUES = {
|
|
385
|
+
action_approval: ['irreversibleCaution', 'decisionThreshold', 'evidenceBar'],
|
|
386
|
+
domain_risk_check: ['evidenceBar', 'speedQuality', 'decisionThreshold'],
|
|
387
|
+
escalation_default: ['decisionThreshold', 'irreversibleCaution'],
|
|
388
|
+
};
|
|
389
|
+
const USES_AMBIGUITY = new Set(['action_approval', 'escalation_default']);
|
|
390
|
+
|
|
300
391
|
// ── Per-type resolvers → { signal, hint, coverage, found } ────────────────────
|
|
301
|
-
function resolveActionApproval(vd, n) {
|
|
302
|
-
const p = profile(vd,
|
|
392
|
+
function resolveActionApproval(vd, n, suppressed) {
|
|
393
|
+
const p = profile(vd, RELEVANT_CUES.action_approval, true, n.domain, suppressed);
|
|
303
394
|
const irreversible = n.reversible === false;
|
|
304
395
|
const mag = magnitudeRank(n.magnitude);
|
|
305
396
|
const thinEvidence = n.evidenceProvided.length === 0;
|
|
@@ -323,22 +414,22 @@ function resolveActionApproval(vd, n) {
|
|
|
323
414
|
const hint = irreversible
|
|
324
415
|
? `On irreversible${mag >= 2 ? ', high-magnitude' : ''} actions${domain}, the subject ${posture}.`
|
|
325
416
|
: `On reversible actions${domain}, the subject ${posture}.`;
|
|
326
|
-
return { signal, hint, coverage: p.coverage, found: p.found, signalConfidence: p.signalConfidence };
|
|
417
|
+
return { signal, hint, coverage: p.coverage, found: p.found, withheld: p.withheld, signalConfidence: p.signalConfidence };
|
|
327
418
|
}
|
|
328
419
|
|
|
329
|
-
function resolveDomainRiskCheck(vd, n) {
|
|
330
|
-
const p = profile(vd,
|
|
420
|
+
function resolveDomainRiskCheck(vd, n, suppressed) {
|
|
421
|
+
const p = profile(vd, RELEVANT_CUES.domain_risk_check, false, n.domain, suppressed);
|
|
331
422
|
const high = p.score >= 0.55;
|
|
332
423
|
const signal = high ? 'clarify' : p.score <= 0.4 ? 'proceed' : 'proceed_with_note';
|
|
333
424
|
const where = n.domain ? ` in ${n.domain}` : '';
|
|
334
425
|
const posture = high ? 'holds a high evidence bar and prefers thoroughness'
|
|
335
426
|
: p.score <= 0.4 ? 'is comfortable moving on the best available signal'
|
|
336
427
|
: 'balances evidence against speed';
|
|
337
|
-
return { signal, hint: `Risk posture${where}: the subject ${posture}.`, coverage: p.coverage, found: p.found, signalConfidence: p.signalConfidence };
|
|
428
|
+
return { signal, hint: `Risk posture${where}: the subject ${posture}.`, coverage: p.coverage, found: p.found, withheld: p.withheld, signalConfidence: p.signalConfidence };
|
|
338
429
|
}
|
|
339
430
|
|
|
340
|
-
function resolveEscalationDefault(vd, n) {
|
|
341
|
-
const p = profile(vd,
|
|
431
|
+
function resolveEscalationDefault(vd, n, suppressed) {
|
|
432
|
+
const p = profile(vd, RELEVANT_CUES.escalation_default, true, n.domain, suppressed);
|
|
342
433
|
let signal;
|
|
343
434
|
if (p.ambiguity.value === 'ask_first') signal = 'clarify';
|
|
344
435
|
else if (p.ambiguity.value === 'proceed') signal = 'proceed_with_note';
|
|
@@ -348,7 +439,7 @@ function resolveEscalationDefault(vd, n) {
|
|
|
348
439
|
: signal === 'escalate'
|
|
349
440
|
? 'When uncertain on consequential calls, the subject defaults to surfacing for a live decision.'
|
|
350
441
|
: 'When uncertain on low-stakes calls, the subject defaults to a reasonable assumption, stated openly.';
|
|
351
|
-
return { signal, hint, coverage: p.coverage, found: p.found, signalConfidence: p.signalConfidence };
|
|
442
|
+
return { signal, hint, coverage: p.coverage, found: p.found, withheld: p.withheld, signalConfidence: p.signalConfidence };
|
|
352
443
|
}
|
|
353
444
|
|
|
354
445
|
// voice_draft: v0 does not generate text. Voice == style == the injection
|
|
@@ -362,6 +453,234 @@ function resolveVoiceDraft() {
|
|
|
362
453
|
};
|
|
363
454
|
}
|
|
364
455
|
|
|
456
|
+
// ── Provenance (local audit only — NOT the §10.3 response) ────────────────────
|
|
457
|
+
// The Decision&Risk cues that actually drove a consultation, for the §10.6 receipt:
|
|
458
|
+
// "I returned this because cues A,B,C fired at these confidences/bases." Reuses the
|
|
459
|
+
// resolver's exact relevance lists + readCue, so it can't drift from what drove the
|
|
460
|
+
// answer. Carries lean/value + confidence + evidence basis — NOT the raw layer text,
|
|
461
|
+
// and it lives ONLY in the local encrypted receipt, never in the response the agent
|
|
462
|
+
// sees (§10.4 holds: provenance is for the subject's audit/calibration, not the agent).
|
|
463
|
+
// Pure + defensive: bad/redirect/abstain inputs → []. Takes the raw request; normalizes
|
|
464
|
+
// internally so callers pass exactly what they passed to the consult.
|
|
465
|
+
export function consultDrivers(vd, request) {
|
|
466
|
+
let n;
|
|
467
|
+
try { n = normalizeRequest(request || {}); } catch { return []; }
|
|
468
|
+
const keys = RELEVANT_CUES[n.type];
|
|
469
|
+
if (!keys) return []; // voice_draft / unknown → no inferred basis
|
|
470
|
+
const suppressed = openConflictKeys(vd); // §4.7: withhold contested cues here too,
|
|
471
|
+
const drivers = []; // so the receipt matches what actually drove it
|
|
472
|
+
// A driver records `origin` only when it's `delegation` — the act-as-me case worth
|
|
473
|
+
// flagging in the audit; omitting it for `self` keeps today's receipts byte-identical.
|
|
474
|
+
const withOrigin = (d, origin) => (origin === 'delegation' ? { ...d, origin } : d);
|
|
475
|
+
for (const key of keys) {
|
|
476
|
+
const r = readCue(vd, CUES[key], n.domain, suppressed);
|
|
477
|
+
if (r.found) drivers.push(withOrigin({ cue: key, lean: r.lean, confidence: r.confidence, basis: r.basis }, r.origin));
|
|
478
|
+
}
|
|
479
|
+
if (USES_AMBIGUITY.has(n.type)) {
|
|
480
|
+
const a = readAmbiguity(vd, n.domain, suppressed);
|
|
481
|
+
if (a.found) drivers.push(withOrigin({ cue: 'ambiguityPosture', value: a.value, confidence: a.confidence, basis: a.basis }, a.origin));
|
|
482
|
+
}
|
|
483
|
+
return drivers;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ── Delegation producer (the act-as-me SOURCE) ────────────────────────────────
|
|
487
|
+
// Derive `delegation`-origin signals from the consult OVERRIDE history (§10.6 receipts)
|
|
488
|
+
// — the act-as-me learning half that pairs with origin-aware resolution above.
|
|
489
|
+
//
|
|
490
|
+
// Honesty rail (docs/roadmap/in-claude-outcome-marking.md): ONLY an override teaches —
|
|
491
|
+
// `user_action` `rejected` or `modified`, the subject correcting the agent. `approved`
|
|
492
|
+
// (exactly what a rubber-stamping autonomous agent would self-report) teaches NOTHING,
|
|
493
|
+
// so the channel can't silently collapse into "agent obedience". The corrected DIRECTION
|
|
494
|
+
// is read from the oracle's own signal: overriding a PERMISSIVE signal (proceed*) means
|
|
495
|
+
// the subject wanted MORE caution on delegated work; overriding a CAUTIOUS one
|
|
496
|
+
// (escalate/decline/clarify) means LESS. Each driving cue of the overridden consult is
|
|
497
|
+
// nudged in that direction, aggregated across receipts (net direction wins; a tie is
|
|
498
|
+
// genuinely ambiguous → skipped). Pure; reuses the CUES catalog so the synthesized value
|
|
499
|
+
// reads back through the same keyword path. A delegated correction is scoped to the
|
|
500
|
+
// action's domain when it had one, so a financial override never reshapes writing.
|
|
501
|
+
const PERMISSIVE_SIGNAL = new Set(['proceed', 'proceed_with_note']);
|
|
502
|
+
const CAUTIOUS_SIGNAL = new Set(['escalate', 'decline', 'clarify']);
|
|
503
|
+
const OVERRIDE_WEIGHT = { rejected: 1.0, modified: 0.5 };
|
|
504
|
+
|
|
505
|
+
export function deriveDelegationSignals(receipts, { now = new Date().toISOString(), floor = 1 } = {}) {
|
|
506
|
+
const tally = new Map(); // `${cue}|${domain}` → { cue, domain, sum, n }
|
|
507
|
+
for (const r of (Array.isArray(receipts) ? receipts : [])) {
|
|
508
|
+
const w = OVERRIDE_WEIGHT[r && r.user_action];
|
|
509
|
+
if (!w) continue; // only rejected/modified teach
|
|
510
|
+
const sig = r?.response?.signal;
|
|
511
|
+
const correct = PERMISSIVE_SIGNAL.has(sig) ? 1 : CAUTIOUS_SIGNAL.has(sig) ? -1 : 0; // +1 cautious / -1 bold
|
|
512
|
+
if (!correct) continue; // abstain/unknown → no direction
|
|
513
|
+
const domain = (typeof r?.request?.action_descriptor?.domain === 'string') ? r.request.action_descriptor.domain : '';
|
|
514
|
+
for (const d of (Array.isArray(r.drivers) ? r.drivers : [])) {
|
|
515
|
+
if (!CUES[d && d.cue]) continue; // scalar caution cues only (ambiguity later)
|
|
516
|
+
const k = `${d.cue}|${domain}`;
|
|
517
|
+
const t = tally.get(k) || { cue: d.cue, domain, sum: 0, n: 0 };
|
|
518
|
+
t.sum += correct * w; t.n += 1;
|
|
519
|
+
tally.set(k, t);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const out = [];
|
|
523
|
+
for (const { cue, domain, sum, n } of tally.values()) {
|
|
524
|
+
if (n < 1 || Math.abs(sum) < floor) continue; // need a clear, sufficient net correction
|
|
525
|
+
const dir = sum > 0 ? 'cautious' : 'bold';
|
|
526
|
+
out.push({
|
|
527
|
+
layer: CUES[cue].layers[0],
|
|
528
|
+
field: CUES[cue].fields[0],
|
|
529
|
+
value: CUES[cue][dir][0], // a keyword the resolver reads as this direction
|
|
530
|
+
polarity: 'positive',
|
|
531
|
+
origin: 'delegation',
|
|
532
|
+
evidence_basis: 'observed',
|
|
533
|
+
source: 'delegation_override',
|
|
534
|
+
confidence: Math.min(0.9, 0.55 + 0.1 * n),
|
|
535
|
+
observation_count: n,
|
|
536
|
+
last_observed: now,
|
|
537
|
+
...(domain ? { domain } : {}),
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
return out;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Merge derived delegation signals into a COPY of vd's layers so the origin-aware
|
|
544
|
+
// resolver sees them (delegation outranks self per dimension). Pure; clones only the
|
|
545
|
+
// layers it touches; never mutates the input vd or its arrays. The derived signals live
|
|
546
|
+
// only in this per-consult in-memory vd — never persisted to the fingerprint, never
|
|
547
|
+
// across §10.4 (they shape the SIGNAL, they don't cross as content).
|
|
548
|
+
export function withDelegationSignals(vd, signals) {
|
|
549
|
+
if (!Array.isArray(signals) || !signals.length || !vd || !vd.phaedo_fingerprint) return vd;
|
|
550
|
+
const fp = vd.phaedo_fingerprint;
|
|
551
|
+
const layers = { ...(fp.layers || {}) };
|
|
552
|
+
for (const s of signals) {
|
|
553
|
+
const lid = s.layer || 'decision_and_risk';
|
|
554
|
+
const L = layers[lid] ? { ...layers[lid] } : {};
|
|
555
|
+
const { layer, ...sig } = s; // drop the routing key from the stored signal
|
|
556
|
+
L.signals = [...(Array.isArray(L.signals) ? L.signals : []), sig];
|
|
557
|
+
layers[lid] = L;
|
|
558
|
+
}
|
|
559
|
+
return { ...vd, phaedo_fingerprint: { ...fp, layers } };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ── Delegation PROMOTION (the reviewable, portable act-as-me step) ────────────
|
|
563
|
+
// `deriveDelegationSignals` above feeds the live resolver an in-memory overlay from
|
|
564
|
+
// EVERY override. Promotion is the durable, reviewable step: when an override pattern is
|
|
565
|
+
// ESTABLISHED (|net corrections| ≥ floor on a dimension) and not already authored, stage
|
|
566
|
+
// a `suggested_rules` candidate — the SAME shape the §E delta-promotion review uses
|
|
567
|
+
// (Proposal 0007), so it rides the existing popup card + endorse path. Endorsing it
|
|
568
|
+
// authors a portable, delegation-origin `standing_rules` entry (syncs, shapes injection
|
|
569
|
+
// + consult). Pure; honesty rail inherited (only overrides count). No-nag: skip a
|
|
570
|
+
// dimension already authored as a delegation rule, or already staged/dismissed.
|
|
571
|
+
export const DELEGATION_PROMOTION_FLOOR = 3; // matches §4.7 RESOLUTION_FLOOR / §E PROMOTION_FLOOR
|
|
572
|
+
|
|
573
|
+
// Each scalar caution cue → its canonical §4.2 Decision&Risk dimension + the catalog
|
|
574
|
+
// value token for each corrective direction, and a first-person *delegation* sentence
|
|
575
|
+
// (act-as-me framing — "when you act on my behalf"). Ambiguity is excluded (its driver
|
|
576
|
+
// carries a value, not a lean — handled when promotion grows past the scalar cues).
|
|
577
|
+
const CUE_DIMENSION = {
|
|
578
|
+
irreversibleCaution: 'reversible_vs_irreversible',
|
|
579
|
+
decisionThreshold: 'evidence_threshold',
|
|
580
|
+
evidenceBar: 'evidence_threshold',
|
|
581
|
+
speedQuality: 'speed_vs_quality',
|
|
582
|
+
};
|
|
583
|
+
const DIRECTION_VALUE = {
|
|
584
|
+
reversible_vs_irreversible: { cautious: 'more_cautious', bold: 'consistent_style' },
|
|
585
|
+
evidence_threshold: { cautious: 'high', bold: 'low' },
|
|
586
|
+
speed_vs_quality: { cautious: 'quality', bold: 'speed' },
|
|
587
|
+
};
|
|
588
|
+
const DELEGATION_TEXT = {
|
|
589
|
+
reversible_vs_irreversible: { cautious: 'When you act on my behalf, slow down and check with me on decisions that are hard to undo.',
|
|
590
|
+
bold: "When acting on my behalf, treat hard-to-undo decisions like any other — don't over-escalate them." },
|
|
591
|
+
evidence_threshold: { cautious: 'When acting on my behalf, make sure the main risks are covered before you commit.',
|
|
592
|
+
bold: "When acting on my behalf, act on the best available signal — don't over-gather evidence." },
|
|
593
|
+
speed_vs_quality: { cautious: 'When acting on my behalf, favor getting it right over getting it fast.',
|
|
594
|
+
bold: 'When acting on my behalf, favor moving fast over polishing.' },
|
|
595
|
+
};
|
|
596
|
+
function delegationText(dimension, dir, domain) {
|
|
597
|
+
const base = (DELEGATION_TEXT[dimension] && DELEGATION_TEXT[dimension][dir]) || `When acting on my behalf, handle ${dimension} as I've corrected you to.`;
|
|
598
|
+
return domain ? base.replace(/\.$/, ` — especially on ${domain} decisions.`) : base;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export function buildDelegationPromotions(receipts, fp, { now = new Date().toISOString(), floor = DELEGATION_PROMOTION_FLOOR } = {}) {
|
|
602
|
+
// Aggregate overrides per CANONICAL (dimension, domain): signed weight + count. Same
|
|
603
|
+
// honesty rail + direction rule as deriveDelegationSignals (only rejected/modified;
|
|
604
|
+
// direction read from the oracle's own signal).
|
|
605
|
+
const tally = new Map(); // `${dimension}|${domain}` → { dimension, domain, sum, n }
|
|
606
|
+
for (const r of (Array.isArray(receipts) ? receipts : [])) {
|
|
607
|
+
const w = OVERRIDE_WEIGHT[r && r.user_action];
|
|
608
|
+
if (!w) continue;
|
|
609
|
+
const sig = r?.response?.signal;
|
|
610
|
+
const correct = PERMISSIVE_SIGNAL.has(sig) ? 1 : CAUTIOUS_SIGNAL.has(sig) ? -1 : 0;
|
|
611
|
+
if (!correct) continue;
|
|
612
|
+
const domain = (typeof r?.request?.action_descriptor?.domain === 'string') ? r.request.action_descriptor.domain : '';
|
|
613
|
+
for (const d of (Array.isArray(r.drivers) ? r.drivers : [])) {
|
|
614
|
+
const dim = CUE_DIMENSION[d && d.cue];
|
|
615
|
+
if (!dim) continue;
|
|
616
|
+
const k = `${dim}|${domain}`;
|
|
617
|
+
const t = tally.get(k) || { dimension: dim, domain, sum: 0, n: 0 };
|
|
618
|
+
t.sum += correct * w; t.n += 1; tally.set(k, t);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
const keyOf = (dim, domain) => `${dim}|${domain || ''}`;
|
|
622
|
+
const authored = new Set((Array.isArray(fp?.standing_rules) ? fp.standing_rules : [])
|
|
623
|
+
.filter((r) => r?.decision?.origin === 'delegation' && r?.decision?.dimension)
|
|
624
|
+
.map((r) => keyOf(r.decision.dimension, r.decision.domain)));
|
|
625
|
+
const staged = new Set((Array.isArray(fp?.suggested_rules) ? fp.suggested_rules : [])
|
|
626
|
+
.filter((s) => s?.source === 'delegation_promotion' && s?.decision?.dimension)
|
|
627
|
+
.map((s) => keyOf(s.decision.dimension, s.decision.domain)));
|
|
628
|
+
const out = [];
|
|
629
|
+
for (const { dimension, domain, sum, n } of tally.values()) {
|
|
630
|
+
if (Math.abs(sum) < floor) continue; // not yet established
|
|
631
|
+
if (authored.has(keyOf(dimension, domain)) || staged.has(keyOf(dimension, domain))) continue; // no-nag
|
|
632
|
+
const dir = sum > 0 ? 'cautious' : 'bold';
|
|
633
|
+
out.push({
|
|
634
|
+
suggestion_id: (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : `dlg-${Math.random().toString(36).slice(2, 10)}`,
|
|
635
|
+
status: 'suggested',
|
|
636
|
+
source: 'delegation_promotion',
|
|
637
|
+
proposed_text: delegationText(dimension, dir, domain),
|
|
638
|
+
decision: { dimension, polarity: 'positive', origin: 'delegation', value: DIRECTION_VALUE[dimension]?.[dir], ...(domain ? { domain } : {}) },
|
|
639
|
+
evidence: { observation_count: n, evidence_basis: 'observed' },
|
|
640
|
+
created: now,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
return out;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ── Honoring an AUTHORED delegation rule (the portable act-as-me preference) ──
|
|
647
|
+
// Once the subject ENDORSES a delegation suggestion (rule-review.js), it becomes a
|
|
648
|
+
// `standing_rules` instruction with `decision.origin:"delegation"` — portable: it syncs
|
|
649
|
+
// across devices and renders in the §9 injection "Operating rules" block verbatim. To
|
|
650
|
+
// also shape CONSULT, convert each such rule into a `delegation`-origin signal the
|
|
651
|
+
// origin-aware resolver already honors (it then OUTRANKS the subject's self pattern, and
|
|
652
|
+
// — authored — reads at full strength). The catalog `decision.value` token fixes the
|
|
653
|
+
// direction; mapped to the keyword the cue readers recognize. Pure.
|
|
654
|
+
const VALUE_DIRECTION = {
|
|
655
|
+
more_cautious: 'cautious', consistent_style: 'bold',
|
|
656
|
+
high: 'cautious', low: 'bold',
|
|
657
|
+
quality: 'cautious', accuracy: 'cautious', speed: 'bold',
|
|
658
|
+
};
|
|
659
|
+
const DIMENSION_KEYWORD = {
|
|
660
|
+
reversible_vs_irreversible: { cautious: 'more_cautious', bold: 'consistent_style' },
|
|
661
|
+
evidence_threshold: { cautious: 'high evidence', bold: 'good enough' },
|
|
662
|
+
speed_vs_quality: { cautious: 'quality over', bold: 'speed over' },
|
|
663
|
+
};
|
|
664
|
+
export function authoredDelegationSignals(fp) {
|
|
665
|
+
const rules = Array.isArray(fp && fp.standing_rules) ? fp.standing_rules : [];
|
|
666
|
+
const out = [];
|
|
667
|
+
for (const r of rules) {
|
|
668
|
+
const d = r && r.decision;
|
|
669
|
+
if (!d || d.origin !== 'delegation' || !d.dimension) continue;
|
|
670
|
+
const dir = VALUE_DIRECTION[d.value];
|
|
671
|
+
const kw = dir && DIMENSION_KEYWORD[d.dimension] && DIMENSION_KEYWORD[d.dimension][dir];
|
|
672
|
+
if (!kw) continue; // unmapped value → can't honor in-consult (still injects verbatim)
|
|
673
|
+
out.push({
|
|
674
|
+
layer: 'decision_and_risk',
|
|
675
|
+
field: d.dimension, value: kw, polarity: 'positive',
|
|
676
|
+
origin: 'delegation', evidence_basis: 'corroborated', source: 'authored_delegation',
|
|
677
|
+
confidence: 0.9, observation_count: 3,
|
|
678
|
+
...(typeof d.domain === 'string' ? { domain: d.domain } : {}),
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
return out;
|
|
682
|
+
}
|
|
683
|
+
|
|
365
684
|
// §10.3 response shape — the ONLY fields that cross the boundary (§10.4).
|
|
366
685
|
export function shape(type, signal, confidence, rationale_hint, deference_level) {
|
|
367
686
|
return {
|
|
@@ -377,11 +696,14 @@ export function shape(type, signal, confidence, rationale_hint, deference_level)
|
|
|
377
696
|
|
|
378
697
|
// ── Rule resolver (deterministic, local, always available — the floor) ─────────
|
|
379
698
|
export function ruleResponse(vd, n) {
|
|
699
|
+
// §4.7 contested dimensions are withheld from the cue readers (the consult-side mirror
|
|
700
|
+
// of injection suppression). Built once per consult and threaded into every resolver.
|
|
701
|
+
const suppressed = openConflictKeys(vd);
|
|
380
702
|
let resolved;
|
|
381
703
|
switch (n.type) {
|
|
382
|
-
case 'action_approval': resolved = resolveActionApproval(vd, n); break;
|
|
383
|
-
case 'domain_risk_check': resolved = resolveDomainRiskCheck(vd, n); break;
|
|
384
|
-
case 'escalation_default': resolved = resolveEscalationDefault(vd, n); break;
|
|
704
|
+
case 'action_approval': resolved = resolveActionApproval(vd, n, suppressed); break;
|
|
705
|
+
case 'domain_risk_check': resolved = resolveDomainRiskCheck(vd, n, suppressed); break;
|
|
706
|
+
case 'escalation_default': resolved = resolveEscalationDefault(vd, n, suppressed); break;
|
|
385
707
|
case 'voice_draft': resolved = resolveVoiceDraft(); break;
|
|
386
708
|
}
|
|
387
709
|
// Abstain floor (§10.3 `insufficient_signal`): a real decision type with ZERO
|
|
@@ -391,7 +713,12 @@ export function ruleResponse(vd, n) {
|
|
|
391
713
|
// C below), which still overrides this abstain. voice_draft never abstains (it's
|
|
392
714
|
// a redirect). An authored policy can still force a decision on top of this.
|
|
393
715
|
if (n.type !== 'voice_draft' && resolved.found === 0) {
|
|
394
|
-
|
|
716
|
+
// Distinguish "no coverage" from "coverage exists but is contested": if every
|
|
717
|
+
// relevant cue was WITHHELD by an open conflict, tell the agent it is under review
|
|
718
|
+
// (so it escalates to the subject rather than treating the subject as a blank slate).
|
|
719
|
+
const contested = resolved.withheld > 0;
|
|
720
|
+
return shape(n.type, 'insufficient_signal', calibrate(vd, 0).confidence,
|
|
721
|
+
contested ? CONTESTED_HINT : ABSTAIN_HINT, 'low');
|
|
395
722
|
}
|
|
396
723
|
const cal = n.type === 'voice_draft'
|
|
397
724
|
? { confidence: 0.2, deference_level: 'low' }
|