phaedo-mcp 0.1.1 → 0.2.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.
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
- // Resolve a relevant cue to { text, confidence, basis }, preferring a structured
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
- return { text: String(Array.isArray(s.value) ? s.value.join(' ') : s.value).toLowerCase(), confidence, basis };
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
- let untagged;
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)) return asCue(s); // domain match wins outright
131
- continue; // wrong domain — skip
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
- if (typeof s.domain === 'string' && !actionDomain) return asCue(s); // no action domain → tags ignored
134
- if (!untagged) untagged = asCue(s); // untagged fallback
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 (untagged) return untagged;
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 hit
208
- // in free text is the weakest evidence (text_scan).
209
- return { found, lean, confidence: found ? (cue ? cue.confidence : 0.6) : 0, basis: found ? (cue ? cue.basis : 'text_scan') : null };
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
- if (hit(text, AMBIGUITY.askFirst)) return { found: true, value: 'ask_first', confidence, basis };
219
- if (hit(text, AMBIGUITY.proceed)) return { found: true, value: 'proceed', confidence, basis };
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 actually used
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(r.confidence * (BASIS_WEIGHT[r.basis] ?? 0.75)); }
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(ambiguity.confidence * (BASIS_WEIGHT[ambiguity.basis] ?? 0.75)); }
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, ['irreversibleCaution', 'decisionThreshold', 'evidenceBar'], true, n.domain);
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, ['evidenceBar', 'speedQuality', 'decisionThreshold'], false, n.domain);
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, ['decisionThreshold', 'irreversibleCaution'], true, n.domain);
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
- return shape(n.type, 'insufficient_signal', calibrate(vd, 0).confidence, ABSTAIN_HINT, 'low');
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' }