nexo-brain 7.1.7 → 7.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.1.7",
3
+ "version": "7.1.10",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,9 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.1.7` is the current packaged-runtime line. It hardens operator-facing email automation language: `email-monitor` now carries the calibrated operator language through its prompt contract and localizes direct `needs_interactive` escalation emails, so Spanish operators stop receiving fallback English monitor mail. No coordinated Desktop release was needed for this patch; the fix lives in Brain.
21
+ Version `7.1.10` is the current packaged-runtime line. It is a follow-up over v7.1.8 that ships two rescue batches of WIP that were stashed aside during the v7.1.8 release window. First rescue: `src/autonomy_mandate.py` expands the mandate-detection vocabulary (hazlo todo / no pares / estás al mando / te dejo al mando / sigue sin parar / haz el plan completo), adds three honest flags on `MandateState` (`execute_until_blocker`, `suppress_mid_task_menus`, `revalidate_after_compaction`) with session filtering, wires post/pre-compact hooks that read those flags, surfaces them through protocol/workflow handlers and session payload, and introduces the new `src/checkpoint_policy.py` module with tests. Second rescue: `scripts/verify_release_readiness.py` gains a smoke-artifact contract pass that validates `release-contracts/smoke/v<version>.json` before any tag push, the release-final audit skill references the new contract, `src/hook_guardrails.py` + `src/hooks/post_tool_use.py` refine the post-tool protocol reminder path with a new contract test, and a couple of core prompts (task-close evidence, r14 correction learning) get wording polish. Both rescues are orthogonal to v7.1.8 and fully covered by tests.
22
+
23
+ Previously in `7.1.8`: batch release over v7.1.7 consolidating the Block K Guardian/Enforcer roadmap (auto-drain of stale `protocol_debt` rows, destructive-command pre-tool gate, `guard_check`-required gate, inline guard ack on `nexo_task_open`, Guardian Health in the morning briefing) with Block D hardcode cleanup (classifier-backed `backfill_task_owner`, migration v50 supersedes the duplicate NEXO-product learning pair, new semantic-hardcodes audit) and Block E product guards (LaunchAgent plist protection, agent-name fallbacks no longer leak the product identity, `francisco_emails` removed from the email-config dict export, `runner-health-check.py` + `nexo_personal_automation.py` promoted from personal to core).
22
24
 
23
25
  Previously in `7.0.1`: hotfix over v7.0.0 (db._core.DB_PATH was only caller still hardcoded to legacy ~/.nexo/data/nexo.db; every shared-DB command silently returned empty results post-migration). Previously in `7.0.0`: **BREAKING — Plan Consolidado fase F0.6**: physical separation of the runtime tree into `~/.nexo/{core,personal,runtime}/`. The flat layout (`~/.nexo/scripts/`, `brain/`, `data/`, `operations/`, ...) is gone. Operators on v6.x are auto-migrated on first `nexo update`; fresh installs land directly in the new tree. New `paths.py` helpers are transition-aware.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.1.7",
3
+ "version": "7.1.10",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -3007,10 +3007,11 @@ def _check_npm_version() -> str | None:
3007
3007
  return None
3008
3008
  if latest != current and not current.endswith(latest):
3009
3009
  try:
3010
- from user_context import get_context
3011
- _name = get_context().assistant_name
3010
+ from user_context import get_context, DEFAULT_ASSISTANT_NAME
3011
+ _name = get_context().assistant_name or DEFAULT_ASSISTANT_NAME
3012
3012
  except Exception:
3013
- _name = "NEXO"
3013
+ from user_context import DEFAULT_ASSISTANT_NAME
3014
+ _name = DEFAULT_ASSISTANT_NAME
3014
3015
  return f"{_name} update available: {current} -> {latest}. Run: npm update -g {pkg_name}"
3015
3016
  except Exception:
3016
3017
  pass
@@ -3385,12 +3386,17 @@ def _find_user_claude_md() -> Path | None:
3385
3386
 
3386
3387
  def _resolve_placeholders(template_text: str) -> str:
3387
3388
  """Fill {{NAME}} and {{NEXO_HOME}} from the user's existing CLAUDE.md or config."""
3388
- # Read operator name from calibration/version
3389
+ # Read operator name from calibration/version. The fallback must never be a
3390
+ # reserved product identity ("NEXO", "NEXO Brain", "NEXO Desktop"); those
3391
+ # are rejected by desktop_bridge.RESERVED_ASSISTANT_NAME_VALUES and leaking
3392
+ # them into the managed CLAUDE.md would conflate the agent (Nova/Nero/etc.)
3393
+ # with the product itself.
3389
3394
  try:
3390
- from user_context import get_context
3391
- name = get_context().assistant_name
3395
+ from user_context import get_context, DEFAULT_ASSISTANT_NAME
3396
+ name = get_context().assistant_name or DEFAULT_ASSISTANT_NAME
3392
3397
  except Exception:
3393
- name = "NEXO"
3398
+ from user_context import DEFAULT_ASSISTANT_NAME
3399
+ name = DEFAULT_ASSISTANT_NAME
3394
3400
 
3395
3401
  return (
3396
3402
  template_text
@@ -45,8 +45,15 @@ MARKERS = (
45
45
  "autonomía total",
46
46
  "autonomia total",
47
47
  "sin esperas",
48
+ "hazlo todo",
48
49
  "todo ya",
50
+ "no pares",
51
+ "estás al mando",
52
+ "estas al mando",
53
+ "te dejo al mando",
49
54
  "no esperes",
55
+ "sigue sin parar",
56
+ "haz el plan completo",
50
57
  "no te escondas",
51
58
  "llevo 3 veces",
52
59
  "lo quiero ya",
@@ -81,6 +88,9 @@ class MandateState:
81
88
  expires_at: float
82
89
  marker: str
83
90
  source: str
91
+ execute_until_blocker: bool = True
92
+ suppress_mid_task_menus: bool = True
93
+ revalidate_after_compaction: bool = True
84
94
 
85
95
  def remaining_seconds(self, now: Optional[float] = None) -> float:
86
96
  now = time.time() if now is None else now
@@ -94,6 +104,9 @@ class MandateState:
94
104
  "expires_at": self.expires_at,
95
105
  "marker": self.marker,
96
106
  "source": self.source,
107
+ "execute_until_blocker": self.execute_until_blocker,
108
+ "suppress_mid_task_menus": self.suppress_mid_task_menus,
109
+ "revalidate_after_compaction": self.revalidate_after_compaction,
97
110
  }
98
111
 
99
112
 
@@ -137,6 +150,9 @@ def load_state() -> Optional[MandateState]:
137
150
  expires_at=float(raw.get("expires_at", 0.0)),
138
151
  marker=str(raw.get("marker", "")),
139
152
  source=str(raw.get("source", "")),
153
+ execute_until_blocker=bool(raw.get("execute_until_blocker", True)),
154
+ suppress_mid_task_menus=bool(raw.get("suppress_mid_task_menus", True)),
155
+ revalidate_after_compaction=bool(raw.get("revalidate_after_compaction", True)),
140
156
  )
141
157
  except (TypeError, ValueError):
142
158
  return None
@@ -150,6 +166,9 @@ def set_mandate(
150
166
  marker: str,
151
167
  source: str = "manual",
152
168
  ttl_seconds: int = DEFAULT_TTL_SECONDS,
169
+ execute_until_blocker: bool = True,
170
+ suppress_mid_task_menus: bool = True,
171
+ revalidate_after_compaction: bool = True,
153
172
  ) -> MandateState:
154
173
  _ensure_dir()
155
174
  now = time.time()
@@ -160,6 +179,9 @@ def set_mandate(
160
179
  expires_at=now + max(60, int(ttl_seconds)),
161
180
  marker=marker,
162
181
  source=source,
182
+ execute_until_blocker=bool(execute_until_blocker),
183
+ suppress_mid_task_menus=bool(suppress_mid_task_menus),
184
+ revalidate_after_compaction=bool(revalidate_after_compaction),
163
185
  )
164
186
  STATE_PATH.write_text(json.dumps(st.to_dict(), ensure_ascii=False, indent=2))
165
187
  return st
@@ -196,6 +218,46 @@ def maybe_ingest_from_text(
196
218
  )
197
219
 
198
220
 
221
+ def _state_applies_to_session(state: MandateState, session_id: str = "") -> bool:
222
+ clean_sid = str(session_id or "").strip()
223
+ if not clean_sid:
224
+ return True
225
+ return not state.session_id or state.session_id == clean_sid
226
+
227
+
228
+ def is_execute_until_blocker_active(
229
+ session_id: str = "",
230
+ state: Optional[MandateState] = None,
231
+ ) -> bool:
232
+ st = state if state is not None else load_state()
233
+ if st is None or not _state_applies_to_session(st, session_id):
234
+ return False
235
+ return bool(st.active and st.execute_until_blocker)
236
+
237
+
238
+ def format_execution_latch_notice(
239
+ session_id: str = "",
240
+ state: Optional[MandateState] = None,
241
+ ) -> str:
242
+ st = state if state is not None else load_state()
243
+ if st is None or not _state_applies_to_session(st, session_id):
244
+ return ""
245
+ if not st.active or not st.execute_until_blocker:
246
+ return ""
247
+
248
+ lines = [
249
+ (
250
+ "EXECUTION MODE: execute-until-blocker active "
251
+ f"(marker='{st.marker}', source={st.source})."
252
+ ),
253
+ "Do not stop for option menus, reprioritization, summaries, or audits.",
254
+ "Pause only for a real blocker, an explicit approval gate, or a requested report.",
255
+ ]
256
+ if st.revalidate_after_compaction:
257
+ lines.append("Re-validate this latch after compaction and continue from the stored next step.")
258
+ return "\n".join(lines)
259
+
260
+
199
261
  def _description_has_exception(description: str, exception: str) -> bool:
200
262
  haystack = f"{description or ''}\n{exception or ''}".lower()
201
263
  return any(kw in haystack for kw in EXCEPTION_KEYWORDS)
@@ -0,0 +1,302 @@
1
+ """Durable checkpoint policy for long-running multi-step work.
2
+
3
+ This module turns task/workflow milestones into a small persistent state file
4
+ and periodically flushes that state into ``session_checkpoints`` so compaction
5
+ and phase switches recover a richer next-step snapshot than the heartbeat-only
6
+ goal stub.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
19
+ STATE_PATH = NEXO_HOME / "runtime" / "data" / "durable_checkpoint_state.json"
20
+ DEFAULT_MILESTONE_INTERVAL = 3
21
+
22
+
23
+ def _now_iso() -> str:
24
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
25
+
26
+
27
+ def _ensure_dir() -> None:
28
+ STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
29
+
30
+
31
+ def _load_all() -> dict[str, dict[str, Any]]:
32
+ try:
33
+ raw = json.loads(STATE_PATH.read_text())
34
+ except FileNotFoundError:
35
+ return {}
36
+ except (OSError, json.JSONDecodeError):
37
+ return {}
38
+ return raw if isinstance(raw, dict) else {}
39
+
40
+
41
+ def _save_all(payload: dict[str, dict[str, Any]]) -> None:
42
+ _ensure_dir()
43
+ STATE_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
44
+
45
+
46
+ def _blank_session_state(session_id: str) -> dict[str, Any]:
47
+ return {
48
+ "session_id": session_id,
49
+ "milestone_count": 0,
50
+ "updated_at": _now_iso(),
51
+ "last_reason": "",
52
+ "task": "",
53
+ "task_status": "active",
54
+ "active_files": [],
55
+ "current_goal": "",
56
+ "decisions_summary": "",
57
+ "blockers": "",
58
+ "reasoning_thread": "",
59
+ "next_step": "",
60
+ "last_flushed_at": "",
61
+ "last_flush_reason": "",
62
+ }
63
+
64
+
65
+ def _coalesce_text(new_value: str, old_value: str = "") -> str:
66
+ clean = str(new_value or "").strip()
67
+ return clean or str(old_value or "").strip()
68
+
69
+
70
+ def _normalize_active_files(active_files: Any) -> list[str]:
71
+ if active_files is None:
72
+ return []
73
+ if isinstance(active_files, str):
74
+ stripped = active_files.strip()
75
+ if not stripped:
76
+ return []
77
+ if stripped.startswith("["):
78
+ try:
79
+ parsed = json.loads(stripped)
80
+ except json.JSONDecodeError:
81
+ parsed = [part.strip() for part in stripped.split(",") if part.strip()]
82
+ else:
83
+ parsed = [part.strip() for part in stripped.split(",") if part.strip()]
84
+ elif isinstance(active_files, (list, tuple, set)):
85
+ parsed = list(active_files)
86
+ else:
87
+ parsed = [str(active_files).strip()]
88
+
89
+ seen: list[str] = []
90
+ for item in parsed:
91
+ clean = str(item or "").strip()
92
+ if clean and clean not in seen:
93
+ seen.append(clean)
94
+ return seen
95
+
96
+
97
+ def _session_state(all_state: dict[str, dict[str, Any]], session_id: str) -> dict[str, Any]:
98
+ existing = all_state.get(session_id)
99
+ if not isinstance(existing, dict):
100
+ return _blank_session_state(session_id)
101
+ base = _blank_session_state(session_id)
102
+ base.update(existing)
103
+ base["session_id"] = session_id
104
+ base["active_files"] = _normalize_active_files(base.get("active_files"))
105
+ try:
106
+ base["milestone_count"] = max(0, int(base.get("milestone_count", 0)))
107
+ except (TypeError, ValueError):
108
+ base["milestone_count"] = 0
109
+ return base
110
+
111
+
112
+ def _extract_active_files_from_payload(payload: dict[str, Any] | None) -> list[str]:
113
+ if not isinstance(payload, dict):
114
+ return []
115
+ for key in ("active_files", "files", "tracked_files"):
116
+ files = _normalize_active_files(payload.get(key))
117
+ if files:
118
+ return files
119
+ return []
120
+
121
+
122
+ def _flush_state(
123
+ all_state: dict[str, dict[str, Any]],
124
+ session_id: str,
125
+ state: dict[str, Any],
126
+ *,
127
+ reason: str,
128
+ ) -> dict[str, Any]:
129
+ from db import save_checkpoint
130
+
131
+ decisions_summary = _coalesce_text(state.get("decisions_summary", ""))
132
+ if reason:
133
+ reason_note = f"checkpoint_reason={reason}"
134
+ decisions_summary = f"{decisions_summary} | {reason_note}".strip(" |")
135
+
136
+ active_files = _normalize_active_files(state.get("active_files"))
137
+ save_result = save_checkpoint(
138
+ sid=session_id,
139
+ task=_coalesce_text(state.get("task", ""), state.get("current_goal", "")),
140
+ task_status=_coalesce_text(state.get("task_status", ""), "active"),
141
+ active_files=json.dumps(active_files, ensure_ascii=False),
142
+ current_goal=_coalesce_text(state.get("current_goal", ""), state.get("task", "")),
143
+ decisions_summary=decisions_summary,
144
+ errors_found=_coalesce_text(state.get("blockers", "")),
145
+ reasoning_thread=_coalesce_text(state.get("reasoning_thread", "")),
146
+ next_step=_coalesce_text(state.get("next_step", "")),
147
+ )
148
+
149
+ state["active_files"] = active_files
150
+ state["milestone_count"] = 0
151
+ state["last_flushed_at"] = _now_iso()
152
+ state["last_flush_reason"] = reason
153
+ state["updated_at"] = _now_iso()
154
+ all_state[session_id] = state
155
+ _save_all(all_state)
156
+ return {
157
+ "ok": True,
158
+ "checkpoint_written": True,
159
+ "session_id": session_id,
160
+ "milestone_count": 0,
161
+ "last_flush_reason": reason,
162
+ "compaction_count": save_result.get("compaction_count", 0),
163
+ }
164
+
165
+
166
+ def record_milestone(
167
+ session_id: str,
168
+ *,
169
+ reason: str,
170
+ task: str = "",
171
+ task_status: str = "active",
172
+ active_files: Any = None,
173
+ current_goal: str = "",
174
+ decisions_summary: str = "",
175
+ blockers: str = "",
176
+ reasoning_thread: str = "",
177
+ next_step: str = "",
178
+ interval: int = DEFAULT_MILESTONE_INTERVAL,
179
+ force_flush: bool = False,
180
+ ) -> dict[str, Any]:
181
+ clean_sid = str(session_id or "").strip()
182
+ if not clean_sid:
183
+ return {"ok": False, "error": "session_id is required"}
184
+
185
+ all_state = _load_all()
186
+ state = _session_state(all_state, clean_sid)
187
+
188
+ state["last_reason"] = str(reason or "").strip()
189
+ state["updated_at"] = _now_iso()
190
+ state["task"] = _coalesce_text(task, state.get("task", ""))
191
+ state["task_status"] = _coalesce_text(task_status, state.get("task_status", "active"))
192
+ state["current_goal"] = _coalesce_text(current_goal, state.get("current_goal", ""))
193
+ state["decisions_summary"] = _coalesce_text(decisions_summary, state.get("decisions_summary", ""))
194
+ state["blockers"] = _coalesce_text(blockers, state.get("blockers", ""))
195
+ state["reasoning_thread"] = _coalesce_text(reasoning_thread, state.get("reasoning_thread", ""))
196
+ state["next_step"] = _coalesce_text(next_step, state.get("next_step", ""))
197
+
198
+ files = _normalize_active_files(active_files)
199
+ if files:
200
+ state["active_files"] = files
201
+
202
+ state["milestone_count"] = max(0, int(state.get("milestone_count", 0))) + 1
203
+ all_state[clean_sid] = state
204
+
205
+ flush_every = max(1, int(interval or DEFAULT_MILESTONE_INTERVAL))
206
+ if force_flush or state["milestone_count"] >= flush_every:
207
+ return _flush_state(all_state, clean_sid, state, reason=str(reason or "").strip())
208
+
209
+ _save_all(all_state)
210
+ return {
211
+ "ok": True,
212
+ "checkpoint_written": False,
213
+ "session_id": clean_sid,
214
+ "milestone_count": state["milestone_count"],
215
+ "flush_interval": flush_every,
216
+ "pending_reason": state["last_reason"],
217
+ }
218
+
219
+
220
+ def force_runtime_checkpoint(session_id: str, *, reason: str = "pre-compact") -> dict[str, Any]:
221
+ clean_sid = str(session_id or "").strip()
222
+ if not clean_sid:
223
+ return {"ok": False, "error": "session_id is required"}
224
+
225
+ from db import get_db, read_checkpoint
226
+
227
+ all_state = _load_all()
228
+ state = _session_state(all_state, clean_sid)
229
+ conn = get_db()
230
+
231
+ session_row = conn.execute(
232
+ "SELECT task FROM sessions WHERE sid = ? LIMIT 1",
233
+ (clean_sid,),
234
+ ).fetchone()
235
+ existing_checkpoint = read_checkpoint(clean_sid) or {}
236
+ workflow_row = conn.execute(
237
+ """SELECT goal, status, current_step_key, next_action, shared_state
238
+ FROM workflow_runs
239
+ WHERE session_id = ?
240
+ ORDER BY updated_at DESC LIMIT 1""",
241
+ (clean_sid,),
242
+ ).fetchone()
243
+
244
+ workflow_state: dict[str, Any] = {}
245
+ if workflow_row and workflow_row["shared_state"]:
246
+ try:
247
+ parsed = json.loads(workflow_row["shared_state"])
248
+ if isinstance(parsed, dict):
249
+ workflow_state = parsed
250
+ except json.JSONDecodeError:
251
+ workflow_state = {}
252
+
253
+ workflow_blocker = ""
254
+ if workflow_row and workflow_row["status"] in {"blocked", "waiting_approval"}:
255
+ workflow_blocker = (
256
+ f"Workflow {workflow_row['status']} at "
257
+ f"{workflow_row['current_step_key'] or 'current-step'}"
258
+ )
259
+
260
+ merged_files = (
261
+ _normalize_active_files(state.get("active_files"))
262
+ or _extract_active_files_from_payload(workflow_state)
263
+ or _normalize_active_files(existing_checkpoint.get("active_files"))
264
+ )
265
+
266
+ state["task"] = _coalesce_text(
267
+ state.get("task", ""),
268
+ (session_row["task"] if session_row else "") or existing_checkpoint.get("task", ""),
269
+ )
270
+ state["current_goal"] = _coalesce_text(
271
+ state.get("current_goal", ""),
272
+ (workflow_row["goal"] if workflow_row else "") or existing_checkpoint.get("current_goal", "") or state.get("task", ""),
273
+ )
274
+ state["decisions_summary"] = _coalesce_text(
275
+ state.get("decisions_summary", ""),
276
+ existing_checkpoint.get("decisions_summary", "") or f"Forced durable checkpoint before {reason}.",
277
+ )
278
+ current_blockers = _coalesce_text(
279
+ state.get("blockers", ""),
280
+ existing_checkpoint.get("errors_found", ""),
281
+ )
282
+ if workflow_blocker:
283
+ if workflow_blocker not in current_blockers:
284
+ current_blockers = f"{workflow_blocker} | {current_blockers}".strip(" |")
285
+ state["blockers"] = current_blockers
286
+ state["reasoning_thread"] = _coalesce_text(
287
+ state.get("reasoning_thread", ""),
288
+ existing_checkpoint.get("reasoning_thread", "") or f"Auto-flushed by checkpoint_policy ({reason}).",
289
+ )
290
+ state["next_step"] = _coalesce_text(
291
+ state.get("next_step", ""),
292
+ (workflow_row["next_action"] if workflow_row else "") or existing_checkpoint.get("next_step", ""),
293
+ )
294
+ state["task_status"] = _coalesce_text(
295
+ state.get("task_status", ""),
296
+ (workflow_row["status"] if workflow_row else "") or existing_checkpoint.get("task_status", "") or "active",
297
+ )
298
+ state["active_files"] = merged_files
299
+ state["updated_at"] = _now_iso()
300
+ state["last_reason"] = reason
301
+ all_state[clean_sid] = state
302
+ return _flush_state(all_state, clean_sid, state, reason=reason)
@@ -11,7 +11,12 @@ Contract:
11
11
  "lo hemos dejado, ya estaría",
12
12
  labels=("done_claim", "status_update", "question", "noise"),
13
13
  )
14
- result == {"label": "done_claim", "confidence": 0.87, "scores": {...}}
14
+ # result is a ``ClassificationResult`` dataclass (see below):
15
+ # result.label == "done_claim"
16
+ # result.confidence == 0.87
17
+ # result.scores == {"done_claim": 0.87, ...}
18
+ # The dataclass keeps attribute access + ``asdict()`` compatibility
19
+ # for callers that previously consumed it as a dict.
15
20
 
16
21
  When transformers is not installed or the download fails (offline),
17
22
  `classify` returns `None` and `classify_fail_closed` returns a
package/src/cli.py CHANGED
@@ -1755,6 +1755,21 @@ def _terminal_client_label(client: str) -> str:
1755
1755
 
1756
1756
 
1757
1757
  def _ordered_available_terminal_clients(preferences: dict, detected: dict) -> list[str]:
1758
+ """Return the terminal clients we should offer the operator, in priority order.
1759
+
1760
+ Policy:
1761
+ 1. Clients that are BOTH detected installed AND enabled in preferences
1762
+ keep their priority (``last_used`` → ``default`` → TERMINAL_CLIENT_ORDER).
1763
+ 2. If that primary list ends up with ≤1 choice, we also surface every
1764
+ other DETECTED-INSTALLED client even when it is not yet marked
1765
+ ``enabled`` in preferences. Rationale (bug Francisco 2026-04-22):
1766
+ operators with both Claude Code and Codex installed were being
1767
+ dropped straight into whichever one was last used (or the only one
1768
+ ever enabled) with no chance to pick. ``nexo chat`` must offer the
1769
+ picker whenever more than one interactive client is available on
1770
+ the machine; the preferences flag stays as a *priority* signal, not
1771
+ as a hard filter that hides a legitimately installed CLI.
1772
+ """
1758
1773
  enabled = preferences.get("interactive_clients", {})
1759
1774
  last_used = str(preferences.get("last_terminal_client", "")).strip()
1760
1775
  preferred = str(preferences.get("default_terminal_client", "")).strip()
@@ -1764,11 +1779,22 @@ def _ordered_available_terminal_clients(preferences: dict, detected: dict) -> li
1764
1779
  if client in TERMINAL_CLIENT_ORDER and client not in ordered:
1765
1780
  ordered.append(client)
1766
1781
 
1767
- return [
1782
+ primary = [
1768
1783
  client
1769
1784
  for client in ordered
1770
1785
  if enabled.get(client, False) and detected.get(client, {}).get("installed", False)
1771
1786
  ]
1787
+ if len(primary) <= 1:
1788
+ # Surface additional installed clients so the operator gets to pick
1789
+ # whenever there is a genuine competing install on disk. We preserve
1790
+ # the priority order built above so the picker still highlights the
1791
+ # last-used/default client first.
1792
+ for client in ordered:
1793
+ if client in primary:
1794
+ continue
1795
+ if detected.get(client, {}).get("installed", False):
1796
+ primary.append(client)
1797
+ return primary
1772
1798
 
1773
1799
 
1774
1800
  def _preferred_terminal_client_label(preferences: dict, clients: list[str]) -> str:
@@ -78,6 +78,22 @@ def add_email_account(
78
78
  raise ValueError("label and email are required")
79
79
  if role not in VALID_ROLES:
80
80
  raise ValueError(f"role must be one of {VALID_ROLES}, got {role!r}")
81
+ # AUDITOR-3RDPASS §Risk 3: the SELECT (``get_email_account``) and the
82
+ # follow-up INSERT ... ON CONFLICT DO UPDATE below used to run as two
83
+ # independent statements on the shared connection. A concurrent writer
84
+ # (Desktop + cron overlap) could slip a metadata mutation between them
85
+ # and get silently overwritten by whatever ``existing.get("metadata")``
86
+ # we captured here. Wrap the read-modify-write in ``BEGIN IMMEDIATE``
87
+ # so the row is pinned against concurrent writers for the duration of
88
+ # the upsert. WAL mode still lets readers through, so no lock storm.
89
+ conn = _get_db()
90
+ try:
91
+ conn.execute("BEGIN IMMEDIATE")
92
+ _owns_txn = True
93
+ except Exception:
94
+ # Already inside a transaction (e.g. caller wrapped the whole flow);
95
+ # trust the caller's boundary instead of nesting an IMMEDIATE.
96
+ _owns_txn = False
81
97
  existing = get_email_account(label) or {}
82
98
  clean_account_type = str(account_type or existing.get("account_type") or "agent").strip().lower()
83
99
  if clean_account_type not in VALID_ACCOUNT_TYPES:
@@ -166,7 +182,10 @@ def add_email_account(
166
182
  now,
167
183
  ),
168
184
  )
169
- conn.commit()
185
+ # Only commit/close the transaction we opened ourselves. Callers that
186
+ # already wrapped the flow in their own transaction must keep control.
187
+ if _owns_txn:
188
+ conn.commit()
170
189
  return get_email_account(label) or {}
171
190
 
172
191
 
package/src/db/_schema.py CHANGED
@@ -1222,6 +1222,75 @@ def _m45_personal_scripts_origin(conn):
1222
1222
  _migrate_add_index(conn, "idx_personal_scripts_origin", "personal_scripts", "origin")
1223
1223
 
1224
1224
 
1225
+ def _m49_protocol_guard_ack_backfill(conn):
1226
+ """Backfill protocol guard-ack columns for installs that already marked
1227
+ migration v22 as applied before those columns were added to the migration
1228
+ body.
1229
+
1230
+ This must remain a standalone migration instead of reusing v22 because
1231
+ real runtimes can legitimately sit at schema version 48 with an older
1232
+ ``protocol_tasks`` shape. Re-running ``init_db()`` skips v22 once it is
1233
+ recorded in ``schema_migrations``, so the missing columns never land
1234
+ without a new version.
1235
+ """
1236
+ _migrate_add_column(conn, "protocol_tasks", "guard_acknowledged", "INTEGER NOT NULL DEFAULT 0")
1237
+ _migrate_add_column(conn, "protocol_tasks", "guard_acknowledged_at", "TEXT DEFAULT NULL")
1238
+
1239
+
1240
+ def _m50_dedupe_nexo_product_learning_pair(conn):
1241
+ """Block D.2 / G7-adjacent: dedupe the two learnings that encode the
1242
+ "NEXO Brain producto público vs instancia personal de Francisco"
1243
+ invariant as a physically separate pair.
1244
+
1245
+ Francisco's runtime has this concept stored twice (historical IDs 212
1246
+ and 224). Guard dedup already collapses them at display time, but
1247
+ the underlying rows stayed split, so list/search/update flows still
1248
+ saw two rows. Physically supersede the older one by pointing its
1249
+ ``supersedes_id`` at the newer duplicate and flipping its status to
1250
+ ``superseded``. Anything newer than both is left untouched.
1251
+
1252
+ Idempotent. Fresh installs that never created either row silently
1253
+ do nothing; installs where an operator has already set the relation
1254
+ manually do nothing. The migration matches on a text-normalised form
1255
+ of the title so synonymous wording on both rows is enough — we don't
1256
+ need identical strings, and we don't need the IDs to literally be
1257
+ 212 and 224.
1258
+ """
1259
+ try:
1260
+ rows = conn.execute(
1261
+ "SELECT id, title, content, status, supersedes_id FROM learnings "
1262
+ "WHERE status = 'active'"
1263
+ ).fetchall()
1264
+ except Exception:
1265
+ return
1266
+
1267
+ def _norm(text: str) -> str:
1268
+ # Collapse whitespace and strip punctuation/case so "NEXO Brain
1269
+ # producto público vs instancia personal" matches its twin no
1270
+ # matter how the operator rephrased it.
1271
+ import re as _re
1272
+ stripped = _re.sub(r"[\W_]+", " ", str(text or "")).strip().lower()
1273
+ return _re.sub(r"\s+", " ", stripped)
1274
+
1275
+ marker = "nexo brain producto"
1276
+ candidates = [r for r in rows if marker in _norm(r[1] or "")]
1277
+ # Need at least two rows for this migration to do anything.
1278
+ if len(candidates) < 2:
1279
+ return
1280
+ # Sort by id ascending; the highest id is the canonical survivor.
1281
+ candidates.sort(key=lambda r: int(r[0] or 0))
1282
+ survivor = candidates[-1]
1283
+ for older in candidates[:-1]:
1284
+ older_id = int(older[0] or 0)
1285
+ if int(older[4] or 0) == int(survivor[0] or 0):
1286
+ continue # already linked
1287
+ conn.execute(
1288
+ "UPDATE learnings SET supersedes_id = ?, status = 'superseded', "
1289
+ "updated_at = strftime('%s','now') WHERE id = ? AND status = 'active'",
1290
+ (int(survivor[0] or 0), older_id),
1291
+ )
1292
+
1293
+
1225
1294
  def _m44_entities_extended_schema(conn):
1226
1295
  """Plan Consolidado 0.3 — extend entities with aliases/metadata/source/confidence/access_mode.
1227
1296
 
@@ -1291,6 +1360,8 @@ MIGRATIONS = [
1291
1360
  (46, "email_accounts", _m46_email_accounts),
1292
1361
  (47, "email_operator_accounts", _m47_email_operator_accounts),
1293
1362
  (48, "email_agent_contract_backfill", _m48_email_agent_contract_backfill),
1363
+ (49, "protocol_guard_ack_backfill", _m49_protocol_guard_ack_backfill),
1364
+ (50, "dedupe_nexo_product_learning_pair", _m50_dedupe_nexo_product_learning_pair),
1294
1365
  ]
1295
1366
 
1296
1367