phaedo-mcp 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/NOTICE +17 -0
  2. package/README.md +182 -19
  3. package/action-binding.js +82 -0
  4. package/agreement.js +86 -2
  5. package/ap2.js +214 -0
  6. package/autonomy-promotion.js +280 -0
  7. package/build-package.mjs +25 -10
  8. package/capture-a11y.js +116 -0
  9. package/capture-local.js +85 -0
  10. package/capture-sync.js +99 -0
  11. package/consult-core.js +126 -8
  12. package/consult-policy.js +427 -33
  13. package/consult-runtime.js +60 -0
  14. package/consult.js +29 -1
  15. package/delegation-sync.js +95 -0
  16. package/escalation-policy.sample.json +13 -0
  17. package/escalation.js +383 -0
  18. package/fingerprint-source.js +112 -13
  19. package/health.js +54 -0
  20. package/hooks/pre-ship.mjs +409 -0
  21. package/hooks/session-end.mjs +244 -0
  22. package/hooks/stop-capture.mjs +140 -0
  23. package/install.mjs +226 -36
  24. package/lib/capture-home.js +46 -0
  25. package/lib/domain.js +101 -0
  26. package/lib/is-main.js +40 -0
  27. package/lib/phaedo-crypto.js +17 -1
  28. package/package.json +29 -5
  29. package/phone-source.js +85 -12
  30. package/projection.js +4 -1
  31. package/pull.js +19 -3
  32. package/receipts-sync.js +197 -0
  33. package/receipts.js +34 -3
  34. package/server.js +323 -54
  35. package/server.json +3 -3
  36. package/ship-actions.js +164 -0
  37. package/ship-readout.js +106 -0
  38. package/signing-audit.js +111 -0
  39. package/signing-request.js +217 -0
  40. package/signing.js +176 -0
  41. package/vendor/authored-source.js +250 -0
  42. package/vendor/capabilities.js +203 -0
  43. package/vendor/capture/capture-core.js +67 -0
  44. package/vendor/capture/detectors.js +287 -0
  45. package/vendor/capture/exchanges.js +77 -0
  46. package/vendor/capture/model-detect.js +195 -0
  47. package/vendor/capture/owner.js +35 -0
  48. package/vendor/capture/package.json +4 -0
  49. package/vendor/capture/source-id.js +56 -0
  50. package/vendor/capture/state.js +148 -0
  51. package/vendor/capture/transcript.js +184 -0
  52. package/vendor/commitments.js +169 -0
  53. package/vendor/context-block.js +251 -18
  54. package/vendor/envelope.js +50 -4
  55. package/vendor/extraction/capture-combine.js +160 -0
  56. package/vendor/extraction/catalog.js +232 -0
  57. package/vendor/extraction/delta-lift.js +109 -0
  58. package/vendor/extraction/fingerprint-update.js +227 -0
  59. package/vendor/extraction/package.json +4 -0
  60. package/vendor/extraction/persist.js +45 -0
  61. package/vendor/extraction/reconcile.js +310 -0
  62. package/vendor/extraction/resolve.js +178 -0
  63. package/vendor/extraction/rule-promotion.js +103 -0
  64. package/vendor/kdf.js +20 -1
  65. package/vendor/protocol.js +7 -0
  66. package/vendor/signal-ledger.js +402 -0
  67. package/vendor/trait-phrases.js +656 -0
  68. package/vendored-files.mjs +86 -0
@@ -0,0 +1,45 @@
1
+ // Phaedo Loop 2 extraction — fingerprint persist merge (the CAS-safe contract).
2
+ //
3
+ // The offscreen pass produces a raw per-layer `update` delta. Merging that delta
4
+ // INTO the persistent fingerprint is a read-modify-write, and it MUST run against
5
+ // the freshest fingerprint under a lock — otherwise a pass that read the
6
+ // fingerprint before it ran will blind-overwrite anything authored in between:
7
+ // a subject-endorsed standing_rule, another tab's accumulated signals, or a
8
+ // just-dismissed conflict (the extension-CAS lost-update finding).
9
+ //
10
+ // This module is the PURE core of that merge: reconcile the delta, stage any
11
+ // newly-promotable suggested rules, stamp updated_at. The service worker
12
+ // (background.js `mergeExtraction`) wraps it in `withVaultLock` against a fresh
13
+ // `readVault`, so the whole read → merge → write is atomic. Keeping the merge
14
+ // pure makes the race-safety contract node-testable without chrome.
15
+
16
+ import { reconcileFingerprint } from './reconcile.js';
17
+ import { promoteRules } from './rule-promotion.js';
18
+ import { applyCaptureSources } from './capture-combine.js';
19
+
20
+ // Reconcile `update` into `fingerprint` and return the next fingerprint. Never
21
+ // mutates the input. reconcile merges layer signals accumulatively and upserts
22
+ // conflict_records (dismissals stay sticky); promoteRules stages suggested_rules
23
+ // for established, not-yet-authored D&R patterns (skipping dimensions the subject
24
+ // already authored). Because both are pure and merge — rather than replace — the
25
+ // current fingerprint, running them on a FRESH read is the whole lost-update fix.
26
+ //
27
+ // `captureSources` (optional): the vault's per-surface capture snapshots
28
+ // (phaedo_capture_sources values). When present, the update's delta signals are
29
+ // replaced by the CROSS-SURFACE combined set before reconcile (capture-combine.js)
30
+ // — otherwise a delta write from one surface would clobber the accumulated counts
31
+ // of the others (delta reconcile is replace-by-key). Callers that hold a vault
32
+ // with capture sources MUST pass them on every delta-writing merge.
33
+ //
34
+ // `attr` (optional { sourceId, context }): which browser this pass ran in. Extraction evidence is
35
+ // then recorded per source (signal-ledger.js), so learning from several browsers adds up at sync
36
+ // instead of the newest copy replacing the rest.
37
+ export function mergeExtractionInto(fingerprint, update, now = new Date().toISOString(), captureSources = null, attr = null) {
38
+ const effective = (Array.isArray(captureSources) && captureSources.length)
39
+ ? applyCaptureSources(update, captureSources, now)
40
+ : update;
41
+ const ropts = attr && attr.sourceId ? { now, sourceId: attr.sourceId, context: attr.context } : { now };
42
+ const merged = promoteRules(reconcileFingerprint(fingerprint || {}, effective, ropts), { now });
43
+ merged.updated_at = now; // bump for sync last-write-wins
44
+ return merged;
45
+ }
@@ -0,0 +1,310 @@
1
+ // Phaedo Loop 2 extraction — reconciliation (update → fingerprint).
2
+ //
3
+ // Merges a partial fingerprint update (projection.buildFingerprintUpdate) into
4
+ // the stored fingerprint, per extraction-task-spec §6/§7 and §4.3:
5
+ //
6
+ // - Signals are keyed by (field, polarity). A new pass either reinforces an
7
+ // existing signal (same key → merge) or adds a new one.
8
+ // - Reinforcement raises confidence (more consistent observations = more
9
+ // likely a stable trait, §7), sums observation_count, unions session_refs,
10
+ // advances last_observed.
11
+ // - Anti-preference precedence (§4.3, §6): when a layer holds both a positive
12
+ // and a negative signal for the same field, the negative (anti-preference)
13
+ // is the effective one. Both are retained for provenance; effectiveSignals()
14
+ // applies the precedence for downstream consumers.
15
+ // - Non-destructive: questionnaire data on a layer (e.g. `responses[]`, the
16
+ // cold-start shape in §2) is preserved. Extraction only writes the §4.2
17
+ // signal-bearing fields. Authored User Configuration (§5) is a sibling
18
+ // artifact extraction never touches.
19
+ //
20
+ // NUMERIC CALIBRATION IS DEFERRED (§7): the confidence-combine curve and
21
+ // contradiction handling below are honest placeholders to be tuned by the eval
22
+ // harness once a real model exists. They are monotone and bounded, not claimed
23
+ // to be calibrated.
24
+
25
+ import { renderLayerSummary } from './fingerprint-update.js';
26
+ import { resolveDimension, valKey, authoredGovernedKeys } from './resolve.js';
27
+ // Per-source ledgers (multi-browser): a classic UMD file, loaded for its side effect and read off
28
+ // globalThis so the same file serves the popup's classic scripts, this module and Node.
29
+ import '../signal-ledger.js';
30
+ const SL = globalThis.PhaedoSignalLedger;
31
+
32
+ const CONFIDENCE_CAP = 0.95;
33
+
34
+ // Noisy-OR: independent consistent observations push confidence up, never past
35
+ // the cap. Placeholder pending calibration; contradiction-driven *lowering*
36
+ // (§7, persona-algorithm "adversarial robustness") is a deferred refinement.
37
+ function combineConfidence(a, b) {
38
+ return Math.min(CONFIDENCE_CAP, 1 - (1 - (a ?? 0)) * (1 - (b ?? 0)));
39
+ }
40
+
41
+ function unionArrays(a = [], b = []) {
42
+ return Array.from(new Set([...a, ...b]));
43
+ }
44
+
45
+ // Merge two signal objects with the same (field, polarity) key.
46
+ //
47
+ // `attr` ({ sourceId, context }, optional): WHO produced this increment. When given, the evidence
48
+ // is recorded in the signal's per-source ledger (signal-ledger.js) and the totals are re-derived
49
+ // from it, so a second browser's learning ADDS to this one's at sync instead of replacing it.
50
+ // Without it (tests, the desktop fold) the behaviour is exactly the pre-ledger accumulate.
51
+ function mergeSignal(existing, incoming, now, attr) {
52
+ // Delta signals carry Path A's CUMULATIVE count — a re-lift is a fresh
53
+ // snapshot of the same counters, not new evidence. Replace wholesale so
54
+ // re-running the lift every pass is idempotent (no count/confidence
55
+ // inflation). Keep the stable signal_id.
56
+ if (incoming.source === 'delta') {
57
+ return { ...incoming, signal_id: existing.signal_id || incoming.signal_id };
58
+ }
59
+ if (attr && attr.sourceId && SL && SL.isLedgered(incoming)) {
60
+ return SL.attribute(existing, incoming, attr.sourceId, { now, context: attr.context });
61
+ }
62
+ const observation_count = (existing.observation_count || 1) + (incoming.observation_count || 1);
63
+ // Value merge: arrays union; scalar/enum/string keep the higher-confidence
64
+ // value (a contradiction across passes resolves to the better-supported one).
65
+ let value = existing.value;
66
+ if (Array.isArray(existing.value) && Array.isArray(incoming.value)) {
67
+ value = unionArrays(existing.value, incoming.value);
68
+ } else if ((incoming.confidence ?? 0) > (existing.confidence ?? 0)) {
69
+ value = incoming.value;
70
+ }
71
+ return {
72
+ ...existing,
73
+ // A questionnaire (self-reported) signal that observation now corroborates is
74
+ // no longer "stated only" — adopt the observing source so it counts as
75
+ // learned (and surfaces in the §5b summary). Pure self-report keeps
76
+ // source 'questionnaire' and stays out of the summary.
77
+ source: incoming.source || existing.source,
78
+ value,
79
+ confidence: Number(combineConfidence(existing.confidence, incoming.confidence).toFixed(3)),
80
+ observation_count,
81
+ session_refs: unionArrays(existing.session_refs, incoming.session_refs),
82
+ last_observed: maxIso(existing.last_observed, incoming.last_observed) || now,
83
+ };
84
+ }
85
+
86
+ function maxIso(a, b) {
87
+ if (!a) return b;
88
+ if (!b) return a;
89
+ return a > b ? a : b;
90
+ }
91
+
92
+ // Dedupe key. Domain-scoped Decision & Risk signals (§4.2.7 optional `domain`)
93
+ // reconcile per (field, polarity, domain) so "financial caution" and "writing
94
+ // boldness" coexist and reinforce independently; an untagged signal keeps the
95
+ // legacy key — fully backward-compatible.
96
+ const sigKey = s => `${s.field}::${s.polarity}${typeof s.domain === 'string' ? `::${s.domain}` : ''}`;
97
+
98
+ // Reconcile one incoming layer doc into an existing layer doc (or undefined).
99
+ // Returns a new layer object; does not mutate inputs. `suppress` (optional Set of
100
+ // "field"/"field::domain") withholds those dimensions from the rendered summary —
101
+ // the §4.7 injection-suppression of an open-conflict dimension. `attr` — see mergeSignal.
102
+ export function reconcileLayer(existingLayer, incomingLayer, now, suppress, attr) {
103
+ const base = existingLayer ? { ...existingLayer } : {};
104
+ const existingSignals = Array.isArray(base.signals) ? base.signals : [];
105
+ const byKey = new Map(existingSignals.map(s => [sigKey(s), s]));
106
+ const attributed = !!(attr && attr.sourceId && SL);
107
+
108
+ for (const inc of incomingLayer.signals || []) {
109
+ const k = sigKey(inc);
110
+ if (byKey.has(k)) byKey.set(k, mergeSignal(byKey.get(k), inc, now, attr));
111
+ // A NEW signal is attributed too, or the same signal first seen independently on two
112
+ // browsers would carry no ledger on either and merge last-write-wins.
113
+ else byKey.set(k, attributed && SL.isLedgered(inc) ? SL.attribute(null, inc, attr.sourceId, { now, context: attr.context }) : inc);
114
+ }
115
+
116
+ const mergedSignals = Array.from(byKey.values());
117
+ return {
118
+ ...base, // preserve questionnaire `responses`, `label`, etc.
119
+ layer_version: incomingLayer.layer_version || base.layer_version || '0.1',
120
+ last_updated: now,
121
+ confidence: Number((mergedSignals.reduce((a, s) => a + (s.confidence || 0), 0) /
122
+ (mergedSignals.length || 1)).toFixed(3)),
123
+ signals: mergedSignals,
124
+ // §5b "Learned from your conversations" should reflect observation, not
125
+ // restate the interview (§5 already renders questionnaire answers). Render
126
+ // only observation-backed signals; pure self-report (source 'questionnaire',
127
+ // never corroborated) stays in signals[] for provenance but is excluded here.
128
+ summary: renderLayerSummary(effectiveSignals(mergedSignals).filter(s => s.source !== 'questionnaire'), suppress),
129
+ };
130
+ }
131
+
132
+ // Apply §4.3/§6 anti-preference precedence: if a field has a negative signal,
133
+ // the positive one for that field is suppressed from the effective set (kept in
134
+ // storage for provenance, dropped from the rendered/consumed view).
135
+ export function effectiveSignals(signals) {
136
+ const negFields = new Set(signals.filter(s => s.polarity === 'negative').map(s => s.field));
137
+ return signals.filter(s => !(s.polarity === 'positive' && negFields.has(s.field)));
138
+ }
139
+
140
+ // ── Conflict records (the update gate's "contradict" outcome) ────────────────
141
+ //
142
+ // mergeSignal() above collapses same-(field,polarity,domain) signals by confidence,
143
+ // so a genuine contradiction — a new OBSERVED value that disagrees with the subject's
144
+ // SELF-REPORT while the observed evidence is still thin (< §4.7 floor) — would vanish
145
+ // silently. Detect it at the merge moment (both values still visible), classify it
146
+ // with the SHARED §4.7 resolver, and stage a durable, reviewable record. This is the
147
+ // producer-written `suggested_rules` pattern (Proposal 0007): never injected, never
148
+ // consulted — a queue the subject reviews. We do NOT change what is stored or injected
149
+ // here (that injection-suppression fix is a separate, behaviour-changing follow-up);
150
+ // this only makes the contradiction visible and keeps both sides' provenance.
151
+
152
+ const uuid = () =>
153
+ (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID()
154
+ : 'cfl-' + Math.random().toString(36).slice(2, 10);
155
+
156
+ const conflictKey = (layer, field, domain) =>
157
+ `${layer}::${field}${typeof domain === 'string' ? `::${domain}` : ''}`;
158
+
159
+ // Compare an incoming layer's signals against the existing ones BEFORE the merge
160
+ // collapses them. For each incoming signal that shares a (field,polarity,domain) key
161
+ // with an existing one but carries a DIFFERENT scalar/enum value, ask the §4.7 resolver:
162
+ // - status "unresolved" (thin inferred value vs a self-report) → an OPEN conflict.
163
+ // - status "resolved" (the inferred side crossed the floor, or observed-vs-observed) →
164
+ // the dimension now settles; if a record was open for it, CLOSE it.
165
+ // Array-valued fields union (never conflict) and are skipped. Pure; no mutation.
166
+ export function detectConflicts(existingSignals, incomingSignals) {
167
+ const byKey = new Map((Array.isArray(existingSignals) ? existingSignals : []).map((s) => [sigKey(s), s]));
168
+ const open = [], settled = [], overrides = [];
169
+ for (const inc of (Array.isArray(incomingSignals) ? incomingSignals : [])) {
170
+ const ex = byKey.get(sigKey(inc));
171
+ if (!ex) continue; // novel → not a conflict
172
+ if (Array.isArray(ex.value) || Array.isArray(inc.value)) continue; // list fields union
173
+ if (valKey(ex.value) === valKey(inc.value)) continue; // agree → corroboration
174
+ const r = resolveDimension([ex, inc]);
175
+ const dim = { field: inc.field, domain: typeof inc.domain === 'string' ? inc.domain : undefined, candidates: r.provenance };
176
+ if (r.status === 'unresolved') { open.push(dim); continue; }
177
+ settled.push(dim);
178
+ // OVERRIDE detection: inferred behavior crossed the §4.7 floor AND there was a
179
+ // contradicting self_reported candidate → behavior just silently overrode what the
180
+ // subject said in the interview. Stage it so the subject SEES it (the existing
181
+ // "settled" path only closes already-open records; without this an override produces
182
+ // zero visibility — the subject's stated preference vanishes silently). Read by
183
+ // upsertConflictRecords to create a resolved record with resolution.by:'system'.
184
+ const selfReport = (r.provenance || []).find(c => c && c.evidence_basis === 'self_reported');
185
+ const winnerNotSelfReport = r.evidence_basis !== 'self_reported';
186
+ if (selfReport && winnerNotSelfReport && valKey(r.value) !== valKey(selfReport.value)) {
187
+ overrides.push({ ...dim, chosen_value: r.value, prior_value: selfReport.value });
188
+ }
189
+ }
190
+ return { open, settled, overrides };
191
+ }
192
+
193
+ // Fold a layer's detected conflicts into the fingerprint-level records list. Open a
194
+ // new record (or refresh an existing open one); close an open record whose dimension
195
+ // now settles. A subject's `dismissed` is sticky — never auto-reopened or closed.
196
+ function upsertConflictRecords(records, layerId, detected, now) {
197
+ const list = (Array.isArray(records) ? records : []).map((r) => ({ ...r }));
198
+ const byKey = new Map(list.map((r) => [conflictKey(r.layer, r.field, r.domain), r]));
199
+ for (const d of detected.open) {
200
+ const k = conflictKey(layerId, d.field, d.domain);
201
+ const ex = byKey.get(k);
202
+ if (ex && ex.status === 'dismissed') continue;
203
+ if (ex && ex.status === 'open') { ex.last_seen = now; ex.candidates = d.candidates; continue; }
204
+ const rec = { conflict_id: uuid(), layer: layerId, field: d.field, status: 'open', detected_at: now, last_seen: now, candidates: d.candidates };
205
+ if (d.domain) rec.domain = d.domain;
206
+ byKey.set(k, rec);
207
+ }
208
+ for (const d of detected.settled) {
209
+ const ex = byKey.get(conflictKey(layerId, d.field, d.domain));
210
+ if (ex && ex.status === 'open') { ex.status = 'resolved'; ex.resolved_at = now; }
211
+ }
212
+ // Overrides: stage a `resolved` record marked as a system-auto-resolution against a
213
+ // self-report, so the subject can see + acknowledge or revert. Dismissed records
214
+ // stay sticky; an existing record gets refreshed last_seen + candidates instead of
215
+ // duplicating. by:'system' is the cue conflict-review uses to surface for review.
216
+ for (const d of (detected.overrides || [])) {
217
+ const k = conflictKey(layerId, d.field, d.domain);
218
+ const ex = byKey.get(k);
219
+ if (ex && ex.status === 'dismissed') continue;
220
+ if (ex) {
221
+ ex.last_seen = now; ex.candidates = d.candidates;
222
+ // Don't re-stamp an override the subject already acknowledged (by:'user').
223
+ if (!ex.resolution || ex.resolution.by !== 'user') {
224
+ ex.status = 'resolved'; ex.resolved_at = now;
225
+ ex.resolution = { chosen_value: d.chosen_value, prior_value: d.prior_value, by: 'system' };
226
+ }
227
+ continue;
228
+ }
229
+ const rec = {
230
+ conflict_id: uuid(), layer: layerId, field: d.field,
231
+ status: 'resolved', detected_at: now, last_seen: now, resolved_at: now,
232
+ candidates: d.candidates,
233
+ resolution: { chosen_value: d.chosen_value, prior_value: d.prior_value, by: 'system' },
234
+ };
235
+ if (d.domain) rec.domain = d.domain;
236
+ byKey.set(k, rec);
237
+ }
238
+ return Array.from(byKey.values());
239
+ }
240
+
241
+ // Reconcile a full update ({ layerId: layerDoc }) into a fingerprint.
242
+ // Fingerprint shape: { layers: { layerId: layerDoc }, ... }. Returns a new
243
+ // fingerprint object (shallow-cloned down to touched layers). Also stages
244
+ // `conflict_records[]` for genuine self-report↔observed contradictions (above).
245
+ export function reconcileFingerprint(fingerprint, update, opts = {}) {
246
+ const now = opts.now || new Date().toISOString();
247
+ const fp = { ...(fingerprint || {}) };
248
+ fp.layers = { ...(fp.layers || {}) };
249
+ let conflicts = Array.isArray(fp.conflict_records) ? fp.conflict_records : null;
250
+ for (const [layerId, incomingLayer] of Object.entries(update || {})) {
251
+ const prev = fp.layers[layerId];
252
+ const detected = detectConflicts(prev && prev.signals, incomingLayer.signals);
253
+ if (detected.open.length || detected.settled.length || (detected.overrides && detected.overrides.length) || (conflicts && conflicts.length)) {
254
+ conflicts = upsertConflictRecords(conflicts || [], layerId, detected, now);
255
+ }
256
+ // Build the summary AFTER the conflict set is finalized, withholding from the
257
+ // projection (§4.7): (a) any dimension with an OPEN conflict, and (b) any dimension an
258
+ // AUTHORED bound rule governs — the authored value is canonical for the projection, so
259
+ // the inferred summary must not restate/contradict it (resolveEffective decides; the
260
+ // rule is injected separately via renderOperatingRules). authoredGovernedKeys runs over
261
+ // both the existing and incoming signals so every field that could render is covered.
262
+ const rules = Array.isArray(fp.standing_rules) ? fp.standing_rules : [];
263
+ const suppress = new Set([
264
+ ...openConflictFieldKeys(conflicts, layerId),
265
+ ...authoredGovernedKeys([...((prev && prev.signals) || []), ...(incomingLayer.signals || [])], rules, opts.domain || null),
266
+ ]);
267
+ fp.layers[layerId] = reconcileLayer(prev, incomingLayer, now, suppress, opts.sourceId ? { sourceId: opts.sourceId, context: opts.context } : null);
268
+ }
269
+ if (conflicts && conflicts.length) fp.conflict_records = conflicts;
270
+ return fp;
271
+ }
272
+
273
+ // Re-render every layer's `summary` from its CURRENT signals, with the same suppression
274
+ // reconcileFingerprint applies (open conflicts + dimensions an authored rule governs).
275
+ //
276
+ // A cross-browser sync merges signals without running a pass (signal-ledger.js mergeLayers), and
277
+ // the injected context reads `summary`, not signals — so without this, evidence that just arrived
278
+ // from another browser would sit in storage and never reach a prompt until this browser's next
279
+ // extraction pass. Returns the same object when no summary changes.
280
+ export function rerenderSummaries(fingerprint, opts = {}) {
281
+ if (!fingerprint || !fingerprint.layers) return fingerprint;
282
+ const rules = Array.isArray(fingerprint.standing_rules) ? fingerprint.standing_rules : [];
283
+ let out = null;
284
+ for (const [layerId, layer] of Object.entries(fingerprint.layers)) {
285
+ if (!layer || !Array.isArray(layer.signals) || !layer.signals.length) continue;
286
+ const suppress = new Set([
287
+ ...openConflictFieldKeys(fingerprint.conflict_records, layerId),
288
+ ...authoredGovernedKeys(layer.signals, rules, opts.domain || null),
289
+ ]);
290
+ const summary = renderLayerSummary(effectiveSignals(layer.signals).filter(s => s.source !== 'questionnaire'), suppress);
291
+ if (summary === layer.summary) continue;
292
+ out = out || { ...fingerprint, layers: { ...fingerprint.layers } };
293
+ out.layers[layerId] = { ...layer, summary };
294
+ }
295
+ return out || fingerprint;
296
+ }
297
+
298
+ // The (field / field::domain) keys of the OPEN conflicts on a layer — the dimensions
299
+ // the projection withholds (renderLayerSummary skips them).
300
+ function openConflictFieldKeys(records, layerId) {
301
+ const out = new Set();
302
+ for (const r of (Array.isArray(records) ? records : [])) {
303
+ if (r && r.layer === layerId && r.status === 'open') {
304
+ out.add(r.field + (typeof r.domain === 'string' ? `::${r.domain}` : ''));
305
+ }
306
+ }
307
+ return out;
308
+ }
309
+
310
+ export { combineConfidence, sigKey };
@@ -0,0 +1,178 @@
1
+ // §4.7 Conflict Resolution (Proposal 0006, Route A) — pure, deterministic, no I/O.
2
+ //
3
+ // Single source of truth for resolving multiple signals on ONE dimension (field)
4
+ // into a single value + provenance. Evidence-tier precedence (corroborated >
5
+ // observed > self_reported), observation-count then recency tiebreak, with a
6
+ // minimum-evidence FLOOR: thin observed evidence does NOT confidently override a
7
+ // self-reported (interview) value — below the floor a genuine conflict resolves to
8
+ // status:"unresolved", which consumers treat conservatively (assert nothing / an
9
+ // agent escalates rather than proceeds).
10
+ //
11
+ // Lives here (browser-safe ESM, packaged with the extension's extraction engine so
12
+ // the producer can render FROM it) and is re-exported by spec/validate-fingerprint.mjs
13
+ // so the spec test-vectors and mcp/health use the exact same resolver. Pure: any
14
+ // conformant implementation computes the same result from the same evidence log, so
15
+ // the fingerprint stays the raw signal log and provenance is derived (no wire change).
16
+
17
+ const isObj = (x) => typeof x === 'object' && x !== null && !Array.isArray(x);
18
+
19
+ export const RESOLUTION_FLOOR = 3; // observed/corroborated needs ≥ this to override self_reported
20
+ const EV_TIER = { corroborated: 3, observed: 2, self_reported: 1 };
21
+ const tierOf = (s) => EV_TIER[s && s.evidence_basis] || 1; // unknown basis → weakest
22
+ const obsOf = (s) => (typeof s.observation_count === 'number' && s.observation_count > 0) ? s.observation_count : 1;
23
+ const isoOf = (s) => typeof s.last_observed === 'string' ? s.last_observed : '';
24
+ export const valKey = (v) => JSON.stringify(v ?? null);
25
+
26
+ // Strict total order: tier, then observation_count, then recency, then value
27
+ // string — fully deterministic, no ties.
28
+ function moreAuthoritative(a, b) {
29
+ if (tierOf(a) !== tierOf(b)) return tierOf(a) - tierOf(b);
30
+ if (obsOf(a) !== obsOf(b)) return obsOf(a) - obsOf(b);
31
+ if (isoOf(a) !== isoOf(b)) return isoOf(a) < isoOf(b) ? -1 : 1;
32
+ const va = valKey(a.value), vb = valKey(b.value);
33
+ return va < vb ? -1 : va > vb ? 1 : 0;
34
+ }
35
+
36
+ export function resolveDimension(signals) {
37
+ const cand = (signals || []).filter(isObj);
38
+ if (!cand.length) return null;
39
+ const winner = cand.reduce((best, s) => moreAuthoritative(s, best) > 0 ? s : best);
40
+ const distinct = new Set(cand.map((s) => valKey(s.value)));
41
+ let status = 'resolved';
42
+ if (distinct.size > 1) { // a genuine value conflict
43
+ const inferredWinner = tierOf(winner) >= EV_TIER.observed;
44
+ const contradictingSelfReport = cand.some(
45
+ (s) => tierOf(s) === EV_TIER.self_reported && valKey(s.value) !== valKey(winner.value));
46
+ if (inferredWinner && contradictingSelfReport && obsOf(winner) < RESOLUTION_FLOOR) status = 'unresolved';
47
+ }
48
+ return {
49
+ field: winner.field,
50
+ value: winner.value,
51
+ evidence_basis: winner.evidence_basis,
52
+ confidence: winner.confidence,
53
+ status, // "resolved" | "unresolved"
54
+ provenance: cand.map((s) => ({
55
+ value: s.value, source: s.source, evidence_basis: s.evidence_basis,
56
+ confidence: s.confidence, observation_count: s.observation_count, last_observed: s.last_observed,
57
+ })),
58
+ };
59
+ }
60
+
61
+ // Resolve a whole layer's signals → { field: resolution }. Groups by field, then
62
+ // resolveDimension per field. (Polarity precedence per §4.3 is applied upstream by
63
+ // the producer's effectiveSignals(); this resolves VALUE conflicts.)
64
+ export function resolveLayer(signals) {
65
+ const byField = new Map();
66
+ for (const s of (signals || [])) {
67
+ if (!isObj(s) || s.field == null) continue;
68
+ if (!byField.has(s.field)) byField.set(s.field, []);
69
+ byField.get(s.field).push(s);
70
+ }
71
+ const out = {};
72
+ for (const [field, sigs] of byField) out[field] = resolveDimension(sigs);
73
+ return out;
74
+ }
75
+
76
+ // ── §4.7 effective resolution in context (v0.1.43, resolves OQ7) ──────────────
77
+ //
78
+ // Composes AUTHORED configuration (§5.2 decision-bound standing instructions)
79
+ // with the INFERRED evidence log above, per dimension, in an optional domain
80
+ // context — the exact rules for "a standing instruction that contradicts a
81
+ // high-confidence fingerprint signal in only one domain". The specificity
82
+ // ladder (a lower tier is consulted only when every higher tier is empty):
83
+ //
84
+ // 1. authored_domain — a decision-bound instruction whose decision.domain
85
+ // matches the context domain (resolver substring namespace: the context
86
+ // domain string CONTAINS the rule's domain, case-insensitive). A
87
+ // domain-bound rule NEVER governs outside its domain: with a
88
+ // non-matching or unknown context it is skipped entirely, so the
89
+ // inferred signal keeps governing everywhere else — that is the OQ7
90
+ // one-domain-override semantics.
91
+ // 2. authored_global — a decision-bound instruction with no domain binding.
92
+ // Authored beats inferred regardless of the signal's confidence or
93
+ // evidence tier (§4.7 authored-canonical, unchanged — now stated at
94
+ // dimension granularity).
95
+ // 3. inferred_domain — §4.2.7 domain-tagged signals matching the context
96
+ // domain, resolved among themselves by resolveDimension. An
97
+ // "unresolved" here does NOT fall through to tier 4: a contested
98
+ // domain-specific dimension stays contested (assert nothing).
99
+ // 4. inferred_global — untagged signals, resolved by resolveDimension.
100
+ // Signals tagged with a DIFFERENT domain are excluded when a context
101
+ // domain is known (they describe another context); with no context
102
+ // domain, all signals resolve together (pre-v0.1.43 behavior).
103
+ //
104
+ // Within an authored tier, ties break §5.2-style: lowest priority number,
105
+ // then created_at, then instruction_id — fully deterministic.
106
+ //
107
+ // Pure; same conformance surface as resolveDimension (TC-RESOLVE vectors).
108
+ const prioOf = (r) => (typeof r.priority === 'number' && Number.isFinite(r.priority)) ? r.priority : Infinity;
109
+ const cmpStr = (a, b) => { const x = String(a ?? ''), y = String(b ?? ''); return x < y ? -1 : x > y ? 1 : 0; };
110
+
111
+ export function resolveEffective({ field, signals = [], rules = [], domain = null } = {}) {
112
+ if (field == null) return null;
113
+ const d = (typeof domain === 'string' && domain) ? domain.toLowerCase() : null;
114
+
115
+ const bound = (rules || []).filter((r) => isObj(r) && r.kind === 'instruction'
116
+ && isObj(r.decision) && r.decision.dimension === field);
117
+ const ruleDomain = (r) => (typeof r.decision.domain === 'string' && r.decision.domain)
118
+ ? r.decision.domain.toLowerCase() : null;
119
+ const tier1 = d ? bound.filter((r) => { const rd = ruleDomain(r); return rd && d.includes(rd); }) : [];
120
+ const tier2 = bound.filter((r) => !ruleDomain(r));
121
+ const pickRule = (arr) => arr.slice().sort((a, b) =>
122
+ (prioOf(a) - prioOf(b)) || cmpStr(a.created_at, b.created_at) || cmpStr(a.instruction_id, b.instruction_id))[0];
123
+
124
+ const authoredTier = tier1.length ? 'authored_domain' : tier2.length ? 'authored_global' : null;
125
+ if (authoredTier) {
126
+ const rule = pickRule(tier1.length ? tier1 : tier2);
127
+ return {
128
+ field,
129
+ status: 'resolved',
130
+ basis: authoredTier,
131
+ authored: true,
132
+ // A bound rule may carry a precise catalog value (template-sourced) or
133
+ // none — a valueless negative binding is a cautious bias, which §10
134
+ // consumers already map (consult-policy.js). Surface both faithfully.
135
+ value: rule.decision.value ?? null,
136
+ polarity: rule.decision.polarity || 'positive',
137
+ rule_id: rule.instruction_id || null,
138
+ };
139
+ }
140
+
141
+ const sigs = (signals || []).filter(isObj);
142
+ const sigDomain = (s) => (typeof s.domain === 'string' && s.domain) ? s.domain.toLowerCase() : null;
143
+ const tagged = d ? sigs.filter((s) => { const sd = sigDomain(s); return sd && d.includes(sd); }) : [];
144
+ const pool = tagged.length ? tagged
145
+ : d ? sigs.filter((s) => !sigDomain(s)) // other-domain-tagged excluded in a known context
146
+ : sigs; // no context → all signals (back-compat)
147
+ const resolved = resolveDimension(pool);
148
+ if (!resolved) return null;
149
+ return { ...resolved, basis: tagged.length ? 'inferred_domain' : 'inferred_global', authored: false };
150
+ }
151
+
152
+ // The (field / field::domain) suppress keys for a layer's INFERRED summary that an
153
+ // AUTHORED bound rule already governs. §4.7 makes a bound rule authored-canonical for
154
+ // its dimension in the projection, so the summary must not ALSO assert a (possibly
155
+ // contradictory) inferred line for that dimension — the authored rule is already injected
156
+ // verbatim (context-block.js renderOperatingRules, higher priority than inferred style).
157
+ // This routes the decision through resolveEffective: a field whose resolution is authored
158
+ // (`authored:true`) has all its rendered-signal keys suppressed. `domain` is optional —
159
+ // null at extraction time, where only authored_GLOBAL rules govern; domain-scoped rules
160
+ // defer to injection-time rendering, which carries the session domain. Pure.
161
+ export function authoredGovernedKeys(signals, rules, domain = null) {
162
+ const out = new Set();
163
+ const list = (Array.isArray(signals) ? signals : []).filter(isObj);
164
+ if (!list.length || !Array.isArray(rules) || !rules.length) return out;
165
+ const byField = new Map();
166
+ for (const s of list) {
167
+ if (typeof s.field !== 'string') continue;
168
+ if (!byField.has(s.field)) byField.set(s.field, []);
169
+ byField.get(s.field).push(s);
170
+ }
171
+ for (const [field, sigs] of byField) {
172
+ const r = resolveEffective({ field, signals: sigs, rules, domain });
173
+ if (r && r.authored) {
174
+ for (const s of sigs) out.add(s.field + (typeof s.domain === 'string' ? `::${s.domain}` : ''));
175
+ }
176
+ }
177
+ return out;
178
+ }
@@ -0,0 +1,103 @@
1
+ // Proposal 0007 §E — delta promotion (the steady-state path).
2
+ //
3
+ // When the extraction engine's INFERRED Decision & Risk signals show a pattern that
4
+ // has recurred enough to be established (evidence_basis observed/corroborated,
5
+ // observation_count ≥ floor) and the subject has NOT already turned it into an
6
+ // authored rule, propose a `suggested` standing rule into a STAGING queue
7
+ // (`phaedo_fingerprint.suggested_rules`). This is the one rule-shaped object the
8
+ // producer writes — and it is explicitly a *proposal*: never injected, never
9
+ // consulted. Only the subject's endorsement (rule-review.js) turns a suggestion into
10
+ // an authored `standing_rules` entry. So the authored-only invariant on standing_rules
11
+ // holds: the producer stages, the user authors.
12
+ //
13
+ // Pure + deterministic, zero-dep — the house style. The review UI lets the subject
14
+ // edit the proposed text before endorsing, so `proposed_text` only needs to be a
15
+ // reasonable starting sentence.
16
+
17
+ import '../trait-phrases.js'; // UMD side-effect import → globalThis.PhaedoTraitPhrases
18
+
19
+ export const PROMOTION_FLOOR = 3; // §4.7 RESOLUTION_FLOOR — same "established" bar
20
+ const PROMOTABLE_TIERS = new Set(['observed', 'corroborated']); // never promote a self_reported guess
21
+ const DR_LAYER = 'decision_and_risk';
22
+
23
+ const uuid = () =>
24
+ (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID()
25
+ : 'sug-' + Math.random().toString(36).slice(2, 10);
26
+
27
+ const keyOf = (dim, val) => `${dim}::${JSON.stringify(val ?? null)}`;
28
+
29
+ // (dimension, value) → the candidate sentence, from the shared phrasebook (trait-phrases.js):
30
+ // one written sentence for every trait and value, so a suggestion never reads
31
+ // "I have a consistent way I handle … (0.85)". The subject can still edit before endorsing.
32
+ export function proposeText(dimension, value) {
33
+ return globalThis.PhaedoTraitPhrases.phrase(dimension, value);
34
+ }
35
+
36
+ function endorsedDimensions(fp) {
37
+ const rules = Array.isArray(fp?.standing_rules) ? fp.standing_rules : [];
38
+ return new Set(rules.map((r) => r?.decision?.dimension).filter(Boolean));
39
+ }
40
+ function stagedKeys(fp) {
41
+ const sug = Array.isArray(fp?.suggested_rules) ? fp.suggested_rules : [];
42
+ return new Set(sug.map((s) => keyOf(s?.decision?.dimension, s?.evidence?.value)));
43
+ }
44
+
45
+ // Inspect a fingerprint's Decision & Risk signals and return suggested-rule
46
+ // candidates (does NOT mutate the fingerprint). A candidate is produced for an
47
+ // established, not-yet-authored, not-yet-staged/dismissed (dimension,value).
48
+ export function detectPromotions(fp, { floor = PROMOTION_FLOOR, now = new Date().toISOString() } = {}) {
49
+ const dr = fp?.layers?.[DR_LAYER];
50
+ const signals = Array.isArray(dr?.signals) ? dr.signals : [];
51
+ const covered = endorsedDimensions(fp);
52
+ const seen = stagedKeys(fp); // already suggested OR dismissed → skip
53
+ const local = new Set();
54
+ const out = [];
55
+ for (const s of signals) {
56
+ if (!s || typeof s !== 'object') continue;
57
+ const dim = s.dimension || s.field;
58
+ if (!dim) continue;
59
+ if (!PROMOTABLE_TIERS.has(s.evidence_basis)) continue;
60
+ if ((s.observation_count || 0) < floor) continue;
61
+ if (covered.has(dim)) continue; // already an authored rule on this dimension
62
+ const k = keyOf(dim, s.value);
63
+ if (seen.has(k) || local.has(k)) continue;
64
+ local.add(k);
65
+ out.push({
66
+ suggestion_id: uuid(),
67
+ status: 'suggested',
68
+ source: 'delta_promotion',
69
+ proposed_text: proposeText(dim, s.value),
70
+ decision: { dimension: dim, polarity: s.polarity === 'negative' ? 'negative' : 'positive', ...(s.domain ? { domain: s.domain } : {}) },
71
+ evidence: {
72
+ observation_count: s.observation_count || 0,
73
+ last_observed: s.last_observed || null,
74
+ evidence_basis: s.evidence_basis,
75
+ value: s.value,
76
+ ...(s.signal_id ? { signal_id: s.signal_id } : {}),
77
+ },
78
+ created: now,
79
+ });
80
+ }
81
+ return out;
82
+ }
83
+
84
+ // Merge new suggestions into the staging queue (dedup by dimension+value). Writes
85
+ // ONLY suggested_rules — never standing_rules. Returns a new fingerprint.
86
+ export function stageSuggestions(fp, suggestions) {
87
+ if (!Array.isArray(suggestions) || !suggestions.length) return fp;
88
+ const existing = Array.isArray(fp?.suggested_rules) ? fp.suggested_rules : [];
89
+ const seen = stagedKeys(fp);
90
+ const add = suggestions.filter((s) => {
91
+ const k = keyOf(s?.decision?.dimension, s?.evidence?.value);
92
+ if (seen.has(k)) return false;
93
+ seen.add(k);
94
+ return true;
95
+ });
96
+ if (!add.length) return fp;
97
+ return { ...fp, suggested_rules: [...existing, ...add] };
98
+ }
99
+
100
+ // Producer convenience: detect + stage in one pass.
101
+ export function promoteRules(fp, opts) {
102
+ return stageSuggestions(fp, detectPromotions(fp, opts));
103
+ }