nexo-brain 5.3.11 → 5.3.13
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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -5
- package/package.json +1 -1
- package/src/agent_runner.py +2 -0
- package/src/auto_update.py +116 -4
- package/src/cli.py +8 -2
- package/src/client_preferences.py +26 -7
- package/src/client_sync.py +3 -2
- package/src/doctor/orchestrator.py +10 -1
- package/src/doctor/planes.py +87 -0
- package/src/hook_guardrails.py +147 -11
- package/src/plugins/doctor.py +3 -2
- package/src/plugins/schedule.py +119 -1
- package/src/runtime_power.py +3 -2
- package/src/scripts/check-context.py +7 -1
- package/src/scripts/deep-sleep/extract.py +8 -2
- package/src/scripts/deep-sleep/synthesize.py +8 -1
- package/src/scripts/nexo-catchup.py +8 -2
- package/src/scripts/nexo-cortex-cycle.py +48 -21
- package/src/scripts/nexo-daily-self-audit.py +56 -23
- package/src/scripts/nexo-evolution-run.py +10 -2
- package/src/scripts/nexo-immune.py +8 -1
- package/src/scripts/nexo-learning-validator.py +9 -1
- package/src/scripts/nexo-postmortem-consolidator.py +9 -1
- package/src/scripts/nexo-sleep.py +7 -1
- package/src/scripts/nexo-synthesis.py +8 -1
- package/src/scripts/rehydrate_learnings_from_archive.py +245 -0
- package/src/server.py +2 -0
- package/src/skills/run-nexo-core-fix-cycle/guide.md +17 -0
- package/src/skills/run-nexo-core-fix-cycle/script.py +276 -0
- package/src/skills/run-nexo-core-fix-cycle/skill.json +58 -0
- package/src/skills/run-release-final-audit/guide.md +5 -3
- package/src/skills/run-release-final-audit/script.py +17 -8
- package/src/skills/run-release-final-audit/skill.json +15 -2
- package/src/skills/run-runtime-doctor/script.py +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.3.
|
|
3
|
+
"version": "5.3.13",
|
|
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
|
-
|
|
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
|
-
- **
|
|
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.
|
|
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.
|
|
3
|
+
"version": "5.3.13",
|
|
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",
|
package/src/agent_runner.py
CHANGED
|
@@ -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."
|
package/src/auto_update.py
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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":
|
|
264
|
-
"model":
|
|
265
|
-
"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(
|
package/src/client_sync.py
CHANGED
|
@@ -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":
|
|
66
|
-
"codex": {"model":
|
|
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
|
package/src/hook_guardrails.py
CHANGED
|
@@ -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
|
-
|
|
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']}. "
|