# OiMy — Implementation Plan
## Companion Quality Sprint: V2 Prompt + nudge-Inspired Architecture

**Date:** June 24, 2026  
**Status:** Ready to execute  
**Est. total:** ~3 weeks across 4 phases  
**Primary file:** `/opt/oimy-engine/api.py`  
**Prompt dir:** `/opt/oimy-engine/prompts/`

---

## Context: What We're Fixing and Why

### Problem 1 — Prompt (V1 → V2)
The companion prompt at `prompts/gemma4_12b_companion_v1.txt` was the Opus 4.8 warmth rewrite from June 7. Today's eval (30 sessions, Gemma 4 12B QAT q4, NPS 7.3) surfaced 17 behavioral patterns needing fixes. The new prompt (`prompts/gemma4_12b_companion_v2.txt`) is already written and on disk. It needs to be wired in and verified.

**Key additions in V2:**
- Energy/register matching (casual ↔ serious)
- Explicit "when they don't want fixing" — stop problem-solving, hold space
- Never defer vulnerable disclosures ("I'll come back to that" banned)
- Stay on the current topic, don't steer back
- Scripts use only stated details + user's own voice
- No US-centric assumptions
- Medical/infant topics always hedge toward "check with your pediatrician"
- Banned clinical words: dysregulation, co-regulation, implement, leverage, modality, executive function, nervous system, somatic, scaffolding
- Never promise future actions or claim personal experience

### Problem 2 — Architecture (nudge-inspired, structural)
The LLM currently decides BOTH what mode to be in AND what to say. This is why we get:
- Coaching advice during casual chat (68 tone_mismatch flags)
- Problem-solving when the user explicitly said "don't fix me"
- BCT scaffolding for a user who wanted to share a funny taco story

**The fix:** Upstream deterministic mode selection. Before calling the LLM, classify the turn into one of 5 modes. The LLM only generates the words for the selected mode.

### Problem 3 — Session facts drift (code fix)
50 hallucination flags: model invents ages, names, details stated earlier in the conversation. Root cause: LLM attention degrades on specifics beyond turn 6-8 with QAT quantization. Fix: extract key facts from conversation history and re-inject as a pinned "session facts" block.

---

## Architecture Reference (current)

```
User message
    ↓
api.py → Router (GPT-5.4-mini) → skill_id + workflow_type
    ↓
chat_light path (workflow_type == "chat_light"):
    → _call_companion(user_input, entity_context, profile_context, conversation_history)
    → returns text reply

coach path (workflow_type == "coach"/"expert"/"planner"):
    → BCT engine → tall model (Grok 4.3 / Kimi K2.6)
    → bridge mode active
```

**Companion call variants:**
- `_call_ollama_companion()` — Gemma 4 12B via local Ollama (Mac app / RunPod)
- `_call_openrouter_companion()` — Cloud models (Grok 4.3, Haiku, etc.)
- `_call_deepseek_v3()` — DeepSeek V3 direct

All three load the companion prompt from `prompts/` directory. V2 is already loaded via:
```python
_prompt_path_v2 = Path(__file__).parent / "prompts" / "gemma4_12b_companion_v2.txt"
_prompt_v1 = Path(__file__).parent / "prompts" / "gemma4_12b_companion_v1.txt"
_prompt_path = _prompt_path_v2 if _prompt_path_v2.exists() else _prompt_v1
```
V2 file exists → it's already being loaded in the Ollama path. Verify it's also loaded in the OpenRouter path (line ~2158 in api.py — same pattern, already correct).

---

## Phase 0 — Verify V2 Prompt Is Active (1 hour)

**Goal:** Confirm v2 is the active prompt on both Ollama and OpenRouter companion paths. Zero new code.

### 0.1 — Check both load sites in api.py

Line ~2050 (Ollama path):
```python
_prompt_path_v2 = _pl_oll.Path(__file__).parent / "prompts" / "gemma4_12b_companion_v2.txt"
_prompt_v1 = _pl_oll.Path(__file__).parent / "prompts" / "gemma4_12b_companion_v1.txt"
_prompt_path = _prompt_path_v2 if _prompt_path_v2.exists() else _prompt_v1
system_content = _prompt_path.read_text().strip() if _prompt_path.exists() else SYSTEM_PROMPT
```

Line ~2158 (OpenRouter path — `_call_openrouter_companion`):
```python
_gemma_prompt_path_v2 = _cos3.path.join(_cos3.path.dirname(__file__), "prompts", "gemma4_12b_companion_v2.txt")
_gemma_prompt_path = _gemma_prompt_path_v2 if _cos3.path.exists(_gemma_prompt_path_v2) else ...
system_content = open(_gemma_prompt_path).read().strip() ...
```

Also check `_call_deepseek_v3()` — if it has its own system prompt hardcoded or loads separately, ensure it also checks for v2 first. Add the same pattern if missing.

### 0.2 — Smoke test (3 messages)

```bash
cd /opt/oimy-engine
python3 - << 'EOF'
import os, json, urllib.request

URL = "http://localhost:8080/chat"
JWT = os.environ.get("OIMY_JWT_TOKEN", "")

def send(uid, msg):
    body = json.dumps({"user_id": uid, "message": msg}).encode()
    req = urllib.request.Request(URL, body, {"Content-Type": "application/json", "Authorization": f"Bearer {JWT}"})
    r = urllib.request.urlopen(req, timeout=30)
    d = json.loads(r.read())
    arch = d.get("archetype", {})
    reply = arch.get("message") or arch.get("reply") or str(arch)[:200]
    return reply

uid = "smoke_v2_test"
send(uid, "Hi! I'm Priya.")
# Should NOT start with "To..."
r1 = send(uid, "lol okay so my 5yo just told his teacher we eat tacos every meal")
print("CASUAL TEST:", r1[:150])
assert "To " != r1[:3], "FAIL: starts with 'To'"

r2 = send(uid, "I'm so tired and I don't want solutions I just want to vent")  
print("VENT TEST:", r2[:150])
# Should NOT end with an action item or advice

r3 = send(uid, "my daughter Priya is 7 and she bites. My son Arjun is 4.")
send(uid, "just checking in about today")
r4 = send(uid, "how old is Arjun again?")
print("MEMORY TEST:", r4[:150])
assert "7" not in r4 or "4" in r4, "Possible hallucination check"
print("All smoke tests passed.")
EOF
```

### 0.3 — Acceptance
- [ ] V2 prompt confirmed active on all three call paths
- [ ] Smoke test: no "To..." opener
- [ ] Smoke test: vent message gets acknowledgment, not an action list
- [ ] Production engine restarted: `systemctl restart oimy-engine.service`

---

## Phase 1 — Session Facts Accumulator (2-3 days)

**Goal:** Fix 50 hallucination flags. Extract explicitly stated facts from conversation history and re-inject as a pinned block every turn, so the model never has to recall them from deep attention.

### 1.1 — Add `_extract_session_facts()` to api.py

Add this function to the `OiMyEngine` class:

```python
def _extract_session_facts(self, conversation_history: list, user_input: str) -> str:
    """
    Scan conversation history for explicitly stated facts (names, ages, constraints).
    Returns a compact pinned block to prepend to the system prompt.
    
    Looks for patterns like:
    - Names: "my son Arjun", "her name is Priya", "my husband Raj"
    - Ages: "she's 7", "4-year-old", "my 12yo"
    - Constraints: "no chicken", "she can't eat gluten", "he has ADHD"
    - Relationships: "single mom", "we're separated", "my partner"
    
    Returns empty string if nothing found (no noise added).
    Only pulls from user turns (not assistant turns).
    """
    import re as _re_sf
    
    facts = []
    seen = set()
    
    # Only scan user turns from history
    user_turns = [h.get("content", "") for h in (conversation_history or []) if h.get("role") == "user"]
    all_user_text = " ".join(user_turns + [user_input])
    
    # Name patterns: "my [relation] [Name]", "her name is X", "[Name] is [N]"
    name_patterns = [
        r'\bmy (?:son|daughter|husband|wife|partner|mom|dad|mother|father|kid)\b[,]?\s+([A-Z][a-z]+)',
        r'\bher name is ([A-Z][a-z]+)',
        r'\bhis name is ([A-Z][a-z]+)',
        r"\bI'm ([A-Z][a-z]+)\b",
    ]
    for pat in name_patterns:
        for m in _re_sf.finditer(pat, all_user_text):
            fact = m.group(0).strip()
            if fact not in seen:
                facts.append(fact)
                seen.add(fact)
    
    # Age patterns
    age_patterns = [
        r'\b(\d+)[\s-]?(?:year[s]?[\s-]?old|yo|y\.?o\.?)\b',
        r"\bshe's (\d+)\b", r"\bhe's (\d+)\b",
    ]
    for pat in age_patterns:
        for m in _re_sf.finditer(pat, all_user_text, _re_sf.IGNORECASE):
            fact = m.group(0).strip()
            if fact not in seen and len(facts) < 8:
                facts.append(fact)
                seen.add(fact)
    
    # Constraint patterns
    constraint_patterns = [
        r'\bno ([a-z]+(?:\s+[a-z]+)?)\b(?=\s+(?:for|in|at|because|since|—|-))',
        r"(?:can't|cannot|won't|doesn't) eat ([a-z]+(?:\s+[a-z]+)?)",
        r'\b(single (?:mom|dad|parent))\b',
        r'\b(ADHD|autism|anxiety|gluten[- ]free|vegan|vegetarian)\b',
    ]
    for pat in constraint_patterns:
        for m in _re_sf.finditer(pat, all_user_text, _re_sf.IGNORECASE):
            fact = m.group(0).strip()
            if fact not in seen and len(facts) < 10:
                facts.append(fact)
                seen.add(fact)
    
    if not facts:
        return ""
    
    # Format as a compact pinned block
    facts_str = "; ".join(facts[:8])  # cap at 8 facts
    return f"\n\n[SESSION FACTS — stated explicitly by this user: {facts_str}]"
```

### 1.2 — Inject into system prompt in all three companion call functions

In `_call_ollama_companion()`, after `system_content = _prompt_path.read_text().strip()`:

```python
# Inject session facts pin
_session_facts = self._extract_session_facts(conversation_history, user_input)
if _session_facts:
    system_content = system_content + _session_facts
```

Same pattern in `_call_openrouter_companion()` and `_call_deepseek_v3()`.

### 1.3 — Acceptance
- [ ] Session facts block appears in system prompt when conversation contains names/ages
- [ ] Block is absent when no facts stated (no noise)
- [ ] Manually test: send "my son Arjun is 4 and my daughter Priya is 7", then 8 turns later ask "how old is Priya?" — should answer correctly
- [ ] No performance regression (the extraction is regex, ~1ms)

---

## Phase 2 — Move Classifier (Turn Mode Detection) (4-5 days)

**Goal:** Deterministically select the conversation mode BEFORE calling the LLM. The LLM only generates words; it no longer decides whether to coach, vent-hold, or just chat.

This is the structural fix for 68 tone_mismatch flags, 27 unsolicited_advice flags, and the casual session failures.

### 2.1 — The 5 modes

| Mode | When | LLM instruction |
|------|------|-----------------|
| `JUST_RESPOND` | Casual chat, no problem stated, user just sharing | "This is casual — be a friend. No coaching, no next steps." |
| `BE_PRESENT` | Distress signals, explicit "don't want advice", grief, shame | "Zero agenda. Acknowledge, witness, sit with it. No advice, no reframes, no sneaky tips." |
| `ACKNOWLEDGE_AND_HELP` | Default — user has a problem, wants help | Normal — no override needed. This is the current default. |
| `PLAN_FORGE` | User just committed / said "I'm going to try X" / "I did it" | "Affirm specifically. If no plan exists, help shape one tiny if-then." |
| `BCT_COACH` | BCT path already detected (coach/expert workflow_type) | Handled by existing BCT routing — no change needed. |

### 2.2 — Add `_classify_turn_mode()` to api.py

```python
def _classify_turn_mode(self, user_input: str, conversation_history: list) -> tuple[str, str]:
    """
    Deterministically select conversation mode for this turn.
    Returns (mode, mode_instruction_for_llm).
    
    Called BEFORE _call_companion(). The returned instruction is appended
    to the system prompt so the LLM knows what mode it's in.
    
    Priority order (checked top-to-bottom):
    1. BE_PRESENT  — distress / explicit don't-fix signals
    2. PLAN_FORGE  — commitment / taking-steps signals
    3. JUST_RESPOND — casual / no-problem signals
    4. ACKNOWLEDGE_AND_HELP — default (most turns)
    """
    import re as _re_tm
    
    text = user_input.lower().strip()
    
    # ── Priority 1: BE_PRESENT ──────────────────────────────────────────
    # Explicit don't-fix signals
    dont_fix_phrases = [
        "don't want advice", "don't need advice", "not looking for advice",
        "just want to vent", "just venting", "just need to vent",
        "don't fix", "not looking for solutions", "just needed to say",
        "just needed to tell someone", "just wanted to share",
        "don't want to be fixed", "already know what to do",
        "just want to be heard", "just listen",
    ]
    for phrase in dont_fix_phrases:
        if phrase in text:
            return ("BE_PRESENT", 
                    "\n\n[TURN MODE: BE_PRESENT — this person said they don't want advice or solutions. "
                    "Zero agenda. Acknowledge what they said. Witness it. Be present. "
                    "Do NOT give tips, reframes, action items, or sneaky advice dressed as empathy. "
                    "The response can just be warmth and acknowledgment.]")
    
    # Distress / emotional flooding signals
    distress_patterns = [
        r'\b(i can\'t do this|i give up|i\'m done|breaking point|completely lost|falling apart)\b',
        r'\b(i hate (being|this|myself)|don\'t want to (be|do) (this|it) anymore)\b',
        r'\b(crying|sobbing|can\'t stop crying|been crying)\b',
        r'\b(panic|panicking|can\'t breathe|overwhelmed and don\'t)\b',
    ]
    for pat in distress_patterns:
        if _re_tm.search(pat, text):
            return ("BE_PRESENT",
                    "\n\n[TURN MODE: BE_PRESENT — distress signals detected. "
                    "Zero agenda. Name what you hear. Slow down. Sit with the feeling. "
                    "No goals, no plans, no next steps. Just presence.]")
    
    # ── Priority 2: PLAN_FORGE ──────────────────────────────────────────
    commitment_patterns = [
        r'\b(i\'m going to|i will|i\'m going to try|i\'ll try|i\'m ready to)\b',
        r'\b(i did it|i actually did|i tried it|i went ahead and|i started)\b',
        r'\b(i committed|i signed up|i made an appointment|i called)\b',
    ]
    for pat in commitment_patterns:
        if _re_tm.search(pat, text):
            return ("PLAN_FORGE",
                    "\n\n[TURN MODE: PLAN_FORGE — this person just committed to something or took a step. "
                    "Respond with genuine, specific warmth first (not generic praise). "
                    "If they have no concrete plan yet, help them shape ONE tiny if-then: "
                    "When [specific cue] → I will [smallest possible action]. "
                    "Keep the action tiny — the firing matters more than the size.]")
    
    # ── Priority 3: JUST_RESPOND ────────────────────────────────────────
    # Casual / no-problem / funny / sharing signals
    casual_signals = [
        # Short casual openers
        len(text) < 60 and any(w in text for w in ["lol", "haha", "omg", "honestly", "okay so", "so today"]),
        # Sharing without asking
        ("wanted to tell" in text or "thought you'd" in text or "just wanted to share" in text),
        # Explicit casual
        any(p in text for p in ["just chatting", "just checking in", "how are you", "what do you think about", 
                                 "funny story", "you won't believe", "guess what"]),
        # No problem, no question mark (very short messages)
        len(text) < 40 and "?" not in text and not any(w in text for w in ["help", "advice", "what should", "how do"]),
    ]
    # Check recent history: if last 2+ turns were casual with no stated problem
    recent_user_turns = [h.get("content","").lower() for h in (conversation_history or [])[-4:] if h.get("role") == "user"]
    recent_casual = sum(1 for t in recent_user_turns if len(t) < 80 and "?" not in t and "help" not in t)
    
    if any(casual_signals) or recent_casual >= 3:
        # Only if there's no distress or explicit problem in this message
        has_problem = any(w in text for w in ["problem", "issue", "struggling", "can't", "won't", "keeps", "always", "never", "exhausted"])
        if not has_problem:
            return ("JUST_RESPOND",
                    "\n\n[TURN MODE: JUST_RESPOND — casual conversation, no active problem. "
                    "Be a friend. No coaching, no BCT, no action items, no reframes. "
                    "Match their energy. If they're funny, be funny back. Just respond like a person.]")
    
    # ── Default: ACKNOWLEDGE_AND_HELP ───────────────────────────────────
    return ("ACKNOWLEDGE_AND_HELP", "")  # no instruction override — normal companion behavior
```

### 2.3 — Wire into the chat_light companion call

In the `chat` handler, **before** the `_call_companion()` call on the chat_light path (~line 1487):

```python
# NEW: classify turn mode before calling companion
_turn_mode, _mode_instruction = self._classify_turn_mode(user_input, _ds_history or [])

ds_result = self._call_companion(
    user_input=user_input,
    entity_context=_ds_entity,
    profile_context=_ds_profile,
    honcho_insight=_ds_honcho,
    conversation_history=_ds_history or None,
    turn_mode_instruction=_mode_instruction,  # NEW parameter
)
```

### 2.4 — Add `turn_mode_instruction` parameter to all three companion functions

In `_call_companion()`, `_call_ollama_companion()`, `_call_openrouter_companion()`, `_call_deepseek_v3()`:

```python
def _call_companion(self, user_input, entity_context="", profile_context="",
                    honcho_insight="", conversation_history=None,
                    is_bridge_mode=False, original_query="",
                    turn_mode_instruction=""):  # NEW
    ...
    # Pass through to the actual call functions
```

In each actual call function, after building `system_content`:

```python
# Apply mode instruction override (from turn classifier)
if turn_mode_instruction:
    system_content = system_content + turn_mode_instruction
```

This goes after the session facts injection from Phase 1.

### 2.5 — Log the mode for debugging

In `_attach_governance()`, add to the response metadata:

```python
"_turn_mode": turn_mode  # add to _inferred dict for visibility
```

### 2.6 — Acceptance tests

```python
# Test 1: Casual → JUST_RESPOND
# "lol my 5yo told his teacher we eat tacos every meal" → no advice, no reframe, just banter
assert turn_mode == "JUST_RESPOND"

# Test 2: Don't-want-advice → BE_PRESENT  
# "I just want to vent, I don't need solutions" → acknowledgment only, no next steps
assert turn_mode == "BE_PRESENT"

# Test 3: Commitment → PLAN_FORGE
# "I'm going to try the morning routine tomorrow" → specific affirm + tiny if-then
assert turn_mode == "PLAN_FORGE"

# Test 4: Normal problem → ACKNOWLEDGE_AND_HELP
# "My 7yo keeps having meltdowns every morning" → stays in default companion mode
assert turn_mode == "ACKNOWLEDGE_AND_HELP"
```

---

## Phase 3 — Thread Tracker (3 days)

**Goal:** Fix incuriosity. When a user mentions a named person, upcoming event, or referral, register it as an open thread. Re-inject at-risk threads into context so the model can naturally pick them up.

**nudge reference:** "Thread tracker ensures that when a user mentions a named person (Amy, Dave), an upcoming event, or a referral, it gets registered as a high-value open thread to gently revisit."

### 3.1 — Add `_track_session_threads()` and `_get_thread_hints()`

```python
# Session-scoped thread store (in-memory, reset per session / user)
# Store as: self._session_threads = {user_id: [{type, value, turn_mentioned, addressed}]}

def _track_session_threads(self, user_id: str, user_input: str, turn_index: int):
    """Extract named people, events, and referrals from this user turn."""
    import re as _re_th
    
    if not hasattr(self, "_session_threads"):
        self._session_threads = {}
    threads = self._session_threads.setdefault(user_id, [])
    
    text = user_input
    
    # Named people: "my [relation] [Name]"
    name_pat = r'\bmy (?:son|daughter|husband|wife|partner|mom|dad|friend|teacher|boss)\s+([A-Z][a-z]{2,})\b'
    for m in _re_th.finditer(name_pat, text):
        name = m.group(1)
        if not any(t["value"] == name for t in threads):
            threads.append({"type": "person", "value": name, "context": m.group(0), 
                           "turn": turn_index, "addressed": False})
    
    # Upcoming events: "meeting on", "appointment", "presentation", "school play"
    event_pat = r'\b((?:meeting|appointment|school play|presentation|game|recital|surgery|interview|date)\s+(?:on|this|next|tomorrow|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday|[A-Z]\w+day))\b'
    for m in _re_th.finditer(event_pat, text, _re_th.IGNORECASE):
        threads.append({"type": "event", "value": m.group(1), "context": m.group(0),
                       "turn": turn_index, "addressed": False})
    
    # Cap at 10 threads per session
    self._session_threads[user_id] = threads[-10:]

def _get_thread_hints(self, user_id: str, current_turn: int) -> str:
    """
    Return a thread hint block if there are unaddressed threads from earlier turns
    that are worth picking up. Only surfaces threads from 3+ turns ago that
    haven't been addressed.
    """
    threads = getattr(self, "_session_threads", {}).get(user_id, [])
    stale = [t for t in threads 
             if not t["addressed"] and current_turn - t["turn"] >= 3 
             and t["type"] in ("person", "event")]
    
    if not stale:
        return ""
    
    hints = [f"{t['type']}: {t['value']} (from: \"{t['context'][:60]}\")" 
             for t in stale[:3]]
    return (f"\n\n[OPEN THREADS — mentioned earlier but not yet followed up: "
            f"{'; '.join(hints)}. "
            f"If a natural moment arises, a brief warm mention would be meaningful. "
            f"Never force it.]")
```

### 3.2 — Wire into the chat flow

Before `_call_companion()`, add:
```python
# Track threads for this turn
_current_turn = len(_ds_history or []) + 1
self._track_session_threads(user_id, user_input, _current_turn)
_thread_hints = self._get_thread_hints(user_id, _current_turn)

# Combine with session facts and mode instruction
_full_system_addendum = _session_facts + _mode_instruction + _thread_hints
```

Pass `_full_system_addendum` instead of individual pieces.

### 3.3 — Acceptance
- [ ] Thread tracker registers "my husband Raj works late" → `{type: person, value: Raj}`
- [ ] Thread hint appears in system prompt 3+ turns later if Raj hasn't been mentioned
- [ ] No thread hint when thread was already addressed in a recent turn
- [ ] Max 3 hints shown at once, never forced

---

## Phase 4 — Directiveness Ceiling (4-5 days)

**Goal:** Gate plan-forging and BCT coaching behind earned trust. New users and guarded users get acknowledgment + gentle reflection. Trust grows with session count and disclosure depth.

**nudge reference:** "Directiveness is earned, not given. New or guarded users get patience and curiosity only."

### 4.1 — Trust score logic

```python
def _get_trust_level(self, user_id: str, conversation_history: list, 
                     user_input: str) -> tuple[int, str]:
    """
    Compute a trust level 0-3 for this user.
    
    0 = New (session 1, no disclosure)
    1 = Returning (session 2-3, surface-level)  
    2 = Engaged (3+ sessions OR deep disclosure this session)
    3 = Established (5+ sessions AND has disclosed something personal)
    
    Returns (level, label) where label is "new"/"returning"/"engaged"/"established"
    """
    import sqlite3 as _sql, os as _os_tr
    
    # Count prior sessions from DB
    db_path = _os_tr.path.join(_os_tr.path.dirname(__file__), "oimy.db")
    session_count = 0
    try:
        conn = _sql.connect(db_path)
        cursor = conn.cursor()
        # Count distinct session days (proxy for session count)
        cursor.execute(
            "SELECT COUNT(DISTINCT date(timestamp)) FROM messages WHERE user_id = ?",
            (user_id,)
        )
        row = cursor.fetchone()
        session_count = row[0] if row else 0
        conn.close()
    except Exception:
        pass  # fail open — default to 1
    
    # Check for disclosure depth in this session
    disclosure_markers = [
        "never told anyone", "ashamed", "scared to say", "secret", 
        "nobody knows", "can't tell my", "i've been hiding",
        "don't know what i feel", "sometimes i wonder if", "honestly i",
    ]
    full_text = " ".join(
        [h.get("content","") for h in (conversation_history or [])] + [user_input]
    ).lower()
    has_disclosure = any(m in full_text for m in disclosure_markers)
    
    # Compute level
    if session_count == 0:
        level = 0  # new
    elif session_count <= 2:
        level = 1  # returning
    elif session_count <= 4 or has_disclosure:
        level = 2  # engaged
    else:
        level = 3  # established
    
    labels = {0: "new", 1: "returning", 2: "engaged", 3: "established"}
    return level, labels[level]
```

### 4.2 — Apply ceiling to move selection

In `_classify_turn_mode()`, add trust-gate logic:

```python
# After computing the base mode, apply trust ceiling
trust_level, trust_label = self._get_trust_level(user_id, conversation_history, user_input)

# PLAN_FORGE requires trust ≥ 2 (earned)
if base_mode == "PLAN_FORGE" and trust_level < 2:
    base_mode = "ACKNOWLEDGE_AND_HELP"
    mode_instruction = ""  # reset to default — don't force plan-forging on new users

# Very new users (trust=0): soften everything, more curiosity, less advice
if trust_level == 0:
    mode_instruction += ("\n\n[TRUST LEVEL: new user, first session. "
                        "Be curious and warm. No action plans, no goal-setting. "
                        "Just get to know them. Reflect and ask (at most one question).]")
```

### 4.3 — Log trust level in response metadata

```python
"_trust_level": trust_label
```

### 4.4 — Acceptance
- [ ] New user (session 1): no plan-forging, no BCT until they've engaged
- [ ] Returning user (session 2): allowed to get gentle suggestions, not plan-forging
- [ ] Engaged user (session 3+): full mode range unlocked
- [ ] Disclosure depth detection: a user who discloses something personal in session 1 gets bumped to trust=2

---

## Phase 5 — Production Deployment & Monitoring

### 5.1 — Pre-deploy checklist

```bash
# On Hetzner:
cd /opt/oimy-engine

# 1. Run preflight
python3 scripts/preflight_prod.py
# Must show: PASS 10 | FAIL 0

# 2. Smoke test the new code
python3 - << 'EOF'
# Send 5 messages covering each mode
# Verify _turn_mode field in response metadata
# Verify _session_facts appears after turn 3
# Verify _trust_level in metadata
EOF

# 3. Restart engine
systemctl restart oimy-engine.service
systemctl status oimy-engine.service | head -20

# 4. Watch logs for first 5 minutes
journalctl -u oimy-engine.service -f
```

### 5.2 — Rollback plan

If anything breaks:
```bash
# Rollback prompt only (no code change needed):
# The code already falls back to v1 if v2 doesn't exist — but don't delete v2.
# Instead, temporarily rename:
mv /opt/oimy-engine/prompts/gemma4_12b_companion_v2.txt \
   /opt/oimy-engine/prompts/gemma4_12b_companion_v2.txt.bak

# Code rollback: git stash / git checkout api.py
```

### 5.3 — Metrics to watch (first 48 hours)

Check in on these in session logs:
- `_turn_mode` distribution: expect JUST_RESPOND 30-40%, ACKNOWLEDGE_AND_HELP 50-60%, BE_PRESENT 5-10%
- `_session_facts` block appearing: should be non-empty after turn 3 in ~60% of sessions
- No increase in error rates or timeouts
- Subjective quality: read 5 random sessions manually

---

## Summary: What Each Phase Fixes

| Phase | What it fixes | Eval evidence | Days |
|-------|--------------|---------------|------|
| 0 | V2 prompt active — 17 behavioral fixes | 68 tone_mismatch, 27 unsolicited_advice, 15 overconfidence flags | 1 |
| 1 | Session facts accumulator | 50 hallucination flags | 2-3 |
| 2 | Move classifier (JUST_RESPOND / BE_PRESENT / PLAN_FORGE) | 68 tone_mismatch, 27 unsolicited_advice, Jamie + Fatima sessions | 4-5 |
| 3 | Thread tracker | Incuriosity flags, Nalini/Hana sessions | 3 |
| 4 | Directiveness ceiling (trust-gated) | Plan-forcing on new users, session 1 over-coaching | 4-5 |

**Total: ~15 working days across 3 weeks**

---

## Key Files

| File | What changes |
|------|-------------|
| `/opt/oimy-engine/api.py` | Add `_extract_session_facts()`, `_classify_turn_mode()`, `_track_session_threads()`, `_get_thread_hints()`, `_get_trust_level()`. Wire all into chat_light path. Add `turn_mode_instruction` param to companion call chain. |
| `/opt/oimy-engine/prompts/gemma4_12b_companion_v2.txt` | Already written. Just needs to be active (Phase 0 verifies this). |
| `/opt/oimy-engine/prompts/gemma4_12b_companion_v1.txt` | Keep as fallback. No changes. |

**No new dependencies required.** All new code uses stdlib only (re, sqlite3).

---

## What This Does NOT Include (future work)

- **DARN-CAT change talk classification** — Valuable but requires a small model or LLM sub-call. After Phase 2 is stable, evaluate whether the move classifier needs it.
- **Multi-session journey arc** (rapport → discover → progress → maintain) — Tier B of nudge's planning stack. Requires session-end background processing. Quarter-level item.
- **Contextual bandit learning (Loop 3)** — Tracks which BCTs work per user. Requires outcome tracking infra. Quarter-level item.
- **Psychological profile** (Big Five, attachment style) — nudge's most advanced feature. OiMy's family-system context makes individual psych profiling complex (applies per family member). 2026 H2 item.
