phaedo-mcp 0.3.0 → 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.
@@ -0,0 +1,656 @@
1
+ // Phaedo — TRAIT PHRASES: every learned trait, in words a person can read (2026-10-08).
2
+ //
3
+ // THE PROBLEM. The review cards ("Rules to review", "Conflicts to settle", "Phaedo learned this")
4
+ // built their sentences from a table that covered seven of the ~30 traits. Everything else fell
5
+ // through to a filler that pasted the internal names straight onto the screen:
6
+ //
7
+ // "I have a consistent way I handle feedback_pref (blunt)."
8
+ // "I have a consistent way I handle how I weigh speed against quality (0.85)."
9
+ //
10
+ // and the subject was asked to decide between two of those. Endorsing one also wrote that sentence
11
+ // into their standing rules, where it was injected into every chat. Randy, 2026-10-08: "they are
12
+ // unusable ... what do they even mean?"
13
+ //
14
+ // THE FIX, in one module so every surface says the same thing:
15
+ // - `phrase(field, value)` — one written sentence for every trait and every value it can take,
16
+ // from both vocabularies that produce them (the extraction catalog and the interview). Scale
17
+ // traits (0..1) read as words: 0.15 on speed-vs-quality is "favor speed", never "0.15". A test
18
+ // (scripts/test_trait_phrases.mjs) walks both vocabularies and fails on any gap, so the filler
19
+ // cannot creep back.
20
+ // - `isSubstantive(field, from, to)` — a change worth asking about. Two different words for the
21
+ // same choice ("quality" / 0.8) are not a change; 0.75 → 0.85 on a scale is not a change.
22
+ // - `reviewCards(fp)` — the one queue every review surface renders: a BEFORE and AFTER for each
23
+ // change, a plain statement for each new pattern, only when the evidence is established
24
+ // (FLOOR observations, the §4.7 resolution floor) and the change is substantive. Smaller
25
+ // shifts still teach Phaedo; they just don't interrupt.
26
+ // - `upgradeFillerRules(rules)` — rewrites rules already accepted in the old filler wording.
27
+ //
28
+ // Pure + dependency-free. UMD: a classic script in popup.html (globalThis.PhaedoTraitPhrases), a
29
+ // side-effect import in extraction/rule-promotion.js, `require` in Node. The phone runs a
30
+ // BYTE-IDENTICAL copy at mobile/src/lib/trait-phrases.js (Metro cannot import outside mobile/);
31
+ // the test asserts the two files are identical.
32
+
33
+ (function (factory) {
34
+ const api = factory();
35
+ if (typeof module !== 'undefined' && module.exports) module.exports = api; // node test / Metro
36
+ if (typeof globalThis !== 'undefined') globalThis.PhaedoTraitPhrases = api; // popup global
37
+ })(function () {
38
+ 'use strict';
39
+
40
+ // Established evidence: the same bar §4.7 uses to let behaviour override what the subject said
41
+ // (extraction/resolve.js RESOLUTION_FLOOR) and rule promotion uses to propose a rule.
42
+ const FLOOR = 3;
43
+
44
+ // Scale traits are stored 0..1. Three plain readings; the word, never the number, is shown.
45
+ const LOW = 0.4, HIGH = 0.6, MIN_MOVE = 0.3;
46
+ const bucket = (n) => (n < LOW ? 'low' : n > HIGH ? 'high' : 'mid');
47
+
48
+ // field → { topic, values?, scale?, aliases?, list? }
49
+ // topic — completes "Phaedo noticed a change in ___" / "About ___".
50
+ // values — one sentence per value.
51
+ // aliases — another vocabulary's word for the same choice (interview vs catalog), or a word
52
+ // standing for a point on the scale.
53
+ // scale — [low, mid, high] sentences for a 0..1 trait.
54
+ // list — (items) => sentence, for traits that hold several values at once.
55
+ const TRAITS = {
56
+ // ── How answers look ──
57
+ response_length_pref: {
58
+ topic: 'how long you like answers',
59
+ values: {
60
+ short: 'Keep answers short and to the point.',
61
+ medium: 'Keep answers medium length: a few paragraphs, with structure when it helps.',
62
+ thorough: 'Give thorough answers that go deep and cover the edge cases.',
63
+ },
64
+ },
65
+ tone_register: {
66
+ topic: 'the tone you like',
67
+ values: {
68
+ casual: 'Use a casual, conversational tone.',
69
+ formal: 'Use a formal, professional tone.',
70
+ neutral: 'Use a plain, neutral tone.',
71
+ warm_enthusiastic: 'Use a warm, upbeat tone.',
72
+ direct_efficient: 'Use a direct, efficient tone.',
73
+ formal_analytical: 'Use a formal, analytical tone.',
74
+ casual_thinking_aloud: 'Use a casual tone, thinking out loud with me.',
75
+ },
76
+ aliases: { s2_a: 'warm_enthusiastic', s2_b: 'direct_efficient', s2_c: 'formal_analytical', s2_d: 'casual_thinking_aloud' },
77
+ },
78
+ formatting_prefs: {
79
+ topic: 'how you like answers laid out',
80
+ items: {
81
+ prose: 'plain paragraphs', bullets: 'bullet lists', numbered: 'numbered steps', tables: 'tables',
82
+ headers: 'headings', code_blocks: 'code blocks', code: 'code blocks',
83
+ inline_examples: 'examples', examples: 'examples',
84
+ },
85
+ list: (xs) => `Use ${xs} when they help.`,
86
+ },
87
+ jargon_tolerance: {
88
+ topic: 'how much technical language you want',
89
+ scale: [
90
+ 'Explain technical terms in plain words.',
91
+ 'Use technical terms when needed, with a quick explanation.',
92
+ "Use technical terms freely; I know them.",
93
+ ],
94
+ },
95
+ directness: {
96
+ topic: 'how direct answers should be',
97
+ scale: [
98
+ 'Some context before the answer is fine.',
99
+ 'Be reasonably direct.',
100
+ 'Get straight to the point, with no warm up.',
101
+ ],
102
+ },
103
+ pet_peeves: {
104
+ topic: 'things that bother you',
105
+ list: (xs) => `Avoid these: ${xs}.`,
106
+ },
107
+
108
+ // ── How you work ──
109
+ iteration_style: {
110
+ topic: 'how you work on drafts',
111
+ values: {
112
+ iterative_refine: 'Work in rounds: give me a draft, then we refine it together.',
113
+ deep_rework: 'Expect big rewrites; the first draft is just to find the shape.',
114
+ single_pass: 'Aim for one finished answer rather than rounds of drafts.',
115
+ light_polish: 'My first drafts are strong; help me polish them, not rewrite them.',
116
+ significant_revision: 'I revise drafts heavily, sometimes rewriting whole sections.',
117
+ },
118
+ aliases: { structural_rework: 'deep_rework' },
119
+ },
120
+ ambiguity_response: {
121
+ topic: 'what to do when a request is unclear',
122
+ values: {
123
+ ask_first: 'When my request is unclear, ask me one question before starting.',
124
+ assume_state_proceed: 'When my request is unclear, make a sensible guess, say what it is, and carry on.',
125
+ offer_options: 'When my request is unclear, show me two readings and let me pick.',
126
+ assume_silent: 'When my request is unclear, make a sensible guess and just carry on.',
127
+ },
128
+ aliases: { assume_state: 'assume_state_proceed', offer_two: 'offer_options' },
129
+ },
130
+ detail_vs_principle: {
131
+ topic: 'whether you want steps or the big idea',
132
+ values: {
133
+ steps: 'Give me the specific steps to follow.',
134
+ principle: 'Explain the underlying idea so I can adapt it myself.',
135
+ principle_first: 'Start with the big idea, then give me the steps.',
136
+ },
137
+ aliases: { specific_steps: 'steps', underlying_principle: 'principle', both_principle_first: 'principle_first' },
138
+ },
139
+ autonomy_pref: {
140
+ topic: 'how much the assistant should do without asking',
141
+ scale: [
142
+ 'Check with me before acting.',
143
+ 'Use your judgment on small things; check with me on big ones.',
144
+ "Just do it; don't stop to ask permission.",
145
+ ],
146
+ },
147
+ reasoning_visibility: {
148
+ topic: 'whether you want to see the reasoning',
149
+ values: {
150
+ show: 'Show me your reasoning.',
151
+ skip: 'Skip the explanation; just give me the answer.',
152
+ },
153
+ },
154
+ scope_discipline: {
155
+ topic: 'whether to stick to what you asked',
156
+ values: {
157
+ stay_scoped: 'Stick to exactly what I asked.',
158
+ wants_proactive: 'Point out things I might be missing, beyond what I asked.',
159
+ },
160
+ },
161
+
162
+ // ── How you think ──
163
+ quality_word: {
164
+ topic: 'what good work means to you',
165
+ values: {
166
+ elegant: 'Good work is elegant: simple and clean.',
167
+ rigorous: 'Good work is rigorous: precise and exact.',
168
+ clear: 'Good work is clear above all.',
169
+ surprising: 'Good work is surprising: an angle nobody else took.',
170
+ useful: 'Good work is useful: practical and it gets the job done.',
171
+ honest: 'Good work is honest.',
172
+ beautiful: 'Good work is beautiful.',
173
+ },
174
+ },
175
+ problem_solving_move: {
176
+ topic: 'how you tackle hard problems',
177
+ values: {
178
+ decompose: 'When a problem is hard, I break it into smaller parts.',
179
+ analogize: "When a problem is hard, I look for a similar problem I've solved.",
180
+ map_system: 'When a problem is hard, I map out the system and how the parts connect.',
181
+ discuss: 'When a problem is hard, I talk it through.',
182
+ sketch: 'When a problem is hard, I sketch or write to find its shape.',
183
+ sit_with: 'When a problem is hard, I sit with it until the structure shows up.',
184
+ },
185
+ aliases: { break_down: 'decompose', similar_problem: 'analogize', talk_through: 'discuss', sketch_write: 'sketch', sit_with_it: 'sit_with' },
186
+ },
187
+ mental_model_inventory: {
188
+ topic: 'the ways you tend to think',
189
+ items: {
190
+ first_principles: 'first principles', systems_thinking: 'systems', theory_of_constraints: 'bottlenecks',
191
+ second_order_effects: 'knock-on effects', tradeoff_reasoning: 'tradeoffs', analogical_reasoning: 'analogies',
192
+ decomposition: 'breaking things down', scenario_planning: 'scenarios',
193
+ },
194
+ list: (xs) => `I tend to think in terms of ${xs}.`,
195
+ },
196
+
197
+ // ── What you know ──
198
+ primary_expertise: { topic: 'your main work', list: (xs) => `My main work: ${xs}.` },
199
+ domain_depth_pref: {
200
+ topic: 'how deep to go in your own field',
201
+ values: {
202
+ surface: 'In my own field, keep it high level; I just need the answer.',
203
+ full_technical: 'In my own field, give me the full technical detail.',
204
+ match_my_question: 'In my own field, match the depth of my question.',
205
+ },
206
+ aliases: { full_detail: 'full_technical', match_signal: 'match_my_question' },
207
+ },
208
+ active_learning_areas: { topic: 'what you are learning', list: (xs) => `I'm currently learning: ${xs}.` },
209
+ domain_vocabulary: { topic: 'the terms you use', list: (xs) => `Terms I use: ${xs}.` },
210
+ expertise_level: {
211
+ topic: 'how much you already know',
212
+ values: {
213
+ power: 'Treat me as an expert.',
214
+ technical: "Assume I'm technical, but not a specialist.",
215
+ general: "Don't assume technical knowledge.",
216
+ },
217
+ },
218
+ learning_style: {
219
+ topic: 'how you learn best',
220
+ items: {
221
+ reading: 'reading', watching: 'watching someone do it', doing_badly: 'doing it badly and improving',
222
+ discussing: 'talking it through', examples: 'studying examples', building_small: 'building a small version',
223
+ },
224
+ list: (xs) => `I learn best by ${xs}.`,
225
+ },
226
+
227
+ // ── Pace and context ──
228
+ early_project_mode: {
229
+ topic: 'how you start new projects',
230
+ values: {
231
+ explore_wide: 'Early in a project, explore widely before narrowing down.',
232
+ frame_then_explore: 'Early in a project, quickly frame the shape, then explore inside it.',
233
+ commit_fast: 'Early in a project, find one good path and commit to it fast.',
234
+ },
235
+ },
236
+ urgency_baseline: {
237
+ topic: 'how much of a hurry you are usually in',
238
+ scale: [
239
+ 'No rush; take the time to get it right.',
240
+ 'A normal pace is fine.',
241
+ "I'm usually short on time; get me something usable fast.",
242
+ ],
243
+ },
244
+ working_mode: {
245
+ topic: 'whether you are exploring or building',
246
+ values: {
247
+ exploring: "I'm usually still exploring and thinking things through.",
248
+ build_mode: "I'm usually in build mode, ready to make things.",
249
+ },
250
+ },
251
+ pressure_response: {
252
+ topic: 'what happens when you are under pressure',
253
+ items: {
254
+ terser: 'I get terser', cut_detail: 'I cut detail', lean_patterns: 'I lean on familiar patterns',
255
+ decisive_fast: 'I make fast calls', more_collab: 'I pull others in', less_collab: 'I just execute',
256
+ quality_slips: 'quality starts to slip',
257
+ },
258
+ list: (xs) => `Under pressure, ${xs}.`,
259
+ },
260
+
261
+ // ── Working with people ──
262
+ audience_adaptation: {
263
+ topic: 'how you explain things to people who know less',
264
+ values: {
265
+ simplify_aggressive: 'When writing for people who know less, simplify hard, even at some cost to accuracy.',
266
+ accuracy_slow: 'When writing for people who know less, stay accurate but slow down and define terms.',
267
+ same_level: 'When writing for people who know less, keep it at my level and trust them to ask.',
268
+ pull_up: 'When writing for people who know less, meet them where they are and bring them up gradually.',
269
+ },
270
+ aliases: { simplify_hard: 'simplify_aggressive', match_pull_up: 'pull_up' },
271
+ },
272
+ disagreement_style: {
273
+ topic: 'how to handle disagreement',
274
+ values: {
275
+ push_back_now: 'If you disagree, push back right away.',
276
+ ask_first: 'If you disagree, first ask questions to understand my reasoning.',
277
+ revisit_later: 'If you disagree, note it and raise it later.',
278
+ disagree_commit: 'If you disagree, say so once, then go with my call.',
279
+ },
280
+ aliases: { note_revisit: 'revisit_later' },
281
+ },
282
+ feedback_pref: {
283
+ topic: 'how you like feedback',
284
+ values: {
285
+ blunt: "Give blunt feedback: just tell me what's wrong.",
286
+ direct_balanced: "Be direct with feedback, but include what's working.",
287
+ diplomatic: 'Give feedback gently, framed as suggestions.',
288
+ depends: 'Match feedback to the stakes: blunt when it matters, gentler when it does not.',
289
+ },
290
+ },
291
+
292
+ // ── Decisions ──
293
+ ambiguity_posture: {
294
+ topic: 'how decisive to be when things are unclear',
295
+ values: {
296
+ default: 'When things are unclear, be decisive rather than hedging.',
297
+ ask: 'When things are unclear, ask me before deciding.',
298
+ delay: 'When things are unclear, wait rather than commit too early.',
299
+ },
300
+ },
301
+ evidence_threshold: {
302
+ topic: 'how sure you need to be before committing',
303
+ scale: [
304
+ "A rough estimate is fine; I don't need everything nailed down.",
305
+ "Get the key facts right, but don't over research.",
306
+ 'Cover the main risks and nail down the key facts before I commit.',
307
+ ],
308
+ aliases: { high: 0.8, low: 0.2 },
309
+ },
310
+ options_vs_recommendation: {
311
+ topic: 'whether you want options or one recommendation',
312
+ values: {
313
+ single_recommendation: 'Give me one clear recommendation, not a list of options.',
314
+ option_set: 'Show me a few options and let me make the call.',
315
+ },
316
+ },
317
+ speed_vs_quality: {
318
+ topic: 'how you balance speed and quality',
319
+ scale: [
320
+ 'Favor speed: good enough now beats perfect later.',
321
+ 'Balance speed and quality.',
322
+ 'Favor quality: getting it right matters more than being fast.',
323
+ ],
324
+ aliases: { speed: 0.15, quality: 0.8, accuracy: 0.8 },
325
+ },
326
+ reversible_vs_irreversible: {
327
+ topic: "how you handle decisions you can't undo",
328
+ values: {
329
+ more_cautious: "On decisions I can't undo, I slow down and get more input.",
330
+ consistent_style: 'I bring the same care to a decision whether or not it can be undone.',
331
+ longer_think: "On decisions I can't undo, I think longer, but decide the same way.",
332
+ fast_adjust: 'I decide quickly when I can fix it later.',
333
+ careful_still: 'I think carefully even when a decision can be undone.',
334
+ depends_stakes: 'How carefully I decide depends on the stakes.',
335
+ },
336
+ },
337
+
338
+ // ── Big picture ──
339
+ stable_principles: { topic: 'the principles you hold', list: (xs) => `Principles I hold: ${xs}.` },
340
+ process_narration_pref: {
341
+ topic: 'whether to narrate the work',
342
+ values: {
343
+ skip_meta: "Skip the commentary about what you're doing; just do it.",
344
+ show_meta: 'Think out loud: tell me your plan as you go.',
345
+ },
346
+ },
347
+ };
348
+ // The interview's names for traits the catalog spells differently.
349
+ const FIELD_ALIASES = { reversibility_sensitivity: 'reversible_vs_irreversible' };
350
+
351
+ const traitOf = (field) => TRAITS[field] || TRAITS[FIELD_ALIASES[field]] || null;
352
+ const words = (s) => String(s).replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim();
353
+ const sentenceCase = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
354
+ function joinWords(xs) {
355
+ if (xs.length <= 1) return xs.join('');
356
+ return `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`;
357
+ }
358
+
359
+ /** Completes "About ___" / "a change in ___". Never an internal name. */
360
+ function topic(field) {
361
+ const t = traitOf(field);
362
+ return t ? t.topic : words(field || 'this');
363
+ }
364
+
365
+ /** The canonical form of a value: an alias resolved, a numeric string read as a number. */
366
+ function canonical(field, value) {
367
+ const t = traitOf(field);
368
+ let v = Array.isArray(value) && value.length === 1 ? value[0] : value;
369
+ if (typeof v === 'string' && /^\s*-?\d*\.?\d+\s*$/.test(v)) v = Number(v);
370
+ if (t && t.aliases && typeof v === 'string' && Object.prototype.hasOwnProperty.call(t.aliases, v)) v = t.aliases[v];
371
+ return v;
372
+ }
373
+
374
+ /** One plain sentence for a (trait, value). Never shows an underscore, a field name or a 0..1 number. */
375
+ function phrase(field, value) {
376
+ const t = traitOf(field);
377
+ const v = canonical(field, value);
378
+ if (t) {
379
+ if (t.scale && typeof v === 'number' && Number.isFinite(v)) {
380
+ return t.scale[{ low: 0, mid: 1, high: 2 }[bucket(v)]];
381
+ }
382
+ if (t.values && typeof v === 'string' && t.values[v]) return t.values[v];
383
+ if (t.list && (Array.isArray(v) || typeof v === 'string')) {
384
+ const xs = (Array.isArray(v) ? v : [v]).filter((x) => x != null && x !== '')
385
+ .map((x) => (t.items && t.items[x]) || words(x));
386
+ if (xs.length) return t.list(joinWords(xs));
387
+ }
388
+ }
389
+ // Unknown trait or value: still a sentence a person can read.
390
+ const about = sentenceCase(t ? t.topic : words(field || 'this'));
391
+ if (typeof v === 'number' && Number.isFinite(v)) return `${about}: ${{ low: 'low', mid: 'in the middle', high: 'high' }[bucket(v)]}.`;
392
+ if (v == null || typeof v === 'object') return `${about}.`;
393
+ return `${about}: ${words(v)}.`;
394
+ }
395
+
396
+ /** Two values that name the same choice ("quality" and 0.8; "steps" and "specific_steps"). */
397
+ function sameChoice(field, a, b) {
398
+ const x = canonical(field, a), y = canonical(field, b);
399
+ if (typeof x === 'number' && typeof y === 'number') return bucket(x) === bucket(y) || Math.abs(x - y) < MIN_MOVE;
400
+ return JSON.stringify(x) === JSON.stringify(y);
401
+ }
402
+
403
+ /** A change worth putting in front of the subject: a different choice, not a nudge on a scale. */
404
+ function isSubstantive(field, from, to) {
405
+ if (from == null || to == null) return false;
406
+ // List traits (formats, principles, …) add up rather than replace; they never conflict.
407
+ const t = traitOf(field);
408
+ if (Array.isArray(from) || Array.isArray(to) || (t && t.list)) return false;
409
+ const x = canonical(field, from), y = canonical(field, to);
410
+ if (Array.isArray(x) || Array.isArray(y) || typeof x === 'object' || typeof y === 'object') return false;
411
+ if ((typeof x === 'number') !== (typeof y === 'number')) return true;
412
+ return !sameChoice(field, x, y);
413
+ }
414
+
415
+ // ── The review queue every surface renders ──────────────────────────────────
416
+
417
+ const countOf = (c) => (c && Number.isFinite(c.observation_count) ? c.observation_count : 0);
418
+
419
+ /**
420
+ * The decisions waiting for the subject, oldest kind first, as cards:
421
+ * { kind: 'change', id, field, topic, before, after, count, source: 'override'|'conflict',
422
+ * beforeValue, afterValue }
423
+ * { kind: 'pattern', id, field, topic, text, count }
424
+ * Only established, substantive changes. `fp` is a fingerprint; anything else yields [].
425
+ * Authorization suggestions (§10.7) are not trait wording and are left to their own card.
426
+ */
427
+ function reviewCards(fp, opts) {
428
+ const floor = (opts && opts.floor) || FLOOR;
429
+ const out = [];
430
+ const records = Array.isArray(fp && fp.conflict_records) ? fp.conflict_records : [];
431
+ for (const c of records) {
432
+ if (!c || !c.field) continue;
433
+ const cands = Array.isArray(c.candidates) ? c.candidates : [];
434
+ const stated = cands.find((o) => o && o.evidence_basis === 'self_reported');
435
+ if (c.status === 'resolved' && c.resolution && c.resolution.by === 'system') {
436
+ // Behaviour already crossed the floor and overrode the interview answer.
437
+ const before = c.resolution.prior_value, after = c.resolution.chosen_value;
438
+ if (!isSubstantive(c.field, before, after)) continue;
439
+ const seen = cands.filter((o) => o && o.evidence_basis !== 'self_reported').reduce((m, o) => Math.max(m, countOf(o)), 0);
440
+ out.push({ kind: 'change', source: 'override', id: c.conflict_id, field: c.field, topic: topic(c.field),
441
+ before: phrase(c.field, before), after: phrase(c.field, after), beforeValue: before, afterValue: after, count: seen });
442
+ } else if (c.status === 'open' && stated) {
443
+ // A thin contradiction is not shown: one chat is not a change. Only once the new
444
+ // behaviour is established does it become a question.
445
+ const observed = cands.filter((o) => o && o.evidence_basis !== 'self_reported' && o.value != null)
446
+ .sort((a, b) => countOf(b) - countOf(a))[0];
447
+ if (!observed || countOf(observed) < floor) continue;
448
+ if (!isSubstantive(c.field, stated.value, observed.value)) continue;
449
+ out.push({ kind: 'change', source: 'conflict', id: c.conflict_id, field: c.field, topic: topic(c.field),
450
+ before: phrase(c.field, stated.value), after: phrase(c.field, observed.value),
451
+ beforeValue: stated.value, afterValue: observed.value, count: countOf(observed) });
452
+ }
453
+ }
454
+ const sugs = Array.isArray(fp && fp.suggested_rules) ? fp.suggested_rules : [];
455
+ for (const s of sugs) {
456
+ if (!s || s.status !== 'suggested' || s.kind === 'authorization') continue;
457
+ const field = s.decision && s.decision.dimension;
458
+ const ev = s.evidence || {};
459
+ const text = field && ev.value != null ? phrase(field, ev.value) : upgradeFillerText(s.proposed_text, field) || String(s.proposed_text || '');
460
+ if (!text) continue;
461
+ out.push({ kind: 'pattern', id: s.suggestion_id, field, topic: topic(field), text, count: countOf(ev), value: ev.value });
462
+ }
463
+ return out;
464
+ }
465
+
466
+ // ── Rules already accepted in the old wording ────────────────────────────────
467
+
468
+ const FILLER = /^I have a consistent way I handle (.+?)(?: \((.*)\))?\.?$/;
469
+
470
+ /** The plain sentence for a rule written in the old filler wording, or null if it is not one. */
471
+ function upgradeFillerText(text, field) {
472
+ const m = FILLER.exec(String(text || '').trim());
473
+ if (!m || !field || !traitOf(field) || m[2] == null || m[2] === '') return null;
474
+ const out = phrase(field, m[2]);
475
+ return out && !FILLER.test(out) ? out : null;
476
+ }
477
+
478
+ /** Same array when nothing needs rewording; a new array with plain sentences otherwise. */
479
+ function upgradeFillerRules(rules) {
480
+ if (!Array.isArray(rules)) return rules;
481
+ let changed = false;
482
+ const next = rules.map((r) => {
483
+ const t = r && upgradeFillerText(r.text, r.decision && r.decision.dimension);
484
+ if (!t) return r;
485
+ changed = true;
486
+ return { ...r, text: t };
487
+ });
488
+ return changed ? next : rules;
489
+ }
490
+
491
+ // ── "Why am I seeing this?" ──────────────────────────────────────────────────
492
+ //
493
+ // Two kinds of evidence, both safe to keep:
494
+ // - what the subject PICKED in the interview — the option's own label, read from the
495
+ // questionnaire schema the caller passes in (the popup's and the phone's copies are the same);
496
+ // - CUE PHRASES: catalog vocabulary ("keep it short", "don't sugarcoat") that appeared in the
497
+ // subject's own messages, collected by extraction/why-cues.js. Only words from Phaedo's fixed
498
+ // vocabulary are ever kept — never the surrounding sentence — because conversation text must
499
+ // never be written to disk (capture privacy §3.1) and a behavioural model must never carry
500
+ // episodic content (§4.6). Kept in the local vault only; never synced, injected or exported.
501
+
502
+ // Interview question → trait, for the single-answer questions (migration.js FIELD_MAP; the
503
+ // test asserts the two agree).
504
+ const INTERVIEW_QUESTIONS = {
505
+ s1: 'response_length_pref', s4: 'jargon_tolerance', b2: 'iteration_style', b3: 'ambiguity_response',
506
+ b4: 'detail_vs_principle', cr1: 'quality_word', cr2: 'problem_solving_move', d2: 'domain_depth_pref',
507
+ t1: 'early_project_mode', c1: 'audience_adaptation', c2: 'feedback_pref', c3: 'disagreement_style',
508
+ r1: 'speed_vs_quality',
509
+ };
510
+
511
+ function findQuestion(schema, id) {
512
+ let hit = null;
513
+ (function walk(o) {
514
+ if (hit || !o || typeof o !== 'object') return;
515
+ if (Array.isArray(o)) { o.forEach(walk); return; }
516
+ if (o.id === id && o.type) { hit = o; return; }
517
+ Object.values(o).forEach(walk);
518
+ })(schema);
519
+ return hit;
520
+ }
521
+
522
+ /** The interview option the subject picked for this (trait, value), in the interview's own words, or null. */
523
+ function interviewAnswer(schema, field, value) {
524
+ if (!schema || value == null) return null;
525
+ for (const [qid, f] of Object.entries(INTERVIEW_QUESTIONS)) {
526
+ if (f !== field && FIELD_ALIASES[f] !== field) continue;
527
+ const q = findQuestion(schema, qid);
528
+ if (!q) continue;
529
+ if (Array.isArray(q.options)) {
530
+ const o = q.options.find((x) => x && x.value != null && sameChoice(field, x.value, value));
531
+ if (o && o.label) return o.label;
532
+ }
533
+ const v = canonical(field, value);
534
+ if (q.type === 'slider' && typeof v === 'number') {
535
+ const b = bucket(v);
536
+ if (b === 'low' && q.anchor_left) return q.anchor_left;
537
+ if (b === 'high' && q.anchor_right) return q.anchor_right;
538
+ if (q.anchor_left && q.anchor_right) return `In between "${q.anchor_left}" and "${q.anchor_right}"`;
539
+ }
540
+ }
541
+ return null;
542
+ }
543
+
544
+ const cueKey = (field, value) => {
545
+ const v = canonical(field, value);
546
+ return `${field}::${typeof v === 'number' ? bucket(v) : JSON.stringify(v)}`;
547
+ };
548
+ const quoteList = (xs) => joinWords(xs.map((x) => `"${x}"`));
549
+ const timesText = (n) => (n === 1 ? 'once' : n === 2 ? 'twice' : `${n} times`);
550
+
551
+ /**
552
+ * One or two short sentences answering "Why am I seeing this?" for a review card.
553
+ * opts.schema — the interview questionnaire schema (for what the subject picked)
554
+ * opts.cues — the local cue store: { "<field>::<value>": { cues: [...] } }
555
+ */
556
+ function whyText(card, opts) {
557
+ const schema = opts && opts.schema;
558
+ const store = (opts && opts.cues) || {};
559
+ const cuesOf = (value) => {
560
+ const e = store[cueKey(card.field, value)];
561
+ return e && Array.isArray(e.cues) ? e.cues.slice(0, 3) : [];
562
+ };
563
+ const n = card.count || 0;
564
+ if (card.kind === 'change') {
565
+ const picked = interviewAnswer(schema, card.field, card.beforeValue);
566
+ const cues = cuesOf(card.afterValue);
567
+ const first = picked ? `In your interview you picked "${picked}".` : 'This is what you told Phaedo before.';
568
+ const second = cues.length
569
+ ? `Since then you've said things like ${quoteList(cues)}${n > 1 ? ` (${timesText(n)})` : ''}.`
570
+ : n > 0 ? `Since then your conversations have pointed the other way ${timesText(n)}.` : '';
571
+ return [first, second].filter(Boolean).join(' ');
572
+ }
573
+ const cues = cuesOf(card.value);
574
+ if (cues.length) return `You've said things like ${quoteList(cues)}${n > 1 ? ` (${timesText(n)})` : ''}.`;
575
+ return n > 0 ? `Phaedo has seen this ${timesText(n)} in your conversations.` : 'Phaedo has seen this in your conversations.';
576
+ }
577
+
578
+ // ── Reviewing the rules already in place ─────────────────────────────────────
579
+
580
+ const normText = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
581
+ const ruleId = (r) => (r && (r.instruction_id || r.id)) || null;
582
+
583
+ /** Which choice a rule's sentence states, when it is one of the phrasebook's own sentences. */
584
+ function ruleChoice(rule) {
585
+ const field = rule && rule.decision && rule.decision.dimension;
586
+ const t = traitOf(field);
587
+ if (!t || !rule.text) return null;
588
+ const text = String(rule.text).trim();
589
+ if (t.scale) {
590
+ const i = t.scale.indexOf(text);
591
+ if (i >= 0) return [0.15, 0.5, 0.85][i];
592
+ }
593
+ if (t.values) for (const [v, sentence] of Object.entries(t.values)) if (sentence === text) return v;
594
+ return null;
595
+ }
596
+
597
+ /**
598
+ * Every authored instruction, grouped for a one-at-a-time review — the ones that need a look first:
599
+ * { type: 'disagree', topic, rules } — two or more rules on one trait that say different things
600
+ * { type: 'duplicate', rules } — the same rule more than once (rules[0] is the one to keep)
601
+ * { type: 'unclear', rules } — still in the old filler wording, with nothing to recover
602
+ * { type: 'rule', rules } — everything else, one per card
603
+ * `key` identifies the item so a "keep" can be remembered. §10.7 authorizations are not wording
604
+ * and have their own surface, so they are left out.
605
+ */
606
+ function ruleReviewItems(rules) {
607
+ const list = (Array.isArray(rules) ? rules : [])
608
+ .filter((r) => r && r.kind !== 'authorization' && typeof r.text === 'string' && r.text.trim() && ruleId(r));
609
+ const used = new Set();
610
+ const items = [];
611
+ const key = (type, rs) => `${type}:${rs.map(ruleId).sort().join(',')}`;
612
+
613
+ // Same trait, different choices.
614
+ const byField = new Map();
615
+ for (const r of list) {
616
+ const f = r.decision && r.decision.dimension;
617
+ const c = ruleChoice(r);
618
+ if (!f || c == null) continue;
619
+ if (!byField.has(f)) byField.set(f, []);
620
+ byField.get(f).push([r, c]);
621
+ }
622
+ for (const [f, rs] of byField) {
623
+ const distinct = rs.filter(([, c], i) => rs.findIndex(([, d]) => sameChoice(f, c, d)) === i);
624
+ if (distinct.length < 2) continue;
625
+ const group = rs.map(([r]) => r);
626
+ group.forEach((r) => used.add(ruleId(r)));
627
+ items.push({ type: 'disagree', topic: topic(f), rules: group, key: key('disagree', group) });
628
+ }
629
+ // The same rule twice.
630
+ const seen = new Map();
631
+ for (const r of list) {
632
+ if (used.has(ruleId(r))) continue;
633
+ const k = `${(r.decision && r.decision.dimension) || ''}|${normText(r.text)}`;
634
+ if (!seen.has(k)) { seen.set(k, [r]); continue; }
635
+ seen.get(k).push(r);
636
+ }
637
+ for (const group of seen.values()) {
638
+ if (group.length < 2) continue;
639
+ group.forEach((r) => used.add(ruleId(r)));
640
+ items.push({ type: 'duplicate', rules: group, key: key('duplicate', group) });
641
+ }
642
+ for (const r of list) {
643
+ if (used.has(ruleId(r)) || !FILLER.test(String(r.text).trim())) continue;
644
+ used.add(ruleId(r));
645
+ items.push({ type: 'unclear', rules: [r], key: key('unclear', [r]) });
646
+ }
647
+ for (const r of list) {
648
+ if (used.has(ruleId(r))) continue;
649
+ items.push({ type: 'rule', rules: [r], key: key('rule', [r]) });
650
+ }
651
+ return items;
652
+ }
653
+
654
+ return { FLOOR, TRAITS, INTERVIEW_QUESTIONS, topic, phrase, canonical, sameChoice, isSubstantive, reviewCards,
655
+ upgradeFillerText, upgradeFillerRules, interviewAnswer, cueKey, whyText, ruleChoice, ruleReviewItems };
656
+ });
@@ -29,6 +29,10 @@ export const ROOT_FILES = [
29
29
  'kdf.js',
30
30
  'identity.js',
31
31
  'commitments.js',
32
+ // extraction/reconcile.js imports it (per-source signal ledgers, multi-browser sync).
33
+ 'signal-ledger.js',
34
+ // extraction/rule-promotion.js imports it (one plain sentence per trait, 2026-10-08).
35
+ 'trait-phrases.js',
32
36
  ];
33
37
 
34
38
  /** capture/ — the capture hook + tier-3 fold closure, vendored to mcp/vendor/capture/. */