nexo-brain 5.3.11 → 5.3.12

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": "5.3.11",
3
+ "version": "5.3.12",
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
@@ -821,9 +821,7 @@ If you want the shell or Python wrappers instead of raw MCP tools:
821
821
  - [docs/reference-verticals.md](docs/reference-verticals.md)
822
822
  - [compare/README.md](compare/README.md)
823
823
 
824
- Recommended defaults:
825
- - Claude Code: `Opus 4.6 with 1M context`
826
- - Codex: `gpt-5.4` with `xhigh` reasoning
824
+ The model you pick during install is used everywhere — interactive sessions, automation scripts, and all task profiles. Change it once in your preferences and every part of the system follows. Default: `Opus 4.6 with 1M context`.
827
825
 
828
826
  Or use the shell alias created during install (e.g. `atlas`), which now runs `nexo chat .` so it opens the terminal client you pick for that session, with the last-used option shown first.
829
827
 
@@ -904,7 +902,7 @@ The Doctor system reads existing health artifacts (immune, watchdog, self-audit)
904
902
  - **macOS or Linux** (Windows via [WSL](https://learn.microsoft.com/en-us/windows/wsl/install))
905
903
  - **Node.js 18+** (for the installer)
906
904
  - **Claude Code is the primary recommended client.** It remains the most mature NEXO path: native hooks, the most battle-tested automation contract, and the clearest parity with historical production behavior.
907
- - **Recommended profiles:** Claude Code + `Opus 4.6 with 1M context`; Codex + `gpt-5.4` with `xhigh` reasoning if you prefer Codex as your terminal or automation backend.
905
+ - **Model:** You pick your model during install and every component uses it. Default is `Opus 4.6 with 1M context`. Scripts and automation profiles read from a single preference no hardcoded model strings.
908
906
  - Python 3, Homebrew, and the selected required client/backend can be installed automatically when NEXO has a supported installer path for that dependency.
909
907
 
910
908
  ## Architecture
@@ -1021,7 +1019,7 @@ When Claude Desktop is installed, `nexo-brain`, `nexo update`, and `nexo clients
1021
1019
 
1022
1020
  ### Codex
1023
1021
 
1024
- When Codex CLI is available, `nexo-brain`, `nexo update`, and `nexo clients sync` register the same `nexo` MCP server via `codex mcp add`, so Codex uses the same local memory store as Claude Code and Claude Desktop. If selected during install, `nexo chat` can open Codex directly and background automation can also run through Codex. Interactive `nexo chat` launches use Codex's aggressive no-confirmation mode so the session does not stall on repetitive approval prompts. The current recommended Codex profile is `gpt-5.4` with `xhigh` reasoning. Runtime Doctor also audits recent Codex sessions for NEXO startup markers and conditioned-file protocol discipline so parity drift does not hide behind the lack of native Claude-style hooks.
1022
+ When Codex CLI is available, `nexo-brain`, `nexo update`, and `nexo clients sync` register the same `nexo` MCP server via `codex mcp add`, so Codex uses the same local memory store as Claude Code and Claude Desktop. If selected during install, `nexo chat` can open Codex directly and background automation can also run through Codex. Interactive `nexo chat` launches use Codex's aggressive no-confirmation mode so the session does not stall on repetitive approval prompts. Codex uses the same model you configured during install — no separate model override is needed. Runtime Doctor also audits recent Codex sessions for NEXO startup markers and conditioned-file protocol discipline so parity drift does not hide behind the lack of native Claude-style hooks.
1025
1023
 
1026
1024
  ### Cursor
1027
1025
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.3.11",
3
+ "version": "5.3.12",
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",
@@ -509,6 +509,8 @@ def _build_codex_prompt(
509
509
  "If that tool is unavailable, call `nexo_guard_check(...)` and `nexo_cortex_check(...)` first.\n"
510
510
  "- For long multi-step or cross-session work, call `nexo_workflow_open(...)` and keep it updated with "
511
511
  "`nexo_workflow_update(...)` so resume/replay use durable state instead of guesswork.\n"
512
+ "- Before diagnosing NEXO, explicitly fix the plane first: `product_public`, `runtime_personal`, `installation_live`, `database_real`, or `cooperator`. "
513
+ "Do not mix planes inside the same diagnosis.\n"
512
514
  "- If a target file has conditioned learnings or blocking guard rules, review them before any read/edit/delete step, and acknowledge guard before any edit/delete step.\n"
513
515
  "- Do not claim done without explicit verification evidence. Close with `nexo_task_close(...)`; if unavailable, capture the change log and state the evidence explicitly.\n"
514
516
  "- When a correction changes the canonical rule, capture or supersede the learning instead of leaving contradictory active rules behind."
@@ -36,6 +36,7 @@ TEMPLATE_FILE = REPO_DIR / "templates" / "CLAUDE.md.template"
36
36
 
37
37
  CHECK_COOLDOWN_SECONDS = 3600 # 1 hour
38
38
  GIT_TIMEOUT_SECONDS = 4 # stay well under the 5s total budget
39
+ CRITICAL_BACKUP_TABLES = ("learnings", "session_diary", "guard_checks", "protocol_debt")
39
40
 
40
41
 
41
42
  def _log(msg: str):
@@ -43,6 +44,108 @@ def _log(msg: str):
43
44
  print(f"[NEXO auto-update] {msg}", file=sys.stderr)
44
45
 
45
46
 
47
+ def _critical_table_count(db_path: Path, table: str) -> int | None:
48
+ """Return COUNT(*) for a critical table when it exists, otherwise None."""
49
+ import sqlite3
50
+
51
+ conn = None
52
+ try:
53
+ conn = sqlite3.connect(str(db_path))
54
+ exists = conn.execute(
55
+ "SELECT name FROM sqlite_master WHERE type='table' AND name = ?",
56
+ (table,),
57
+ ).fetchone()
58
+ if not exists:
59
+ return None
60
+ row = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()
61
+ return int(row[0]) if row else 0
62
+ except Exception:
63
+ return None
64
+ finally:
65
+ if conn is not None:
66
+ try:
67
+ conn.close()
68
+ except Exception:
69
+ pass
70
+
71
+
72
+ def _find_primary_db_path() -> Path | None:
73
+ """Return the main nexo.db path if present."""
74
+ for candidate in (DATA_DIR / "nexo.db", NEXO_HOME / "nexo.db", SRC_DIR / "nexo.db"):
75
+ if candidate.is_file():
76
+ return candidate
77
+ return None
78
+
79
+
80
+ def _validate_db_backup(source_db: Path, backup_db: Path) -> dict:
81
+ """Check that a backup preserves non-empty critical tables from the source DB."""
82
+ report = {
83
+ "ok": True,
84
+ "source_db": str(source_db),
85
+ "backup_db": str(backup_db),
86
+ "source_counts": {},
87
+ "backup_counts": {},
88
+ "regressions": [],
89
+ "errors": [],
90
+ }
91
+ if not source_db.is_file():
92
+ report["ok"] = False
93
+ report["errors"].append(f"source db missing: {source_db}")
94
+ return report
95
+ if not backup_db.is_file():
96
+ report["ok"] = False
97
+ report["errors"].append(f"backup db missing: {backup_db}")
98
+ return report
99
+
100
+ for table in CRITICAL_BACKUP_TABLES:
101
+ source_count = _critical_table_count(source_db, table)
102
+ backup_count = _critical_table_count(backup_db, table)
103
+ report["source_counts"][table] = source_count
104
+ report["backup_counts"][table] = backup_count
105
+
106
+ if source_count is None:
107
+ continue
108
+ if backup_count is None:
109
+ report["regressions"].append({
110
+ "table": table,
111
+ "source": source_count,
112
+ "backup": None,
113
+ "reason": "missing_in_backup",
114
+ })
115
+ continue
116
+ if source_count > 0 and backup_count == 0:
117
+ report["regressions"].append({
118
+ "table": table,
119
+ "source": source_count,
120
+ "backup": backup_count,
121
+ "reason": "critical_rows_lost",
122
+ })
123
+
124
+ if report["regressions"] or report["errors"]:
125
+ report["ok"] = False
126
+ return report
127
+
128
+
129
+ def _create_validated_db_backup() -> tuple[str | None, dict | None]:
130
+ """Create a DB backup and validate that critical tables still contain data."""
131
+ backup_dir = _backup_dbs()
132
+ if not backup_dir:
133
+ return None, None
134
+
135
+ source_db = _find_primary_db_path()
136
+ if source_db is None:
137
+ return backup_dir, None
138
+
139
+ report = _validate_db_backup(source_db, Path(backup_dir) / source_db.name)
140
+ if not report["ok"]:
141
+ details = ", ".join(
142
+ f"{item['table']} {item['source']}->{item['backup']}"
143
+ for item in report["regressions"]
144
+ ) or "; ".join(report["errors"])
145
+ _log(f"DB backup validation failed: {details}")
146
+ return backup_dir, report
147
+
148
+
46
149
  def _read_last_check() -> dict:
47
150
  """Read last check state from disk."""
48
151
  try:
@@ -677,6 +780,11 @@ def _check_git_updates() -> str | None:
677
780
  if rc != 0:
678
781
  return None
679
782
 
783
+ db_backup_dir, backup_report = _create_validated_db_backup()
784
+ if backup_report is not None and not backup_report["ok"]:
785
+ _log("Skipping auto-update because the validated pre-update DB backup is not trustworthy.")
786
+ return None
787
+
680
788
  rc, pull_out, pull_err = _git("pull", "--ff-only")
681
789
  if rc != 0:
682
790
  _log(f"git pull --ff-only failed: {pull_err}")
@@ -685,9 +793,6 @@ def _check_git_updates() -> str | None:
685
793
  new_version = _read_package_version()
686
794
  new_req_hash = _requirements_hash()
687
795
 
688
- # Backup databases before any changes that might run migrations
689
- db_backup_dir = _backup_dbs()
690
-
691
796
  # Reinstall pip deps if requirements.txt content changed (not just version)
692
797
  if old_req_hash != new_req_hash:
693
798
  if not _reinstall_pip_deps():
@@ -2057,7 +2162,14 @@ def manual_sync_update(*, interactive: bool = False, allow_source_pull: bool = T
2057
2162
  pulled = True
2058
2163
 
2059
2164
  _emit_progress(progress_fn, "Creating runtime backups...")
2060
- db_backup_dir = _backup_dbs()
2165
+ db_backup_dir, backup_report = _create_validated_db_backup()
2166
+ if backup_report is not None and not backup_report["ok"]:
2167
+ return {
2168
+ "ok": False,
2169
+ "mode": "sync",
2170
+ "error": "DB backup validation failed before runtime sync.",
2171
+ "backup_dir": db_backup_dir,
2172
+ }
2061
2173
  tree_backup_dir = _backup_runtime_tree(NEXO_HOME)
2062
2174
  sync_result = {"ok": False, "mode": "sync", "pulled_source": pulled, "backup_dir": db_backup_dir, "tree_backup": tree_backup_dir}
2063
2175
  try:
package/src/cli.py CHANGED
@@ -26,7 +26,7 @@ Entry points:
26
26
  nexo skills evolution [--json]
27
27
  nexo clients sync [--json]
28
28
  nexo contributor status|on|off [--json]
29
- nexo doctor [--tier boot|runtime|deep|all] [--json] [--fix]
29
+ nexo doctor [--tier boot|runtime|deep|all] [--plane runtime_personal|installation_live|database_real] [--json] [--fix]
30
30
  nexo uninstall [--dry-run] [--delete-data] [--json]
31
31
  """
32
32
  from __future__ import annotations
@@ -1210,7 +1210,7 @@ def _doctor(args):
1210
1210
  init_db()
1211
1211
  tier_label = getattr(args, "tier", "boot") or "boot"
1212
1212
  print(f"[NEXO] Inspecting {tier_label} diagnostics... please wait.", file=sys.stderr, flush=True)
1213
- report = run_doctor(tier=args.tier, fix=args.fix)
1213
+ report = run_doctor(tier=args.tier, fix=args.fix, plane=getattr(args, "plane", ""))
1214
1214
  output = format_report(report, fmt="json" if args.json else "text")
1215
1215
  print(output)
1216
1216
 
@@ -1749,6 +1749,12 @@ def main():
1749
1749
  doctor_parser = sub.add_parser("doctor", help="Unified diagnostics")
1750
1750
  doctor_parser.add_argument("--tier", default="boot", choices=["boot", "runtime", "deep", "all"],
1751
1751
  help="Diagnostic tier (default: boot)")
1752
+ doctor_parser.add_argument(
1753
+ "--plane",
1754
+ default="",
1755
+ choices=["", "runtime_personal", "installation_live", "database_real", "product_public", "cooperator"],
1756
+ help="Diagnostic plane. Doctor only runs on runtime_personal, installation_live, or database_real.",
1757
+ )
1752
1758
  doctor_parser.add_argument("--json", action="store_true", help="JSON output")
1753
1759
  doctor_parser.add_argument("--fix", action="store_true", help="Apply deterministic fixes")
1754
1760
 
@@ -48,10 +48,13 @@ INSTALL_PREFERENCE_KEYS = {
48
48
  }
49
49
  DEFAULT_CLAUDE_CODE_MODEL = "claude-opus-4-6[1m]"
50
50
  DEFAULT_CLAUDE_CODE_REASONING_EFFORT = ""
51
- DEFAULT_CODEX_MODEL = "gpt-5.4"
52
- DEFAULT_CODEX_REASONING_EFFORT = "xhigh"
53
- DEFAULT_FAST_MODEL = "gpt-5.4-mini"
54
- DEFAULT_FAST_REASONING_EFFORT = "medium"
51
+ # Codex/fast defaults: fall back to the user's configured model, not a hardcoded
52
+ # third-party model. The user picks their model once at install time; every
53
+ # profile and backend should honour that choice.
54
+ DEFAULT_CODEX_MODEL = DEFAULT_CLAUDE_CODE_MODEL
55
+ DEFAULT_CODEX_REASONING_EFFORT = ""
56
+ DEFAULT_FAST_MODEL = DEFAULT_CLAUDE_CODE_MODEL
57
+ DEFAULT_FAST_REASONING_EFFORT = ""
55
58
 
56
59
 
57
60
  def _user_home() -> Path:
@@ -260,9 +263,9 @@ def default_automation_task_profiles() -> dict[str, dict[str, str]]:
260
263
  "reasoning_effort": "",
261
264
  },
262
265
  "fast": {
263
- "backend": CLIENT_CODEX,
264
- "model": DEFAULT_FAST_MODEL,
265
- "reasoning_effort": DEFAULT_FAST_REASONING_EFFORT,
266
+ "backend": "",
267
+ "model": "",
268
+ "reasoning_effort": "",
266
269
  },
267
270
  "balanced": {
268
271
  "backend": "",
@@ -523,6 +526,22 @@ def resolve_terminal_client(requested: str | None = None, *, preferences: dict |
523
526
  )
524
527
 
525
528
 
529
+ def resolve_user_model(preferences: dict | None = None) -> str:
530
+ """Return the single model the user has configured.
531
+
532
+ Scripts and automation tasks should call this instead of hardcoding a model
533
+ string. The value comes from the user's ``client_runtime_profiles`` for
534
+ their ``default_terminal_client`` (usually ``claude_code``).
535
+ """
536
+ normalized = preferences or load_client_preferences()
537
+ client = normalize_default_terminal_client(
538
+ normalized["default_terminal_client"],
539
+ interactive_clients=normalized["interactive_clients"],
540
+ )
541
+ profile = resolve_client_runtime_profile(client, preferences=normalized)
542
+ return profile["model"]
543
+
544
+
526
545
  def resolve_automation_backend(preferences: dict | None = None) -> str:
527
546
  normalized = preferences or load_client_preferences()
528
547
  return normalize_automation_backend(
@@ -61,9 +61,10 @@ except Exception:
61
61
  }
62
62
 
63
63
  def resolve_client_runtime_profile(client: str, preferences: dict | None = None) -> dict:
64
+ _default_model = "claude-opus-4-6[1m]"
64
65
  defaults = {
65
- "claude_code": {"model": "claude-opus-4-6[1m]", "reasoning_effort": ""},
66
- "codex": {"model": "gpt-5.4", "reasoning_effort": "xhigh"},
66
+ "claude_code": {"model": _default_model, "reasoning_effort": ""},
67
+ "codex": {"model": _default_model, "reasoning_effort": ""},
67
68
  }
68
69
  return dict(defaults.get(client, {}))
69
70
 
@@ -6,6 +6,7 @@ import time
6
6
  import traceback
7
7
 
8
8
  from doctor.models import DoctorCheck, DoctorReport
9
+ from doctor.planes import diagnostic_plane_preflight
9
10
  from doctor.providers.boot import run_boot_checks
10
11
  from doctor.providers.runtime import run_runtime_checks
11
12
  from doctor.providers.deep import run_deep_checks
@@ -22,12 +23,13 @@ _TIER_ORDER = ["boot", "runtime", "deep"]
22
23
  VALID_TIERS = frozenset(_TIER_ORDER) | {"all"}
23
24
 
24
25
 
25
- def run_doctor(tier: str = "boot", fix: bool = False) -> DoctorReport:
26
+ def run_doctor(tier: str = "boot", fix: bool = False, plane: str = "") -> DoctorReport:
26
27
  """Run diagnostic checks for the specified tier(s).
27
28
 
28
29
  Args:
29
30
  tier: "boot", "runtime", "deep", or "all"
30
31
  fix: If True, apply deterministic fixes where possible
32
+ plane: Explicit diagnostic plane — runtime_personal, installation_live, or database_real
31
33
  """
32
34
  report = DoctorReport(overall_status="healthy")
33
35
  start = time.monotonic()
@@ -44,6 +46,13 @@ def run_doctor(tier: str = "boot", fix: bool = False) -> DoctorReport:
44
46
  report.duration_ms = int((time.monotonic() - start) * 1000)
45
47
  return report
46
48
 
49
+ _, preflight = diagnostic_plane_preflight(plane)
50
+ if preflight is not None:
51
+ report.add(preflight)
52
+ report.compute_status()
53
+ report.duration_ms = int((time.monotonic() - start) * 1000)
54
+ return report
55
+
47
56
  tiers = _TIER_ORDER if tier == "all" else [tier]
48
57
 
49
58
  for t in tiers:
@@ -0,0 +1,87 @@
1
+ """Diagnostic plane preflight for NEXO Doctor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from doctor.models import DoctorCheck
6
+
7
+ VALID_DIAGNOSTIC_PLANES = {
8
+ "product_public": {
9
+ "label": "producto público",
10
+ "use": "release contracts, artefactos publicados, compare/, docs y surfaces públicas del repo",
11
+ },
12
+ "runtime_personal": {
13
+ "label": "runtime personal",
14
+ "use": "~/.nexo, scripts personales, followups, reminders y hábitos operativos del operador",
15
+ },
16
+ "installation_live": {
17
+ "label": "instalación viva",
18
+ "use": "runtime instalado, hooks activos, clientes conectados, cron sync y parity de la instalación local",
19
+ },
20
+ "database_real": {
21
+ "label": "BD real",
22
+ "use": "SQLite/MySQL reales, filas, schema, deudas, sesiones y evidencia persistida",
23
+ },
24
+ "cooperator": {
25
+ "label": "co-operador",
26
+ "use": "comportamiento del agente, protocolo, comunicación y decisiones del asistente",
27
+ },
28
+ }
29
+
30
+ DOCTOR_COMPATIBLE_PLANES = {"runtime_personal", "installation_live", "database_real"}
31
+
32
+
33
+ def normalize_diagnostic_plane(plane: str = "") -> str:
34
+ clean = (plane or "").strip().lower().replace("-", "_").replace(" ", "_")
35
+ return clean if clean in VALID_DIAGNOSTIC_PLANES else ""
36
+
37
+
38
+ def diagnostic_plane_choices() -> list[str]:
39
+ return sorted(VALID_DIAGNOSTIC_PLANES)
40
+
41
+
42
+ def diagnostic_plane_preflight(plane: str = "") -> tuple[str, DoctorCheck | None]:
43
+ clean_plane = normalize_diagnostic_plane(plane)
44
+ if not clean_plane:
45
+ options = ", ".join(diagnostic_plane_choices())
46
+ return "", DoctorCheck(
47
+ id="orchestrator.diagnostic_plane_required",
48
+ tier="orchestrator",
49
+ status="critical",
50
+ severity="error",
51
+ summary="El diagnóstico está bloqueado hasta fijar explícitamente el plano",
52
+ evidence=[
53
+ f"planes válidos: {options}",
54
+ "Usa `runtime_personal` para ~/.nexo y hábitos del runtime; `installation_live` para hooks/clientes/instalación; `database_real` para filas y schema reales.",
55
+ ],
56
+ repair_plan=[
57
+ "Repite `nexo_doctor` o `nexo doctor` con `plane='runtime_personal'`, `plane='installation_live'` o `plane='database_real'`.",
58
+ "Si el problema pertenece a producto público o al co-operador, usa el surface correcto en vez de NEXO Doctor.",
59
+ ],
60
+ escalation_prompt=(
61
+ "NEXO mezcló planos en diagnósticos anteriores. El doctor no debe correr hasta que se elija "
62
+ "explícitamente si el problema está en producto público, runtime personal, instalación viva, BD real o co-operador."
63
+ ),
64
+ )
65
+
66
+ if clean_plane not in DOCTOR_COMPATIBLE_PLANES:
67
+ plane_info = VALID_DIAGNOSTIC_PLANES[clean_plane]
68
+ return clean_plane, DoctorCheck(
69
+ id="orchestrator.diagnostic_plane_mismatch",
70
+ tier="orchestrator",
71
+ status="degraded",
72
+ severity="warn",
73
+ summary=f"NEXO Doctor no es la superficie correcta para el plano {plane_info['label']}",
74
+ evidence=[
75
+ f"plane: {clean_plane}",
76
+ f"este plano se diagnostica mejor desde: {plane_info['use']}",
77
+ ],
78
+ repair_plan=[
79
+ "Si quieres diagnosticar runtime/instalación/BD, vuelve a lanzar el doctor con el plano correcto.",
80
+ "Si el problema es del producto público o del co-operador, usa release checks, repo checks o herramientas de protocolo/sesión en vez de Doctor.",
81
+ ],
82
+ escalation_prompt=(
83
+ "El plano elegido no corresponde al runtime doctor. Cambia de plano o de herramienta antes de seguir para no mezclar diagnóstico técnico con producto o comportamiento del agente."
84
+ ),
85
+ )
86
+
87
+ return clean_plane, None
@@ -14,6 +14,21 @@ from protocol_settings import get_protocol_strictness
14
14
  READ_LIKE_TOOLS = {"Read"}
15
15
  WRITE_LIKE_TOOLS = {"Edit", "MultiEdit", "Write"}
16
16
  DELETE_LIKE_TOOLS = {"Delete"}
17
+ NON_TRIVIAL_PROTOCOL_TOOLS = {"Read", "Bash", "Grep", "Glob", "Edit", "MultiEdit", "Write", "Delete"}
18
+ PROTOCOL_SKIP_TOOLS = {
19
+ "nexo_startup",
20
+ "nexo_smart_startup",
21
+ "nexo_stop",
22
+ "nexo_heartbeat",
23
+ "nexo_task_open",
24
+ "nexo_task_close",
25
+ "nexo_workflow_open",
26
+ "nexo_workflow_update",
27
+ "nexo_guard_check",
28
+ "nexo_guard_file_check",
29
+ "nexo_rules_check",
30
+ }
31
+ ACTION_TASK_TYPES = {"edit", "execute", "delegate"}
17
32
  NEXO_CODE_ROOT = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent))).expanduser().resolve()
18
33
  LIVE_REPO_ROOT = NEXO_CODE_ROOT.parent if NEXO_CODE_ROOT.name == "src" else NEXO_CODE_ROOT
19
34
  PUBLIC_REPO_DIRS = {
@@ -50,6 +65,11 @@ def _operation_kind(tool_name: str) -> str:
50
65
  return "other"
51
66
 
52
67
 
68
+ def _short_tool_name(tool_name: str) -> str:
69
+ clean = str(tool_name or "").strip()
70
+ return clean.rsplit("__", 1)[-1] if "__" in clean else clean
71
+
72
+
53
73
  def _normalize_file_path(path: str) -> str:
54
74
  return _normalize_path_token(str(Path(path)))
55
75
 
@@ -154,7 +174,8 @@ def _resolve_nexo_sid(conn, external_session_id: str) -> str:
154
174
  def _find_open_task_for_file(conn, sid: str, filepath: str) -> dict | None:
155
175
  target = _normalize_file_path(filepath)
156
176
  rows = conn.execute(
157
- """SELECT task_id, files, guard_has_blocking
177
+ """SELECT task_id, files, guard_has_blocking, task_type, plan, unknowns,
178
+ verification_step, opened_with_guard, must_change_log, must_verify
158
179
  FROM protocol_tasks
159
180
  WHERE session_id = ? AND status = 'open'
160
181
  ORDER BY opened_at DESC""",
@@ -173,7 +194,8 @@ def _find_open_task_for_file(conn, sid: str, filepath: str) -> dict | None:
173
194
 
174
195
  def _find_any_open_task(conn, sid: str) -> dict | None:
175
196
  row = conn.execute(
176
- """SELECT task_id, files, guard_has_blocking
197
+ """SELECT task_id, files, guard_has_blocking, task_type, plan, unknowns,
198
+ verification_step, opened_with_guard, must_change_log, must_verify
177
199
  FROM protocol_tasks
178
200
  WHERE session_id = ? AND status = 'open'
179
201
  ORDER BY opened_at DESC
@@ -183,6 +205,26 @@ def _find_any_open_task(conn, sid: str) -> dict | None:
183
205
  return dict(row) if row else None
184
206
 
185
207
 
208
+ def _find_any_open_workflow(conn, sid: str) -> dict | None:
209
+ row = conn.execute(
210
+ """SELECT run_id, protocol_task_id, current_step_key
211
+ FROM workflow_runs
212
+ WHERE session_id = ? AND status IN ('open', 'running', 'blocked', 'waiting_approval')
213
+ ORDER BY updated_at DESC, run_id DESC
214
+ LIMIT 1""",
215
+ (sid,),
216
+ ).fetchone()
217
+ return dict(row) if row else None
218
+
219
+
220
+ def _session_has_guard_check(conn, sid: str) -> bool:
221
+ row = conn.execute(
222
+ "SELECT 1 FROM guard_checks WHERE session_id = ? LIMIT 1",
223
+ (sid,),
224
+ ).fetchone()
225
+ return bool(row)
226
+
227
+
186
228
  def _find_open_debt(conn, *, session_id: str, task_id: str, debt_type: str, file_token: str) -> dict | None:
187
229
  row = conn.execute(
188
230
  """SELECT *
@@ -241,6 +283,98 @@ def _ensure_protocol_debt(
241
283
  )
242
284
 
243
285
 
286
+ def _task_list_field(task: dict | None, key: str) -> list:
287
+ if not task:
288
+ return []
289
+ try:
290
+ parsed = json.loads(task.get(key) or "[]")
291
+ except Exception:
292
+ return []
293
+ return parsed if isinstance(parsed, list) else []
294
+
295
+
296
+ def _task_needs_workflow(task: dict | None) -> bool:
297
+ if not task:
298
+ return False
299
+ if str(task.get("task_type") or "").strip() not in ACTION_TASK_TYPES:
300
+ return False
301
+ if len(_task_list_field(task, "plan")) > 1:
302
+ return True
303
+ if len(_task_list_field(task, "unknowns")) > 0:
304
+ return True
305
+ if len(_task_list_field(task, "files")) > 1:
306
+ return True
307
+ return bool(str(task.get("verification_step") or "").strip())
308
+
309
+
310
+ def _append_protocol_warning(warnings: list[dict], message: str) -> None:
311
+ clean = (message or "").strip()
312
+ if not clean:
313
+ return
314
+ if any((item.get("message") or "").strip() == clean for item in warnings):
315
+ return
316
+ warnings.append({"message": clean})
317
+
318
+
319
+ def _collect_protocol_warnings(conn, *, sid: str, tool_name: str) -> list[dict]:
320
+ short_name = _short_tool_name(tool_name)
321
+ if short_name in PROTOCOL_SKIP_TOOLS or short_name not in NON_TRIVIAL_PROTOCOL_TOOLS:
322
+ return []
323
+
324
+ warnings: list[dict] = []
325
+ if not sid:
326
+ _append_protocol_warning(
327
+ warnings,
328
+ "Trabajo no trivial detectado antes de `nexo_startup(...)`. Arranca NEXO, abre `nexo_task_open(...)`, y si esto va a durar varias fases abre también `nexo_workflow_open(...)` antes de seguir.",
329
+ )
330
+ return warnings
331
+
332
+ task = _find_any_open_task(conn, sid)
333
+ has_guard = _session_has_guard_check(conn, sid)
334
+ if not task:
335
+ guard_note = (
336
+ " Ejecuta `nexo_guard_check(...)` antes de leer código condicionado o compartido."
337
+ if short_name in {"Read", "Bash", "Grep", "Glob"} and not has_guard
338
+ else ""
339
+ )
340
+ _append_protocol_warning(
341
+ warnings,
342
+ "Trabajo no trivial detectado sin `nexo_task_open(...)`. Ábrelo ahora y, si esto va a cruzar varios pasos o mensajes, añade `nexo_workflow_open(...)`." + guard_note,
343
+ )
344
+ _append_protocol_warning(
345
+ warnings,
346
+ "Recordatorio protocolario: mantén `nexo_heartbeat(...)` al día y no cierres en optimista; si hay cambios reales, registra `nexo_change_log(...)` o cierra con `nexo_task_close(...)` más evidencia.",
347
+ )
348
+ return warnings
349
+
350
+ task_id = str(task.get("task_id") or "").strip()
351
+ if str(task.get("task_type") or "").strip() in ACTION_TASK_TYPES and not (task.get("opened_with_guard") or has_guard):
352
+ _append_protocol_warning(
353
+ warnings,
354
+ f"La tarea {task_id} está activa sin guard visible. Ejecuta `nexo_guard_check(...)` antes de tocar código condicionado o compartido.",
355
+ )
356
+
357
+ workflow = _find_any_open_workflow(conn, sid)
358
+ if _task_needs_workflow(task) and not workflow:
359
+ _append_protocol_warning(
360
+ warnings,
361
+ f"La tarea {task_id} ya tiene pinta de multi-step y sigue sin `nexo_workflow_open(...)`. Ábrelo para que checkpoints, resume y replay no dependan de memoria implícita.",
362
+ )
363
+
364
+ if str(task.get("task_type") or "").strip() in ACTION_TASK_TYPES and short_name in {"Bash", "Edit", "MultiEdit", "Write", "Delete"}:
365
+ change_note = (
366
+ " Si editas de verdad y no vas a usar `nexo_task_close(...)` inmediatamente, captura `nexo_change_log(...)`."
367
+ if task.get("must_change_log")
368
+ else ""
369
+ )
370
+ _append_protocol_warning(
371
+ warnings,
372
+ f"Recordatorio protocolario para {task_id}: mantén `nexo_heartbeat(...)` al día y ciérrala con `nexo_task_close(...)` más evidencia antes de decir que está resuelta.{change_note}",
373
+ )
374
+
375
+ return warnings
376
+
377
+
244
378
  def _collect_automation_live_repo_blocks(
245
379
  conn,
246
380
  *,
@@ -429,21 +563,20 @@ def process_pre_tool_event(payload: dict) -> dict:
429
563
  def process_tool_event(payload: dict) -> dict:
430
564
  tool_name = str(payload.get("tool_name", "")).strip()
431
565
  op = _operation_kind(tool_name)
432
- if op == "other":
433
- return {"ok": True, "skipped": True, "reason": "tool not monitored"}
434
-
435
566
  tool_input = payload.get("tool_input")
436
567
  files = _extract_touched_files(tool_input)
437
- if not files:
438
- return {"ok": True, "skipped": True, "reason": "no touched files found"}
439
-
440
568
  conn = get_db()
441
569
  sid = _resolve_nexo_sid(conn, str(payload.get("session_id", "")))
442
- if not sid:
570
+ warnings = _collect_protocol_warnings(conn, sid=sid, tool_name=tool_name)
571
+
572
+ if op == "other" and not warnings:
573
+ return {"ok": True, "skipped": True, "reason": "tool not monitored"}
574
+ if not files and op in {"read", "write", "delete"} and not warnings:
575
+ return {"ok": True, "skipped": True, "reason": "no touched files found"}
576
+ if not sid and not warnings:
443
577
  return {"ok": True, "skipped": True, "reason": "session not mapped to nexo"}
444
578
 
445
- conditioned = _load_conditioned_learnings(conn, files)
446
- warnings: list[dict] = []
579
+ conditioned = _load_conditioned_learnings(conn, files) if sid else {}
447
580
  violations: list[dict] = []
448
581
 
449
582
  for filepath in files:
@@ -545,6 +678,9 @@ def format_hook_message(result: dict) -> str:
545
678
  return ""
546
679
  lines = ["NEXO DISCIPLINE:"]
547
680
  for item in result.get("warnings", []):
681
+ if item.get("message") and not item.get("learning_ids"):
682
+ lines.append(f"- PROTOCOL REMINDER: {item['message']}")
683
+ continue
548
684
  if item.get("debt_id"):
549
685
  lines.append(
550
686
  f"- REVIEW FILE RULES: {item['file']} -> learnings {item['learning_ids']}. "