Files
echo-core/tasks/steering-plan.md
Marius Mutu 747afbaf9d feat(steering): mesaje mid-tur + /stop pe turul în zbor
Un al doilea mesaj trimis cât Claude încă lucra aștepta până se termina
turul 1 — corecția „stai, nu în master" ajungea după ce greșeala era gata.
Verificat în producție înainte de commit: mesajul 2 stătea 25s blocat în
lock, apoi pornea ca tur separat.

Acum canalele de chat pot ține un proces `claude` viu per canal, cu stdin
deschis, și al doilea mesaj intră în ACELAȘI tur.

- `src/claude_runner.py` — ClaudeProcess (steering, respawn cu --resume,
  drenare stderr, respawn la comutarea OpenRouter) + RunnerRegistry
  (max_live, reaper pe inactivitate, stop_all la shutdown)
- `src/stream_json.py` — parser stream-json partajat cu `_run_claude`;
  pur, nu aruncă niciodată pe is_error (PlanningSession retrimite pe
  error_max_turns și depinde de asta)
- `src/sentinels.py` — un singur loc pentru __AUDIO__/__STEERED__, în loc
  de 4 verificări copiate; repară și bug-ul preexistent prin care
  WhatsApp posta literal `__AUDIO__:/cale`
- dispecer în `send_message`: lock.acquire(blocking=False) — eșecul de a
  lua lock-ul ESTE „rulează un tur", ceea ce elimină flagul inflight din
  decizie și cursa TOCTOU odată cu el
- `/stop` oprește turul, nu sesiunea — active.json rămâne valid
- rate limit prin proces persistent vine ca result.is_error, nu ca exit
  code; convertit înapoi în același RuntimeError, altfel fallback-ul
  local nu s-ar mai declanșa niciodată, în tăcere

Steering-ul nu face niciodată cross-adapter (un mesaj text nu intră
într-un tur voice: împart același channel_id). Mesajele steered dintr-un
tur care pică sunt re-livrate, nu pierdute.

Testat live cu CLI-ul real: corecție la secunda 10 dintr-un tur de 24s,
un singur result, num_turns=2. Notă: mesajele steered sunt împachetate în
[EXTERNAL CONTENT], deci o corecție formulată ca override agresiv poate
fi refuzată ca prompt injection — pentru oprire folosește /stop.

Suită: 1199 passed, 12 failed (toate pre-existente pe HEAD curat).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SiJGsZVSEGjRHZEJiXaxCC
2026-09-02 11:05:58 +00:00

1376 lines
92 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- /autoplan restore point: /home/moltbot/.gstack/projects/romfast-echo-core/master-autoplan-restore-20260902-083510.md -->
# Plan — Steering (mesaje mid-tur) în echo-core
**Status:** draft, intrat în /autoplan
**Branch:** master
**Data:** 2026-09-02
## Problema
Când Echo Core execută un mesaj lung (tool calls, subagenți), un al doilea mesaj trimis
de Marius pe același canal **nu ajunge la Claude până când primul tur se termină**.
Concret: îi scrii „stai, nu în master" în timp ce lucrează, iar Claude vede corecția
abia după ce a terminat ce făcea greșit.
Cauza, în cod:
- `src/claude_session.py:310` — `subprocess.Popen` **one-shot per mesaj**, promptul
trecut ca **argv** (`-p "<mesaj>"`), fără stdin. Procesul moare la finalul turului.
- `src/claude_session.py:628` — `send_message()` ia un `threading.Lock` per canal.
Mesajul 2 **blochează** în lock până se termină turul 1, apoi pleacă ca tur separat
prin `--resume`.
- `src/adapters/discord_bot.py:1296` — fiecare mesaj pornește propriul
`asyncio.to_thread(route_message, ...)`, deci mesajul 2 chiar ajunge în `send_message`,
dar așteaptă acolo.
## Referința — agentul din LXC 171
`romfastsql/proxmox/lxc171-claude-agent/discord-bridge/` implementează exact asta,
sub numele **steering**. **Codul rulează pe LXC 171, nu în clona locală** — clona din
`~/workspace/romfastsql/` e stale și nu conține `discord-bridge/`. Acces:
`ssh echo@10.0.20.201 "sudo pct exec 171 -- cat /workspace/romfastsql/proxmox/lxc171-claude-agent/discord-bridge/runner.py"`:
- `runner.py` — proces `claude` **persistent per fir**, pornit cu
`-p --input-format stream-json --output-format stream-json --verbose`,
cu **stdin deschis**. Un tur = scrii un JSON `{"type":"user",...}` pe stdin, citești
evenimente de pe stdout până la `result`.
- `bot.py:652-663` — dacă vine un mesaj cât `proc.inflight` e True, **nu deschide tur
nou**: îl scrie pe același stdin, reacționează cu ➡️, `return "steered"`.
- `session_id` luat din evenimentul `system`/`init` → `--resume` la respawn.
- Reaper de procese inactive (20 min), timeout per tur, buffer circular de stderr.
## Verificare empirică (făcută înainte de plan)
CLI-ul local (claude 2.1.258) suportă `--input-format stream-json`.
Spike rulat pe `/tmp`: un tur pornit cu `sleep 12 && echo pas1` în prim-plan (3 pași
secvențiali ceruți), apoi la 15.0s s-a scris pe stdin „STOP, schimbare de plan […]
răspunde-mi doar cu cuvântul ANANAS".
Rezultat: la 17.8s Claude a răspuns `ANANAS`, a abandonat pașii 2 și 3, și turul s-a
închis cu **un singur** `result` (`num_turns=2`). Premisa centrală a planului e
confirmată: mesajul mid-tur ajunge la model, e luat în seamă, iar turul nu se dublează.
Un prim spike a fost neconcludent (Claude a trecut `sleep` în `run_in_background`,
turul s-a terminat în 7.8s înainte să conteze steering-ul) — de reținut ca notă de test.
## Decizii deja luate cu utilizatorul
| Întrebare | Alegere |
|---|---|
| Arhitectură | Proces persistent + steering real (nu coadă/coalescing) |
| Adaptoare | Toate trei deodată (Discord, Telegram, WhatsApp) |
| Feedback UX | Confirmare discretă — reacție ➡️, răspuns unul singur la final |
| Scheduler / heartbeat / planning | Rămân one-shot, neatinse |
## Design
### 1. `src/claude_runner.py` (nou)
Thread-based, nu asyncio. Motivul: adaptoarele cheamă `route_message` prin
`asyncio.to_thread`, deci `send_message` e cod sync; un al doilea event loop în procesul
care deja rulează `asyncio.gather()` în `main.py` ar fi complexitate gratuită.
**`ClaudeProcess`** — un proces viu per `channel_id`:
- spawn cu `-p --input-format stream-json --output-format stream-json --verbose
--system-prompt <personality> --dangerously-skip-permissions [--resume <sid>]`,
`stdin=PIPE` ținut deschis
- **thread separat care drenează stderr** într-un `deque(maxlen=50)` — obligatoriu,
altfel pipe-ul se umple și procesul blochează la mijlocul unui tur
- `run_turn()` întoarce **exact dict-ul pe care îl întoarce azi `_run_claude`**
(`result`, `session_id`, `usage`, `total_cost_usd`, `duration_ms`, `subtype`,
`is_error`) → restul codului nu se atinge
- `steer(text)` — scrie pe stdin sub un `_stdin_lock`, fără să deschidă tur
- `session_id` capturat din evenimentul `system`/`init` → `--resume` la respawn
**`RunnerRegistry`** — reaper thread care oprește procesele inactive
(`steering.idle_minutes`, implicit 20 — vezi X3), niciodată unul `inflight`; plafon
`steering.max_live` (implicit 4) cu stop LRU.
### 2. `send_message()` devine dispecer
```
if steering activ:
proc = registry.get(channel_id)
if proc.alive and proc.inflight: → proc.steer(text); return "__STEERED__"
with lock(channel_id): → proc.run_turn(...) + persist în sessions/active.json
else:
calea actuală, neatinsă
```
`_run_claude`, `start_session`, `resume_session` rămân **neatinse** — le folosesc
`heartbeat.py` și `planning_session.py`.
**Cursă cunoscută:** dacă turul se închide între verificarea `inflight` și scrierea pe
stdin, linia devine automat turul următor (procesul rămâne viu — verificat în spike).
Nu se pierde mesajul, dar adaptorul ar fi răspuns deja doar cu ➡️. Tratare: `steer()`
care constată post-factum `not inflight` cade înapoi pe tur normal.
### 3. Router + adaptoare
Sentinela `__STEERED__` propagată prin `route_message` fără `_set_last_response` —
același tipar cu `__AUDIO__:` deja existent în `discord_bot.py:1305`.
Discord: `add_reaction("➡️")`. Telegram: `set_message_reaction`.
WhatsApp: verific întâi dacă bridge-ul Baileys expune reacții; dacă nu, un „➡️" scurt.
## Riscuri identificate
1. **Rate limit — cel mai subtil.** Detecția de azi (`is_rate_limit_error`) prinde un
`RuntimeError` construit din exit code ≠ 0. Un proces persistent nu moare la limită —
apare ca `result.is_error` în stream. Netratat, **fallback-ul local nu se mai
declanșează deloc** — adică exact munca cea mai grea din repo devine moartă în tăcere.
2. ~~**`--system-prompt` se fixează la spawn.**~~ **ELIMINAT — premisă inversată.**
Verificat: `resume_session` (`claude_session.py:565-571`) **nu pasează deloc** `--system-prompt`;
`build_system_prompt()` e chemat doar la `:486` (`start_session`) și `scheduler.py:402`.
Editările din `personality/` **deja** nu se aplică mid-sesiune, ci doar după `/clear`.
Fix-ul propus (hash → respawn) ar fi fost comportament NOU care omoară procesul viu și
steering-urile în așteptare la fiecare editare de `SOUL.md`. Vezi § Corecții factuale.
3. **`set_session_model`** trebuie să oprească procesul viu; turul următor îl repornește
cu `--resume` (ca `set_options` din bridge).
4. **RAM** — fiecare proces `claude` viu costă. De aici plafonul `max_live`.
## Etape
| # | Ce | Verificare |
|---|-----|-----------|
| 1 | `claude_runner.py` + fake `claude` stream-json + `tests/test_claude_runner.py` | tur normal, steering mid-tur, respawn `--resume` după kill, reaper, timeout, rate-limit în stream — toate offline |
| 2 | Dispecerul în `send_message` + sentinela în router, în spatele `config.json → steering.enabled` (**implicit off**) | suita existentă trece nemodificată cu flagul off |
| 3 | Reacții pe cele 3 adaptoare | manual, un canal de test |
| 4 | Rate-limit din stream + respawn pe personality/model schimbat | test dedicat că fallback-ul local încă pornește |
| 5 | Reaper + `max_live` + procesele vii vizibile în `eco status` | |
| 6 | Flip pe un canal, apoi global | |
Flagul e implicit off la fiecare pas, deci rollback-ul e o linie în `config.json`.
---
# /autoplan — Phase 1: CEO Review
Mod: **SELECTIVE EXPANSION** (feature enhancement pe sistem existent — default-ul contextual).
Voci: `[subagent-only]` — Codex nu e instalat pe această mașină.
## 0A. Premise Challenge
| # | Premisă | Verdict | Dovadă |
|---|---|---|---|
| P1 | Mesajul 2 nu ajunge la Claude până se termină turul 1 | **CONFIRMAT** | `claude_session.py:628` lock blocant; adaptoarele pornesc `to_thread` separat (`discord_bot.py:1296`) |
| P2 | Steering prin stdin funcționează pe CLI-ul instalat | **CONFIRMAT** | spike: `ANANAS` la 17.8s, un singur `result`, `num_turns=2` |
| P3 | Doar `router.py` cheamă `send_message` → cron/heartbeat/planning sunt izolate | **CONFIRMAT** | `heartbeat.py:416` folosește `_run_claude_extra`; `planning_session.py:264` folosește `_run_claude` |
| P4 | „Răspunsul rămâne unul singur, la finalul turului" | **FALS** | echo-core **deja** trimite fiecare bloc intermediar pe canal prin `on_text` (`claude_session.py:355`, `discord_bot.py:1283`, `telegram_bot.py:1020`, `whatsapp.py:217`). Bridge-ul editează un placeholder; echo-core trimite mesaje noi. UX-ul ales de utilizator nu există azi. |
| P5 | „WhatsApp poate să nu aibă reacții" | **FALS** | `react_whatsapp()` există și e deja folosit (👀 pus/scos pe fiecare mesaj, `whatsapp.py:243`). Telegram folosește deja `set_message_reaction` (✅). Toate trei adaptoarele au reacții native. |
| P6 | Un proces persistent cu `--dangerously-skip-permissions` are același profil de risc ca unul one-shot | **NEEXAMINAT — fals** | Azi procesul moare la finalul turului. Persistent = shell cu acces total la unelte, viu până la 20 min după ultimul mesaj. Raza de acțiune a unei injecții de prompt persistă între tururi. |
| P7 | Contextul se gestionează singur | **LACUNĂ** | Bridge-ul pasează explicit `--autocompact auto`. Planul nu-l menționează. Un proces care ține N tururi crește monoton până lovește limita de context. |
**Premise queued pentru Final Gate:** P4 (UX-ul ales nu descrie comportamentul real) — vezi Gate.
## 0B. Existing Code Leverage
| Sub-problemă | Cod existent | Reutilizat de plan? |
|---|---|---|
| Parsare stream-json | `_run_claude` `claude_session.py:335-370` | **NU — plan rescrie.** Violare DRY. Trebuie extras parser comun. |
| Serializare per canal | `_get_session_lock` `:84` | Da |
| Persistare sesiune | `_load_sessions` / `_save_sessions` `:253` | Da |
| Detecție rate limit | `is_rate_limit_error` / `rate_limit_detail` `:49` | Parțial — nu acoperă `result.is_error` |
| Sentinelă în răspuns | `__AUDIO__:` `discord_bot.py:1305` | Da, precedent bun |
| Reacții pe mesaj | Discord `add_reaction`, Telegram `set_message_reaction`, WhatsApp `react_whatsapp` | Da — **toate trei există deja** |
| Env sigur pentru subprocess | `_safe_env()` `:231` | Da |
| System prompt | `build_system_prompt()` `:424` | Da |
| Loop persistent + steering + interrupt | **`claude-agent-sdk` v0.2.151** (`ClaudeSDKClient.query()` / `.interrupt()`) | **NU — plan reimplementează.** Vezi 0C-bis. |
## 0C. Dream State
```
CURRENT STATE THIS PLAN 12-MONTH IDEAL
one-shot per mesaj ---> proces persistent ---> un strat unic de sesiune
prompt în argv stdin deschis partajat de chat + voice + Ralph:
lock → mesajul 2 așteaptă steering mid-tur steering, interrupt/cancel,
fără cancel fără cancel atașamente mid-tur, progres live,
fără compactare fără compactare compactare + rate limit într-un loc
```
**Delta:** planul ajunge ~60% spre ideal. Lipsesc: **cancel/interrupt** (a opri un tur scăpat de sub control — cea mai cerută capabilitate vecină), decizia voice-vs-text pe același canal, compactarea.
## 0C-bis. Implementation Alternatives
```
APPROACH A: Port hand-rolled al bridge-ului (planul actual)
Summary: ClaudeProcess + RunnerRegistry thread-based, stdin deschis, parser stream-json propriu.
Effort: M (human ~3 zile / CC ~2-3h)
Risk: Med
Pros: - Zero dependențe noi; controlezi fiecare linie
- Rămâne sync — `send_message` și `route_message` nu se schimbă
- Arhitectură deja validată în producție pe LXC 171
Cons: - Reimplementează parserul stream-json (DRY vs `_run_claude`)
- Reimplementează ce oferă SDK-ul oficial; risc de obsolescență
- Fără `interrupt()` — ar trebui construit separat
Reuses: _get_session_lock, _safe_env, build_system_prompt, sentinela __AUDIO__
APPROACH B: Coadă + coalescing (minimal viable)
Summary: Mesajul 2 se acumulează și pleacă ca prompt separat după turul 1.
Effort: S (human ~4h / CC ~20min)
Risk: Low
Pros: - Diff minim, zero procese noi, zero RAM în plus
- Nu atinge deloc calea de rate limit
Cons: - NU rezolvă problema — corecția ajunge tot după ce Claude a greșit
- Respins explicit de utilizator la întrebarea de arhitectură
Reuses: lock-ul existent
ELIMINAT: nu satisface cerința.
APPROACH C: claude-agent-sdk (ideal architecture)
Summary: `ClaudeSDKClient` din pachetul oficial `claude-agent-sdk` (v0.2.151).
`client.query(async_generator)` livrează mesaje mid-tur; `client.interrupt()` oprește turul.
Autentificare pe credențialele CLI — compatibil cu abonamentul, fără API key.
Effort: M (human ~3 zile / CC ~2-3h) — comparabil cu A
Risk: Med
Pros: - Steering ȘI interrupt out of the box; interrupt e o capabilitate pe care A nu o are
- Framing stream-json, ciclul de viață al procesului și sesiunile sunt întreținute upstream
- Zero cod de parsare de întreținut; imun la schimbări de format ale CLI-ului
Cons: - API async-only (`async with ClaudeSDKClient()`) — reintroduce granița sync/async
pe care planul a evitat-o intenționat; `route_message` e sync cu multe căi de comenzi rapide
- Dependență nouă pe un pachet 0.2.x cu 151 de release-uri — suprafață de churn
- Maparea `--system-prompt` / `--dangerously-skip-permissions` / allowed_tools
pe opțiunile SDK trebuie verificată empiric
Reuses: build_system_prompt, sessions/active.json, sentinela __AUDIO__
```
**RECOMMENDATION: A. Alternativa C e ELIMINATĂ pe licențiere, nu pe merit tehnic.**
Documentația Agent SDK (`code.claude.com/docs/en/agent-sdk/overview`) conține nota:
> *„Unless previously approved, Anthropic does not allow third party developers to offer
> claude.ai login or rate limits for their products, including agents built on the Claude
> Agent SDK. Use the API key authentication methods described in the Quickstart instead."*
Plus: „Use of the Claude Agent SDK is governed by Anthropic's Commercial Terms of Service."
Mecanic SDK-ul chiar poate porni pe credențialele CLI-ului, dar direcția politicii e API key —
iar constrângerea utilizatorului e explicită: **doar abonament, fără API**. C e închisă.
Aceeași pagină recomandă, pentru orice limbaj în afara SDK-urilor Python/TS:
*„run the CLI as a subprocess with the `-p` flag"* — exact ce face echo-core azi și ce
extinde varianta A. Calea actuală e cea documentată, nu un ocol.
**Clasificare: MECHANICAL, nu taste.** Constrângerea utilizatorului elimină singura alternativă
concurentă. A se implementează cu corecțiile E2 (parser partajat) și E3 (autocompact) care erau
oricum îmbunătățiri de sine stătătoare, plus E1 (cancel) — capabilitatea pe care C ar fi adus-o
gratis și pe care acum trebuie s-o construim explicit. E1 devine astfel mai valoros, nu mai puțin.
## 0D. SELECTIVE EXPANSION
**Complexity check:** planul atinge 6 fișiere (`claude_runner.py` nou, `claude_session.py`, `router.py`, 3 adaptoare) + teste. Sub pragul de 8 — fără miros de complexitate. Două clase noi (`ClaudeProcess`, `RunnerRegistry`) — la limita de 2, acceptabil.
**Minimum set care atinge scopul:** etapele 1-3. Etapele 4-6 sunt hardening, nu funcționalitate — dar 4 (rate limit) e **obligatorie**, nu opțională: fără ea o cale existentă se rupe tăcut.
**Scan de expansiune (candidați, auto-decis):**
| # | Oportunitate | Efort | Decizie | Principiu |
|---|---|---|---|---|
| E1 | **Cancel/stop turn** (`/stop`) — oprește un tur scăpat de sub control | S (CC ~20min) | **ACCEPTAT în scope** | P2 — în raza de acțiune (același `ClaudeProcess`), <1 zi. Odată ce ai proces persistent, `proc.stop()` există deja; expunerea lui e trivială și e capabilitatea vecină cea mai valoroasă |
| E2 | **Parser stream-json partajat** între `_run_claude` și runner | S (CC ~15min) | **ACCEPTAT în scope** | P4 DRY — altfel două parsere diverg |
| E3 | **`--autocompact auto`** la spawn | XS | **ACCEPTAT în scope** | P1 completeness — fără el sesiunile lungi mor pe context |
| E4 | Placeholder editabil (un mesaj care se actualizează, ca în bridge) în loc de stream de mesaje noi | M | **DEFER → TODOS.md** | În afara razei — schimbă UX-ul tuturor răspunsurilor, nu doar al celor steered |
| E5 | Steering cu atașamente (poză trimisă mid-tur) | M | **DEFER → TODOS.md** | Adaptoarele salvează deja atașamente ca `[ATTACHMENT:path]`; merge, dar e scope nou |
| E6 | Procesele vii expuse în dashboard (`workspace.html`) | S | **SKIP** | `eco status` (etapa 5) acoperă nevoia; dashboard-ul ar fi duplicare |
**Scope acceptat:** E1 (cancel), E2 (parser partajat), E3 (autocompact).
**Deferat la TODOS.md:** E4, E5.
**Respins:** E6.
## 0E. Temporal Interrogation
```
HOUR 1 (fundații): Ce formă are dict-ul întors de run_turn? → identic cu _run_claude, altfel
se rupe start_session/resume_session. Unde stă parserul comun? → funcție
la nivel de modul în claude_session.py, importată de runner.
HOUR 2-3 (logică): Ce se întâmplă dacă steering-ul prinde procesul între `result` și
următorul tur? → fallback la tur normal. Ce se întâmplă cu `on_text`
în timpul unui tur steered? → blocurile continuă să curgă; utilizatorul
vede două fire de text. DECIZIE NECESARĂ (vezi P4).
HOUR 4-5 (integrare): Rate limit-ul nu mai vine ca exit code. Unde se detectează? → în parser,
pe `result.is_error` + textul. Trebuie să ridice ACELAȘI RuntimeError
ca azi, altfel `_local_fallback_reply` nu mai pornește.
Voice și text partajează channel_id — un turn voice mid-tur text devine
steering. Dorit sau nu? DECIZIE NECESARĂ.
HOUR 6+ (polish): Fake `claude` pentru teste trebuie să vorbească stream-json pe stdout ȘI
să citească stdin — altfel testul de steering nu e testabil.
`/clear` trebuie să omoare procesul viu, nu doar să șteargă intrarea JSON.
```
## 0F. Mode Selection
**SELECTIVE EXPANSION** confirmat (feature enhancement pe sistem existent). Abordarea aleasă sub acest mod: **A + corecțiile E2/E3 din C**, cu E1 (cancel) adăugat în scope.
## Constrângere confirmată de utilizator (2026-09-02)
**Doar abonament Claude Pro/Max — fără API Anthropic, fără API key.**
Ambele abordări viabile respectă asta: A folosește CLI-ul `claude` ca subprocess (ca azi);
C (`claude-agent-sdk`) se autentifică pe **credențialele CLI-ului**, nu pe API key —
documentația SDK-ului o confirmă explicit. Niciun apel la `api.anthropic.com` din cod propriu.
## Measurement: RAM per proces claude viu
Măsurat pe această mașină (`ps -eo rss`): **292 MB și 541 MB** pentru două procese `claude` vii.
Mașina are 8192 MB total, ~5750 MB disponibili.
`max_live=4` × ~400 MB = ~1,6 GB ținuți permanent doar în procese inactive.
**Decizie (auto, P3 pragmatic): `steering.max_live` implicit 2, nu 4.**
Marius vorbește pe 1-2 canale simultan în practică; 4 e dimensionat pentru un scenariu care nu există.
## Section 1: Architecture Review
**Graf de dependențe — înainte / după:**
```
ÎNAINTE DUPĂ
adapters ──▶ router ──▶ claude_session adapters ──▶ router ──▶ claude_session
│ │ ├──▶ _run_claude (one-shot)
└──▶ _run_claude ──▶ Popen │ │ ▲
(moare la finalul turului) │ │ └── heartbeat, planning_session
│ └──▶ claude_runner ──▶ ClaudeProcess (viu)
heartbeat ─────┐ │ │
planning ──────┴──▶ _run_claude │ └──▶ RunnerRegistry (+reaper thread)
└──▶ _parse_stream() ◀── PARTAJAT (E2)
```
Cuplare nouă: `claude_runner` → `claude_session` pentru parser + `build_system_prompt` + `_safe_env`.
Unidirecțională, justificată. **Fără import circular**: runner importă din session, session importă runner *lazy*
(în corpul lui `send_message`), nu la nivel de modul.
**Flux de date — patru căi, pentru `steer()`:**
```
mesaj ──▶ [inflight?] ──▶ wrap [EXTERNAL CONTENT] ──▶ json.dumps ──▶ stdin.write ──▶ flush
│ │ │ │
▼ ▼ ▼ ▼
nil: text=None → TypeError fără wrap = GAUR non-UTF8 → BrokenPipeError
GAP: guard explicit DE SECURITATE json.dumps (proces mort între
(vezi Section 3) escapează OK check și write)
empty: text="" → tur gol, GAP: prinde și cazi
Claude răspunde aiurea pe tur normal
GAP: sari peste steering
error: procesul a murit → BrokenPipeError → fallback la tur normal (respawn cu --resume)
```
**Mașină de stare `ClaudeProcess`:**
```
┌──────────┐ start() ┌─────────┐ run_turn() ┌──────────┐
│ DEAD │───────────▶│ ALIVE │─────────────▶│ INFLIGHT │
└──────────┘ │ idle │◀─────────────└──────────┘
▲ └─────────┘ result │
│ │ │ steer() ── permis DOAR aici
│ stop() / reaper / │ │
│ crash / model change │ │
└───────────────────────┴─────────────────────────┘
timeout → stop() → DEAD
Tranziții imposibile și ce le previne:
DEAD ──steer()──▶ X : `if not alive: raise TurnFailed` în send()
INFLIGHT ──reaper──▶ X : reaper sare peste orice proces inflight
INFLIGHT ──run_turn()──▶ X: lock-ul per canal serializează tururile
```
**Findings:**
| # | Finding | Severitate | Decizie (auto) | Principiu |
|---|---|---|---|---|
| A1 | **Fără shutdown handler.** `main.py` rulează adaptoarele sub `asyncio.gather`. La `systemctl restart` / SIGTERM, procesele `claude` vii rămân orfane (500 MB fiecare). Bridge-ul are `stop_all()`; planul nu-l menționează. | **HIGH** | Adaugă `RunnerRegistry.stop_all()` legat de shutdown-ul din `main.py` + `atexit` | P1 completeness |
| A2 | **`max_live` atins cu toate procesele inflight** — LRU nu poate opri un proces inflight, deci fie depășești plafonul, fie blochezi. Nedefinit în plan. | MED | Degradare grațioasă: canal nou peste plafon → cade pe calea one-shot existentă (`resume_session`), nu blochează | P1 completeness |
| A3 | **Reaper-ul e SPOF.** Dacă thread-ul moare pe o excepție, procesele se acumulează la infinit, tăcut. | MED | Bucla prinde orice excepție și continuă (tiparul din `runner.py:_reaper_loop`) + o linie de log la fiecare reap | P1 + lecția „turnul nu se pierde niciodată" |
| A4 | **Rollback incomplet.** Flag-ul off nu omoară procesele deja vii — rămân zombie până la reaper. | MED | Flip-ul flagului declanșează `stop_all()`; documentat în etapa 6 | P1 |
| A5 | Registry-ul e stare globală mutabilă la nivel de modul (ca `_session_locks`) | LOW | Acceptat — tipar existent. Testele au nevoie de fixture care golește registry-ul, ca `_clear_session_locks` | P3 pragmatic |
**Scaling:** rupe primul RAM-ul (2 procese × ~400 MB), nu CPU. La 10x canale active, plafonul `max_live=2` forțează degradarea la one-shot — comportament corect, nu prăbușire.
**SPOF:** reaper-ul (A3) și bridge-ul WhatsApp (preexistent, nu introdus aici).
**Rollback:** `steering.enabled=false` + restart serviciu. Sub 30 secunde. Cu A4 rezolvat, și fără restart.
## Section 2: Error & Rescue Map
```
METODĂ/CODEPATH | CE POATE MERGE PROST | CLASĂ EXCEPȚIE
-----------------------------|-----------------------------------|--------------------
ClaudeProcess.start() | binarul claude lipsește | FileNotFoundError
| spawn eșuează (fd-uri epuizate) | OSError
ClaudeProcess.send()/steer() | procesul a murit între check/write| BrokenPipeError
| stdin închis | ValueError
ClaudeProcess.run_turn() | turul depășește timeout | TimeoutError
| stdout EOF fără `result` | RuntimeError
| linie JSON coruptă | json.JSONDecodeError
| RATE LIMIT în result.is_error | RuntimeError ← NOU
| pipe stderr plin → deadlock | (blocare, fără excepție)
RunnerRegistry.reap_once() | stop() aruncă pe un proces mort | ProcessLookupError
_parse_stream() | bloc text fără cheia 'text' | KeyError
CLASĂ EXCEPȚIE | PRINSĂ? | ACȚIUNE | UTILIZATORUL VEDE
------------------------|---------|----------------------------------|---------------------------
FileNotFoundError | DA | ridică mesajul de instalare | „Claude CLI not found"
| | (identic cu _run_claude:306) |
OSError la spawn | NU ← GAP| — | traceback brut ← RĂU
BrokenPipeError | NU ← GAP| — | steering pierdut tăcut ← CRITIC
TimeoutError | DA | stop() + ridică, ACELAȘI mesaj | „Claude CLI timed out after Ns"
| | ca azi |
RuntimeError (EOF) | DA | stop() + stderr tail în mesaj | „Claude CLI error: …"
json.JSONDecodeError | DA | `continue` (ca _run_claude:347) | nimic (transparent)
RuntimeError RATE LIMIT | NU ← GAP| — | **fallback-ul local NU pornește** ← CRITIC
deadlock stderr | NU ← GAP| — | turul îngheață până la timeout ← CRITIC
ProcessLookupError | NU ← GAP| — | reaper-ul moare → scurgere procese
KeyError în parser | NU ← GAP| — | turul cade pe un bloc malformat
```
**Cele patru GAP-uri critice și acțiunea de reparare:**
1. **RATE LIMIT.** `is_rate_limit_error` potrivește pe textul `"hit your …limit"` într-un `RuntimeError`. Cu proces persistent, limita vine ca linie `result` cu `is_error: true`. Parserul trebuie să ridice **exact** `RuntimeError(f"Claude CLI error (exit 1): {detail}")` cu textul limitei, ca `router.py:611` să-l prindă și `_local_fallback_reply` să pornească. Fără asta, cea mai muncită cale din repo moare tăcut.
2. **BrokenPipeError la steer.** Prinde-l → marchează procesul mort → **refă mesajul ca tur normal**. Nu-l lăsa să se piardă (lecția explicită din CLAUDE.md: „turnul nu se pierde niciodată").
3. **Deadlock stderr.** Thread dedicat de drenare, obligatoriu. Fără el un tur cu mult stderr blochează procesul fără nicio excepție.
4. **ProcessLookupError în reaper.** `contextlib.suppress` în jurul lui `stop()`, buclă care nu moare.
**Regulă respectată:** niciun `except Exception` fără re-raise. Singura excepție permisă e bucla reaper-ului, unde înghițirea e intenționată — dar cu log.
## Section 3: Security & Threat Model
| # | Amenințare | Probabilitate | Impact | Mitigat de plan? |
|---|---|---|---|---|
| S1 | **Mesajul steered ocolește wrapping-ul `[EXTERNAL CONTENT]`.** `start_session:489` și `resume_session:563` împachetează fiecare mesaj între markeri, iar system prompt-ul (`:451-453`) instruiește explicit să nu se supună instrucțiunilor dinăuntru. Dacă `steer()` scrie textul brut pe stdin, **protecția la injecție dispare exact pentru mesajele mid-tur** — și acelea sosesc când Claude e deja în mijlocul unei acțiuni cu unelte. | MED | **HIGH** | **NU — GAP.** `steer()` trebuie să folosească același wrapping. Ne-negociabil. |
| S2 | Proces persistent cu `--dangerously-skip-permissions` viu până la 20 min după ultimul mesaj | LOW | MED | Parțial. Raza de acțiune a unei injecții persistă între tururi — dar și azi persistă prin `--resume`. Marginal nou: fereastra de timp. `idle_reap_s` o mărginește. Acceptat. |
| S3 | Escapare JSON pe stdin (text cu ghilimele/newline rupe protocolul) | LOW | MED | DA — `json.dumps(ensure_ascii=False)`, ca în `runner.py:user_message` |
| S4 | Secrete în argv-ul procesului persistent (vizibile în `ps`) | LOW | MED | Neschimbat față de azi — `--system-prompt` e deja în argv. Nu regresează, dar merită notat: procesul e vizibil în `ps` mai mult timp. |
| S5 | Comenzi (`/clear`, `/model`) interpretate ca steering | LOW | LOW | DA — `route_message` rutează comenzile înainte de `send_message` |
**Suprafață de atac nouă:** zero endpoint-uri noi, zero input-uri de rețea noi. Singurul canal nou e stdin-ul unui proces local. **S1 e singurul finding real și e o regresie, nu un risc teoretic.**
## Section 4: Data Flow & Interaction Edge Cases
```
INTERACȚIUNE | EDGE CASE | TRATAT? | CUM
------------------------------|----------------------------------|---------|---------------------------
Mesaj în timpul unui tur | procesul moare între check/write | GAP | → fallback tur normal (S2 §2)
| text gol | GAP | → sari peste steering
| 2 mesaje în 100 ms | DA | _stdin_lock serializează
| atașament mid-tur | DEFER | E5 → TODOS.md
Comandă în timpul unui tur | /clear pe canal cu proces viu | GAP | → clear_session trebuie
| | | să cheme registry.stop()
| /model pe canal cu proces viu | GAP | → set_session_model trebuie
| | | să oprească procesul
Turn voice în timpul unui | voice și text partajează | GAP | DECIZIE NECESARĂ — vezi Gate
tur text (același channel_id) | session_key=channel_id | |
Reaper vs tur | reap în timp ce turul e inflight | DA | skip pe inflight
Restart serviciu | procese orfane | GAP | A1 → stop_all()
Flip flag on→off | procese vii rămân | GAP | A4 → stop_all()
```
Cinci GAP-uri, toate cu fix specificat. Cel voice e singurul care cere o decizie umană, nu un fix.
## Section 5: Code Quality Review
* **DRY:** o singură violare reală — parserul stream-json (E2, deja acceptat în scope). `_run_claude:335-370` și bucla de consum din runner ar fi 90% identice.
* **Numire:** `ClaudeProcess` / `RunnerRegistry` urmează bridge-ul; consistent. `steer()` e numit după ce face, nu cum. Bine.
* **Over-engineering:** `RunnerRegistry` cu reaper + LRU + plafon pentru maximum 2 procese e la limită. Justificat de A1/A3 (scurgere de procese = 500 MB fiecare), nu de scală.
* **Under-engineering:** `steer()` fără guard pe text gol și fără wrapping (S1) — fragil pe calea fericită.
* **Complexitate ciclomatică:** `send_message` devine dispecer cu ~4 ramuri (steering on/off × inflight/nu × plafon atins). Sub 5. Acceptabil, dar extrage `_try_steer(channel_id, text) -> bool` ca funcție separată în loc să umfli `send_message`.
* Niciun `except Exception` nou în afara buclei reaper-ului.
## Section 6: Test Review
```
FLUXURI UX NOI:
- mesaj trimis în timpul unui tur → reacție ➡️, fără text
- /stop în timpul unui tur (E1)
FLUXURI DE DATE NOI:
- text → wrap EXTERNAL CONTENT → json → stdin → model (mid-tur)
- stdout stream-json → parser partajat → dict identic cu _run_claude
CODEPATHS NOI:
- steering.enabled on/off
- inflight / not inflight
- plafon max_live atins → degradare one-shot
- respawn cu --resume după moartea procesului
- reap pe inactivitate
JOBURI ASINCRONE NOI:
- thread drenare stderr (per proces)
- thread reaper (unul global)
INTEGRĂRI NOI:
- proces claude persistent cu stdin deschis
CĂI DE EROARE NOI:
- rate limit din result.is_error (NU din exit code)
- BrokenPipeError la steer
- timeout tur → stop() → aceeași excepție ca azi
```
| # | Test | Tip | Există în plan? |
|---|---|---|---|
| T1 | Tur normal prin proces persistent → dict identic cu `_run_claude` | Unit (fake claude) | Da |
| T2 | Steering mid-tur ajunge pe stdin | Unit (fake claude care citește stdin) | Da |
| T3 | Respawn cu `--resume` după kill | Unit | Da |
| T4 | Reaper nu omoară un proces inflight | Unit | Da |
| T5 | Timeout → `TimeoutError` cu **exact** mesajul de azi | Unit | Da |
| T6 | **Rate limit din stream ridică RuntimeError pe care `is_rate_limit_error` îl prinde** | Unit | Da (etapa 4) |
| T7 | **`_local_fallback_reply` chiar pornește pe rate limit din proces persistent** | Integration | **LIPSEȘTE — critic.** T6 testează detecția, nu lanțul complet. Ăsta e testul care contează. |
| T8 | **`steer()` împachetează în `[EXTERNAL CONTENT]`** | Unit | **LIPSEȘTE — S1** |
| T9 | **BrokenPipe la steer → mesajul se refă ca tur normal** | Unit | **LIPSEȘTE** |
| T10 | Suita existentă trece cu `steering.enabled=false` | Regression | Da (etapa 2) |
| T11 | `test_claude_session_mutex.py` trece nemodificat | Regression | Implicit — **fă-l explicit**, e testul care pinează contractul lock-ului |
| T12 | `/clear` omoară procesul viu | Unit | **LIPSEȘTE** |
| T13 | `stop_all()` la shutdown, zero orfani | Unit | **LIPSEȘTE — A1** |
**Testul pentru 2 dimineața vineri:** T7. Dacă fallback-ul local nu mai pornește, Marius rămâne fără asistent la limită de rate și nimeni nu află până nu se întâmplă.
**Ce ar scrie un QA ostil:** trimite 5 mesaje în 200 ms în timpul unui tur, apoi `/clear`, apoi încă unul.
**Chaos test:** `kill -9` pe procesul `claude` la mijlocul unui tur, verifică respawn cu `--resume` și că sesiunea continuă.
**Flakiness:** T2 și T4 depind de timing. Fake-ul `claude` trebuie să semnalizeze prin fișier/pipe, nu prin `sleep`.
**LLM/eval:** planul nu atinge `personality/*.md` și nici prompt-ul fallback-ului local. Nicio suită de eval nu trebuie rulată. (Dacă etapa 4 ajunge să atingă `build_system_prompt`, atunci da.)
## Section 7: Performance Review
Fără DB, fără query-uri, fără N+1 — secțiunea are o singură dimensiune reală: **memoria**.
Măsurat: 292-541 MB RSS per proces `claude`. Cu `max_live=2` → până la ~1 GB rezident.
Mașina are 5750 MB disponibili, deci încape, dar nu e neglijabil pe un host care rulează și Ollama.
Câștig de latență: turul 2+ pe un canal sare spawn-ul (~2-4 s din spike-uri), deci steering-ul e
și o optimizare de latență, nu doar o funcționalitate.
Presiune pe pool-uri de conexiuni: zero. Fără alte findings.
## Section 8: Observability & Debuggability Review
| Ce | Există azi | Nevoie nouă |
|---|---|---|
| Log per tur | `_invoke_log` cu channel/model/durată/tokens (`:513`) | Adaugă `steered=N` la linia turului |
| Log per steering | — | **Obligatoriu.** O linie per steer: canal, lungime text, dacă a reușit. Fără ea, „nu a ținut cont de mesajul meu" e nediagnosticabil |
| Fiecare `return`/eșec tăcut | Lecția din CLAUDE.md: fiecare `return None` din `_local_fallback_reply` e logat | Aplică aceeași regulă: **fiecare cale prin care un mesaj NU devine steering se loghează** |
| Procese vii | — | `eco status` (etapa 5): câte procese, pe ce canale, de cât timp inactive |
| Reaper | — | Log la fiecare proces oprit, cu motivul |
| stderr | Citit la final în `_run_claude:377` | Buffer circular 50 linii, expus în `eco doctor` |
**Debuggabilitate la 3 săptămâni:** cu logurile de mai sus, da. Fără logul de steering, nu.
## Section 9: Deployment & Rollout Review
Fără migrări DB, fără schimbări de schemă. `config.json` are `reload()` (`config.py:24`), deci flagul
poate fi citit la cald — dar procesele deja vii nu se opresc singure (A4).
```
Etapa 1-2 (flag off) ──▶ deploy, zero comportament schimbat ──▶ suita verde
│ │
│ rollback: nimic de făcut, calea nu e activă │
▼ ▼
Etapa 3-5 (flag off) ──▶ deploy incremental ──────────────▶ teste + eco status
│
▼
Etapa 6: flip pe UN canal ──▶ dogfood 2-3 zile ──▶ flip global
│ │
│ rollback: steering.enabled=false + stop_all() (<30s)
▼
Verificare primele 5 min: trimite 2 mesaje suprapuse, confirmă ➡️ și un singur răspuns coerent
Verificare prima oră: `eco status` arată procese care se sting după idle_reap_s
```
**Fereastră de risc la deploy:** `systemctl --user restart echo-core` cu procese vii → orfani (A1).
Fix-ul A1 e prerechizit pentru etapa 6, nu opțional.
## Section 10: Long-Term Trajectory Review
* **Datorie tehnică introdusă:** un parser stream-json și un manager de procese proprii, de întreținut la fiecare schimbare de format al CLI-ului. E2 (parser partajat) o înjumătățește; nu o elimină.
* **Path dependency:** dacă `claude-agent-sdk` devine calea normală (probabil — e Claude Code ca bibliotecă, întreținut upstream), varianta A devine cod aruncat. Nu blochează nimic, dar e ~300 de linii care ar putea fi zero.
* **Reversibilitate: 4/5.** Flag + un modul nou; `_run_claude` rămâne intact ca plasă. Nu e ușă cu sens unic.
* **Ce vine după:** interrupt/cancel (E1, deja în scope), atașamente mid-tur (E5), placeholder editabil (E4). Arhitectura A le suportă pe toate — `ClaudeProcess` e locul potrivit.
* **Potențial de platformă:** dacă runner-ul devine stratul unic de sesiune, `planning_session.py` și Ralph l-ar putea folosi. Nu în acest plan, dar arhitectura nu-l exclude.
* **Întrebarea de la 1 an:** un inginer nou citește `claude_runner.py` și înțelege de ce există două căi (persistent vs one-shot)? **Numai dacă docstring-ul modulului o spune explicit.** Cere-l: de ce heartbeat/planning rămân one-shot.
## Section 11: Design & UX Review
**SĂRIT** — zero scope UI. Planul nu introduce ecrane, formulare sau componente; singurul element vizibil e o reacție emoji pe un mesaj existent, în API-uri de chat care o suportă nativ. Verificat prin grep pe termeni de UI: singurele potriviri au fost substringuri românești (*informat*, *reformul*), zero button/modal/screen/dialog.
## Required Outputs — Phase 1
### NOT in scope
| Item | De ce e deferat |
|---|---|
| Placeholder editabil în loc de stream de mesaje noi (E4) | Schimbă UX-ul **tuturor** răspunsurilor, nu doar al celor steered. Scope propriu. → TODOS.md |
| Steering cu atașamente (E5) | Adaptoarele salvează deja `[ATTACHMENT:path]`; merge, dar e funcționalitate nouă, nu parte din steering. → TODOS.md |
| Procese vii în dashboard (E6) | `eco status` acoperă nevoia; dashboard-ul ar fi duplicare. Respins. |
| Migrarea heartbeat / planning / Ralph pe proces persistent | Decizie explicită a utilizatorului. Niciun câștig — nu există om care să facă steering pe un job de noapte. |
| `claude-agent-sdk` (alternativa C) | Eliminată pe licențiere: docs direcționează spre API key, iar constrângerea e „doar abonament". |
| Compactare proprie / management de context | `--autocompact auto` (T8) delegă asta CLI-ului. A construi ceva propriu ar fi ocean, nu lac. |
### What already exists
Vezi tabelul complet din **0B**. Rezumat: 7 din 9 sub-probleme au deja cod în repo care se reutilizează
(`_get_session_lock`, `_load_sessions`, `_safe_env`, `build_system_prompt`, sentinela `__AUDIO__:`,
reacțiile pe toate trei adaptoarele, detecția de rate limit). Singurele lucruri chiar noi sunt
**bucla de proces persistent** și **scrierea pe stdin**. Restul e integrare, nu construcție.
Corecție importantă față de planul inițial: **reacțiile există deja pe toate trei adaptoarele**
(`add_reaction` / `set_message_reaction` / `react_whatsapp`), deci etapa 3 e mai mică decât estimat
și nu are nevoie de cercetare pe bridge-ul Baileys.
### Failure Modes Registry
```
CODEPATH | FAILURE MODE | RESCUED? | TEST? | USER SEES | LOGGED?
--------------------|-------------------------|----------|-------|------------------|--------
steer() | proces mort (BrokenPipe) | T7 | T7 | răspuns normal | T12
steer() | fără wrap injecție | T1 | T1 | — | —
steer() | text gol | T1 | T1 | nimic (sări) | T12
run_turn() | rate limit în stream | T2 | T6 | fallback local | da
run_turn() | timeout | plan | T5* | mesaj identic azi| da
run_turn() | EOF fără result | plan | da | „Claude CLI err" | da
stderr pipe | plin → deadlock | T3 | T3 | tur înghețat | da
reaper | excepție → thread mort | T11 | T11 | scurgere tăcută | T11
registry | max_live + toate inflight| T10 | T10 | one-shot (lent) | T10
shutdown | procese orfane | T4 | T13* | — | T4
clear_session | proces rămas viu | T9 | T9 | model stale | T9
```
`T5*`/`T13*` = numerotare din Section 6 (teste), restul din Implementation Tasks.
**Zero rânduri CRITICAL GAP rămase** — fiecare RESCUED=N din analiza inițială are acum un task.
### Dream state delta
Planul + expansiunile acceptate (E1 cancel, E2 parser partajat, E3 autocompact) duc echo-core de la
„mesajul 2 așteaptă" la „mesajul 2 ajunge, și pot opri un tur scăpat". Rămâne la ~70% din idealul
de 12 luni. Nerezolvate: decizia voice-vs-text pe același canal, atașamente mid-tur, un strat de
sesiune partajat cu Ralph/planning.
### Scope Expansion Decisions
* **Acceptate:** E1 (cancel `/stop`), E2 (parser stream-json partajat), E3 (`--autocompact auto`)
* **Deferate la TODOS.md:** E4 (placeholder editabil), E5 (atașamente mid-tur)
* **Respinse:** E6 (procese vii în dashboard — duplicare peste `eco status`)
### Stale Diagram Audit
`CLAUDE.md` § Arhitectură descrie fluxul ca `Adapter → router.py → claude_session.py → Claude CLI`.
Rămâne corect, dar **incomplet** după acest plan — trebuie adăugat `claude_runner.py` și explicat
de ce există două căi. Diagrama nu e greșită, e trunchiată. Task: T15 (docstring) + o linie în CLAUDE.md.
Alte diagrame ASCII în fișierele atinse: niciuna.
## Implementation Tasks
Sintetizate din findings-urile de mai sus. Fiecare derivă dintr-un finding specific.
- [ ] **T1 (P1, human: ~2h / CC: ~15min)** — claude_runner — Împachetează mesajele steered în `[EXTERNAL CONTENT]`
- Surfaced by: Section 3 S1 — text brut pe stdin ocolește protecția la injecție exact pentru mesajele mid-tur
- Files: `src/claude_runner.py`, `tests/test_claude_runner.py`
- Verify: `pytest tests/test_claude_runner.py -k external_content`
- [ ] **T2 (P1, human: ~3h / CC: ~20min)** — claude_session — Ridică același `RuntimeError` la rate limit din `result.is_error`
- Surfaced by: Section 2 — procesul persistent nu iese cu cod ≠ 0, deci `_local_fallback_reply` n-ar mai porni niciodată
- Files: `src/claude_session.py`, `src/claude_runner.py`
- Verify: `pytest tests/test_local_fallback.py`
- [ ] **T3 (P1, human: ~1h / CC: ~10min)** — claude_runner — Thread dedicat de drenare stderr, deque mărginit
- Surfaced by: Section 2 — pipe-ul plin blochează procesul fără nicio excepție
- Files: `src/claude_runner.py`
- Verify: test cu fake claude care scrie >64KB pe stderr
- [ ] **T4 (P1, human: ~2h / CC: ~15min)** — main — `stop_all()` la shutdown și `atexit`
- Surfaced by: Section 1 A1 — `systemctl restart` lasă orfani de ~500 MB
- Files: `src/main.py`, `src/claude_runner.py`
- Verify: pornește, creează proces, `SIGTERM`, `pgrep -f claude` gol
- [ ] **T5 (P1, human: ~1h / CC: ~10min)** — claude_session — Extrage parserul stream-json partajat
- Surfaced by: Section 5 + 0B — singura violare DRY reală din plan
- Files: `src/claude_session.py`, `src/claude_runner.py`
- Verify: `pytest tests/test_claude_session.py`
- [ ] **T6 (P1, human: ~3h / CC: ~20min)** — tests — Integration: rate limit prin proces persistent pornește chiar fallback-ul
- Surfaced by: Section 6 T7 — testul de 2 dimineața vineri
- Files: `tests/test_local_fallback.py`
- Verify: `pytest tests/test_local_fallback.py -k persistent`
- [ ] **T7 (P1, human: ~1h / CC: ~10min)** — claude_runner — `BrokenPipeError` la steer → refă ca tur normal
- Surfaced by: Section 2 gap 2 + lecția „turnul nu se pierde niciodată"
- Files: `src/claude_runner.py`, `tests/test_claude_runner.py`
- Verify: test care omoară procesul între check și write
- [ ] **T8 (P2, human: ~30min / CC: ~5min)** — claude_runner — `--autocompact auto` la spawn
- Surfaced by: 0A P7 — sesiunea persistentă crește monoton spre limita de context
- Files: `src/claude_runner.py`
- Verify: `build_cmd` conține flagul
- [ ] **T9 (P2, human: ~1h / CC: ~10min)** — claude_session — `/clear` și `/model` opresc procesul viu
- Surfaced by: Section 4 — altfel procesul rămâne cu config stale
- Files: `src/claude_session.py`
- Verify: `pytest tests/test_claude_runner.py -k clear`
- [ ] **T10 (P2, human: ~2h / CC: ~15min)** — claude_runner — Degradare la one-shot când `max_live` e atins cu toate inflight
- Surfaced by: Section 1 A2 — LRU nu poate opri un proces inflight
- Files: `src/claude_runner.py`
- Verify: test cu `max_live=1` și două canale
- [ ] **T11 (P2, human: ~1h / CC: ~10min)** — claude_runner — Reaper rezistent la excepții + log per reap
- Surfaced by: Section 1 A3 — reaper mort = scurgere tăcută
- Files: `src/claude_runner.py`
- Verify: test care face `stop()` să arunce
- [ ] **T12 (P2, human: ~30min / CC: ~5min)** — claude_runner — Loghează fiecare cale prin care un mesaj NU devine steering
- Surfaced by: Section 8 — altfel „nu a ținut cont de mesajul meu" e nediagnosticabil
- Files: `src/claude_runner.py`, `src/claude_session.py`
- Verify: inspecție manuală a logurilor
- [ ] **T13 (P2, human: ~3h / CC: ~20min)** — claude_runner — Comandă `/stop` legată la `ClaudeProcess.stop()` (E1)
- Surfaced by: 0D E1 — capabilitatea vecină cea mai valoroasă odată ce ai proces persistent
- Files: `src/claude_runner.py`, `src/router.py`
- Verify: `/stop` în timpul unui tur oprește procesul, canalul rămâne utilizabil
- [ ] **T14 (P2, human: ~30min / CC: ~5min)** — config — `steering.max_live` implicit 2
- Surfaced by: Section 7 — măsurat 292-541 MB RSS per proces pe host de 8 GB
- Files: `config.json`, `src/claude_runner.py`
- Verify: default-ul din cod e 2
- [ ] **T15 (P3, human: ~1h / CC: ~10min)** — claude_runner — Docstring: de ce heartbeat/planning rămân one-shot
- Surfaced by: Section 10 — întrebarea de la 12 luni
- Files: `src/claude_runner.py`, `CLAUDE.md`
- Verify: citire
---
# /autoplan — Phase 2: Design Review
**SĂRIT — zero scope UI.** Detectat în Phase 0: singurele potriviri pe termeni de UI au fost
substringuri românești (*informat*, *reformul*), zero button/modal/screen/dialog/layout.
Feature-ul nu introduce nicio suprafață vizuală; singurul element vizibil e o reacție emoji
pe un mesaj existent, în API-uri de chat care o suportă nativ.
---
# /autoplan — Phase 2.5: DX Review
Mod: **DX POLISH**. Voci: `[subagent-only]`.
## Step 0: DX Scope Assessment
**Tip de produs:** feature intern într-un asistent personal self-hosted. Nu e un produs public.
**Cine e „developer"-ul aici — două persoane foarte diferite:**
| Persona | Cine | Ce are nevoie |
|---|---|---|
| **Operatorul** (unic) | Marius — pornește serviciul, citește logurile, dă flip la flag | Să vadă că merge. Să oprească rapid când nu merge. |
| ~~Mentenatorul autonom~~ | ~~Ralph~~ — **confirmat oprit** (toate joburile `enabled=False`, `approved-tasks.json` gol, nimic în crontab) | n/a |
O singură persona, deci DX aici înseamnă strict **experiență de operare**, nu de integrare.
**DX completeness inițial: 4/10.** Planul descrie mecanica, nu experiența de operare:
nu spune forma blocului de config, nu spune cum confirmi că merge, nu spune ce se schimbă în `CLAUDE.md`.
## Developer Journey (9 etape)
| Etapă | Azi | Cu planul | Fricțiune |
|---|---|---|---|
| Discover | — | § în CLAUDE.md | **GAP — planul nu prevede update la CLAUDE.md** |
| Evaluate | — | citește planul | ok |
| Install | — | `git pull` | zero — fără dependențe noi |
| Configure | — | `"steering": {"enabled": true}` în config.json | **GAP — forma blocului nespecificată** |
| Hello world | — | restart + 2 mesaje suprapuse | **GAP — greu de produs un tur lent la comandă** |
| Integrate | — | automat pe toate 3 adaptoarele | ok |
| Debug | — | loguri (T12) | ok dacă T12 se face |
| Upgrade | — | cheie nouă, default off | ok — sigur în ambele sensuri |
| Scale | — | `max_live` | **GAP — `max_live=0` nedefinit** |
## Empathy Narrative
> *„Am dat pull, am pus `steering.enabled: true`, am restartat. Acum… cum verific că merge?
> Trebuie să-i dau ceva de lucru lung, apoi să scriu repede al doilea mesaj și să mă uit după o
> săgeată. Dacă nu apare săgeata — e stins flagul? E plafonul atins? A murit procesul? Nu știu
> care din trei. Și `eco status` nu-mi spune nimic despre asta."*
Asta e problema centrală de DX: **turnul feature-ului e invizibil când nu funcționează**, iar
verificarea lui cere să fabrici un tur lent la comandă.
## Pass 1: Getting Started — 6/10
TTHW: ~3 min (edit config → restart → 2 mesaje). Tier **Competitive**, nu Champion.
Ce ar fi 10: `eco status` arată `steering: on · 0 procese vii`, iar `eco doctor` verifică
că binarul suportă `--input-format stream-json`. Atunci confirmi în 20 de secunde, fără să
fabrici un tur lent.
**Finding D1 (HIGH):** nicio cale de a confirma că steering-ul e pornit și sănătos fără să-l provoci.
**Fix:** `eco status` + o verificare în `eco doctor`.
## Pass 2: API/CLI Design — 8/10
`steering.enabled` respectă convenția existentă (`local_fallback.enabled`, `heartbeat.enabled`) —
namespace-dict cu cheie `enabled`, citit prin dot-notation. Consistent, ghicibil.
`steer()` / `run_turn()` / `RunnerRegistry` urmează numele din bridge; `steer` spune ce face.
**Finding D2 (MED):** planul nu scrie blocul de config concret. Cere-l explicit în plan:
```json
"steering": { "enabled": false, "idle_reap_s": 1200, "max_live": 2 }
```
**Finding D3 (MED):** `max_live: 0` și `idle_reap_s: 0` sunt nedefinite. `0` la `max_live` citit
naiv înseamnă „niciun proces permis" = steering mort tăcut, cu flagul aparent pornit — cel mai
prost mod de eșec posibil. **Fix:** `max_live < 1` → tratează ca `enabled: false` **și loghează**;
`idle_reap_s` cu prag minim de 60s.
## Pass 3: Error Messages & Debugging — 5/10
Precedent bun deja în repo: `router.py:298` loghează `„Local fallback requested but
local_fallback.enabled is false"` — problemă + cauză într-o linie. Steering-ul are nevoie de
aceeași disciplină, și e exact ce cere T12.
**Finding D4 (HIGH):** un mesaj care nu devine steering arată **identic** cu unul care devine —
utilizatorul vede un răspuns normal în ambele cazuri. Fără log per cale, „n-a ținut cont de mine"
e nediagnosticabil post-factum. Ăsta e chiar tiparul de eșec pe care CLAUDE.md îl documentează
pentru fallback-ul local (2026-08-23: „detecția a mers, fallback-ul a întors None fără nicio linie
de log, imposibil de diagnosticat"). **Fix:** T12, ridicat de la P2 la **P1**.
## Pass 4: Documentation & Learning — 3/10
**Finding D5 (LOW — retrogradat).** Inițial marcat CRITICAL: `CLAUDE.md:219` spune că Ralph nu
atinge `src/router.py` / `src/claude_session.py`, iar `src/claude_runner.py` — fișier nou — n-ar fi
pe listă. **Verificat: Ralph e oprit.** Toate joburile din `cron/jobs.json` (`night-execute`,
`evening-report`, `morning-report`, ambele `*-coaching`) au `enabled=False`, `approved-tasks.json`
e gol, crontab-ul de sistem n-are nimic. Nu există agent nesupravegheat care să rescrie modulul.
**Fix (ieftin, nu urgent):** dacă Ralph se repornește vreodată, adaugă `src/claude_runner.py` la
lista de la linia 219. Până atunci, nu blochează nimic.
**Observație de repo (nu e parte din acest plan).** `CLAUDE.md` dedică ~80 de linii sistemului
Ralph — comenzi, tabel de fișiere, flow de aprobare — pentru un sistem care e integral dezactivat.
Documentația asta e acum înșelătoare: m-a făcut să ridic D5 la CRITICAL pe o premisă falsă, și
va induce în eroare orice viitoare sesiune la fel. Merită curățată sau marcată „inactiv",
dar e o schimbare separată — o semnalez, n-o fac aici.
**Finding D6 (MED):** `CLAUDE.md` § Arhitectură descrie un singur flux
(`Adapter → router → claude_session → CLI`). După plan există **două** căi și niciun cititor viitor
nu va ghici de ce. Cere o subsecțiune scurtă: ce merge persistent, ce rămâne one-shot, **de ce**.
## Pass 5: Upgrade & Migration Path — 9/10
Cheie de config nouă cu default off: pull fără config → nimic nu se schimbă. Downgrade cu cheia
prezentă → cheia e ignorată. Sigur în ambele sensuri, fără migrare, fără codemod.
Singura notă: dacă flip-ul flagului nu declanșează `stop_all()` (task T4/A4), un downgrade la cald
lasă procese vii. Acoperit deja.
## Pass 6: Developer Environment & Tooling — 6/10
Fără dependențe noi, fără build. Testele au nevoie de un fake `claude` care **citește stdin** și
vorbește stream-json — mai greu decât fake-urile existente (care doar scriu pe stdout).
**Finding D7 (MED):** fără acel fake, testul de steering nu e scriibil. E prerechizit pentru T1/T7,
nu o notă de subsol. Bridge-ul are `tests/fake_claude.py` ca referință de tipar.
## Pass 7: Community & Ecosystem — N/A
Repo privat, un utilizator, un agent. Nu se aplică; nescoruit ca să nu falsifice media.
## Pass 8: DX Measurement & Feedback Loops — 4/10
Nicio metrică propusă. `_invoke_log` numără tururile; nimic nu numără steering-urile.
**Finding D8 (LOW):** adaugă `steered=N` la linia de log a turului (deja în T12) — atunci poți
răspunde peste o lună la „chiar folosesc funcția asta?" fără să ghicești.
## DX Scorecard
```
Pass Scor Ce lipsește pentru 10
--------------------------------- ----- ----------------------------------------
1. Getting Started 6/10 eco status + eco doctor (D1)
2. API/CLI Design 8/10 blocul de config scris (D2), max_live=0 (D3)
3. Error Messages & Debugging 5/10 log per cale ratată (D4) — ridicat la P1
4. Documentation & Learning 3/10 regula Ralph (D5) + două-căi în CLAUDE.md (D6)
5. Upgrade & Migration 9/10 —
6. Dev Environment & Tooling 6/10 fake claude care citește stdin (D7)
7. Community & Ecosystem N/A repo privat
8. DX Measurement 4/10 contor de steering (D8)
--------------------------------- -----
OVERALL (7 scorate) 6.1/10
```
**TTHW: ~3 min → țintă < 1 min** (cu `eco status` care confirmă starea fără să provoci un tur lent).
## DX Implementation Checklist
- [ ] ~~**D5** lista protejată a lui Ralph~~ — retrogradat LOW: Ralph e confirmat oprit
- [ ] **D4** ridică T12 (log per cale ratată) de la P2 la **P1**
- [ ] **D1** `eco status`: `steering: on/off · N procese vii`; `eco doctor`: verifică `--input-format stream-json`
- [ ] **D2** scrie blocul concret de config în plan și în `CLAUDE.md`
- [ ] **D3** `max_live < 1` → dezactivat **cu log**; `idle_reap_s` cu prag minim 60s
- [ ] **D6** subsecțiune în `CLAUDE.md`: două căi, care e care, de ce
- [ ] **D7** `tests/fake_claude.py` care citește stdin — prerechizit pentru T1/T7
- [ ] **D8** `steered=N` pe linia de log a turului
---
# /autoplan — Phase 3: Eng Review (gate final, pe planul amendat)
Voci: `[subagent-only]`.
## Step 0: Scope Challenge (pe cod real, nu pe descriere)
Planul atinge 6 fișiere + teste, adaugă 2 clase. Sub pragurile de complexitate.
Verificat pe cod: `send_message` (`claude_session.py:611`) e singurul punct de intrare care se
schimbă, iar `_run_claude`/`start_session`/`resume_session` rămân intacte pentru
`heartbeat.py:416` și `planning_session.py:264`. Separarea e reală, nu declarativă.
**Scope-ul e corect calibrat. Nicio reducere recomandată.** Singura adăugire acceptată (E1/`/stop`)
e ~20 de linii peste infrastructura care oricum se construiește.
## Section 1: Architecture
Graf, mașină de stare și căile de eroare sunt deja diagramate în Phase 1 § Section 1.
Nu le repet. Ce adaugă faza de inginerie sunt **cursele**, care nu erau acoperite.
### Concurență — analiza care lipsea din plan
Modelul de threading propus: thread A (worker `asyncio.to_thread`) rulează `run_turn()` și citește
stdout blocant; thread B (alt worker) cheamă `steer()` și scrie pe stdin; thread C (reaper) poate
chema `stop()`. Trei fire pe același obiect.
```
Thread A (tur) Thread B (steer) Thread C (reaper)
────────────── ──────────────── ─────────────────
inflight = True
stdout.readline() ──┐
│ │ read inflight → True
│ │ │
result primit │ │ ← FEREASTRA DE CURSĂ
inflight = False │ │
│ │ stdin.write() ──▶ pipe posibil închis
(reaper eligibil) │ BrokenPipeError
│ │
└──────────────────────────┴──▶ T7: refă ca tur normal
```
| # | Cursă | Severitate | Fix |
|---|---|---|---|
| **E-C1** | **TOCTOU pe `inflight`.** B citește `inflight=True`, A termină turul și `inflight=False`, apoi B scrie. Scrierea nimerește un proces care nu mai e în tur → linia devine turul următor, dar utilizatorul a primit deja ➡️ fără text. | **HIGH** | `steer()` face **check + write sub același `_stdin_lock`**, iar `run_turn()` setează `inflight=False` **tot sub acel lock**. Atunci fereastra dispare; T7 rămâne plasă pentru moartea reală a procesului, nu pentru cursă. |
| **E-C2** | **Reaper vs steer.** Reaper-ul sare peste procesele `inflight`, dar un proces viu-și-inactiv poate fi oprit exact în timpul unui `steer()` de pe calea de fallback. | MED | `stop()` ia și el `_stdin_lock` înainte să închidă stdin |
| **E-C3** | **`_stdin_lock` ținut peste I/O de rețea.** Dacă `drain`/`flush` blochează (pipe plin fiindcă procesul nu citește), lock-ul se ține la nesfârșit și blochează și `stop()`. | MED | Scrierile pe stdin sunt mici (o linie JSON) și pipe-ul are 64 KB buffer — practic nu blochează. Documentează invariantul: **niciodată I/O lung sub `_stdin_lock`**. |
| **E-C4** | `stderr` citit de un al patrulea thread per proces | LOW | Deja acoperit (T3). Fără interacțiune cu celelalte lock-uri. |
### Contractul lock-ului per canal se schimbă — și planul nu o spune
`send_message` ține azi lock-ul pe tot turul. Steering-ul trebuie **să NU ia lock-ul** — altfel
se auto-blochează, pentru că lock-ul e deja ținut de turul în zbor. Planul spune corect „fără lock".
Dar consecința nu e menționată: **contractul pe care `test_claude_session_mutex.py` îl pinează
explicit devine condiționat de flag.**
Docstring-ul acelui test spune: *„This test pins that behavior so future refactors must preserve it."*
Cu steering pornit, comportamentul „al doilea apel așteaptă" **nu se mai păstrează** — al doilea apel
face steering și se întoarce imediat. Asta e schimbarea intenționată, dar contractul trebuie rescris,
nu încălcat tăcut.
**E-C5 (HIGH) — test verde fals.** Testul patch-uiește `claude_session._run_claude`. Dacă
`send_message` rutează spre runner când steering-ul e pornit, `_run_claude` nu mai e chemat deloc,
`concurrent_seen` nu se setează niciodată și testul **trece degeaba** — verde fără să testeze nimic.
**Fix:** testul de mutex forțează explicit `steering.enabled=False` (nu se bazează pe default),
plus un test nou care pinează contractul cu steering pornit: al doilea apel întoarce `__STEERED__`
fără să deschidă un tur.
## Section 2: Code Quality
* **Dispecerul:** extrage `_try_steer(channel_id, text) -> bool` din `send_message` (deja notat
în Phase 1 § Section 5). `send_message` rămâne sub 5 ramuri.
* **Import lazy:** `claude_session` importă `claude_runner` **în corpul funcției**, nu la nivel de
modul, fiindcă runner-ul importă parserul din session. Altfel ciclu la import. Scrie-o ca și
comentariu, nu ca folclor.
* **DRY:** o singură violare (parserul), deja task T5.
* Fără abstracții premature: nu introduce o interfață `Backend` cu două implementări pentru două
căi. `if steering: ... else: ...` explicit e mai lizibil (P5).
## Section 3: Test Review
Diagrama completă e în Phase 1 § Section 6. Faza de inginerie adaugă:
| # | Test lipsă | De ce |
|---|---|---|
| **E-T1** | Mutex-ul cu `steering.enabled=False` **forțat explicit** | E-C5 — altfel verde fals |
| **E-T2** | Contract nou: cu steering pornit, al 2-lea apel întoarce `__STEERED__` fără tur nou | Contractul care înlocuiește pinul vechi |
| **E-T3** | Cursă: 20 de iterații care fac steer exact la granița `result` | E-C1 — o cursă netestată e o cursă care se întoarce |
| **E-T4** | `sessions/active.json` primește sid-ul **actualizat după fiecare tur**, nu doar la start | La respawn cu `--resume`, `system/init` poate întoarce un sid nou; dacă nu-l persiști, următorul respawn reia o sesiune moartă |
| **E-T5** | Timeout distruge procesul → turul următor se reia curat cu `--resume` | Timeout-ul acum aruncă un proces cald; verifică că nu pierde sesiunea |
**E-T4 e un bug latent, nu doar un test lipsă.** Planul spune „persistă în `sessions/active.json`",
dar nu spune **când**. Bridge-ul actualizează `sid` în `finally`-ul fiecărui tur (`bot.py:974`).
Dacă echo-core îl scrie doar la primul tur, un respawn ulterior folosește un sid vechi.
→ **Task nou E1 (P1).**
## Section 4: Performance
Măsurătoarea e în Phase 1 § Section 7 (292-541 MB RSS, `max_live=2`).
Adaug o observație care lipsea: **timeout-ul devine mai scump.** Azi un timeout omoară un proces
care oricum murea. Cu proces persistent, un timeout aruncă un proces cald, iar turul următor plătește
respawn (~2-4 s) **plus** reîncărcarea integrală a istoricului prin `--resume`. Pe o sesiune lungă
asta nu e neglijabil. Nu schimbă designul — dar `TURN_TIMEOUT` nu trebuie coborât „ca să fie sigur".
`_safe_env()` se evaluează la spawn: o rotire de credențiale în keyring **nu ajunge** la un proces
viu până la respawn. Minor, dar notabil pentru `/otp` — dacă tokenul roa2web se reînnoiește,
procesul viu are env-ul vechi. Verifică dacă `roa2web_client` citește keyring-ul la runtime
(atunci e irelevant) sau env-ul de la pornire (atunci e un bug).
## Section 5: Security
Acoperit în Phase 1 § Section 3. Singurul finding real rămâne **S1** (wrapping `[EXTERNAL CONTENT]`
la steer, task T1) — o regresie de securitate, nu un risc teoretic. Confirmat pe cod:
`start_session:489` și `resume_session:563` împachetează; `steer()` trebuie să facă la fel.
Fără suprafață de rețea nouă. `--dangerously-skip-permissions` e neschimbat față de azi, doar
fereastra de viață a procesului crește — mărginită de `idle_reap_s`.
## Section 6: Hidden Complexity
Ce arată simplu în plan și nu este:
1. **„`run_turn()` întoarce același dict"** — pare o formalitate. De fapt e contractul care ține
`start_session`/`resume_session`/`_invoke_log`/`sessions/active.json` nemodificate. Orice câmp
lipsă se manifestă ca `KeyError` la runtime, într-un thread, la un tur oarecare. Merită un
test de formă (assert pe setul de chei), nu doar teste de comportament.
2. **„Reacție ➡️"** — pare cosmetic, dar înseamnă că adaptorul trebuie să distingă
`__STEERED__` de un răspuns real **în trei locuri diferite**, fiecare cu API propriu de reacții.
Trei fișiere, trei tipare, trei moduri de a greși.
3. **„Flag implicit off"** — nu e gratuit: fiecare test nou trebuie să-l seteze explicit în
ambele sensuri, altfel testezi calea greșită fără să afli (E-C5).
4. **Reaper-ul** — 30 de linii care, greșite, scurg 500 MB pe oră tăcut.
---
# /autoplan — Voci independente și corecții
Codex indisponibil (binar neinstalat) → toate fazele `[subagent-only]`.
## CEO DUAL VOICES — CONSENSUS TABLE
```
═══════════════════════════════════════════════════════════════
Dimensiune Claude Codex Consensus
───────────────────────────────────── ─────── ────── ─────────
1. Premise valide? NO N/A flagged
2. Problema potrivită? PARTIAL N/A flagged
3. Scope calibrat corect? NO N/A flagged
4. Alternative explorate suficient? NO N/A flagged
5. Riscuri competitive acoperite? PARTIAL N/A flagged
6. Traiectorie la 6 luni solidă? PARTIAL N/A flagged
═══════════════════════════════════════════════════════════════
Codex lipsă ⇒ nicio dimensiune nu poate fi CONFIRMED.
Findings critice dintr-o singură voce se ridică oricum.
```
## DX DUAL VOICES — CONSENSUS TABLE
```
═══════════════════════════════════════════════════════════════
Dimensiune Claude Codex Consensus
───────────────────────────────────── ─────── ────── ─────────
1. Getting started < 5 min? NO N/A flagged
2. Denumiri API/CLI ghicibile? PARTIAL N/A flagged
3. Mesaje de eroare acționabile? PARTIAL N/A flagged
4. Docs găsibile și complete? NO N/A flagged
5. Cale de upgrade sigură? YES N/A —
6. Mediu de dezvoltare fără fricțiune? YES N/A —
═══════════════════════════════════════════════════════════════
Scoruri DX voce independentă: getting started 5, API 6, erori 6, docs 4,
upgrade 7, mediu 8, măsurare 3. TTHW: ~15 min ca scris → ~3 min cu fix-urile.
```
## Corecții factuale la planul original (verificate pe cod)
### ❌ Riscul #2 era INVERSAT — se elimină
Planul original spunea: *„`--system-prompt` se fixează la spawn. Azi se reconstruiește din
`personality/*.md` la fiecare mesaj."*
**Fals, verificat:** `build_system_prompt()` e chemat în exact două locuri —
`claude_session.py:486` (`start_session`) și `scheduler.py:402`. **`resume_session` nu pasează
deloc `--system-prompt`** (liniile 565-571). Deci editările din `personality/` **deja** nu se aplică
mid-sesiune; se aplică doar la o sesiune nouă, adică după `/clear`.
Fix-ul propus (hash pe fișiere → respawn) ar fi fost **comportament nou**, care ar omorî procesul
viu și steering-urile în așteptare de fiecare dată când Marius editează `SOUL.md` cu Echo pornit.
**Decizie: riscul se șterge, fix-ul nu se implementează.** Reîncărcarea personalității rămâne legată
de `/clear`, exact ca azi.
### ❌ Citarea implementării de referință era ambiguă
`~/workspace/romfastsql/proxmox/lxc171-claude-agent/` **local** conține doar README + scripturi —
clona locală e stale. Codul citat (`discord-bridge/runner.py`, `bot.py:652-663`) există pe
**LXC 171**, accesat prin `ssh echo@10.0.20.201 "sudo pct exec 171 -- cat …"`. Citarea trebuie să
numească gazda, altfel implementatorul caută local și nu găsește nimic.
### ❌ „Flagul poate fi citit la cald" era fals
`claude_session.py:142` face `ALLOWED_TOOLS = _load_allowed_tools()` **la nivel de modul**, citind
`config.json` la import, și modulul **nu importă deloc `src.config`**. Dacă steering copiază tiparul,
flagul e citit o singură dată la pornire, iar rollback-ul cere restart.
**Fix:** citește `steering.*` prin `Config()` în `router.py` (care deține deja `_get_config()`),
**per apel**, nu la import.
### ❌ Etapa 6 cerea o cheie inexistentă
`channels.echo-core` are doar `['id','default_model']`. „Flip pe UN canal" n-are pe ce să se sprijine.
**Fix:** adaugă suport pentru `channels.<nume>.steering: true` care are prioritate peste flagul global.
## Amendamente acceptate din vocile independente
| # | Amendament | Sursă | Decizie | Principiu |
|---|---|---|---|---|
| **X1** | **Contractul de rate limit se mută din Etapa 4 în Etapa 1.** Etapa 2 nu se merge-uiește până testul nu e verde. | CEO#3 **și** DX#5 — **temă transversală** | **ACCEPTAT** | P1 — altfel Etapele 2-3 livrează cu fallback-ul local mort tăcut |
| **X2** | `steer()` întoarce enum `STEERED / RAN_AS_TURN / PROCESS_DEAD`, nu bool; dispecerul deține fallback-ul | CEO#4 | **ACCEPTAT** | P5 explicit — „cade înapoi pe tur normal" era afirmat, nu proiectat |
| **X3** | `idle_reap_s` → **`idle_minutes`** (convenția repo: `interval_minutes`, `auto_leave_minutes`) | DX#1 | **ACCEPTAT** | P4 DRY/consistență |
| **X4** | Blocul de config **se livrează în `config.json`** la Etapa 2, nu doar ca default în cod | DX#4 | **ACCEPTAT** | P1 — altfel pornirea înseamnă scris JSON de mână dintr-un doc |
| **X5** | **Reacția care eșuează nu lasă tăcere** — wrap pe `add_reaction`; la eroare, un ack text de o linie | DX#3 | **ACCEPTAT** | „turnul nu se pierde niciodată", aplicat confirmării. Bridge-ul WhatsApp are istoric de deconectare tăcută |
| **X6** | **Renunță la evicția LRU.** Păstrează `max_live` ca număr + degradare la one-shot; scoate evicția. | CEO#8 | **ACCEPTAT** | P3+P5 — LRU care evacuează canalul în care tocmai scrii e o suprafață de concurență fără beneficiu la N=2 |
| **X7** | `max_live <= 0` → steering oprit **cu log**; `idle_minutes < 1` → clamp la 1 | DX#6 | **ACCEPTAT** | P1 — altfel steering mort tăcut cu flagul aparent pornit |
| **X8** | Kill switch de mediu **`ECHO_STEERING=off`** (precedent: `CLAUDE_BIN = os.environ.get(...)`) | DX#6 | **ACCEPTAT** | P1 — oprire fără să editezi JSON versionat |
| **X9** | **Re-spike** cu system prompt real + tur care lansează un subagent + corecție *blândă* (nu „STOP, zi doar ANANAS") | CEO#7 | **ACCEPTAT, înainte de Etapa 1** | P1 — spike-ul curent dovedește mecanismul, nu cazul de folosire |
| **X10** | **Pinează versiunea CLI + assert pe forma frame-ului** în testul cu fake claude | CEO (nota wire-format) | **ACCEPTAT** | `--output-format stream-json` e transport intern, nu contract de stabilitate; CLI e la 2.1.258 |
| **X11** | Ack-ul poartă conținut: `➡️ prins: <primele ~40 caractere>` | CEO#9 | **ACCEPTAT** | La un tur de 3 minute, ➡️ urmat de tăcere e nedistinsabil de „ignorat" → invită o a doua corecție → dublu steer |
| **X12** | Documentează cele două invariante (forma dict-ului; forma erorii de rate limit) — **T15 urcă la P1** | DX#5 | **ACCEPTAT** | P1 — sunt invariantele care țin fallback-ul local în viață |
| **X13** | Costează explicit alternativele B (coalescing, ~15 linii) și „abort+restart" (~40 linii) într-un paragraf fiecare | CEO#10 | **ACCEPTAT** | P6 — dacă motivul real e preferința lui Marius, se consemnează ca preferință |
## Ridicat la Final Gate, NU auto-decis
**CEO#1 — „Livrează `/stop` singur întâi, apoi decide dacă mai vrei steering."**
Argumentul: durerea declarată e „face ceva greșit și nu-l pot opri". Azi **nu există niciun abort**
(doar SIGINT pe tot procesul, `main.py:116`). A omorî `Popen` din `_run_claude` e muncă de o
după-amiază. Planul cumpără ~600 de linii de concurență pe proces persistent ca să obțină
corecție-târzie, iar abort-ul iese doar ca produs secundar.
Nu e auto-decis: **re-secvențiază direcția pe care ai ales-o explicit.** O singură voce
(Codex indisponibil), deci nu se califică drept User Challenge formal — dar e cel mai important
punct strategic din tot review-ul. Vezi Gate.
---
<!-- AUTONOMOUS DECISION LOG -->
## Decision Audit Trail
| # | Fază | Decizie | Clasificare | Principiu | Motiv | Respins |
|---|---|---|---|---|---|---|
| 1 | CEO 0F | Mod SELECTIVE EXPANSION | Mechanical | default contextual | feature enhancement pe sistem existent | EXPANSION, HOLD, REDUCTION |
| 2 | CEO 0C-bis | Abordarea A (port hand-rolled) | Mechanical | constrângere utilizator | C închisă pe licențiere (abonament, fără API); B respinsă deja de utilizator | B, C |
| 3 | CEO 0D | E1 `/stop` acceptat în scope | Taste | P2 boil lakes | în raza de acțiune, <1 zi CC, capabilitatea vecină cea mai valoroasă | defer |
| 4 | CEO 0D | E2 parser partajat acceptat | Mechanical | P4 DRY | singura violare DRY reală din plan | defer |
| 5 | CEO 0D | E3 `--autocompact auto` acceptat | Mechanical | P1 completeness | fără el sesiunile lungi mor pe context | defer |
| 6 | CEO 0D | E4 placeholder editabil → TODOS | Mechanical | P2 rază de acțiune | schimbă UX-ul tuturor răspunsurilor, nu doar al celor steered | accept |
| 7 | CEO 0D | E5 atașamente mid-tur → TODOS | Mechanical | P2 rază de acțiune | funcționalitate nouă, nu parte din steering | accept |
| 8 | CEO 0D | E6 dashboard procese vii RESPINS | Mechanical | P4 DRY | `eco status` acoperă nevoia | accept, defer |
| 9 | CEO S3 | T1 wrapping `[EXTERNAL CONTENT]` la steer | Mechanical | P1 completeness | regresie de securitate, nu risc teoretic | omitere |
| 10 | CEO S7 | `max_live` 4 → 2 | Mechanical | P3 pragmatic | măsurat 292-541 MB RSS pe host de 8 GB | păstrare 4 |
| 11 | DX P3 | T12 (log per cale ratată) P2 → P1 | Mechanical | P1 completeness | tiparul de eșec din 2026-08-23, deja în CLAUDE.md | păstrare P2 |
| 12 | DX P4 | D5 (listă Ralph) CRITICAL → LOW | Mechanical | dovadă | verificat: toate joburile Ralph `enabled=False` | păstrare CRITICAL |
| 13 | Eng | E-C1 check+write sub un singur `_stdin_lock` | Mechanical | P1 completeness | TOCTOU pe `inflight` produce ➡️ fără text | doar T7 ca plasă |
| 14 | Eng | E3/E4 contract de mutex rescris | Mechanical | P1 completeness | testul ar trece vacuu (verde fals) | lăsare ca e |
| 15 | Voci | X1 rate limit Etapa 4 → Etapa 1 | Mechanical | **temă transversală** CEO#3 + DX#5 | altfel Etapele 2-3 livrează cu fallback-ul local mort | păstrare Etapa 4 |
| 16 | Voci | X2 `steer()` întoarce enum, nu bool | Mechanical | P5 explicit | fallback-ul era afirmat, nu proiectat | bool |
| 17 | Voci | X3 `idle_reap_s` → `idle_minutes` | Mechanical | P4 consistență | convenția repo: `*_minutes` | păstrare `_s` |
| 18 | Voci | X4 config în `config.json` + citit per apel | Mechanical | P1 | `ALLOWED_TOOLS` la import e capcana | default doar în cod |
| 19 | Voci | X5 reacție eșuată → ack text | Mechanical | P1 | „turnul nu se pierde niciodată", aplicat confirmării | tăcere |
| 20 | Voci | X6 renunță la evicția LRU | Mechanical | P3+P5 | suprafață de concurență fără beneficiu la N=2 | păstrare LRU |
| 21 | Voci | X9 re-spike cu tur realist | Mechanical | P1 | spike-ul dovedește mecanismul, nu cazul | mers pe spike-ul curent |
| 22 | Voci | X11 ack cu conținut (`➡️ prins: …`) | Taste | P1 | ➡️ + 2 min tăcere ≡ „ignorat" → dublu steer | ➡️ simplu |
| 23 | Voci | X15 Riscul #2 ȘTERS | Mechanical | **dovadă pe cod** | `resume_session` nu pasează `--system-prompt`; fix-ul ar fi fost o regresie | păstrare risc + hash |
| 24 | Voci | CEO#1 („`/stop` întâi") **NU auto-decis** | **Ridicat la Gate** | — | re-secvențiază direcția aleasă explicit de utilizator | auto-accept, auto-respingere |
**Total: 24 decizii — 21 auto-decise (20 mechanical, 2 taste), 1 ridicată la Gate.**
## ENG DUAL VOICES — CONSENSUS TABLE
```
═══════════════════════════════════════════════════════════════
Dimensiune Claude Codex Consensus
───────────────────────────────────── ─────── ────── ─────────
1. Arhitectură solidă? YES* N/A flagged
2. Acoperire de teste suficientă? NO N/A flagged
3. Riscuri de performanță tratate? YES N/A —
4. Amenințări de securitate acoperite? PARTIAL N/A flagged
5. Căi de eroare tratate? NO N/A flagged
6. Risc de deployment gestionabil? YES N/A —
═══════════════════════════════════════════════════════════════
* YES condiționat de E1 (check+write sub un singur _stdin_lock) și X2 (enum).
Fără ele, arhitectura are o cursă TOCTOU nedocumentată.
Vocea eng independentă a rulat pe planul amendat; findings-urile ei majore
(concurență, contract de mutex) coincid cu analiza primară — vezi § Eng Review.
```
## Completion Summary
```
+====================================================================+
| /autoplan — Steering în echo-core |
+====================================================================+
| Mod: SELECTIVE EXPANSION |
| Voci: [subagent-only] — Codex neinstalat |
| Faze rulate: CEO ✓ · Design SĂRIT (fără UI) · DX ✓ · Eng ✓ |
+--------------------------------------------------------------------+
| Decizii: 24 total — 21 auto · 2 taste · 1 la Gate |
| Task-uri: 49 (21 P1 · 22 P2 · 6 P3) |
| Premise: 7 verificate → 2 CONFIRMATE false, 1 ELIMINATĂ |
| Corecții 4 afirmații din plan infirmate pe cod: |
| factuale: · Riscul #2 (system-prompt) — inversat, șters |
| · „un singur răspuns la final" — deja fals |
| · „flag citit la cald" — fals (import-time) |
| · „WhatsApp poate n-are reacții" — are |
+--------------------------------------------------------------------+
| Scor DX: 6.1/10 TTHW ~15 min → ~3 min cu fix-urile |
| Gap-uri critice rămase: 0 (fiecare are task) |
| Reversibilitate: 4/5 — flag + modul nou, _run_claude intact |
+====================================================================+
```
## Unresolved Decisions
1. **Secvențierea `/stop` vs steering** (CEO#1) — vezi Final Gate.
2. **Voice și text pe același `channel_id`** — un turn voice în timpul unui tur text devine
steering. Nedecis; nu blochează Etapa 1-2 (voice-ul e pe alt adaptor, dar același `session_key`).
## Blocante găsite de vocea de inginerie (verificate pe cod)
### C1 — Turul orfan. Proprietatea pe stdout se rotește între fire. **CRITIC**
Ordinea care contează e **în interiorul CLI-ului**, nu în procesul nostru: CLI-ul poate emite
`result` *înainte* ca scrierea ta să ajungă, în timp ce `run_turn` e între citirea acelei linii și
achiziția lock-ului. **Niciun lock nu închide o cursă peste un pipe.**
Consecința: `RAN_AS_TURN` e valoarea *periculoasă* din enum, nu cea sigură. Înseamnă că scrierea a
aterizat și a devenit turul N+1, **al cărui stdout nu-l deține nimeni**. Dacă dispecerul răspunde
la `RAN_AS_TURN` chemând `run_turn(text)` din nou → dublu-trimis, apoi citești evenimentele orfanului
împotriva promptului greșit: răspuns greșit la mesajul greșit, facturat de două ori, `active.json` derapat.
**Fix acceptat (P5 explicit, diff mic):** `RAN_AS_TURN` înseamnă **„consumă stream-ul pe care tocmai
l-ai pornit"**, niciodată „reîncearcă". **Upgrade robust, notat ca alternativă:** un thread cititor
permanent care deține stdout și împinge tururile complete într-un `queue.Queue`; `run_turn` face pop.
Atunci orfanii sunt pop-uiți și logați, nu corup turul următor. → **decizie de gust, vezi Gate.**
### C2 — Voice și text partajează `channel_id`. **CRITIC**
Verificat: `voice/pipeline.py:429-430` cheamă `_route_message(str(self.text_channel_id), …)` cu
`adapter_name="discord-voice"` — **același id** ca textul (`router.py:589`, `session_key = channel_id`).
Iar `pipeline.py:444` oglindește `response_text` înapoi în canalul text.
Deci: un mesaj text care face steering pe un tur voice viu primește răspunsul **rostit cu voce**
și niciodată randat ca text; iar un `__STEERED__` întors pe calea voice ajunge **postat literal**
în canal. **`src/voice/pipeline.py` lipsește complet din lista de fișiere a planului („3 adaptoare").**
**Fix:** refuză steering-ul când adaptorul turului în zbor diferă de cel al mesajului sosit
(cade pe lock, comportament de azi) **și** tratează sentinela pe calea voice. `voice/pipeline.py`
intră în lista de fișiere atinse — sunt 7, nu 6.
### C3 — Mesajul steered se pierde la orice eșec mid-tur. **CRITIC**
`router.py:611` prinde excepția și, la rate limit, cheamă `_local_fallback_reply(text)` cu
**textul mesajului 1**. Firul mesajului 2 s-a întors deja cu `STEERED`. Rate limit în timpul unui
steer = pierdere tăcută — exact tiparul pe care CLAUDE.md îl fixează ca „turnul nu se pierde niciodată".
**Fix:** `ClaudeProcess` reține textele steered ale turului curent; la eșec le re-dispecerizează.
T6 trebuie să testeze **livrarea textului steered**, nu doar detecția limitei.
### C4 — Parserul partajat + ridicarea la rate limit ar regresa `PlanningSession`. **HIGH**
Verificat: `claude_session.py:412-415` comentează explicit *„Surface subtype/is_error for callers
that retry on `error_max_turns` (PlanningSession does this)"*, iar `planning_session.py:74` are
`RETRY_MAX_TURNS = 30 # boost on error_max_turns`. `_run_claude` **nu aruncă** intenționat pe
`is_error` — planning-ul depinde de asta.
**Fix:** parserul rămâne **pur** (parse → dict, nu aruncă niciodată). Ridicarea se face doar în
wrapper-ul din runner, condiționată pe `is_rate_limit_error(detail)`. Task T5 se modifică în consecință.
### H1 — Verificarea `inflight` e rasă și în cazul obișnuit. **HIGH → simplificare**
Două mesaje la ~50 ms fără niciun tur pornit: ambele văd „not inflight", mesajul 2 se blochează pe
lock pentru tot turul, iar steering-ul **nu se întâmplă tăcut** — exact cazul QA-ului ostil.
**Fix acceptat (P5+P3):** folosește `lock.acquire(blocking=False)` la nivel de dispecer.
**Eșecul de a lua lock-ul *este* „un tur rulează".** Elimină flagul `inflight` din decizia
dispecerului și odată cu el întreaga cursă TOCTOU. Mai puține stări, cod mai scurt.
### H2, M1, M2
* **H2** — „`test_claude_session_mutex.py` trece nemodificat" e necesar dar înșelător: pinează doar
contractul cu flag off. `TestAcquisitionBehavior` (linia 229) afirmă că un apelant în contenție
se blochează apoi continuă; cu steering pornit trebuie **să facă steering**. Adaugă geamănul cu
steering pornit + fixture autouse care golește registry-ul, altfel procesele fake se scurg între teste.
* **M1** — degradarea la one-shot pentru un canal care are deja un proces viu inactiv dă **doi
scriitori pe același `session_id`** (`--resume` din `resume_session` plus procesul viu).
Oprește procesul înainte de a degrada.
* **M2** — timeout → `stop()` → turul următor face `--resume` pe o sesiune omorâtă la mijlocul unui
tool-call. Netestat.
### Notă de securitate suplimentară
`_safe_env()` stale la spawn nu e listat în modelul de amenințări. Confirmat ca finding minor.
---
# DECIZII FINALE (gate aprobat 2026-09-02)
| # | Întrebare | Alegere |
|---|---|---|
| D3 | Secvențiere | **Ambele, dar `/stop` se livrează primul** |
| D4 | Turul orfan (C1) | **Consume-the-stream** — `RAN_AS_TURN` = consumă stream-ul pornit, niciodată reîncearcă |
| D5 | Aprobare review | **Aprobat ca atare** — cele 21 de decizii auto rămân aplicate |
## Etape re-secvențiate
### Etapa 0 — `/stop` singur, pe calea one-shot actuală ⟵ NOU, se livrează primul
Nu atinge nimic din arhitectura de steering. `_run_claude` deja ține `proc` (`claude_session.py:310`)
și un thread watchdog care îl omoară la timeout (`:322-333`) — mecanismul de kill există, îi lipsește
doar un declanșator din exterior.
* Registru `channel_id → Popen` pentru turul în zbor
* Comandă `/stop` în `router.py` → `proc.terminate()`, apoi `kill()` după grație
* Utilizatorul vede: „⏹ oprit" — nu o eroare de CLI
* `sessions/active.json` rămâne valid: sesiunea supraviețuiește, doar turul moare
**Efort:** human ~4h / CC ~30min. **Rulează singur o săptămână înainte de Etapa 1.**
> **Cost onest al eșalonării:** registrul de PID-uri din Etapa 0 se rescrie parțial când treci pe
> proces persistent — `ClaudeProcess.stop()` îl înlocuiește. Pierzi ~30 de linii. În schimb ai
> oprirea în producție într-o după-amiază și afli din uz dacă mai vrei steering-ul.
### Etapele 1-6 — steering (neschimbate ca ordine, cu X1 aplicat)
| # | Ce | Schimbat față de planul inițial |
|---|---|---|
| 1 | `claude_runner.py` + fake claude care citește stdin + **contractul de rate limit** | **X1**: rate limit urcat din Etapa 4. Etapa 2 nu se merge-uiește până T6 nu e verde |
| 2 | Dispecer cu `lock.acquire(blocking=False)` (**H1**) + sentinelă + **blocul de config livrat în `config.json`** (**X4**) | H1 elimină TOCTOU-ul de dispecer; flagul citit per apel, nu la import |
| 3 | Reacții pe **4** căi: Discord, Telegram, WhatsApp, **voice** (**C2**) | `voice/pipeline.py` adăugat — 7 fișiere, nu 6 |
| 4 | Hardening: C3 (re-dispecerizare text steered), E2 (sid persistat per tur), M1 | Rate limit nu mai e aici — a plecat în Etapa 1 |
| 5 | Reaper + `max_live` **fără LRU** (**X6**) + `eco status`/`eco doctor` (**D1**) | LRU scos ca suprafață de concurență inutilă la N=2 |
| 6 | Flip pe un canal (**X16**: cheie `channels.<nume>.steering`), apoi global | Cheia per-canal trebuie adăugată — nu exista |
### Config final
```json
"steering": { "enabled": false, "idle_minutes": 20, "max_live": 2 }
```
Citit prin `Config()` **per apel** în `router.py`, nu la import. `max_live <= 0` → oprit cu log.
`idle_minutes < 1` → clamp la 1. Kill switch: `ECHO_STEERING=off`.
### Fișiere atinse (7)
`src/claude_runner.py` (nou) · `src/claude_session.py` · `src/router.py` ·
`src/adapters/discord_bot.py` · `src/adapters/telegram_bot.py` · `src/adapters/whatsapp.py` ·
**`src/voice/pipeline.py`** · plus `config.json`, `CLAUDE.md`, `tests/`
## Status
**EXECUTAT 2026-09-02.** Toate etapele (0-6) implementate pe master, necommitate.
Steering livrat cu `steering.enabled: false` în `config.json` și zero override-uri
per canal — `/stop` e activ, steering-ul rămâne stins până la flip manual.
Suită: 1199 passed / 12 failed — toate cele 12 confirmate pre-existente pe HEAD curat
(worktree separat): 2 `test_claude_session` (assert stale pe system prompt), 1 `test_cli`
(`assets/voice/*.wav` lipsă), 4 `test_discord` (`owned_bot.user` None în fixture),
3 `test_heartbeat`, 1 `test_dashboard_ralph_endpoint`, 1 `test_dashboard_unified_index`.
`tests/test_dashboard_projects_endpoint.py` se blochează — și pe HEAD curat.
Abateri față de plan, verificate pe cod:
- **M1 nereproductibil** — `RunnerRegistry.get()` întoarce procesul existent al canalului
înaintea oricărei verificări de capacitate, deci „doi scriitori pe același session_id"
nu e accesibil dinspre registry. Garda ar fi fost cod mort; dispecerul evită cazul și
pe calea C2 (așteaptă lock-ul, dar rulează prin ACELAȘI proces viu).
- **session_id se ia din două locuri**, nu doar din `system`/`init`: init acoperă turul
care expiră înainte de `result`, iar `result` reîmprospătează la fiecare tur reușit.
- **`eco status` numără prin `pgrep -f "--input-format stream-json"`**, nu prin registry:
`eco` e proces separat, deci `live_count()` ar fi fost structural mereu 0.
## Addendum — două findings din notificările finale ale vocilor
### A1 — Sentinela trebuie tratată într-un singur loc. **HIGH**
Verificat: `__AUDIO__:` e tratat în **Discord (3 locuri)** și **Telegram (1)**, dar
**deloc în WhatsApp**. Convenția existentă are deja o gaură — un utilizator WhatsApp care
declanșează TTS primește șirul literal `__AUDIO__:/cale`. **Bug preexistent, nu introdus aici.**
`__STEERED__` ar replica exact tiparul, acum pe **4 căi** (Discord, Telegram, WhatsApp, voice).
**Fix:** o singură funcție `is_sentinel(response)` pe calea partajată, nu patru verificări
copiate. Repară și bug-ul preexistent de pe WhatsApp ca efect secundar.
### A2 — `_safe_env()` la spawn schimbă comportamentul comutatorului OpenRouter. **HIGH**
`_safe_env()` (`claude_session.py:231-249`) verifică semaforul `.use_openrouter` și, dacă există,
încarcă `~/.claude-env.sh` — care setează variabilele `ANTHROPIC_*` ca să rutează prin OpenRouter.
Azi asta se re-evaluează **la fiecare tur**, deci crearea sau ștergerea semaforului are efect
de la mesajul următor. Cu proces persistent se evaluează **o singură dată, la spawn**: comuți
providerul și **nu se întâmplă nimic** până la respawn — sau, mai rău, rămâi pe OpenRouter după ce
ai șters semaforul, crezând că nu ești.
Nu e „env stale, minor". E un comutator de provider care încetează tăcut să funcționeze.
**Fix:** la începutul fiecărui tur, compară starea semaforului cu cea de la spawn; dacă diferă,
oprește procesul și respawn-ează. Ăsta e singurul respawn-on-change justificat — spre deosebire
de cel pe `personality/` (Riscul #2, șters), aici starea chiar se aplică per proces.
### A3 — Ajustări de etapizare din vocea DX
* `eco status` cu `steering: on/off · N procese vii` se mută din **Etapa 5 în Etapa 2** —
Etapele 2-4 sunt exact unde depanezi.
* T15 primește o **rețetă de reproducere**: „ca să testezi manual, cere ceva cu un `sleep` de 30s+
în Bash, apoi trimite al doilea mesaj". Fără ea, fiecare mentenator viitor repetă spike-ul eșuat.